diff --git a/.config/docker_example.yml b/.config/docker_example.yml index f8124bc9df..39682e1542 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── @@ -62,15 +79,30 @@ redis: #prefix: example-prefix #db: 1 -# ┌─────────────────────────────┐ -#───┘ Elasticsearch configuration └───────────────────────────── +#redisForPubsub: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 -#elasticsearch: -# host: localhost -# port: 9200 -# ssl: false -# user: -# pass: +#redisForJobQueue: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── + +#meilisearch: +# host: meilisearch +# port: 7700 +# apiKey: '' +# ssl: true # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index dcc7bff11b..8663c77946 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── @@ -62,15 +79,30 @@ redis: #prefix: example-prefix #db: 1 -# ┌─────────────────────────────┐ -#───┘ Elasticsearch configuration └───────────────────────────── - -#elasticsearch: +#redisForPubsub: # host: localhost -# port: 9200 -# ssl: false -# user: -# pass: +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForJobQueue: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── + +#meilisearch: +# host: localhost +# port: 7700 +# apiKey: '' +# ssl: true # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── @@ -100,16 +132,20 @@ id: 'aid' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 +#deliverJobConcurrency: 128 +#inboxJobConcurrency: 16 +#relashionshipJobConcurrency: 16 +# What's relashionshipJob?: +# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 16 +#deliverJobPerSec: 128 +#inboxJobPerSec: 16 +#relashionshipJobPerSec: 64 # Job attempts -# deliverJobMaxAttempts: 12 -# inboxJobMaxAttempts: 8 +#deliverJobMaxAttempts: 12 +#inboxJobMaxAttempts: 8 # IP address family used for outgoing request (ipv4, ipv6 or dual) #outgoingAddressFamily: ipv4 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fde7ec0f2b..a47804ab07 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,8 +4,24 @@ "service": "app", "workspaceFolder": "/workspace", "features": { - "ghcr.io/devcontainers-contrib/features/pnpm:2": {} + "ghcr.io/devcontainers-contrib/features/pnpm:2": {}, + "ghcr.io/devcontainers/features/node:1": { + "version": "18.16.0" + } }, "forwardPorts": [3000], - "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh" + "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", + "customizations": { + "vscode": { + "extensions": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "Vue.volar", + "Vue.vscode-typescript-vue-plugin", + "Orta.vscode-jest", + "dbaeumer.vscode-eslint", + "mrmlnc.vscode-json5" + ] + } + } } diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 8a363a15dc..1d761ae75e 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── @@ -62,15 +79,30 @@ redis: #prefix: example-prefix #db: 1 -# ┌─────────────────────────────┐ -#───┘ Elasticsearch configuration └───────────────────────────── +#redisForPubsub: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 -#elasticsearch: -# host: localhost -# port: 9200 -# ssl: false -# user: -# pass: +#redisForJobQueue: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── + +#meilisearch: +# host: meilisearch +# port: 7700 +# apiKey: '' +# ssl: true # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 6ec3c86a4a..8f8c5a13ab 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -16,7 +16,7 @@ services: - external_network redis: - restart: always + restart: unless-stopped image: redis:7-alpine networks: - internal_network diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index 450c3920c3..bcad3e6d85 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -4,6 +4,7 @@ set -xe sudo chown -R node /workspace git submodule update --init +pnpm config set store-dir /home/node/.local/share/pnpm/store pnpm install --frozen-lockfile cp .devcontainer/devcontainer.yml .config/default.yml pnpm build diff --git a/.dockerignore b/.dockerignore index 8f984831ef..6b9ff75b2c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,6 @@ build/ built/ db/ docker-compose.yml -elasticsearch/ node_modules/ packages/*/node_modules redis/ @@ -25,6 +24,10 @@ fluent-emojis/ !.yarn/sdks !.yarn/versions +.pnpm-store + .idea/ packages/*/.vscode/ packages/backend/test/docker-compose.yml + +.pnpm-store/ diff --git a/.editorconfig b/.editorconfig index 6db1367645..a6f988f8d7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,5 +7,5 @@ charset = utf-8 insert_final_newline = true end_of_line = lf -[*.yml] +[*.{yml,yaml}] indent_style = space diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.md b/.github/ISSUE_TEMPLATE/01_bug-report.md index ddead9d79a..50e3174aee 100644 --- a/.github/ISSUE_TEMPLATE/01_bug-report.md +++ b/.github/ISSUE_TEMPLATE/01_bug-report.md @@ -37,7 +37,10 @@ Please include errors from the developer console and/or server log files if you ## 📌 Environment + CherryPick version: +PostgreSQL version: +Redis version: Your OS: Your browser: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE/01_bug.md similarity index 71% rename from .github/PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE/01_bug.md index 79ca97dfa0..0739fee709 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE/01_bug.md @@ -4,14 +4,20 @@ Thank you for your PR! Before creating a PR, please check the contribution guide https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md --> -# What +## What -# Why +## Why -# Additional info (optional) +## Additional info (optional) + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/PULL_REQUEST_TEMPLATE/02_enhance.md b/.github/PULL_REQUEST_TEMPLATE/02_enhance.md new file mode 100644 index 0000000000..0739fee709 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/02_enhance.md @@ -0,0 +1,23 @@ + + +## What + + + +## Why + + + +## Additional info (optional) + + + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/PULL_REQUEST_TEMPLATE/03_release.md b/.github/PULL_REQUEST_TEMPLATE/03_release.md new file mode 100644 index 0000000000..b5b832e1dc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/03_release.md @@ -0,0 +1,20 @@ +## Summary +This is a release PR. + +For more information on the release instructions, please see: +https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md#release + +## For reviewers +- CHANGELOGに抜け漏れは無いか +- バージョンの上げ方は適切か +- 他にこのリリースに含めなければならない変更は無いか +- 全体的な変更内容を俯瞰し問題は無いか +- レビューされていないコミットがある場合は、それが問題ないか +- 最終的な動作確認を行い問題は無いか + +などを確認し、リリースする準備が整っていると思われる場合は approve してください。 + +## Checklist +- [ ] package.jsonのバージョンが正しく更新されている +- [ ] CHANGELOGが過不足無く更新されている +- [ ] CIが全て通っている diff --git a/.github/labeler.yml b/.github/labeler.yml index b4fd0dd5df..137be487c0 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,12 +1,21 @@ -'⚙️Server': +'packages/backend': - packages/backend/**/* -'🖥️Client': -- packages/frontend/**/* - -'🧪Test': -- cypress/**/* +'packages/backend:test': - packages/backend/test/**/* -'‼️ wrong locales': -- any: ['locales/*.yml', '!locales/ja-JP.yml'] +'packages/frontend': +- packages/frontend/**/* + +'packages/frontend:test': +- cypress/**/* + +'packages/sw': +- packages/sw/**/* + +'packages/misskey-js': +- packages/misskey-js/**/* + +'packages/misskey-js:test': +- packages/misskey-js/test/**/* +- packages/misskey-js/test-d/**/* diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e78b82c47c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ + + +## What + + + +## Why + + + +## Additional info (optional) + + + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Add story of storybook +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/reviewer-lottery.yml b/.github/reviewer-lottery.yml new file mode 100644 index 0000000000..fd2fb1913f --- /dev/null +++ b/.github/reviewer-lottery.yml @@ -0,0 +1,10 @@ +groups: + - name: devs + reviewers: 2 + internal_reviewers: 1 + usernames: + - syuilo + - acid-chicken + - EbiseLutica + - rinsuki + - tamaina diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml new file mode 100644 index 0000000000..ed004c78dc --- /dev/null +++ b/.github/workflows/api-misskey-js.yml @@ -0,0 +1,36 @@ +name: API report (misskey.js) + +on: [push, pull_request] + +jobs: + report: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3.3.0 + + - run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v3.6.0 + with: + node-version-file: '.node-version' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Build + run: pnpm --filter misskey-js build + + - name: Check files + run: ls packages/misskey-js/built + + - name: API report + run: pnpm --filter misskey-js api-prod + + - name: Show report + if: always() + run: cat packages/misskey-js/temp/misskey-js.api.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6a579bffc8..0f3702f958 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,11 +17,11 @@ jobs: submodules: true - uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile @@ -36,6 +36,7 @@ jobs: - backend - frontend - sw + - misskey-js steps: - uses: actions/checkout@v3.3.0 with: @@ -47,8 +48,34 @@ jobs: run_install: false - uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile - - run: pnpm --filter ${{ matrix.workspace }} run lint + - run: pnpm --filter ${{ matrix.workspace }} run eslint + + typecheck: + needs: [pnpm_install] + runs-on: ubuntu-latest + continue-on-error: true + strategy: + matrix: + workspace: + - backend + - misskey-js + steps: + - uses: actions/checkout@v3.3.0 + with: + fetch-depth: 0 + submodules: true + - uses: pnpm/action-setup@v2 + with: + version: 7 + run_install: false + - uses: actions/setup-node@v3.6.0 + with: + node-version-file: '.node-version' + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - run: pnpm --filter ${{ matrix.workspace }} run typecheck diff --git a/.github/workflows/pr-preview-destroy.yml b/.github/workflows/pr-preview-destroy.yml index 49f1ba8a34..8adfad9dab 100644 --- a/.github/workflows/pr-preview-destroy.yml +++ b/.github/workflows/pr-preview-destroy.yml @@ -9,14 +9,46 @@ name: Destroy preview environment jobs: destroy-preview-environment: runs-on: ubuntu-latest - if: github.repository == github.event.pull_request.head.repo.full_name steps: + - uses: actions/github-script@v6.3.3 + 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/reviewer_lottery.yml b/.github/workflows/reviewer_lottery.yml new file mode 100644 index 0000000000..33228d7465 --- /dev/null +++ b/.github/workflows/reviewer_lottery.yml @@ -0,0 +1,13 @@ +name: "Reviewer lottery" +on: + pull_request_target: + types: [opened, ready_for_review, reopened] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: uesteibar/reviewer-lottery@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml new file mode 100644 index 0000000000..eb6ace27da --- /dev/null +++ b/.github/workflows/storybook.yml @@ -0,0 +1,94 @@ +name: Storybook + +on: + push: + branches: + - master + - develop + pull_request_target: + +jobs: + build: + runs-on: ubuntu-latest + + env: + NODE_OPTIONS: "--max_old_space_size=7168" + + steps: + - uses: actions/checkout@v3.3.0 + with: + fetch-depth: 0 + submodules: true + - name: Checkout HEAD + if: github.event_name == 'pull_request' + run: git checkout ${{ github.head_ref }} + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + - name: Use Node.js 18.x + uses: actions/setup-node@v3.6.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 storybook + run: pnpm --filter frontend build-storybook + - name: Publish to Chromatic + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' + run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static + env: + CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + - name: Publish to Chromatic + if: github.event_name != 'pull_request' && github.ref != 'refs/heads/master' + id: chromatic_push + run: | + DIFF="${{ github.event.before }} HEAD" + if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then + DIFF="HEAD" + fi + CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" + if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + fi + pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static $(echo "$CHROMATIC_PARAMETER") + env: + CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + - name: Publish to Chromatic + if: github.event_name == 'pull_request' + id: chromatic_pull_request + run: | + DIFF="${{ github.base_ref }} HEAD" + if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then + DIFF="HEAD" + fi + CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" + if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + fi + pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static $(echo "$CHROMATIC_PARAMETER") + env: + CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + - name: Notify that Chromatic will skip testing + uses: actions/github-script@v6.4.0 + if: github.event_name == 'pull_request' && steps.chromatic_pull_request.outputs.skip == 'true' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'Chromatic will skip testing but you may still have to [review the changes on Chromatic](https://www.chromatic.com/pullrequests?appId=6428f7d7b962f0b79f97d6e4).' + }) + - name: Upload Artifacts + uses: actions/upload-artifact@v3 + with: + name: storybook + path: packages/frontend/storybook-static diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml new file mode 100644 index 0000000000..d7be15bd4f --- /dev/null +++ b/.github/workflows/test-backend.yml @@ -0,0 +1,59 @@ +name: Test (backend) + +on: + push: + branches: + - master + - develop + pull_request: + +jobs: + jest: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + services: + postgres: + image: postgres:13 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:7 + ports: + - 56312:6379 + + steps: + - uses: actions/checkout@v3.3.0 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .github/misskey/test.yml .config + - name: Build + run: pnpm build + - name: Test + run: pnpm jest-and-coverage + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/coverage/coverage-final.json diff --git a/.github/workflows/test.yml b/.github/workflows/test-frontend.yml similarity index 87% rename from .github/workflows/test.yml rename to .github/workflows/test-frontend.yml index c419d328cc..658f142d7e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test-frontend.yml @@ -1,4 +1,4 @@ -name: Test +name: Test (frontend) on: push: @@ -8,26 +8,13 @@ on: pull_request: jobs: - jest: + vitest: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x] - services: - postgres: - image: postgres:13 - ports: - - 54312:5432 - env: - POSTGRES_DB: test-cherrypick - POSTGRES_HOST_AUTH_METHOD: trust - redis: - image: redis:6 - ports: - - 56312:6379 - steps: - uses: actions/checkout@v3.3.0 with: @@ -35,7 +22,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3.6.0 @@ -51,12 +38,12 @@ jobs: - name: Build run: pnpm build - name: Test - run: pnpm jest-and-coverage + run: pnpm --filter frontend test-and-coverage - name: Upload Coverage uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./packages/backend/coverage/coverage-final.json + files: ./packages/frontend/coverage/coverage-final.json e2e: runs-on: ubuntu-latest @@ -76,7 +63,7 @@ jobs: POSTGRES_DB: test-cherrypick POSTGRES_HOST_AUTH_METHOD: trust redis: - image: redis:6 + image: redis:7 ports: - 56312:6379 @@ -119,7 +106,7 @@ jobs: install: false start: pnpm start:test wait-on: 'http://localhost:61812' - headless: false + headed: true browser: ${{ matrix.browser }} - uses: actions/upload-artifact@v2 if: failure() diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml new file mode 100644 index 0000000000..b15e704c7f --- /dev/null +++ b/.github/workflows/test-misskey-js.yml @@ -0,0 +1,52 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Test (misskey.js) + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + +jobs: + test: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - name: Checkout + uses: actions/checkout@v3.3.0 + + - run: corepack enable + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + + - name: Build + run: pnpm --filter misskey-js build + + - name: Test + run: pnpm --filter misskey-js test + env: + CI: true + + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/misskey-js/coverage/coverage-final.json diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml new file mode 100644 index 0000000000..5243a83777 --- /dev/null +++ b/.github/workflows/test-production.yml @@ -0,0 +1,42 @@ +name: Test (production install and build) + +on: + push: + branches: + - master + - develop + pull_request: + +env: + NODE_ENV: production + +jobs: + production: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + steps: + - uses: actions/checkout@v3.3.0 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .github/misskey/test.yml .config/default.yml + - name: Build + run: pnpm build diff --git a/.gitignore b/.gitignore index cee9d5a682..07723a4033 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ packages/frontend/.yarn/cache packages/backend/.yarn/cache packages/sw/.yarn/cache +# pnpm +.pnpm-store + # Cypress cypress/screenshots cypress/videos @@ -41,7 +44,7 @@ built /data /.cache-loader /db -/elasticsearch +/meili_data npm-debug.log *.pem run.bat @@ -52,6 +55,8 @@ api-docs.json .DS_Store /files ormconfig.json +temp +/packages/frontend/src/**/*.stories.ts # blender backups *.blend1 @@ -59,3 +64,6 @@ ormconfig.json *.blend3 *.blend4 *.blend5 + +.pnpm-store/ + diff --git a/.node-version b/.node-version index 0e9dc6b586..6d80269a4f 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v18.13.0 +18.16.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 42264548ea..baca8db246 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,11 @@ { "recommendations": [ "editorconfig.editorconfig", - "eg2.vscode-npm-script", "dbaeumer.vscode-eslint", "Vue.volar", - "Vue.vscode-typescript-vue-plugin" + "Vue.vscode-typescript-vue-plugin", + "Orta.vscode-jest", + "dbaeumer.vscode-eslint", + "mrmlnc.vscode-json5" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index b7e7b20c17..71fb02a59d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,11 @@ { "search.exclude": { "**/node_modules": true - } + }, + "typescript.tsdk": "node_modules/typescript/lib", + "files.associations": { + "*.test.ts": "typescript" + }, + "jest.jestCommandLine": "pnpm run jest", + "jest.autoRun": "off" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d81f5de0..50e097c683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,415 @@ + +## 13.12.1 + +### Client +- プロフィール画面におけるモデレーションノートの表示を調整 +- Fix: 一部ダイアログが表示されない問題を修正 +- Fix: MkUserInfoのフォローボタンが変な位置にある問題を修正 + +### Server +- Fix: リモートサーバーの情報が更新できない問題を修正 +- Fix: 13.11を経験しない状態で13.12にアップデートした場合ユーザープロフィール関連の画像が消失する問題を修正 + +## 13.12.0 + +### NOTE +- Node.js 18.6.0以上が必要になりました + +### General +- アカウントの引っ越し(フォロワー引き継ぎ)に対応 +- Meilisearchを全文検索に使用できるようになりました +- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加 +- ユーザーへの自分用メモ機能 + * ユーザーに対して、自分だけが見られるメモを追加できるようになりました。 + (自分自身に対してもメモを追加できます。) + * ユーザーメニューから追加できます。 + (デスクトップ表示ではusernameの右側のボタンからも追加可能) +- チャンネルに色を設定できるようになりました。各ノートに設定した色のインジケーターが表示されます。 +- チャンネルをアーカイブできるようになりました。 + * アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。 +- アンテナのエクスポート・インポートができるようになりました +- ロールタイムラインをロールごとに表示するかどうかの選択できるようになりました。 + * デフォルトがオフになるので、ロールタイムラインを表示する場合はオンにしてください。 +- ロールに強制的にNSFWを付与するポリシーを追加 + * アップロード済みのファイルはNSFWにならない為注意してください。 +- モデレーションノートがユーザーのプロフィールページからも閲覧および編集できるようになりました。 +- カスタム絵文字のライセンスを複数でセットできるようになりました。 +- 管理者が予約ユーザー名を設定できるようになりました。 +- Fix: フォローリクエストの通知が残る問題を修正 + +### Client +- アカウント作成時に初期設定ウィザードを表示するように +- チャンネル内検索ができるように +- チャンネル検索ですべてのチャンネルの取得/表示ができるように +- 通知の表示をカスタマイズできるように +- ドライブのファイル一覧から直接ノートを作成できるように +- ノートメニューからRenoteしたユーザーの一覧を見れるように +- コントロールパネルのカスタム絵文字ページおよびaboutのカスタム絵文字の検索インプットで、`:emojiname1::emojiname2:`のように検索して絵文字を検索できるように + * 絵文字ピッカーから入力可能になります +- データセーバーモードを追加 + * 画像が全て隠れた状態で表示されるようになります +- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように +- モデレーターはノートに添付された画像上から直接NSFW設定できるように +- プロフィール設定「追加情報」の項目の削除と並び替えができるように +- 新しい実績を追加 +- AiScriptを0.13.2に更新 +- Fix: AiScript APIのMk:dialogで何も返していなかったのをNULLを返すように修正 +- Fix: 1:1ではない画像のリアクション通知バッジが左や上に寄ってしまっていたのを中央に来るように修正 +- Fix: リアクションをホバーした時のユーザーリストで猫耳が切れてしまっていた問題を修正 +- Fix: NSFWメディアの上に表示された「もっと見る」ボタンが押しづらい問題を修正 + +### Server +- channel/searchのqueryが空の場合に全てのチャンネルを返すように変更 +- 環境変数MISSKEY_CONFIG_YMLで設定ファイルをdefault.ymlから変更可能に +- Fix: 他のサーバーの情報が取得できないことがある問題を修正 +- Fix: エクスポートデータの拡張子がunknownになる問題を修正 +- Fix: Content-Dispositionのパースでエラーが発生した場合にダウンロードが完了しない問題を修正 +- Fix: API: i/update avatarIdとbannerIdにnullを渡した時、画像がリセットされない問題を修正 +- Fix: .wav, .flacが再生できない問題を修正(新しくアップロードされたファイルのみ修正が適用されます) +- Fix: 凍結されたユーザーが一部APIのレスポンスに含まれる問題を修正 +- Fix: メモリの使用量を`used - buffers - cached`ではなく`total - available`で求めるように(環境によって正常に計測できていなかったため) + +## 13.11.3 + +### General +- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加 + - Deckのカラムとしても追加可能 +- カスタム絵文字関連の改善 + * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように + * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように +- カスタム絵文字でリアクションできないことがある問題を修正 + +### Client +- チャンネルのピン留めされたノートの順番が正しくない問題を修正 + +### Server +- フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io +- Misskey Webでのサーバーサイドエラー画面を改善 +- Misskey Webでのサーバーサイドエラーのログが残るように +- ノート作成時のアンテナ追加パフォーマンスを改善 +- アンテナとロールTLのuntil/sinceプロパティが動くように + +## 13.11.2 + +### Note +- 13.11.0または13.11.1から13.11.2以降にアップデートする場合、Redisのカスタム絵文字のキャッシュを削除する必要があります(https://github.com/misskey-dev/misskey/issues/10502#issuecomment-1502790755 参照) + +### General +- チャンネルの検索用ページの追加 + +### Client +- 常に広告を見られるオプションを追加 +- ユーザーページの画像一覧が表示されない問題を修正 +- webhook, 連携アプリ一覧でコンテンツが重複して表示される問題を修正 +- iPhoneで絵文字ピッカーの表示が崩れる問題を修正 +- iPhoneでウィジェットドロワーの「ウィジェットを編集」が押しにくい問題を修正 +- 投稿フォームのデザインを調整 +- ギャラリーの人気の投稿が無限にページングされる問題を修正 + +### Server +- channels/search Endpoint APIの追加 +- APIパラメータサイズ上限を32kbから1mbに緩和 +- プッシュ通知送信時のパフォーマンスを改善 +- ローカルのカスタム絵文字のキャッシュが効いていなかった問題を修正 +- アンテナのノート、チャンネルのノート、通知が正常に作成できないことがある問題を修正 +- ストリーミングのLTLチャンネルでサーバー側にエラーログが出るのを修正 + +### Service Worker +- 「通知が既読になったらプッシュ通知を削除する」を復活 + * 「プッシュ通知が更新されました」の挙動を変えた(ホストとバージョンを表示するようにし、一定時間後の削除は行わないように) +- プッシュ通知が実績を解除 (achievementEarned) に対応 +- プッシュ通知のアクションから既存のクライアントの投稿フォームを開くことになった際の挙動を修正 +- たくさんのプッシュ通知を閉じた際、その通知の数だけnotifications/mark-all-as-readを叩くのをやめるように + +## 13.11.1 + +### General +- チャンネルの投稿を過去までさかのぼれるように + +### Client +- PWA時の絵文字ピッカーの位置をホームバーに重ならないように調整 +- リスト管理の画面でリストが無限に読み込まれる問題を修正 +- 自分のクリップが無限に読み込まれる問題を修正 +- チャンネルのお気に入りが無限に読み込まれる問題を修正 +- さがすのローカルユーザー(ピンどめ)が無限に生成される問題を修正 +- チャンネルを新規作成できない問題を修正 +- ユーザープレビューが表示されない問題を修正 + +### Server +- 通知読み込みでエラーが発生する場合がある問題を修正 +- リアクションできないことがある問題を修正 +- IDをaid以外に設定している場合の問題を修正 +- 連合しているインスタンスについて予期せず配送が全て停止されることがある問題を修正 + +## 13.11.0 + +### NOTE +- このバージョンからRedis 7.xが必要です。 +- アップデートを行うと全ての通知およびアンテナのノートはリセットされます。 + +### General +- チャンネルをお気に入りに登録できるように + - タイムラインのアンテナ選択などでは、フォローしているアンテナの代わりにお気に入りしたアンテナが表示されるようになっています。チャンネルをお気に入りに登録するには、当該チャンネルのページ→概要→⭐️のボタンを押します。 +- チャンネルにノートをピン留めできるように + +### Client +- 投稿フォームのデザインを改善 +- 検索ページでURLを入力した際に照会したときと同等の挙動をするように +- ノートのリアクションを大きく表示するオプションを追加 +- ギャラリー一覧にメディア表示と同じように NSFW 設定を反映するように(ホバーで表示) +- オブジェクトストレージの設定画面を分かりやすく +- 広告・お知らせが新規登録時に増殖しないように +- 「にゃああああああああああああああ!!!!!!!!!!!!」 (`isCat`) 有効時にアバターに表示される猫耳について挙動を変更 + - 「UIにぼかし効果を使用」 (`useBlurEffect`) で次の挙動が有効になります + - 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように + - 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります +- Add Minimizing ("folding") of windows +- 「データセーバー」モードを追加 +- 非NSFWメディアが隠れている際にも「閲覧注意」が出てしまう問題を修正 + +### Server +- PostgreSQLのレプリケーション対応 + - 設定ファイルの `dbReplications` および `dbSlaves` にて設定できます +- イベント用Redisを別サーバーに分離できるように +- ジョブキュー用Redisを別サーバーに分離できるように +- サーバーの全体的なパフォーマンスを向上 +- ノート作成時のパフォーマンスを向上 +- アンテナのタイムライン取得時のパフォーマンスを向上 +- チャンネルのタイムライン取得時のパフォーマンスを向上 +- 通知に関する全体的なパフォーマンスを向上 +- webhookがcontent-type text/plain;charset=UTF-8 で飛んでくる問題を修正 + +## 13.10.3 + +### Changes +- オブジェクトストレージのリージョン指定が必須になりました + - リージョンの指定の無いサービスは us-east-1 を設定してください + - 値が空の場合は設定ファイルまたは環境変数の使用を試みます + - e.g. ~/aws/config, AWS_REGION + +### General +- コンディショナルロールの条件に「投稿数が~以下」「投稿数が~以上」を追加 +- リアクション非対応AP実装からのLikeアクティビティの解釈を👍から♥に + +### Client +- クリップボタンをノートアクションに追加できるように +- センシティブワードの一覧にピン留めユーザーのIDが表示される問題を修正 + +### Server +- リモートユーザーのチャート生成を無効にするオプションを追加 +- リモートサーバーのチャート生成を無効にするオプションを追加 +- ドライブのチャートはローカルユーザーのみ生成するように +- 空のアンテナが作成できるのを修正 + +## 13.10.2 + +### Server +- 絵文字を編集すると保存できないことがある問題を修正 + +### Client +- ドライブファイルのメニューが正常に動作しない問題を修正 + +## 13.10.1 + +### Client +- Misskey PlayのPlayボタンを押した時にエラーが発生する問題を修正 + +## 13.10.0 + +### General +- ユーザーごとにRenoteをミュートできるように +- ノートごとに絵文字リアクションを受け取るか設定できるように +- クリップをお気に入りに登録できるように +- ノート検索の利用可否をロールで制御可能に(デフォルトでオフ) +- ロールの並び順を設定可能に +- カスタム絵文字にライセンス情報を付与できるように +- 指定した文字列を含む投稿の公開範囲をホームにできるように +- 使われてないアンテナは自動停止されるように + +### Client +- 設定から自分のロールを確認できるように +- 広告一覧ページを追加 +- ドライブクリーナーを追加 +- DM作成時にメンションも含むように +- フォロー申請のボタンのデザインを改善 +- 付箋ウィジェットの高さを設定可能に +- APオブジェクトを入力してフェッチする機能とユーザーやノートの検索機能を分離 +- ナビゲーションバーの項目に「プロフィール」を追加できるように +- ナビゲーションバーのカスタマイズをドラッグ&ドロップで行えるように +- ジョブキューの再試行をワンクリックでできるように +- AiScriptを0.13.1に更新 +- oEmbedをサポートしているウェブサイトのプレビューができるように + - YouTubeをoEmbedでロードし、プレビューで共有ボタンを押すとOSの共有画面がでるように + - ([FirefoxでSpotifyのプレビューを開けるとフルサイズじゃなくプレビューサイズだけ再生できる問題](https://bugzilla.mozilla.org/show_bug.cgi?id=1792395)があります) + - (すでにブラウザーでキャッシュされたリンクに対しては以前のプレビュー行動が行われてます。その場合、ブラウザーのキャッシュをクリアしてまた試してください。) +- プロフィールで設定した情報が削除できない問題を修正 +- ロールで広告を無効にするとadmin/adsでプレビューがでてこない問題を修正 +- /api-consoleページにアクセスすると404が出る問題を修正 +- Safariでプラグインが複数ある場合に正常に読み込まれない問題を修正 +- Bookwyrmのユーザーのプロフィールページで「リモートで表示」をタップしても反応がない問題を修正 +- 非ログイン時の「Misskeyについて」の表示を修正 +- PC版にて「設定」「コントロールパネル」のリンクを2度以上続けてクリックした際に空白のページが表示される問題を修正 + +### Server +- OpenAPIエンドポイントを復旧 +- WebP/AVIF/JPEGのweb公開用画像は、サーバーサイドではJPEGではなくWebPに変換するように +- アニメーション画像のサムネイルを生成するように +- アクティブユーザー数チャートの記録上限値を拡張 +- Playのソースコード上限文字数を2倍に拡張 +- 配送先サーバーが410 Goneで応答してきた場合は自動で配送停止をするように +- avatarBlurHash/bannerBlurHashの型をstringに限定 +- タイムライン取得時のパフォーマンスを改善 +- SMTP Login id length is too short +- API上で`visibility`を`followers`に設定してrenoteすると連合や削除で不具合が発生する問題を修正 +- AWS S3からのファイル削除でNoSuchKeyエラーが出ると進めらない状態になる問題を修正 +- `disableCache: true`を設定している場合に絵文字管理操作でエラーが出る問題を修正 +- リテンション分析が上手く機能しないことがあるのを修正 +- 空のアンテナが作成できないように修正 +- 特定の条件で通報が見れない問題を修正 +- 絵文字の名前に任意の文字が使用できる問題を修正 + +## 13.9.2 (2023/03/06) + +### Improvements +- クリップ、チャンネルページに共有ボタンを追加 +- チャンネルでタイムライン上部に投稿フォームを表示するかどうかのオプションを追加 +- ブラウザでメディアプロキシ(/proxy)からファイルを保存した際に、なるべくオリジナルのファイル名を継承するように +- ドライブの「URLからアップロード」で、content-dispositionのfilenameがあればそれをファイル名に +- Identiconがローカルとリモートで同じになるように + - これまでのIdenticonは異なる画像になります +- サーバーのパフォーマンスを改善 + +### Bugfixes +- ロールの権限で「一般ユーザー」のロールがいきなり設定できない問題を修正 +- ユーザーページのバッジ表示を適切に折り返すように @arrow2nd +- fix(client): みつけるのロール一覧でコンディショナルロールが含まれるのを修正 +- macOSでDev Containerが動作しない問題を修正 @RyotaK + +## 13.9.1 (2023/03/03) + +### Bugfixes +- ノートに添付したファイルが表示されない場合があるのを修正 + +## 13.9.0 (2023/03/03) + +### Improvements +- 時限ロール +- アンテナでCWも検索対象にするように +- ノートの操作部をホバー時のみ表示するオプションを追加 +- サウンドを追加 +- サーバーのパフォーマンスを改善 + +### Bugfixes +- 外部メディアプロキシ使用時にアバタークロップができない問題を修正 +- fix(server): メールアドレス更新時にバリデーションが正しく行われていないのを修正 +- fix(server): チャンネルでミュートが正しく機能していないのを修正 +- プッシュ通知でカスタム絵文字リアクションを表示できなかった問題を修正 + +## 13.8.1 (2023/02/26) + +### Bugfixes +- モバイルでドロワーメニューが表示されない問題を修正 + +## 13.8.0 (2023/02/26) + +### Improvements +- チャンネル内ハイライト +- ホームタイムラインのパフォーマンスを改善 +- renoteした際の表示を改善 +- バックグラウンドで一定時間経過したらページネーションのアイテム更新をしない +- enhance(client): MkUrlPreviewの閉じるボタンを見やすく +- Add dialog to remove follower +- enhance(client): improve clip menu ux +- 検索画面の統合 +- enhance(client): ノートメニューからユーザーメニューを開けるように +- photoswipe 表示時に戻る操作をしても前の画面に戻らないように + +### Bugfixes +- Windows環境でswcを使うと正しくビルドできない問題の修正 +- fix(client): Android ChromeでPWAとしてインストールできない問題を修正 +- 未知のユーザーが deleteActor されたら処理をスキップする +- fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする +- fix(server): notes/createのバリデーションが機能していないのを修正 +- fix(server): エラーのスタックトレースは返さないように + +## 13.7.5 (2023/02/24) + +### Note +13.7.0以前から直接このバージョンにアップデートする場合は全ての通知が削除**されません。** + +### Improvements +- 紛らわしいため公開範囲の「ローカルのみ」オプションの名称を「連合なし」に変更 +- Frontend: スマホ・タブレットの場合、チャンネルの投稿フォームに自動でフォーカスしないように + +### Bugfixes +- 全ての通知が削除されてしまうのを修正 + +## 13.7.3 (2023/02/23) + +### Note +~~13.7.0以前から直接このバージョンにアップデートする場合は全ての通知が削除**されません。**~~ + ### Improvements ### Bugfixes -- +- Client: 「キャッシュを削除」した後、ローカルのカスタム絵文字が表示されなくなるされなくなる問題を修正 +- Client: 通知設定画面で以前からグループの招待を有効化していた場合、通知の表示に失敗する問題の修正 +- Client: 通知設定画面に古いトグルが残っていた問題を修正 -You should also include the user name that made the change. ---> -## 13.x.x (unreleased) +## 13.7.2 (2023/02/23) + +### Note +13.7.0以前からアップデートする場合は全ての通知が削除されます。 + +### Improvements +- enhance: make pwa icon maskable +- chore(client): tweak custom emoji size + +### Bugfixes +- マイグレーションが失敗することがあるのを修正 + +## 13.7.1 (2023/02/23) + +### Improvements +- pnpm buildではswcを使うように + +### Bugfixes +- NODE_ENV=productionでビルドできないのを修正 + +## 13.7.0 (2023/02/22) + +### Changes +- チャット機能が削除されました ### Improvements - Server: URLプレビュー(summaly)はプロキシを通すように +- Client: 2FA設定のUIをまともにした +- セキュリティキーの名前を変更できるように +- enhance(client): add quiz preset for play +- 広告開始時期を設定できるように +- みつけるで公開ロール一覧とそのメンバーを閲覧できるように +- enhance(client): MFMのx3, x4が含まれていたらノートをたたむように +- enhance(client): make possible to reload page of window ### Bugfixes -- +- ユーザー検索ダイアログでローカルユーザーを絞って検索できない問題を修正 +- fix(client): MkHeader及びデッキのカラムでチャンネル一覧を選択したとき、最大5個までしか表示されない +- 管理画面の広告を10個以上見えるように +- Moderation note が保存できない +- ユーザーのハッシュタグ検索が機能していないのを修正 ## 13.6.1 (2023/02/12) @@ -122,8 +517,8 @@ You should also include the user name that made the change. ## 13.3.2 (2023/02/04) ### Improvements -- 外部メディアプロキシへの対応を強化しました - 外部メディアプロキシのFastify実装を作りました +- 外部メディアプロキシへの対応を強化しました + 外部メディアプロキシのFastify実装を作りました https://github.com/misskey-dev/media-proxy - Server: improve performance @@ -286,7 +681,7 @@ You should also include the user name that made the change. - ユーザーごとのドライブ容量設定はロールに統合されました。 - インスタンスデフォルトのドライブ容量設定はロールに統合されました。アップデート後、ベースロールもしくはコンディショナルロールでドライブ容量を編集してください。 - LTL/GTLの解放状態はロールに統合されました。 -- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。 +- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。 https://github.com/misskey-dev/misskey/pull/9560 #### For users @@ -514,7 +909,7 @@ You should also include the user name that made the change. ## 12.112.2 (2022/07/08) ### Bugfixes -- Fix Docker doesn't work @mei23 +- Fix Docker doesn't work @mei23 Still not working on arm64 environment. (See 12.112.0) ## 12.112.1 (2022/07/07) @@ -556,7 +951,7 @@ same as 12.112.0 - Improve player detection in URL preview @mei23 - Add Badge Image to Push Notification #8012 @tamaina - Server: Improve performance -- Server: Supports IPv6 on Redis transport. @mei23 +- Server: Supports IPv6 on Redis transport. @mei23 IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`. - Server: Add possibility to log IP addresses of users @syuilo - Add additional drive capacity change support @CyberRex0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48d8a40dea..f6b3804f84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Before creating an issue, please check the following: - To avoid duplication, please search for similar issues before creating a new issue. - Do not use Issues to ask questions or troubleshooting. - Issues should only be used to feature requests, suggestions, and bug tracking. - - Please ask questions or troubleshooting in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3). + - Please ask questions or troubleshooting in ~~the [Misskey Forum](https://forum.misskey.io/)~~ [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3). > **Warning** > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. @@ -83,11 +83,18 @@ An actual domain will be assigned so you can test the federation. - The title must be in the format `Release: x.y.z`. - `x.y.z` is the new version you are trying to release. 3. Deploy and perform a simple QA check. Also verify that the tests passed. -4. Merge it. +4. Merge it. (Do not squash commit) 5. Create a [release of GitHub](https://github.com/misskey-dev/misskey/releases) - The target branch must be `master` - The tag name must be the version +> **Note** +> Why this instruction is necessary: +> - To perform final QA checks +> - To distribute responsibility +> - To check direct commits to develop +> - To celebrate the release together 🎉 + ## Localization (l10n) Misskey uses [Crowdin](https://crowdin.com/project/misskey) for localization management. You can improve our translations with your Crowdin account. @@ -158,6 +165,11 @@ pnpm jest -- foo.ts ### e2e tests TODO +## Environment Variable + +- `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`). +- `MISSKEY_WEBFINGER_USE_HTTP`: If it's set true, WebFinger requests will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. + ## Continuous integration Misskey uses GitHub Actions for executing automated tests. Configuration files are located in [`/.github/workflows`](/.github/workflows). @@ -196,6 +208,115 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。 これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。 +## Storybook + +Misskey uses [Storybook](https://storybook.js.org/) for UI development. + +### Setup & Run + +#### Universal + +##### Setup + +```bash +pnpm --filter misskey-js build +pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js) +``` + +##### Run + +```bash +node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev +``` + +#### macOS & Linux + +##### Setup + +```bash +pnpm --filter misskey-js build +``` + +##### Run + +```bash +pnpm --filter frontend storybook-dev +``` + +### Usage + +When you create a new component (in this example, `MyComponent.vue`), the story file (`MyComponent.stories.ts`) will be automatically generated by the `.storybook/generate.js` script. +You can override the default story by creating a impl story file (`MyComponent.stories.impl.ts`). + +```ts +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MyComponent from './MyComponent.vue'; +export const Default = { + render(args) { + return { + components: { + MyComponent, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + foo: 'bar', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; +``` + +If you want to opt-out from the automatic generation, create a `MyComponent.stories.impl.ts` file and add the following line to the file. + +```ts +import MyComponent from './MyComponent.vue'; +void MyComponent; +``` + +You can override the component meta by creating a meta story file (`MyComponent.stories.meta.ts`). + +```ts +export const argTypes = { + scale: { + control: { + type: 'range', + min: 1, + max: 4, + }, +}; +``` + +Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers. + +```ts +import { rest } from 'msw'; +export const handlers = [ + rest.post('/api/notes/timeline', (req, res, ctx) => { + return res( + ctx.json([]), + ); + }), +]; +``` + +Don't forget to re-run the `.storybook/generate.js` script after adding, editing, or removing the above files. + ## Notes ### How to resolve conflictions occurred at pnpm-lock.yaml? @@ -278,9 +399,10 @@ SQLでは配列のインデックスは**1始まり**。 ### null IN nullが含まれる可能性のあるカラムにINするときは、そのままだとおかしくなるのでORなどでnullのハンドリングをしよう。 -### `undefined`にご用心 -MongoDBの時とは違い、findOneでレコードを取得する時に対象レコードが存在しない場合 **`undefined`** が返ってくるので注意。 -MongoDBは`null`で返してきてたので、その感覚で`if (x === null)`とか書くとバグる。代わりに`if (x == null)`と書いてください +### enumの削除は気をつける +enumの列挙の内容の削除は、その値をもつレコードを全て削除しないといけない + +削除が重たかったり不可能だったりする場合は、削除しないでおく ### Migration作成方法 packages/backendで: @@ -291,6 +413,27 @@ pnpm dlx typeorm migration:generate -d ormconfig.js -o - 生成後、ファイルをmigration下に移してください - 作成されたスクリプトは不必要な変更を含むため除去してください +### JSON SchemaのobjectでanyOfを使うとき +JSON Schemaで、objectに対してanyOfを使う場合、anyOfの中でpropertiesを定義しないこと。 +バリデーションが効かないため。(SchemaTypeもそのように作られており、objectのanyOf内のpropertiesは捨てられます) +https://github.com/misskey-dev/misskey/pull/10082 + +テキストhogeおよびfugaについて、片方を必須としつつ両方の指定もありうる場合: + +``` +export const paramDef = { + type: 'object', + properties: { + hoge: { type: 'string', minLength: 1 }, + fuga: { type: 'string', minLength: 1 }, + }, + anyOf: [ + { required: ['hoge'] }, + { required: ['fuga'] }, + ], +} as const; +``` + ### コネクションには`markRaw`せよ **Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。 diff --git a/Dockerfile b/Dockerfile index b439716bea..fb389659bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,10 @@ # syntax = docker/dockerfile:1.4 -ARG NODE_VERSION=18.13.0-bullseye +ARG NODE_VERSION=18.16.0-bullseye -FROM node:${NODE_VERSION} AS builder +# build assets & compile TypeScript + +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS native-builder RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ @@ -21,6 +23,7 @@ COPY --link ["scripts", "./scripts"] COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/frontend/package.json", "./packages/frontend/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] +COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output @@ -33,33 +36,49 @@ RUN git submodule update --init RUN pnpm build RUN rm -rf .git/ -FROM node:${NODE_VERSION}-slim AS runner +# build native dependencies for target platform + +FROM --platform=$TARGETPLATFORM node:${NODE_VERSION} AS target-builder + +RUN apt-get update \ + && apt-get install -yqq --no-install-recommends \ + build-essential + +RUN corepack enable + +WORKDIR /misskey + +COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] +COPY --link ["scripts", "./scripts"] +COPY --link ["packages/backend/package.json", "./packages/backend/"] + +RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm i --frozen-lockfile --aggregate-output + +FROM --platform=$TARGETPLATFORM node:${NODE_VERSION}-slim AS runner ARG UID="991" ARG GID="991" -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - rm -f /etc/apt/apt.conf.d/docker-clean \ - ; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \ - && apt-get update \ +RUN apt-get update \ && apt-get install -y --no-install-recommends \ ffmpeg tini curl \ && corepack enable \ && groupadd -g "${GID}" misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ - && find / -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ - && find / -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; + && find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ + && find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists USER misskey WORKDIR /misskey -COPY --chown=misskey:misskey --from=builder /misskey/node_modules ./node_modules -COPY --chown=misskey:misskey --from=builder /misskey/built ./built -COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/node_modules ./packages/backend/node_modules -COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/built ./packages/backend/built -COPY --chown=misskey:misskey --from=builder /misskey/packages/frontend/node_modules ./packages/frontend/node_modules -COPY --chown=misskey:misskey --from=builder /misskey/fluent-emojis /misskey/fluent-emojis +COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules +COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules +COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built +COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ ENV NODE_ENV=production diff --git a/README.md b/README.md index c12882ca32..2aae4bb865 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,25 @@ With Misskey's built in drive, you get cloud storage right in your social media, Misskey Documentation can be found at [Misskey Hub](https://misskey-hub.net/), some of the links and graphics above also lead to specific portions of it. ## Sponsors +
RSS3
+ +## Thanks + +Chromatic + +Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions. + +Codecov + +Thanks to [Codecov](https://about.codecov.io/for/open-source/) for providing the code coverage platform that helps us improve our test coverage. + +Crowdin + +Thanks to [Crowdin](https://crowdin.com/) for providing the localization platform that helps us translate Misskey into many languages. + +Docker + +Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production. diff --git a/ROADMAP.md b/ROADMAP.md index c95bb8d92b..420f728758 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,9 @@ Also, the later tasks are more indefinite and are subject to change as developme ## (1) Improve maintainability \ This is the phase we are at now. We need to make a high-maintenance environment that can withstand future development. -- Make the number of type errors zero (backend) +- ~~Make the number of type errors zero (backend)~~ → Done ✔️ - Improve CI - - Fix tests + - ~~Fix tests~~ → Done ✔️ - Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986 - Add more tests - ~~May need to implement a mechanism that allows for DI~~ → Done ✔️ diff --git a/chart/files/default.yml b/chart/files/default.yml index 398ac69c60..b1f523da4f 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -72,25 +72,58 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── redis: host: localhost port: 6379 + #family: 0 # 0=Both, 4=IPv4, 6=IPv6 #pass: example-pass #prefix: example-prefix #db: 1 -# ┌─────────────────────────────┐ -#───┘ Elasticsearch configuration └───────────────────────────── - -#elasticsearch: +#redisForPubsub: # host: localhost -# port: 9200 -# ssl: false -# user: -# pass: +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForJobQueue: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── + +#meilisearch: +# host: localhost +# port: 7700 +# apiKey: '' +# ssl: true # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/chart/templates/Deployment.yml b/chart/templates/Deployment.yml index 3d4a9bd301..ab2d2a1a87 100644 --- a/chart/templates/Deployment.yml +++ b/chart/templates/Deployment.yml @@ -3,16 +3,16 @@ kind: Deployment metadata: name: {{ include "misskey.fullname" . }} labels: - {{- include "misskey.labels" . | nindent 4 }} + {{- include "misskey.labels" . | nindent 4 }} spec: selector: matchLabels: - {{- include "misskey.selectorLabels" . | nindent 6 }} + {{- include "misskey.selectorLabels" . | nindent 6 }} replicas: 1 template: metadata: labels: - {{- include "misskey.selectorLabels" . | nindent 8 }} + {{- include "misskey.selectorLabels" . | nindent 8 }} spec: containers: - name: cherrypick diff --git a/chart/templates/Service.yml b/chart/templates/Service.yml index 3209581298..afd851a9f1 100644 --- a/chart/templates/Service.yml +++ b/chart/templates/Service.yml @@ -11,4 +11,4 @@ spec: protocol: TCP name: http selector: - {{- include "misskey.selectorLabels" . | nindent 4 }} + {{- include "misskey.selectorLabels" . | nindent 4 }} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..410f064be2 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,4 @@ +coverage: + status: + project: false + patch: false diff --git a/crowdin.yml b/crowdin.yml index 774ddc7a63..42cd197282 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,4 +1,4 @@ files: - - source: /locales/ja-JP.yml + - source: /locales/ko-KR.yml translation: /locales/%locale%.yml update_option: update_as_unapproved diff --git a/cypress/e2e/basic.cy.js b/cypress/e2e/basic.cy.js index eb5195c4b2..73f6e7a0f0 100644 --- a/cypress/e2e/basic.cy.js +++ b/cypress/e2e/basic.cy.js @@ -10,14 +10,14 @@ describe('Before setup instance', () => { }); it('successfully loads', () => { - cy.visit('/'); + cy.visitHome(); }); it('setup instance', () => { - cy.visit('/'); + cy.visitHome(); cy.intercept('POST', '/api/admin/accounts/create').as('signup'); - + 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(); @@ -43,22 +43,49 @@ describe('After setup instance', () => { }); it('successfully loads', () => { - cy.visit('/'); + cy.visitHome(); }); it('signup', () => { - cy.visit('/'); + cy.visitHome(); cy.intercept('POST', '/api/signup').as('signup'); cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); + cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); + cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); + cy.get('[data-cy-signup-rules-continue]').click(); + + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-username] input').type('alice'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-password] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-password-retype] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('not.be.disabled'); cy.get('[data-cy-signup-submit]').click(); cy.wait('@signup'); }); + + it('signup with duplicated username', () => { + cy.registerUser('alice', 'alice1234'); + + cy.visitHome(); + + // ユーザー名が重複している場合の挙動確認 + cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); + cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); + cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); + cy.get('[data-cy-signup-rules-continue]').click(); + + cy.get('[data-cy-signup-username] input').type('alice'); + cy.get('[data-cy-signup-password] input').type('alice1234'); + cy.get('[data-cy-signup-password-retype] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); + }); }); describe('After user signup', () => { @@ -79,11 +106,11 @@ describe('After user signup', () => { }); it('successfully loads', () => { - cy.visit('/'); + cy.visitHome(); }); it('signin', () => { - cy.visit('/'); + cy.visitHome(); cy.intercept('POST', '/api/signin').as('signin'); @@ -101,7 +128,7 @@ describe('After user signup', () => { userId: this.alice.id, }); - cy.visit('/'); + cy.visitHome(); cy.get('[data-cy-signin]').click(); cy.get('[data-cy-signin-username] input').type('alice'); @@ -112,7 +139,7 @@ describe('After user signup', () => { }); }); -describe('After user singed in', () => { +describe('After user signed in', () => { beforeEach(() => { cy.resetState(); @@ -132,15 +159,73 @@ describe('After user singed in', () => { }); it('successfully loads', () => { - cy.get('[data-cy-open-post-form]').should('be.visible'); + cy.get('[data-cy-user-setup-continue]').should('be.visible'); }); + it('account setup wizard', () => { + cy.get('[data-cy-user-setup-continue]').click(); + + cy.get('[data-cy-user-setup-user-name] input').type('ありす'); + cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ'); + // TODO: アイコン設定テスト + + cy.get('[data-cy-user-setup-continue]').click(); + + // フォローはスキップ + + cy.get('[data-cy-user-setup-continue]').click(); + + // プッシュ通知設定はスキップ + + cy.get('[data-cy-user-setup-continue]').click(); + + cy.get('[data-cy-user-setup-continue]').click(); + }); +}); + +describe('After user setup', () => { + beforeEach(() => { + cy.resetState(); + + // インスタンス初期セットアップ + cy.registerUser('admin', 'pass', true); + + // ユーザー作成 + cy.registerUser('alice', 'alice1234'); + + cy.login('alice', 'alice1234'); + + // アカウント初期設定ウィザード + cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click(); + cy.get('[data-cy-modal-dialog-ok]').click(); + }); + + afterEach(() => { + // テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。 + // waitを入れることでそれを防止できる + cy.wait(1000); + }); + it('note', () => { + cy.get('[data-cy-open-post-form]').should('be.visible'); cy.get('[data-cy-open-post-form]').click(); cy.get('[data-cy-post-form-text]').type('Hello, Misskey!'); cy.get('[data-cy-open-post-form-submit]').click(); cy.contains('Hello, Misskey!'); + }); + + it('open note form with hotkey', () => { + // Wait until the page loads + cy.get('[data-cy-open-post-form]').should('be.visible'); + // Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards. + cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" }); + // See if the form is opened + cy.get('[data-cy-post-form-text]').should('be.visible'); + // Close it + cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" }); + // See if the form is closed + cy.get('[data-cy-post-form-text]').should('not.be.visible'); }); }); diff --git a/cypress/e2e/widgets.cy.js b/cypress/e2e/widgets.cy.js index 7d2039ff91..f5a982eb0a 100644 --- a/cypress/e2e/widgets.cy.js +++ b/cypress/e2e/widgets.cy.js @@ -10,6 +10,10 @@ describe('After user signed in', () => { cy.registerUser('alice', 'alice1234'); cy.login('alice', 'alice1234'); + + // アカウント初期設定ウィザード + cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click(); + cy.get('[data-cy-modal-dialog-ok]').click(); }); afterEach(() => { @@ -19,27 +23,27 @@ describe('After user signed in', () => { }); it('widget edit toggle is visible', () => { - cy.get('.mk-widget-edit').should('be.visible'); + cy.get('[data-cy-widget-edit]').should('be.visible'); }); it('widget select should be visible in edit mode', () => { - cy.get('.mk-widget-edit').click(); - cy.get('.mk-widget-select').should('be.visible'); + cy.get('[data-cy-widget-edit]').click(); + cy.get('[data-cy-widget-select]').should('be.visible'); }); it('first widget should be removed', () => { - cy.get('.mk-widget-edit').click(); - cy.get('.data-cy-customize-container:first-child .data-cy-customize-container-remove._button').click(); - cy.get('.data-cy-customize-container').should('have.length', 2); + cy.get('[data-cy-widget-edit]').click(); + cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click(); + cy.get('[data-cy-customize-container]').should('have.length', 2); }); function buildWidgetTest(widgetName) { it(`${widgetName} widget should get added`, () => { - cy.get('.mk-widget-edit').click(); - cy.get('.mk-widget-select select').select(widgetName, { force: true }); - cy.get('.data-cy-bg._modalBg.data-cy-transparent').click({ multiple: true, force: true }); - cy.get('.mk-widget-add').click({ force: true }); - cy.get(`.data-cy-mkw-${widgetName}`).should('exist'); + cy.get('[data-cy-widget-edit]').click(); + cy.get('[data-cy-widget-select] select').select(widgetName, { force: true }); + cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true }); + cy.get('[data-cy-widget-add]').click({ force: true }); + cy.get(`[data-cy-mkw-${widgetName}]`).should('exist'); }); } diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 04a6d98b01..91a4d7abe6 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -24,6 +24,11 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +Cypress.Commands.add('visitHome', () => { + cy.visit('/'); + cy.get('button', { timeout: 30000 }).should('be.visible'); +}) + Cypress.Commands.add('resetState', () => { cy.window(win => { win.indexedDB.deleteDatabase('keyval-store'); @@ -43,7 +48,7 @@ Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => { }); Cypress.Commands.add('login', (username, password) => { - cy.visit('/'); + cy.visitHome(); cy.intercept('POST', '/api/signin').as('signin'); diff --git a/docker-compose.yml.example b/docker-compose.yml.example index d3c49e8dbc..d5fd29bd69 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -7,7 +7,7 @@ services: links: - db - redis -# - es +# - meilisearch depends_on: db: condition: service_healthy @@ -48,16 +48,18 @@ services: interval: 5s retries: 20 -# es: +# meilisearch: # restart: always -# image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.2 +# image: getmeili/meilisearch:v1.1.1 # environment: -# - "ES_JAVA_OPTS=-Xms512m -Xmx512m" -# - "TAKE_FILE_OWNERSHIP=111" +# - MEILI_NO_ANALYTICS=true +# - MEILI_ENV=production +# env_file: +# - .config/meilisearch.env # networks: # - internal_network # volumes: -# - ./elasticsearch:/usr/share/elasticsearch/data +# - ./meili_data:/meili_data networks: internal_network: diff --git a/gulpfile.js b/gulpfile.js index a04ab4c1ad..6507aad60e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,7 +45,7 @@ gulp.task('build:backend:script', () => { }); gulp.task('build:backend:style', () => { - return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css']) + return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css']) .pipe(cssnano({ zindex: false })) diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 998d11e98f..c04a9bedd2 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -19,6 +19,7 @@ noNotes: "لم يُعثر على أية ملاحظات" noNotifications: "ليس هناك أية اشعارات" instance: "مثيل الخادم" settings: "الاعدادات" +notificationSettings: "إعدادات الإشعارات" basicSettings: "الاعدادات الأساسية" otherSettings: "إعدادات أخرى" openInWindow: "افتح في نافذة جديدة" @@ -127,6 +128,7 @@ unblockConfirm: "أمتأكد من إلغاء حجب هذا الحساب؟" suspendConfirm: "أمتأكد من تعليق الحساب؟" unsuspendConfirm: "أمتأكد من إلغاء تعليق؟" selectList: "اختر قائمة" +selectChannel: "اختر قناة" selectAntenna: "اختر هوائيًا" selectWidget: "اختر ودجة" editWidgets: "عدّل الودجات" @@ -250,12 +252,15 @@ noMoreHistory: "لا يوجد المزيد من التاريخ" startMessaging: "ابدأ محادثة" nUsersRead: "قرأه {n}" agreeTo: "اوافق على {0}" -tos: "شروط الخدمة" +agree: "أقبل" +basicNotesBeforeCreateAccount: "ملاحظات مهمة" +termsOfService: "شروط الخدمة" start: "البداية" home: "الرئيسي" remoteUserCaution: "هذه المعلومات قد لا تكون مكتملة بما أن المستخدم من مثيل بعيد." activity: "النشاط" images: "الصور" +image: "الصور" birthday: "تاريخ الميلاد" yearsOld: "{age} سنة" registeredDate: "انضم في" @@ -379,13 +384,12 @@ about: "عن" aboutMisskey: "عن CherryPick" administrator: "المدير" token: "الرمز المميز" -twoStepAuthentication: "الإستيثاق بعاملَيْن" +2fa: "الاستيثاق بعاملَيْن" +totp: "تطبيق استيثاق" moderator: "مشرِف" moderation: "الإشراف" nUsersMentioned: "{n} مستخدمين أُشير إليهم" securityKey: "مفتاح الأمان" -securityKeyName: "اسم المفتاح" -registerSecurityKey: "سجل مفتاح أمان" lastUsed: "آخر استخدام" unregister: "إلغاء التسجيل" passwordLessLogin: "لِج مِن دون كلمة سرية" @@ -442,7 +446,6 @@ passwordMatched: "التطابق صحيح!" passwordNotMatched: "غير متطابقتان" signinWith: "الولوج عبر {x}" signinFailed: "فشل الولوج، خطأ في اسم المستخدم أو كلمة المرور." -tapSecurityKey: "أنقر مفتاح الأمان" or: "أو" language: "اللغة" uiLanguage: "لغة واجهة المستخدم" @@ -522,6 +525,7 @@ userSuspended: "عُلق هذا المستخدم." userSilenced: "كُتم هذا المستخدم." yourAccountSuspendedTitle: "هذا الحساب معلق" yourAccountSuspendedDescription: "عُلق الحساب بسبب انتهاك شروط خدمة المثيل و ما شابه. إذا أردت معرفة التفصيل تواصل مع مدير المثيل. رجاءً لا تنشئ حساب جديد." +accountDeleted: "حُذف الحساب" menu: "القائمة" divider: "فاصل" addItem: "إضافة عنصر" @@ -561,7 +565,6 @@ tokenRequested: "منح حق الوصول إلى الحساب" pluginTokenRequestedDescription: "ستتمكن الإضافة من استخدام هذه الأذونات." notificationType: "أنواع الإشعارات" edit: "التعديل" -useStarForReactionFallback: "استخدم ★ كبديل إذا كان التفاعل مجهولًا" emailServer: "خادم البريد الإلكتروني" emailConfigInfo: "يستخدم لتأكيد عنوان بريدك الإلكتروني ولإعادة تعيين كلمة المرور إن نسيتها." email: "البريد الإلكتروني " @@ -803,6 +806,7 @@ size: "الحجم" numberOfColumn: "عدد الأعمدة" searchByGoogle: "غوغل" mutePeriod: "مدة الكتم" +period: "ينتهي استطلاع الرأي في" indefinitely: "أبدًا" tenMinutes: "10 دقائق" oneHour: "ساعة" @@ -819,6 +823,8 @@ cannotLoad: "تعذر التحميل" like: "أعجبني" show: "المظهر" color: "اللون" +horizontal: "جانبي" +youFollowing: "متابَع" _role: priority: "الأولوية" _priority: @@ -989,6 +995,7 @@ _ago: weeksAgo: "منذ {n} أسابيع" monthsAgo: "منذ {n} أشهر" yearsAgo: "منذ {n} سنوات" + invalid: "لا يوجد شيء هنا" _time: second: "ثا" minute: "د" @@ -1019,12 +1026,11 @@ _tutorial: step7_3: "حظًا سعيدًا واستمتع بوقتك مع ميسكي! 🚀" _2fa: alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين." - registerDevice: "سجّل جهازًا جديدًا" - registerKey: "تسجيل مفتاح أمان جديد" step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})." step2: "امسح رمز الاستجابة السريعة الموجد على الشاشة." step3: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت." step4: "من هذه اللحظة أثناء ولوجك سيُطلب منك الرمز." + renewTOTPCancel: "ليس اﻵن" _permissions: "read:account": "اعرض معلومات حسابك" "write:account": "تعديل معلومات حسابك" @@ -1133,8 +1139,6 @@ _visibility: followersDescription: "اجعلها مرئية لمتابِعيك فقط" specified: "مباشرة" specifiedDescription: "اجعلها مرئية لمستخدمين محددين" - localOnly: "المحلي فقط" - localOnlyDescription: "ليس مرئيًا للمستخدمين البِعاد" _postForm: replyPlaceholder: "رد على هذه الملاحظة…" quotePlaceholder: "اقتبس هذه الملاحظة…" @@ -1298,3 +1302,6 @@ _deck: channel: "القنوات" mentions: "الإشارات" direct: "مباشرة" +_webhookSettings: + name: "الإسم" + active: "مفعّل" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 75ebf3a634..04ab3d889a 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -252,12 +252,12 @@ noMoreHistory: "আর কোন ইতিহাস নেই" startMessaging: "চ্যাট শুরু করুন" nUsersRead: "{n} জন পড়েছেন" agreeTo: "{0} এর প্রতি আমি সম্মত" -tos: "পরিষেবার শর্তাদি" start: "শুরু করুন" home: "মূল পাতা" remoteUserCaution: "এই ব্যাবহারকারী রিমোট ইন্সট্যান্সের, নিম্নক্ত তথ্য অসম্পূর্ণ হতে পারে।" activity: "কার্যকলাপ" images: "ছবি" +image: "ছবি" birthday: "জন্মদিন" yearsOld: "{age} বছর" registeredDate: "যোগদানের তারিখ" @@ -382,12 +382,9 @@ about: "আপনার সম্পর্কে" aboutMisskey: "CherryPick সম্পর্কে" administrator: "প্রশাসক" token: "টোকেন" -twoStepAuthentication: "২-ধাপ প্রমাণীকরণ" moderator: "মডারেটর" nUsersMentioned: "{n} জনকে উল্লেখ করা হয়েছে" securityKey: "সিকিউরিটি কী" -securityKeyName: "কী'র নাম" -registerSecurityKey: "সিকিউরিটি কী নিবন্ধন করুন" lastUsed: "শেষ ব্যাবহার করা হয়েছে" unregister: "নিবন্ধনমুক্ত হন" passwordLessLogin: "পাসওয়ার্ড-বিহীন লগইন সেট আপ করুন" @@ -444,7 +441,6 @@ passwordMatched: "মিলেছে" passwordNotMatched: "মিলেনি" signinWith: "{x} এর সাহায্যে সাইন ইন করুন" signinFailed: "লগ ইন করা যায়নি। আপনার ব্যবহারকারীর নাম এবং পাসওয়ার্ড চেক করুন." -tapSecurityKey: "সিকিউরিটি কী স্পর্শ করুন" or: "অথবা" language: "ভাষা" uiLanguage: "UI এর ভাষা" @@ -578,7 +574,6 @@ tokenRequested: "অ্যাকাউন্টে অ্যাক্সেস pluginTokenRequestedDescription: "এই প্লাগইনটি এখানে দেওয়া অনুমুতিসমূহ ব্যাবহার করবে" notificationType: "বিজ্ঞপ্তির ধরন" edit: "সম্পাদনা" -useStarForReactionFallback: "রিঅ্যাকশনের ইমোজি না জানলে ★ ব্যবহার করুন" emailServer: "ইমেইল সার্ভার" enableEmail: "ইমেইল বিতরণ চালু করুন" emailConfigInfo: "আপনার ইমেল ঠিকানা নিশ্চিত করতে এবং আপনার পাসওয়ার্ড পুনরায় সেট করতে ব্যবহৃত হয়" @@ -837,6 +832,7 @@ instanceDefaultLightTheme: "ইন্সট্যান্সের ডিফল instanceDefaultDarkTheme: "ইন্সট্যান্সের ডিফল্ট ডার্ক থিম" instanceDefaultThemeDescription: "অবজেক্ট ফরম্যাটে থিম কোড লিখুন" mutePeriod: "মিউটের সময়কাল" +period: "পোলের সময়সীমা" indefinitely: "অনির্দিষ্ট" tenMinutes: "১০ মিনিট" oneHour: "১ ঘণ্টা" @@ -854,6 +850,8 @@ account: "অ্যাকাউন্টগুলি" like: "পছন্দ করা" show: "প্রদর্শন" color: "রং" +horizontal: "পাশে" +youFollowing: "অনুসরণ করা হচ্ছে" _role: priority: "অগ্রাধিকার" _priority: @@ -1115,6 +1113,7 @@ _ago: weeksAgo: "{n} সপ্তাহ আগে" monthsAgo: "{n} মাস আগে" yearsAgo: "{n} বছর আগে" + invalid: "এখানে কিছুই নাই" _time: second: "সেকেন্ড" minute: "মিনিট" @@ -1145,8 +1144,6 @@ _tutorial: step7_3: "এখন CherryPick উপভোগ করুন 🚀" _2fa: alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷" - registerDevice: "নতুন ডিভাইস নিবন্ধন করুন" - registerKey: "সিকিউরিটি কী নিবন্ধন করুন" step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷" step2: "এরপরে, অ্যাপের সাহায্যে প্রদর্শিত QR কোডটি স্ক্যান করুন।" step2Url: "ডেস্কটপ অ্যাপে, নিম্নলিখিত URL লিখুন:" @@ -1267,8 +1264,6 @@ _visibility: followersDescription: "শুধুমাত্র আপনার অনুসরণকারীদের নিকট পোস্ট করুন" specified: "ডাইরেক্ট নোট" specifiedDescription: "শুধুমাত্র নির্দিষ্ট ব্যাবহারকারীর নিকট পাঠান" - localOnly: "শুধুমাত্র লোকাল" - localOnlyDescription: "রিমোট ব্যাবহারকারীদের নিকট দৃশ্যমান নয়" _postForm: replyPlaceholder: "নোটটির জবাব দিন..." quotePlaceholder: "নোটটিকে উদ্ধৃত করুন..." @@ -1443,3 +1438,6 @@ _deck: channel: "চ্যানেলগুলি" mentions: "উল্লেখসমূহ" direct: "ডাইরেক্ট নোটগুলি" +_webhookSettings: + name: "নাম" + active: "চালু" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 0033a872ea..344a5505b6 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -216,6 +216,7 @@ instanceFollowers: "Seguidors del servidor" instanceUsers: "Usuaris del servidor" changePassword: "Canvia la contrasenya" security: "Seguretat" +retypedNotMatch: "L'entrada no coincideix" currentPassword: "Contrasenya actual" newPassword: "Contrasenya nova" newPasswordRetype: "Contrasenya nou (repeteix-la)" @@ -239,6 +240,7 @@ start: "Comença" home: "Inici" activity: "Activitat" images: "Imatges" +image: "Imatges" birthday: "Aniversari" yearsOld: "{age} anys" registeredDate: "Data de registre" @@ -315,13 +317,10 @@ userList: "Llistes" about: "Informació" aboutMisskey: "Quant a CherryPick" administrator: "Administrador/a" -twoStepAuthentication: "Verificació en dos passos" moderator: "Moderador/a" moderation: "Moderació" nUsersMentioned: "{n} usuaris mencionats" securityKey: "Clau de seguretat" -securityKeyName: "Nom de la clau" -registerSecurityKey: "Registra la clau de seguretat" unregister: "Cancel·la el registre" passwordLessLogin: "Inici de sessió sense contrasenya" resetPassword: "Restableix la contrasenya" @@ -334,7 +333,6 @@ help: "Ajuda" invites: "Convida" next: "Següent" noteOf: "Publicació de: {user}" -inviteToGroup: "Convida'l al grup" invitations: "Convida" tags: "Etiquetes" docSource: "Font del document" @@ -393,7 +391,6 @@ _antennaSources: homeTimeline: "Publicacions dels usuaris seguits" users: "Publicacions d'usuaris específics" userList: "Publicacions d'una llista d'usuaris" - userGroup: "Publicacions d'usuaris d'un grup" _widgets: profile: "Perfil" instanceInfo: "Informació del fitxer d'instal·lació" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 2b0dd66c96..2162aae8b1 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -12,12 +12,14 @@ fetchingAsApObject: "Načítám data z Fediversu..." ok: "Potvrdit" gotIt: "Rozumím!" cancel: "Zrušit" +noThankYou: "Ne děkuji" enterUsername: "Zadej uživatelské jméno" renotedBy: "{user} přeposla/a" noNotes: "Žádné poznámky" noNotifications: "Žádná oznámení" instance: "Instance" settings: "Nastavení" +notificationSettings: "Nastavení oznámení" basicSettings: "Obecná nastavení" otherSettings: "Rozšířená nastavení" openInWindow: "Otevřít v novém okně" @@ -236,12 +238,12 @@ noMoreHistory: "To je vše" startMessaging: "Zahájit chat" nUsersRead: "přečteno {n} uživateli" agreeTo: "Souhlasím s {0}" -tos: "Podmínky užívání" start: "Začít" home: "Domů" remoteUserCaution: "Tyto informace nemusí být aktuální jelikož uživatel je ze vzdálené instance." activity: "Aktivita" images: "Obrázky" +image: "Obrázky" birthday: "Datum narození" yearsOld: "{age} let" registeredDate: "Datum registrace" @@ -337,12 +339,9 @@ about: "Informace" aboutMisskey: "O CherryPick" administrator: "Administrátor" token: "Token" -twoStepAuthentication: "Dvoufaktorová autentikace" moderator: "Moderátor" nUsersMentioned: "{n} uživatelů zmínilo" securityKey: "Bezpečnostní klíč" -securityKeyName: "Název klíče" -registerSecurityKey: "Registrovat bezpečnostní klíč" lastUsed: "Naposledy použito" unregister: "Odstranit" resetPassword: "Resetovat heslo" @@ -359,13 +358,7 @@ markAsReadAllTalkMessages: "Označit všechny zprávy za přečtené" help: "Nápověda" inputMessageHere: "Sem zadejte zprávu" close: "Zavřít" -group: "Skupina" -groups: "Skupiny" -createGroup: "Vytvořit skupinu" -ownedGroups: "Vlastněné skupiny" -joinedGroups: "Členství ve skupinách" invites: "Pozvat" -groupName: "Název skupiny" members: "Členové" transfer: "Převod" title: "Titulek" @@ -374,7 +367,6 @@ enable: "Povolit" next: "Další" retype: "Zadejte znovu" noteOf: "{user} poznámky" -inviteToGroup: "Pozvat do skupiny" quoteAttached: "Citace" quoteQuestion: "Přiložit jako citaci?" noMessagesYet: "Zatím tu nejsou žádné zprávy" @@ -396,14 +388,10 @@ passwordMatched: "Hesla se schodují" passwordNotMatched: "Hesla se neschodují" signinWith: "Přihlásit se s {x}" signinFailed: "Nelze se přihlásit. Zkontrolujte prosím své uživatelské jméno a heslo." -tapSecurityKey: "Ťukněte na bezpečnostní klíč" or: "Nebo" language: "Jazyk" uiLanguage: "Jazyk uživatelského rozhraní" -groupInvited: "Pozvat do skupiny" aboutX: "O {x}" -youHaveNoGroups: "Nemáte žádné skupiny" -joinOrCreateGroup: "Můžete požádat o pozvání do stávající skupiny nebo vytvořit novou." noHistory: "Žádná historie" signinHistory: "Historie přihlášení" category: "Kategorie" @@ -673,13 +661,13 @@ _sfx: _ago: future: "Budoucí" justNow: "Teď" + invalid: "Nic nebylo nalezeno" _time: second: "Sekund" minute: "Minut" hour: "Hodin" _2fa: - registerDevice: "Přidat zařízení" - registerKey: "Přidat bezpečnostní klíč" + renewTOTPCancel: "Ne děkuji" _weekday: sunday: "Neděle" monday: "Pondělí" @@ -773,7 +761,6 @@ _pages: button: "Tlačítko" _notification: youWereFollowed: "Máte nového následovníka" - youWereInvitedToGroup: "Pozvat do skupiny" _types: all: "Vše" follow: "Sledovaní" @@ -793,3 +780,6 @@ _deck: list: "Seznamy" channel: "Kanály" mentions: "Zmínění" +_webhookSettings: + name: "Jméno" + active: "Zapnuto" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 07f8571518..cb1a1bfc72 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -20,6 +20,7 @@ noNotes: "Keine Notizen gefunden" noNotifications: "Keine Benachrichtigungen gefunden" instance: "Instanz" settings: "Einstellungen" +notificationSettings: "Benachrichtigungseinstellungen" basicSettings: "Allgemeine Einstellungen" otherSettings: "Weitere Einstellungen" openInWindow: "In einem Fenster öffnen" @@ -67,7 +68,7 @@ import: "Import" export: "Export" files: "Dateien" download: "Herunterladen" -driveFileDeleteConfirm: "Möchtest du die Datei „{name}“ wirklich löschen? Notizen mit dieser Datei werden ebenso verschwinden." +driveFileDeleteConfirm: "Möchtest du die Datei „{name}“ wirklich löschen? Sie wird in allen Inhalten, die sie verwenden, auch verschwinden." unfollowConfirm: "Möchtest du {name} wirklich nicht mehr folgen?" exportRequested: "Du hast einen Export angefragt. Dies kann etwas Zeit in Anspruch nehmen. Sobald der Export abgeschlossen ist, wird er deiner Drive hinzugefügt." importRequested: "Du hast einen Import angefragt. Dies kann etwas Zeit in Anspruch nehmen." @@ -103,6 +104,8 @@ renoted: "Renote getätigt." cantRenote: "Renote dieses Beitrags nicht möglich." cantReRenote: "Renote einer Renote nicht möglich." quote: "Zitieren" +inChannelRenote: "Kanal-interner Renote" +inChannelQuote: "Kanal-internes Zitat" pinnedNote: "Angeheftete Notiz" pinned: "Angeheftet" you: "Du" @@ -120,6 +123,8 @@ unmarkAsSensitive: "Als nicht NSFW markieren" enterFileName: "Dateinamen eingeben" mute: "Stummschalten" unmute: "Stummschaltung aufheben" +renoteMute: "Renotes stummschalten" +renoteUnmute: "Renote-Stummschaltung aufheben" block: "Blockieren" unblock: "Blockierung aufheben" suspend: "Sperren" @@ -151,6 +156,7 @@ flagShowTimelineReplies: "Antworten in der Chronik anzeigen" flagShowTimelineRepliesDescription: "Ist diese Option aktiviert, so werden Antworten von Benutzern auf die Notizen anderer Benutzer in der Chronik angezeigt." autoAcceptFollowed: "Follow-Anfragen von Benutzern, denen du folgst, automatisch akzeptieren" addAccount: "Benutzerkonto hinzufügen" +reloadAccountsList: "Benutzerkontoliste aktualisieren" loginFailed: "Anmeldung fehlgeschlagen" showOnRemote: "Auf Ursprungsinstanz ansehen" general: "Allgemein" @@ -191,7 +197,7 @@ instanceInfo: "Instanzinformationen" statistics: "Statistiken" clearQueue: "Warteschlange leeren" clearQueueConfirmTitle: "Möchtest du die Warteschlange wirklich leeren?" -clearQueueConfirmText: "Hierdurch werden jegliche noch nicht gesendete Notizen nicht förderiert. Normalerweise wird dies nicht benötigt." +clearQueueConfirmText: "Hierdurch werden jegliche noch nicht gesendete Notizen nicht föderiert. Normalerweise wird dies nicht benötigt." clearCachedFiles: "Cache leeren" clearCachedFilesConfirm: "Sollen alle im Cache gespeicherten Dateien von anderen Instanzen wirklich gelöscht werden?" blockedInstances: "Blockierte Instanzen" @@ -257,14 +263,16 @@ noMoreHistory: "Kein weiterer Verlauf vorhanden" startMessaging: "Neuen Chat erstellen" nUsersRead: "Von {n} Benutzern gelesen" agreeTo: "Ich stimme {0} zu" +agree: "Zustimmen" agreeBelow: "Ich stimme Untenstehendem zu" basicNotesBeforeCreateAccount: "Wichtige Infos" -tos: "Nutzungsbedingungen" +termsOfService: "Nutzungsbedingungen" start: "Anfangen" home: "Startseite" remoteUserCaution: "Diese Informationen sind möglicherweise unvollständig, da der Benutzer von einer fremden Instanz stammt." activity: "Aktivität" images: "Bilder" +image: "Bild" birthday: "Geburtstag" yearsOld: "{age} Jahre alt" registeredDate: "Registrationsdatum" @@ -343,7 +351,7 @@ basicInfo: "Grundlegende Informationen" pinnedUsers: "Angeheftete Benutzer" pinnedUsersDescription: "Gib durch Leerzeichen getrennte Benutzer an, die an die \"Erkunden\"-Seite angeheftet werden sollen." pinnedPages: "Angeheftete Seiten" -pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfäde zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen.\n" +pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfade zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen." pinnedClipId: "ID des anzuheftenden Clips" pinnedNotes: "Angeheftete Notizen" hcaptcha: "hCaptcha" @@ -391,16 +399,19 @@ about: "Über" aboutMisskey: "Über CherryPick" administrator: "Administrator" token: "Token" -twoStepAuthentication: "Zwei-Faktor-Authentifizierung" +2fa: "Zwei-Faktor-Authentifizierung" +totp: "Authentifizierungs-App" +totpDescription: "Logge dich via Authentifizierungs-App mit Einmalpasswort ein" moderator: "Moderator" moderation: "Moderation" nUsersMentioned: "Von {n} Benutzern erwähnt" +securityKeyAndPasskey: "Security-Tokens und Passkeys" securityKey: "Sicherheitsschlüssel" -securityKeyName: "Schlüsselname" -registerSecurityKey: "Sicherheitsschlüssel registrieren" lastUsed: "Zuletzt benutzt" +lastUsedAt: "Zuletzt verwendet: {t}" unregister: "Deaktivieren" -passwordLessLogin: "Passwortloses Anmelden einrichten" +passwordLessLogin: "Passwortloses Anmelden" +passwordLessLoginDescription: "Ermöglicht passwortfreies Einloggen, nur via Security-Token oder Passkey" resetPassword: "Passwort zurücksetzen" newPasswordIs: "Das neue Passwort ist „{password}“" reduceUiAnimation: "Animationen der Benutzeroberfläche reduzieren" @@ -454,7 +465,6 @@ passwordMatched: "Stimmt überein" passwordNotMatched: "Stimmt nicht überein" signinWith: "Mit {x} anmelden" signinFailed: "Anmeldung fehlgeschlagen. Überprüfe Benutzername und Passswort." -tapSecurityKey: "Tippe deinen Sicherheitsschlüssel an" or: "Oder" language: "Sprache" uiLanguage: "Sprache der Benutzeroberfläche" @@ -465,16 +475,21 @@ native: "Nativ" disableDrawer: "Keine ausfahrbaren Menüs verwenden" youHaveNoGroups: "Keine Gruppen vorhanden" joinOrCreateGroup: "Lass dich zu einer Gruppe einladen oder erstelle deine eigene." +showNoteActionsOnlyHover: "Notizmenü nur bei Mouseover anzeigen" noHistory: "Kein Verlauf gefunden" signinHistory: "Anmeldungsverlauf" +enableAdvancedMfm: "Erweitertes MFM aktivieren" +enableAnimatedMfm: "Animiertes MFM aktivieren" doing: "In Bearbeitung …" category: "Kategorie" -tags: "Schlagwörter" +tags: "Aliasse" docSource: "Quellcode dieses Dokuments" createAccount: "Benutzerkonto erstellen" existingAccount: "Bestehendes Benutzerkonto" regenerate: "Regenerieren" fontSize: "Schriftgröße" +mediaListWithOneImageAppearance: "Höhe von Medienlisten mit nur einem Bild" +limitTo: "Auf {x} begrenzen" noFollowRequests: "Keine ausstehenden Follow-Anfragen vorhanden" openImageInNewTab: "Bilder in neuem Tab öffnen" dashboard: "Dashboard" @@ -502,15 +517,17 @@ objectStoragePrefixDesc: "Dateien werden in Ordnern unter diesem Prefix gespeich objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "Im Falle von S3 leerlassen, für andere Anbieter den relevanten Endpoint im Format „“ oder „:“ angeben." objectStorageRegion: "Region" -objectStorageRegionDesc: "Gib eine Region wie z.B. „xx-east-1“ an. Falls dein Anbieter nicht zwischen Regionen unterscheidet, lass dieses Feld leer oder gib „us-east-1“ an." +objectStorageRegionDesc: "Gib eine Region wie z.B. „xx-east-1“ an. Falls dein Anbieter nicht zwischen Regionen unterscheidet, gib „us-east-1“ an. Lasse es leer bei Verwendung von AWS Konfigurationsdateien oder Umgebungsvariablen." objectStorageUseSSL: "SSL verwenden" objectStorageUseSSLDesc: "Deaktiviere dies, falls du für API-Verbindungen kein HTTPS verwenden wirst" objectStorageUseProxy: "Über Proxy verbinden" objectStorageUseProxyDesc: "Deaktiviere dies, falls du für Verbindungen zur API keinen Proxy verwenden wirst" objectStorageSetPublicRead: "Bei Upload auf \"public-read\" stellen" +s3ForcePathStyleDesc: "Ist s3ForcePathStyle aktiviert, so muss der Bucketname nicht im Hostnamen der URL, sondern im Pfad der URL angeben werden. Diese Option muss eventuell aktiviert werden, wenn Dienste wie z.B. eine selbstbetriebene Minio-Instanz verwendet werden." serverLogs: "Serverprotokolle" deleteAll: "Alle löschen" showFixedPostForm: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen" +showFixedPostFormInChannel: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen (Kanäle)" newNoteRecived: "Es gibt neue Notizen" sounds: "Töne" sound: "Töne" @@ -548,9 +565,14 @@ userSuspended: "Dieser Benutzer wurde gesperrt." userSilenced: "Dieser Benutzer wurde instanzweit stummgeschaltet." yourAccountSuspendedTitle: "Dieses Benutzerkonto ist gesperrt" yourAccountSuspendedDescription: "Dieses Benutzerkonto wurde gesperrt, da es gegen die Nutzungsbedingungen dieses Servers verstoßen hat. Trete mit dem Betreiber in Kontakt, falls du weitere Details erfahren möchtest. Bitte erstelle kein neues Benutzerkonto." +tokenRevoked: "Ungültiger Token" +tokenRevokedDescription: "Der Token ist abgelaufen. Bitte melde dich erneut an." +accountDeleted: "Benutzerkonto wurde gelöscht" +accountDeletedDescription: "Dieses Konto wurde gelöscht." menu: "Menü" divider: "Trenner" addItem: "Element hinzufügen" +rearrange: "Sortieren" relays: "Relays" addRelay: "Relay hinzufügen" inboxUrl: "inbox-URL" @@ -591,7 +613,6 @@ tokenRequested: "Zugriff zum Benutzerkonto gewähren" pluginTokenRequestedDescription: "Dieses Plugin wird die hier konfigurierten Berechtigungen verwenden können." notificationType: "Art der Benachrichtigung" edit: "Bearbeiten" -useStarForReactionFallback: "Verwende ★ falls das Reaktions-Emoji unbekannt ist" emailServer: "Email-Server" enableEmail: "Email-Versand aktivieren" emailConfigInfo: "Zur Email-Bestätigung bei Registrierung oder zum Zurücksetzen des Passworts verwendet" @@ -695,6 +716,8 @@ contact: "Kontakt" useSystemFont: "Standardschriftart des Systems verwenden" clips: "Clips" experimentalFeatures: "Experimentelle Funktionalitäten" +experimental: "Experimentell" +thisIsExperimentalFeature: "Dies ist eine experimentelle Funktion. Änderungen an ihrer Funktionsweise sind vorbehalten, zudem kann eine Verwendung zu unerwarteten Effekten führen." developer: "Entwickler" makeExplorable: "Benutzerkonto in „Erkunden“ sichtbar machen" makeExplorableDescription: "Wenn diese Option deaktiviert ist, ist dein Benutzerkonto nicht im „Erkunden“-Bereich sichtbar." @@ -785,6 +808,7 @@ popularPosts: "Beliebte Beiträge" shareWithNote: "Mit Notiz teilen" ads: "Werbung" expiration: "Frist" +startingperiod: "Start" memo: "Merkzettel" priority: "Priorität" high: "Hoch" @@ -817,6 +841,7 @@ lastCommunication: "Letzte Kommunikation" resolved: "Gelöst" unresolved: "Ungelöst" breakFollow: "Follower entfernen" +breakFollowConfirm: "Diesen Follower wirklich entfernen?" itsOn: "Eingeschaltet" itsOff: "Ausgeschaltet" emailRequiredForSignup: "Angabe einer Email-Adresse als benötigt markieren" @@ -853,11 +878,13 @@ instanceDefaultLightTheme: "Instanzweites Standardfarbschema (Hell)" instanceDefaultDarkTheme: "Instanzweites Standardfarbschema (Dunkel)" instanceDefaultThemeDescription: "Gib den Farbschemencode im Objektformat ein." mutePeriod: "Stummschaltungsdauer" +period: "Zeitlimit" indefinitely: "Dauerhaft" tenMinutes: "10 Minuten" oneHour: "Eine Stunde" oneDay: "Einen Tag" oneWeek: "Eine Woche" +oneMonth: "1 Monat" reflectMayTakeTime: "Es kann etwas dauern, bis sich dies widerspiegelt." failedToFetchAccountInformation: "Benutzerkontoinformationen konnten nicht abgefragt werden" rateLimitExceeded: "Versuchsanzahl überschritten" @@ -899,6 +926,7 @@ remoteOnly: "Nur für fremde Instanzen" failedToUpload: "Hochladen fehlgeschlagen" cannotUploadBecauseInappropriate: "Diese Datei kann nicht hochgeladen werden, da Anteile der Datei als möglicherweise NSFW festgestellt wurden." cannotUploadBecauseNoFreeSpace: "Die Datei konnte nicht hochgeladen werden, da dein Drive-Speicherplatz aufgebraucht ist." +cannotUploadBecauseExceedsFileSizeLimit: "Diese Datei kann wegen Überschreitung der Maximalgröße nicht hochgeladen werden." beta: "Beta" enableAutoSensitive: "NSFW-Automarkierung" enableAutoSensitiveDescription: "Setzt soweit möglich durch Verwendung von Machine Learning automatisch NSFW-Markierungen für Medien, die NSFW-Anteile beinhalten. Auch wenn du diese Option deaktiviert hast, ist sie möglicherweise auf Instanzebene aktiviert." @@ -912,9 +940,10 @@ subscribePushNotification: "Push-Benachrichtigungen aktivieren" unsubscribePushNotification: "Push-Benachrichtigungen deaktivieren" pushNotificationAlreadySubscribed: "Push-Benachrichtigungen sind bereits aktiviert" pushNotificationNotSupported: "Entweder dein Browser oder deine Instanz unterstützt Push-Benachrichtigungen nicht" -sendPushNotificationReadMessage: "Push-Benachrichtigungen löschen, sobald die relevanten Benachrichtigungen oder Nachrichten gelesen wurden" -sendPushNotificationReadMessageCaption: "Eine Push-Benachrichtigungen mit dem Inhalt \"{emptyPushNotificationMessage}\" wird kurz eingeblendet. Dies kann gegebenenfalls den Batterieverbrauch deines Gerätes erhöhen." +sendPushNotificationReadMessage: "Push-Benachrichtigungen löschen, sobald sie gelesen wurden" +sendPushNotificationReadMessageCaption: "Dies kann gegebenenfalls den Batterieverbrauch deines Gerätes erhöhen." windowMaximize: "Maximieren" +windowMinimize: "Minimieren" windowRestore: "Wiederherstellen" caption: "Beschreibung" loggedInAsBot: "Momentan als Bot angemeldet" @@ -931,6 +960,7 @@ didYouLikeMisskey: "Gefällt dir CherryPick?" pleaseDonate: "CherryPick ist die kostenlose Software, die von {host} verwendet wird. Wir würden uns über Spenden freuen, damit dessen Entwicklung weitergeführt werden kann!" roles: "Rollen" role: "Rolle" +noRole: "Rolle nicht gefunden" normalUser: "Standardbenutzer" undefined: "Undefiniert" assign: "Zuweisen" @@ -940,11 +970,124 @@ manageCustomEmojis: "Kann benutzerdefinierte Emojis verwalten" youCannotCreateAnymore: "Du hast das Erstellungslimit erreicht." cannotPerformTemporary: "Vorübergehend nicht verfügbar" cannotPerformTemporaryDescription: "Diese Aktion ist wegen des Überschreitenes des Ausführungslimits temporär nicht verfügbar. Bitte versuche es nach einiger Zeit erneut." +invalidParamError: "Ungültige Parameter" +invalidParamErrorDescription: "Die Anfrageparameter sind fehlerhaft. Dies liegt meist an einem Bug, kann aber auch durch eine zu langen Eingabe o.ä. ausgelöst werden." +permissionDeniedError: "Aktion verweigert" +permissionDeniedErrorDescription: "Dieses Benutzerkonto besitzt nicht die Berechtigung, um diese Aktion auszuführen." preset: "Vorlage" selectFromPresets: "Aus Vorlagen wählen" achievements: "Errungenschaften" gotInvalidResponseError: "Ungültige Antwort des Servers" gotInvalidResponseErrorDescription: "Eventuell ist der Server momentan nicht erreichbar oder untergeht Wartungsarbeiten. Bitte versuche es später noch einmal." +thisPostMayBeAnnoying: "Dieser Beitrag stört eventuell andere Benutzer." +thisPostMayBeAnnoyingHome: "Zur Startseite schicken" +thisPostMayBeAnnoyingCancel: "Abbrechen" +thisPostMayBeAnnoyingIgnore: "Trotzdem schicken" +collapseRenotes: "Bereits gesehene Renotes verkürzt anzeigen" +internalServerError: "Serverinterner Fehler" +internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten." +copyErrorInfo: "Fehlerdetails kopieren" +joinThisServer: "Bei dieser Instanz registrieren" +exploreOtherServers: "Eine andere Instanz finden" +letsLookAtTimeline: "Die Chronik durchstöbern" +disableFederationConfirm: "Föderation wirklich deaktivieren?" +disableFederationConfirmWarn: "Auch mit deaktivierter Föderation bleiben Notizen, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird dies nicht benötigt." +disableFederationOk: "Deaktivieren" +invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst einen validen Einladungscode eingeben, um dich zu registrieren." +emailNotSupported: "Diese Instanz unterstützt das Versenden von Emails nicht" +postToTheChannel: "In Kanal senden" +cannotBeChangedLater: "Kann später nicht mehr geändert werden." +reactionAcceptance: "Reaktionsannahme" +likeOnly: "Nur \"Gefällt mir\"" +likeOnlyForRemote: "Nur \"Gefällt mir\" für fremde Instanzen" +rolesAssignedToMe: "Mir zugewiesene Rollen" +resetPasswordConfirm: "Wirklich Passwort zurücksetzen?" +sensitiveWords: "Sensible Wörter" +sensitiveWordsDescription: "Die Notizsichtbarkeit aller Notizen, die diese Wörter enthalten, wird automatisch auf \"Startseite\" gesetzt. Durch Zeilenumbrüche können mehrere konfiguriert werden." +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." +notesSearchNotAvailable: "Die Notizsuche ist nicht verfügbar." +license: "Lizenz" +unfavoriteConfirm: "Wirklich aus Favoriten entfernen?" +myClips: "Meine Clips" +drivecleaner: "Drive-Reiniger" +retryAllQueuesNow: "Sofort Warteschlangen erneut ausführen" +retryAllQueuesConfirmTitle: "Wirklich erneut versuchen?" +retryAllQueuesConfirmText: "Dies wird zu einer temporären Erhöhung der Serverlast führen." +enableChartsForRemoteUser: "Diagramme für Nutzer fremder Instanzen erstellen" +enableChartsForFederatedInstances: "Diagramme für fremde Instanzen erstellen" +showClipButtonInNoteFooter: "\"Clip\" zum Notizmenu hinzufügen" +largeNoteReactions: "Reaktionen vergrößert anzeigen" +noteIdOrUrl: "Notiz-ID oder URL" +video: "Video" +videos: "Videos" +dataSaver: "Datensparmodus" +accountMigration: "Kontomigration" +accountMoved: "Dieser Benutzer ist zu einem neuen Konto migriert:" +accountMovedShort: "Dieses Konto wurde migriert." +operationForbidden: "Aktion nicht möglich" +forceShowAds: "Werbung immer anzeigen" +addMemo: "Bemerkung hinzufügen" +editMemo: "Bemerkung bearbeiten" +reactionsList: "Reaktionen" +renotesList: "Renotes" +notificationDisplay: "Benachrichtigungen" +leftTop: "Oben links" +rightTop: "Oben rechts" +leftBottom: "Unten links" +rightBottom: "Unten rechts" +stackAxis: "Stapelrichtung" +vertical: "Vertikal" +horizontal: "Horizontal" +position: "Position" +serverRules: "Serverregeln" +pleaseConfirmBelowBeforeSignup: "Lies bitte Untenstehendes vor der Registration." +pleaseAgreeAllToContinue: "Zum Fortfahren muss allen obigen Feldern zugestimmt werden." +continue: "Fortfahren" +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" +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." +displayOfNote: "Anzeige von Notizen" +initialAccountSetting: "Kontoeinrichtung" +youFollowing: "Gefolgt" +preventAiLearning: "Verwendung in machinellem Lernen (Generative bzw. Prediktive AI/KI) ablehnen" +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" +_initialAccountSetting: + accountCreated: "Dein Konto wurde erfolgreich erstellt!" + letsStartAccountSetup: "Lass uns nun dein Konto einrichten." + letsFillYourProfile: "Lass uns zuerst dein Profil einrichten." + profileSetting: "Profileinstellungen" + privacySetting: "Privatsphäreneinstellungen" + theseSettingsCanEditLater: "Diese Einstellungen kannst du jederzeit ändern." + youCanEditMoreSettingsInSettingsPageLater: "In den Einstellungen findest du noch viele weitere Optionen. Schau dort später mal vorbei." + followUsers: "Folge zuerst ein paar Nutzern, um deine Chronik zu füllen." + pushNotificationDescription: "Durch die Aktivierung von Push-Benachrichtigungen kannst du von {name} Benachrichtigungen direkt auf dein Gerät erhalten." + initialAccountSettingCompleted: "Kontoeinrichtung abgeschlossen!" + haveFun: "Viel Spaß mit {name}!" + ifYouNeedLearnMore: "Besuche {link}, falls du mehr über {name} (Misskey) lernen möchtest." + skipAreYouSure: "Die Kontoeinrichtung wirklich überspringen?" +_serverRules: + description: "Eine Reihe von Regeln, die vor der Registrierung angezeigt werden. Eine Zusammenfassung der Nutzungsbedingungen anzuzeigen ist empfohlen." +_accountMigration: + moveFrom: "Von einem anderen Konto zu diesem migrieren" + moveFromSub: "Alias für ein anderes Konto erstellen" + moveFromLabel: "Migrationsursprung #{n}" + moveFromDescription: "Um von einem anderen Konto zu diesem zu migrieren, muss zuvor hier ein Alias eingerichtet werden.\nGib das Konto, von dem migriert werden soll, in folgendem Format ein: @username@server.example.com\n\nZum Löschen des Alias kann das Feld leergelassen werden (nicht empfohlen)." + moveTo: "Dieses Konto zu einem neuen migrieren" + moveToLabel: "Umzugsziel:" + moveCannotBeUndone: "Die Migration eines Benutzerkontos ist unwiderruflich." + moveAccountDescription: "Hierdurch wird dein Konto zu einem anderen migriert.\n ・Follower von diesem Konto werden automatisch auf das neue Konto migriert\n ・Dieses Konto wird allen Nutzern, denen es derzeit folgt, nicht mehr folgen\n ・Mit diesem Konto können keine neuen Notizen usw. erstellt werden\n\nWährend die Migration der Follower automatisch erfolgt, muss die Migration der Konten, denen du folgst, manuell vorbereitet werden. Exportiere hierzu die Liste der gefolgten Nutzer über das Einstellungsmenu, und importiere diese Liste im neuen Konto. Das gleiche Verfahren gilt für erstellte Listen und stummgeschaltete oder blockierte Nutzer.\n\n(Diese Erklärung gilt für Misskey v13.12.0 oder später. Die Funktionsweise andere ActivityPub-Software, beispielsweise Mastodon, kann hiervon abweichen.)" + moveAccountHowTo: "Um ein Konto zu migrieren, erstelle zuerst auf dem Umzugsziel einen Alias für dieses Konto.\nGib dann das Umzugsziel in folgendem Format ein: @username@server.example.com" + startMigration: "Migrieren" + migrationConfirm: "Dieses Konto wirklich zu {account} umziehen? Sobald der Umzug beginnt, kann er nicht rückgängig gemacht werden, und dieses Konto nicht wieder im ursprünglichen Zustand verwendet werden." + movedAndCannotBeUndone: "\nDieses Konto wurde migriert.\nDiese Aktion ist unwiderruflich." + postMigrationNote: "Dieses Konto wird 24 Stunden nach Abschluss der Migration allen Konten, denen es derzeit folgt, nicht mehr folgen.\n\nSowohl die Anzahl der Follower als auch die der Konten, denen dieses Konto folgt, wird dann auf Null gesetzt. Um zu vermeiden, dass Follower dieses Kontos dessen Beiträge, welche nur für Follower bestimmt sind, nicht mehr sehen können, werden sie diesem Konto jedoch weiterhin folgen." + movedTo: "Umzugsziel:" _achievements: earnedAt: "Freigeschaltet am" _types: @@ -971,7 +1114,7 @@ _achievements: title: "Supernotiz" description: "10.000 Notizen gesendet" _notes20000: - title: "Brauche... mehr... Notizen" + title: "Brauche... mehr... Notizen..." description: "20.000 Notizen gesendet" _notes30000: title: "Notizen, Notizen, Notizen" @@ -1092,7 +1235,7 @@ _achievements: title: "Beliebt" description: "Die Anzahl deiner Follower hat 100 überschritten" _followers300: - title: "Stellt euch bitte in einer Reihe auf" + title: "Eine geordnete Reihe, bitte!" description: "Die Anzahl deiner Follower hat 300 überschritten" _followers500: title: "Funkmast" @@ -1115,7 +1258,10 @@ _achievements: description: "Du hast einen verborgenen Schatz gefunden" _client30min: title: "Kurze Pause" - description: "Habe CherryPick für 30 Minuten geöffnet" + description: "Habe CherryPick für mindestens 30 Minuten geöffnet" + _client60min: + title: "Munter mit CherryPick" + description: "Habe CherryPick für mindestens 60 Minuten geöffnet" _noteDeletedWithin1min: title: "Ups" description: "Lösche eine Notiz innerhalb von 1 Minute nachdem sie gesendet wurde" @@ -1153,7 +1299,7 @@ _achievements: description: "Du hast hier geklickt" _justPlainLucky: title: "Pures Glück" - description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.01% erhalten werden" + description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.005% erhalten werden" _setNameToSyuilo: title: "Gottkomplex" description: "Setze deinen Namen auf \"syuilo\"" @@ -1204,6 +1350,10 @@ _role: iconUrl: "Icon-URL" asBadge: "Als Abzeichen anzeigen" descriptionOfAsBadge: "Ist dies aktiviert, so wird das Icon dieser Rolle an der Seite der Namen von Benutzern mit dieser Rolle angezeigt." + isExplorable: "Rollenchronik veröffentlichen" + descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Rollenchronik dieser Rolle frei zugänglich. Die Chronik von Rollen, welche nicht öffentlich sind, wird auch bei Aktivierung nicht veröffentlicht." + displayOrder: "Position" + descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position." canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen" descriptionOfCanEditMembersByModerator: "Wenn aktiviert, so können Moderatoren und Adminstratoren anderen Benutzern diese Rolle zuweisen bzw. diese Zuweisung aufheben. Wenn deaktiviert, so ist es nur Administratoren möglich, Zuweisungen dieser Rolle zu verwalten." priority: "Priorität" @@ -1215,9 +1365,10 @@ _role: gtlAvailable: "Kann auf die globale Chronik zugreifen" ltlAvailable: "Kann auf die lokale Chronik zugreifen" canPublicNote: "Kann öffentliche Notizen erstellen" - canInvite: "Kann Einladungscodes für diese Instanz erstellen" + canInvite: "Erstellung von Einladungscodes für diese Instanz" canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten" driveCapacity: "Drive-Kapazität" + alwaysMarkNsfw: "Dateien immer als NSFW markieren" pinMax: "Maximale Anzahl an angehefteten Notizen" antennaMax: "Maximale Anzahl an Antennen" wordMuteMax: "Maximale Zeichenlänge für Wortstummschaltungen" @@ -1229,6 +1380,7 @@ _role: rateLimitFactor: "Versuchsanzahl" descriptionOfRateLimitFactor: "Je niedriger desto weniger restriktiv, je höher destro restriktiver." canHideAds: "Kann Werbung ausblenden" + canSearchNotes: "Nutzung der Notizsuchfunktion" _condition: isLocal: "Lokaler Benutzer" isRemote: "Benutzer fremder Instanz" @@ -1238,6 +1390,8 @@ _role: followersMoreThanOrEq: "Hat X oder mehr Follower" followingLessThanOrEq: "Folgt X oder weniger Benutzern" followingMoreThanOrEq: "Folgt X oder mehr Benutzern" + notesLessThanOrEq: "Beitragszahl ist kleiner-gleich" + notesMoreThanOrEq: "Beitragszahl ist größer-gleich" and: "UND-Bedingung" or: "ODER-Bedingung" not: "NICHT-Bedingung" @@ -1413,6 +1567,8 @@ _channel: following: "Gefolgt" usersCount: "{n} Teilnehmer" notesCount: "{n} Notizen" + nameAndDescription: "Name und Beschreibung" + nameOnly: "Nur Name" _menuDisplay: sideFull: "Seitlich" sideIcon: "Seitlich (Icons)" @@ -1431,7 +1587,7 @@ _instanceMute: instanceMuteDescription: "Schaltet alle Notizen/Renotes stumm, die von den gelisteten Instanzen stammen, inklusive Antworten von Benutzern an einen Benutzer einer stummgeschalteten Instanz." instanceMuteDescription2: "Instanzen getrennt durch Zeilenumbrüchen angeben" title: "Blendet Notizen von stummgeschalteten Instanzen aus." - heading: "Liste der stummzuschaltenden Instanzen" + heading: "Stummzuschaltende Instanzen" _theme: explore: "Farbschemata erforschen" install: "Farbschemata installieren" @@ -1524,47 +1680,47 @@ _ago: weeksAgo: "vor {n} Woche(n)" monthsAgo: "vor {n} Monat(en)" yearsAgo: "vor {n} Jahr(en)" + invalid: "Ungültig" _time: second: "Sekunde(n)" minute: "Minute(n)" hour: "Stunde(n)" day: "Tag(en)" -_tutorial: +_timelineTutorial: title: "Wie du CherryPick verwendest" - step1_1: "Willkommen!" - step1_2: "Diese Seite ist die „Chronik“. Sie zeigt dir deine geschrieben „Notizen“ sowie die aller Benutzer, denen du „folgst“, in chronologischer Reihenfolge." - step1_3: "Deine Chronik sollte momentan leer sein, da du bis jetzt noch keine Notizen geschrieben hast und auch noch keinen Benutzern folgst." - step2_1: "Lass uns zuerst dein Profil vervollständigen, bevor du Notizen schreibst oder jemandem folgst." - step2_2: "Informationen darüber, was für eine Person du bist, macht es anderen leichter zu wissen, ob sie deine Notizen sehen wollen und ob sie dir folgen möchten." - step3_1: "Mit dem Einrichten deines Profils fertig?" - step3_2: "Dann lass uns als nächstes versuchen, eine Notiz zu schreiben. Dies kannst du tun, indem du auf den Knopf mit dem Stift-Icon auf dem Bildschirm drückst." - step3_3: "Fülle das Fenster aus und drücke auf den Knopf oben rechts zum Senden." - step3_4: "Fällt dir nichts ein, das du schreiben möchtest? Versuch's mit \"Hallo CherryPick!\"" - step4_1: "Fertig mit dem Senden deiner ersten Notiz?" - step4_2: "Falls deine Notiz nun in deiner Chronik auftaucht, hast du alles richtig gemacht." - step5_1: "Lass uns nun deiner Chronik etwas mehr Leben einhauchen, indem du einigen anderen Benutzern folgst." - step5_2: "{featured} zeigt dir beliebte Notizen dieser Instanz. In {explore} kannst du beliebte Benutzer finden. Schau dort, ob du Benutzer findest, die dich interessieren." - step5_3: "Klicke zum Anzeigen des Profils eines Benutzers auf dessen Profilbild und dann auf den \"Folgen\"-Knopf, um diesem zu folgen." - step5_4: "Je nach Benutzer kann es etwas Zeit in Anspruch nehmen, bis dieser deine Follow-Anfrage bestätigt." - step6_1: "Wenn du nun auch die Notizen anderer Benutzer in deiner Chronik siehst, hast du auch diesmal alles richtig gemacht." - step6_2: "Du kannst ebenso „Reaktionen“ verwenden, um schnell auf Notizen anderer Benutzer zu reagieren." - step6_3: "Um eine Reaktion anzufügen, klicke auf das „+“-Symbol in der Notiz und wähle ein Emoji aus, mit dem du reagieren möchtest." - step7_1: "Glückwunsch! Du hast die Einführung in die Verwendung von CherryPick abgeschlossen." - step7_2: "Wenn du mehr über CherryPick lernen möchtest, schau dich im {help}-Bereich um." - step7_3: "Und nun, viel Spaß mit CherryPick! 🚀" - step8_1: "Möchtest du abschließend Push-Benachrichtigungen aktivieren?" - step8_2: "Push-Benachrichtigungen erlauben es dir, über Reaktionen, Follows oder Erwähnungen usw. zu erfahren, auch wenn CherryPick zu dieser Zeit nicht geöffnet ist." - step8_3: "Diese Einstellung kannst du jederzeit ändern." + step1_1: "Dieser Bildschirm ist die \"Chronik\". Hier werden alle \"Notizen\" von {name} angezeigt." + step1_2: "Es gibt einige verschiedene Chroniken. Beispielsweise werden in der \"Startseite\" alle Notizen von Nutzern, denen du folgst, angezeigt, und in der \"Lokalen Chronik\" werden Notizen aller Nutzer auf {name} angezeigt." + step2_1: "Lass uns als nächstes versuchen, eine Notiz zu schreiben. Dies kannst du tun, indem du auf den Knopf mit dem Stift-Icon drückst." + step2_2: "Stell dich den anderen vor oder schreibe einfach \"Hallo {name}!\", wenn du darauf keine Lust hast oder dir nichts einfällt." + step3_1: "Fertig mit dem Senden deiner ersten Notiz?" + step3_2: "Falls deine Notiz nun in deiner Chronik auftaucht, hast du alles richtig gemacht." + step4_1: "Notizen können zusätzlich mit \"Reaktionen\" ausgestattet werden." + step4_2: "Um eine Reaktion anzufügen, klicke auf das „+“-Symbol einer Notiz und wähle ein Emoji aus, mit dem du reagieren möchtest." _2fa: alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung registriert." - registerDevice: "Neues Gerät registrieren" - registerKey: "Neuen Sicherheitsschlüssel registrieren" + registerTOTP: "Authentifizierungs-App registrieren" + passwordToTOTP: "Bitte Passwort eingeben" step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem Gerät." step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät." + step2Click: "Durch Klicken dieses QR-Codes kannst du Verifikation mit deinem Security-Token oder einer App registrieren." step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:" + step3Title: "Authentifizierungsscode eingeben" step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird." - step4: "Alle folgenden Anmeldungsversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." + step4: "Alle folgenden Anmeldeversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." + securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens." + registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren." securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten." + chromePasskeyNotSupported: "Chrome-Passkeys werden zur Zeit nicht unterstützt." + registerSecurityKey: "Security-Token oder Passkey registrieren" + securityKeyName: "Schlüsselname eingeben" + tapSecurityKey: "Bitten folge den Anweisungen deines Browsers zur Registrierung" + removeKey: "Sicherheitsschlüssel entfernen" + removeKeyConfirm: "Den Schlüssel {name} wirklich löschen?" + whyTOTPOnlyRenew: "Solange ein Sicherheitsschlüssel registriert ist, kann die Authentifizierungs-App nicht entfernt werden." + renewTOTP: "Authentifizierungs-App neu einrichten" + renewTOTPConfirm: "Codes der bisherigen App werden hierdurch nutzlos" + renewTOTPOk: "Neu einrichten" + renewTOTPCancel: "Abbrechen" _permissions: "read:account": "Deine Benutzerkontoinformationen lesen" "write:account": "Deine Benutzerkontoinformationen bearbeiten" @@ -1688,8 +1844,8 @@ _visibility: followersDescription: "Nur für Follower sichtbar" specified: "Direkt" specifiedDescription: "Nur für bestimmte Benutzer sichtbar" - localOnly: "Nur Lokal" - localOnlyDescription: "Unsichtbar für Benutzer anderer Instanzen" + disableFederation: "Deföderieren" + disableFederationDescription: "Nicht an andere Instanzen übertragen" _postForm: replyPlaceholder: "Dieser Notiz antworten …" quotePlaceholder: "Diese Notiz zitieren …" @@ -1849,6 +2005,7 @@ _notification: receiveFollowRequest: "Erhaltene Follow-Anfragen" followRequestAccepted: "Akzeptierte Follow-Anfragen" groupInvited: "Erhaltene Gruppeneinladungen" + achievementEarned: "Errungenschaft freigeschaltet" app: "Benachrichtigungen von Apps" _actions: followBack: "folgt dir nun auch" @@ -1881,3 +2038,27 @@ _deck: channel: "Kanal" mentions: "Erwähnungen" direct: "Direktnachrichten" + roleTimeline: "Rollenchronik" +_dialog: + charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" + charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" +_disabledTimeline: + title: "Chronik deaktiviert" + description: "Mit deinen jetzigen Rollen ist diese Chronik nicht verfügbar." +_drivecleaner: + orderBySizeDesc: "Absteigende Dateigrößen" + orderByCreatedAtAsc: "Aufsteigendes Erstelldatum" +_webhookSettings: + createWebhook: "Webhook erstellen" + name: "Name" + secret: "Secret" + events: "Webhook-Ereignisse" + active: "Aktiviert" + _events: + follow: "Wenn du jemandem folgst" + followed: "Wenn dir jemand folgt" + note: "Wenn du eine Notiz schickst" + reply: "Wenn du eine Antwort erhältst" + renote: "Wenn du ein Renote erhältst" + reaction: "Wenn du eine Reaktion erhältst" + mention: "Wenn du erwähnt wirst" diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 889be05578..41b1ea7c65 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -172,11 +172,11 @@ explore: "Εξερευνήστε" messageRead: "Διαβάστηκε" startMessaging: "Ξεκινήστε μία συνομιλία" nUsersRead: "διαβάστηκε από {n}" -tos: "Όροι χρήσης" start: "Ας αρχίσουμε" home: "Κεντρικό" activity: "Δραστηριότητα" images: "Εικόνες" +image: "Εικόνες" birthday: "Γενέθλια" registeredDate: "Έγινε μέλος στις" location: "Τοποθεσία" @@ -230,21 +230,13 @@ moderator: "Συντονιστής" moderation: "Συντονισμός" cacheClear: "Εκκαθάριση προσωρινής μνήμης" markAsReadAllNotifications: "Όλες οι ειδοποιήσεις διαβάστηκαν" -group: "Ομάδα" -groups: "Ομάδες" -createGroup: "Δημιουργία ομάδας" -ownedGroups: "Οι ομάδες σας" -groupName: "Όνομα ομάδας" members: "Μέλη" transfer: "Μεταφορά" -messagingWithUser: "Ιδιωτική συνομιλία" -messagingWithGroup: "Ομαδική συνομιλία" title: "Τίτλος" text: "Κείμενο" enable: "Ενεργοποίηση" next: "Επόμενο" noteOf: "Σημείωμα από {user}" -inviteToGroup: "Πρόσκληση στην ομάδα" quoteAttached: "Παράθεση" signinRequired: "Παρακαλούμε δημιουργήστε λογαριασμό ή συνδεθείτε πριν συνεχίσετε" category: "Κατηγορία" @@ -337,7 +329,6 @@ _antennaSources: homeTimeline: "Σημειώματα από μέλη που ακολουθείτε" users: "Σημειώματα από συγκεκριμένα μέλη" userList: "Σημειώματα από καθορισμένη λίστα μελών" - userGroup: "Σημειώματα από μέλη καθορισμένης ομάδας" _widgets: profile: "Προφίλ" instanceInfo: "Πληροφορίες του instance" @@ -382,7 +373,6 @@ _pages: blocks: image: "Εικόνες" _notification: - youGotMessagingMessageFromUser: "{name} σάς έστειλε ένα μήνυμα συνομιλίας" youWereFollowed: "σε ακολούθησε" _types: follow: "Νέοι ακόλουθοι" @@ -402,3 +392,5 @@ _deck: antenna: "Αντένες" list: "Λίστα" mentions: "Επισημάνσεις" +_webhookSettings: + name: "Όνομα" diff --git a/locales/en-US.yml b/locales/en-US.yml index 6d7698ec71..6ca9b202fc 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -29,6 +29,7 @@ noNotes: "No notes" noNotifications: "No notifications" instance: "Instance" settings: "Settings" +notificationSettings: "Notification Settings" basicSettings: "Basic Settings" otherSettings: "Other Settings" openInWindow: "Open in window" @@ -80,7 +81,7 @@ import: "Import" export: "Export" files: "Files" download: "Download" -driveFileDeleteConfirm: "Are you sure you want to delete the file \"{name}\"? Notes with this file attached will also be deleted." +driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? It will also vanish from all contents that use it." unfollowConfirm: "Are you sure you want to unfollow {name}?" exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed." importRequested: "You've requested an import. This may take a while." @@ -116,6 +117,8 @@ renoted: "Renoted." cantRenote: "This post can't be renoted." cantReRenote: "A renote can't be renoted." quote: "Quote" +inChannelRenote: "Channel-only Renote" +inChannelQuote: "Channel-only Quote" pinnedNote: "Pinned note" pinned: "Pin to profile" you: "You" @@ -133,6 +136,8 @@ unmarkAsSensitive: "Unmark as NSFW" enterFileName: "Enter filename" mute: "Mute" unmute: "Unmute" +renoteMute: "Mute Renotes" +renoteUnmute: "Unmute Renotes" block: "Block" unblock: "Unblock" suspend: "Suspend" @@ -164,6 +169,7 @@ flagShowTimelineReplies: "Show replies in timeline" flagShowTimelineRepliesDescription: "Shows replies of users to notes of other users in the timeline if turned on." autoAcceptFollowed: "Automatically approve follow requests from users you're following" addAccount: "Add account" +reloadAccountsList: "Reload account list" loginFailed: "Failed to sign in" showOnRemote: "View on remote instance" general: "General" @@ -208,7 +214,7 @@ clearQueueConfirmText: "Any undelivered notes remaining in the queue will not be clearCachedFiles: "Clear cache" clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?" blockedInstances: "Blocked Instances" -blockedInstancesDescription: "List the hostnames of the instances that you want to block. Listed instances will no longer be able to communicate with this instance." +blockedInstancesDescription: "List the hostnames of the instances that you want to block separated by linebreaks. Listed instances will no longer be able to communicate with this instance." muteAndBlock: "Mutes and Blocks" mutedUsers: "Muted users" blockedUsers: "Blocked users" @@ -270,14 +276,16 @@ noMoreHistory: "There is no further history" startMessaging: "Start a new chat" nUsersRead: "read by {n}" agreeTo: "I agree to {0}" +agree: "Agree" agreeBelow: "I agree to the below" basicNotesBeforeCreateAccount: "Important notes" -tos: "Terms of Service" +termsOfService: "Terms of Service" start: "Begin" home: "Home" remoteUserCaution: "As this user is from a remote instance, the shown information may be incomplete." activity: "Activity" images: "Images" +image: "Image" birthday: "Birthday" yearsOld: "{age} years old" registeredDate: "Joined on" @@ -404,16 +412,19 @@ about: "About" aboutMisskey: "About CherryPick" administrator: "Administrator" token: "Token" -twoStepAuthentication: "Two-factor authentication" +2fa: "Two-factor authentication" +totp: "Authenticator App" +totpDescription: "Use an authenticator app to enter one-time passwords" moderator: "Moderator" moderation: "Moderation" nUsersMentioned: "Mentioned by {n} users" +securityKeyAndPasskey: "Security- and passkeys" securityKey: "Security key" -securityKeyName: "Key name" -registerSecurityKey: "Register a security key" lastUsed: "Last used" +lastUsedAt: "Last used: {t}" unregister: "Unregister" passwordLessLogin: "Password-less login" +passwordLessLoginDescription: "Allows password-less login using a security- or passkey only" resetPassword: "Reset password" newPasswordIs: "The new password is \"{password}\"" reduceUiAnimation: "Reduce UI animations" @@ -467,7 +478,6 @@ passwordMatched: "Matches" passwordNotMatched: "Does not match" signinWith: "Sign in with {x}" signinFailed: "Unable to sign in. The entered username or password is incorrect." -tapSecurityKey: "Tap your security key" or: "Or" language: "Language" uiLanguage: "User interface language" @@ -478,18 +488,21 @@ native: "Native" disableDrawer: "Don't use drawer-style menus" youHaveNoGroups: "You have no groups" joinOrCreateGroup: "Get invited to a group or create your own." +showNoteActionsOnlyHover: "Only show note actions on hover" noHistory: "No history available" signinHistory: "Login history" enableAdvancedMfm: "Enable advanced MFM" -enableAnimatedMfm: "Enable MFM with animation" +enableAnimatedMfm: "Enable animated MFM" doing: "Processing..." category: "Category" -tags: "Tags" +tags: "Aliases" docSource: "Source of this document" createAccount: "Create account" existingAccount: "Existing account" regenerate: "Regenerate" fontSize: "Font size" +mediaListWithOneImageAppearance: "Height of media lists with one image only" +limitTo: "Limit to {x}" noFollowRequests: "You don't have any pending follow requests" openImageInNewTab: "Open images in new tab" dashboard: "Dashboard" @@ -517,15 +530,17 @@ objectStoragePrefixDesc: "Files will be stored under directories with this prefi objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "Leave this empty if you are using AWS S3, otherwise specify the endpoint as '' or ':', depending on the service you are using." objectStorageRegion: "Region" -objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does not distinguish between regions, leave this blank or enter 'us-east-1'." +objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does not distinguish between regions, enter 'us-east-1'. Leave empty if using AWS configuration files or environment variables." objectStorageUseSSL: "Use SSL" objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for API connections" objectStorageUseProxy: "Connect over Proxy" objectStorageUseProxyDesc: "Turn this off if you are not going to use a Proxy for API connections" objectStorageSetPublicRead: "Set \"public-read\" on upload" +s3ForcePathStyleDesc: "If s3ForcePathStyle is enabled, the bucket name has to included in the path of the URL as opposed to the hostname of the URL. You may need to enable this setting when using services such as a self-hosted Minio instance." serverLogs: "Server logs" deleteAll: "Delete all" showFixedPostForm: "Display the posting form at the top of the timeline" +showFixedPostFormInChannel: "Display the posting form at the top of the timeline (Channels)" newNoteRecived: "There are new notes" sounds: "Sounds" sound: "Sounds" @@ -546,7 +561,7 @@ nothing: "There's nothing to see here" installedDate: "Authorized at" lastUsedDate: "Last used at" state: "State" -sort: "Sort" +sort: "Sorting order" ascendingOrder: "Ascending" descendingOrder: "Descending" scratchpad: "Scratchpad" @@ -563,9 +578,14 @@ userSuspended: "This user has been suspended." userSilenced: "This user is being silenced." yourAccountSuspendedTitle: "This account is suspended" yourAccountSuspendedDescription: "This account has been suspended due to breaking the server's terms of services or similar. Contact the administrator if you would like to know a more detailed reason. Please do not create a new account." +tokenRevoked: "Invalid token" +tokenRevokedDescription: "This token has expired. Please log in again." +accountDeleted: "Account deleted" +accountDeletedDescription: "This account has been deleted." menu: "Menu" divider: "Divider" addItem: "Add Item" +rearrange: "Rearrange" relays: "Relays" addRelay: "Add Relay" inboxUrl: "Inbox URL" @@ -606,7 +626,6 @@ tokenRequested: "Grant access to account" pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here." notificationType: "Notification type" edit: "Edit" -useStarForReactionFallback: "Use ★ as fallback if the reaction emoji is unknown" emailServer: "Email server" enableEmail: "Enable email distribution" emailConfigInfo: "Used to confirm your email during sign-up or if you forget your password" @@ -710,6 +729,8 @@ contact: "Contact" useSystemFont: "Use the system's default font" clips: "Clips" experimentalFeatures: "Experimental features" +experimental: "Experimental" +thisIsExperimentalFeature: "This is an experimental feature. Its functionality is subject to change, and it may not operate as intended." developer: "Developer" makeExplorable: "Make account visible in \"Explore\"" makeExplorableDescription: "If you turn this off, your account will not show up in the \"Explore\" section." @@ -800,6 +821,7 @@ popularPosts: "Popular posts" shareWithNote: "Share with note" ads: "Advertisements" expiration: "Deadline" +startingperiod: "Start" memo: "Memo" priority: "Priority" high: "High" @@ -832,6 +854,7 @@ lastCommunication: "Last communication" resolved: "Resolved" unresolved: "Unresolved" breakFollow: "Remove follower" +breakFollowConfirm: "Really remove this follower?" itsOn: "Enabled" itsOff: "Disabled" emailRequiredForSignup: "Require email address for sign-up" @@ -868,11 +891,13 @@ instanceDefaultLightTheme: "Instance-wide default light theme" instanceDefaultDarkTheme: "Instance-wide default dark theme" instanceDefaultThemeDescription: "Enter the theme code in object format." mutePeriod: "Mute duration" +period: "Time limit" indefinitely: "Permanently" tenMinutes: "10 minutes" oneHour: "One hour" oneDay: "One day" oneWeek: "One week" +oneMonth: "One month" reflectMayTakeTime: "It may take some time for this to be reflected." failedToFetchAccountInformation: "Could not fetch account information" rateLimitExceeded: "Rate limit exceeded" @@ -914,6 +939,7 @@ remoteOnly: "Remote only" failedToUpload: "Upload failed" cannotUploadBecauseInappropriate: "This file could not be uploaded because parts of it have been detected as potentially NSFW." cannotUploadBecauseNoFreeSpace: "Upload failed due to lack of Drive capacity." +cannotUploadBecauseExceedsFileSizeLimit: "This file cannot be uploaded as it exceeds the file size limit." beta: "Beta" enableAutoSensitive: "Automatic NSFW-Marking" enableAutoSensitiveDescription: "Allows automatic detection and marking of NSFW media through Machine Learning where possible. Even if this option is disabled, it may be enabled instance-wide." @@ -927,9 +953,10 @@ subscribePushNotification: "Enable push notifications" unsubscribePushNotification: "Disable push notifications" pushNotificationAlreadySubscribed: "Push notifications are already enabled" pushNotificationNotSupported: "Your browser or instance does not support push notifications" -sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications or messages have been read" -sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the battery usage of your device, if applicable." +sendPushNotificationReadMessage: "Delete push notifications once they have been read" +sendPushNotificationReadMessageCaption: "This may increase the power consumption of your device." windowMaximize: "Maximize" +windowMinimize: "Minimize" windowRestore: "Restore" caption: "Caption" loggedInAsBot: "Currently logged in as bot" @@ -946,6 +973,7 @@ didYouLikeMisskey: "Have you taken a liking to CherryPick?" pleaseDonate: "{host} uses the free software, CherryPick. We would highly appreciate your donations so development of CherryPick can continue!" roles: "Roles" role: "Role" +noRole: "Role not found" normalUser: "Normal user" undefined: "Undefined" assign: "Assign" @@ -955,6 +983,10 @@ manageCustomEmojis: "Manage Custom Emojis" youCannotCreateAnymore: "You've hit the creation limit." cannotPerformTemporary: "Temporarily unavailable" cannotPerformTemporaryDescription: "This action cannot be performed temporarily due to exceeding the execution limit. Please wait for a while and then try again." +invalidParamError: "Invalid parameters" +invalidParamErrorDescription: "The request parameters are invalid. This is normally caused by a bug, but may also be due to inputs exceeding size limits or similar." +permissionDeniedError: "Operation denied" +permissionDeniedErrorDescription: "This account does not have the permission to perform this action." preset: "Preset" selectFromPresets: "Choose from presets" achievements: "Achievements" @@ -964,6 +996,111 @@ thisPostMayBeAnnoying: "This note may annoy others." thisPostMayBeAnnoyingHome: "Post to home timeline" thisPostMayBeAnnoyingCancel: "Cancel" thisPostMayBeAnnoyingIgnore: "Post anyway" +collapseRenotes: "Collapse renotes you've already seen" +internalServerError: "Internal Server Error" +internalServerErrorDescription: "The server has run into an unexpected error." +copyErrorInfo: "Copy error details" +joinThisServer: "Sign up at this instance" +exploreOtherServers: "Look for another instance" +letsLookAtTimeline: "Have a look at the timeline" +disableFederationConfirm: "Really disable federation?" +disableFederationConfirmWarn: "Even if defederated, posts will continue to be public unless set otherwise. You usually do not need to do this." +disableFederationOk: "Disable" +invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up." +emailNotSupported: "This instance does not support sending emails" +postToTheChannel: "Post to channel" +cannotBeChangedLater: "This cannot be changed later." +reactionAcceptance: "Reaction Acceptance" +likeOnly: "Only likes" +likeOnlyForRemote: "Only likes for remote instances" +rolesAssignedToMe: "Roles assigned to me" +resetPasswordConfirm: "Really reset your password?" +sensitiveWords: "Sensitive words" +sensitiveWordsDescription: "The visibility of all notes containing any of the configured words will be set to \"Home\" automatically. You can list multiple by separating them via line breaks." +sensitiveWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression." +notesSearchNotAvailable: "Note search is unavailable." +license: "License" +unfavoriteConfirm: "Really remove from favorites?" +myClips: "My clips" +drivecleaner: "Drive Cleaner" +retryAllQueuesNow: "Retry running all queues" +retryAllQueuesConfirmTitle: "Really retry all?" +retryAllQueuesConfirmText: "This will temporarily increase the server load." +enableChartsForRemoteUser: "Generate remote user data charts" +enableChartsForFederatedInstances: "Generate remote instance data charts" +showClipButtonInNoteFooter: "Add \"Clip\" to note action menu" +largeNoteReactions: "Enlargen displayed reactions" +noteIdOrUrl: "Note ID or URL" +video: "Video" +videos: "Videos" +dataSaver: "Data Saver" +accountMigration: "Account Migration" +accountMoved: "This user has moved to a new account:" +accountMovedShort: "This account has been migrated." +operationForbidden: "Operation forbidden" +forceShowAds: "Always show ads" +addMemo: "Add memo" +editMemo: "Edit memo" +reactionsList: "Reactions" +renotesList: "Renotes" +notificationDisplay: "Notifications" +leftTop: "Top left" +rightTop: "Top right" +leftBottom: "Bottom left" +rightBottom: "Bottom right" +stackAxis: "Stacking direction" +vertical: "Vertical" +horizontal: "Horizontal" +position: "Position" +serverRules: "Server rules" +pleaseConfirmBelowBeforeSignup: "Please confirm the below before signing up." +pleaseAgreeAllToContinue: "You must agree to all above fields to continue." +continue: "Continue" +preservedUsernames: "Reserved usernames" +preservedUsernamesDescription: "List usernames to reserve separated by linebreaks. These will become unable during normal account creation, but can be used by administrators to manually create accounts. Already existing accounts using these usernames will not be affected." +createNoteFromTheFile: "Compose note from this file" +archive: "Archive" +channelArchiveConfirmTitle: "Really archive {name}?" +channelArchiveConfirmDescription: "An archived channel won't appear in the channel list or search results anymore. New posts can also not be added to it anymore." +thisChannelArchived: "This channel has been archived." +displayOfNote: "Note display" +initialAccountSetting: "Profile setup" +youFollowing: "Followed" +preventAiLearning: "Reject usage in Machine Learning (Generative AI)" +preventAiLearningDescription: "Requests crawlers to not use posted text or image material etc. in machine learning (Predictive / Generative AI) data sets. This is achieved by adding a \"noai\" HTML-Response flag to the respective content. A complete prevention can however not be achieved through this flag, as it may simply be ignored." +options: "Options" +specifyUser: "Specific user" +_initialAccountSetting: + accountCreated: "Your account was successfully created!" + letsStartAccountSetup: "For starters, let's set up your profile." + letsFillYourProfile: "First, let's set up your profile." + profileSetting: "Profile settings" + privacySetting: "Privacy settings" + theseSettingsCanEditLater: "You can always change these settings later." + youCanEditMoreSettingsInSettingsPageLater: "There are many more settings you can configure from the \"Settings\" page. Be sure to visit it later." + followUsers: "Try following some users that interest you to build up your timeline." + pushNotificationDescription: "Enabling push notifications will allow you to receive notifications from {name} directly on your device." + initialAccountSettingCompleted: "Profile setup complete!" + haveFun: "Enjoy {name}!" + ifYouNeedLearnMore: "If you'd like to learn more about how to use {name} (Misskey), please visit {link}." + skipAreYouSure: "Really skip profile setup?" +_serverRules: + description: "A set of rules to be displayed before registration. Setting a summary of the Terms of Service is recommended." +_accountMigration: + moveFrom: "Migrate another account to this one" + moveFromSub: "Create alias to another account" + moveFromLabel: "Original Account #{n}" + moveFromDescription: "You must create an alias for the account to move from on this account.\nEnter the account to migrate from in the following format: @username@server.example.com\nTo delete the alias, leave the field empty (not recommended)." + moveTo: "Migrate this account to a different one" + moveToLabel: "Account to move to:" + moveCannotBeUndone: "Account migration cannot be undone." + moveAccountDescription: "This will migrate your account to a different one.\n ・Followers from this account will automatically be migrated to the new account\n ・This account will unfollow all users it is currently following\n ・You will be unable to create new notes etc. on this account\n\nWhile migration of followers is automatic, you must manually prepare some steps to migrate the list of users you are following. To do so, carry out a follows export that you will later import on the new account in the settings menu. The same procedure applies to your lists as well as your muted and blocked users.\n\n(This explanation applies to Misskey v13.12.0 and later. Other ActivityPub software, such as Mastodon, might function differently.)" + moveAccountHowTo: "To migrate, first create an alias for this account on the account to move to.\nAfter you have created the alias, enter the account to move to in the following format: @username@server.example.com" + startMigration: "Migrate" + migrationConfirm: "Really migrate this account to {account}? Once started, this process cannot be stopped or taken back, and you will not be able to use this account in its original state anymore." + movedAndCannotBeUndone: "\nThis account has been migrated.\nMigration cannot be reversed." + postMigrationNote: "This account will unfollow all accounts it is currently following 24 hours after migration finishes.\nBoth the number of follows and followers will then become zero. To avoid your followers from being unable to see followers only posts of this account, they will however continue following this account." + movedTo: "Account to move to:" _achievements: earnedAt: "Unlocked at" _types: @@ -1134,7 +1271,10 @@ _achievements: description: "You've found the hidden treasure" _client30min: title: "Short break" - description: "Spend 30 minutes on CherryPick" + description: "Keep CherryPick opened for at least 30 minutes" + _client60min: + title: "No \"Miss\" in CherryPick" + description: "Keep CherryPick opened for at least 60 minutes" _noteDeletedWithin1min: title: "Nevermind" description: "Delete a note within a minute of posting it" @@ -1172,7 +1312,7 @@ _achievements: description: "You've clicked here" _justPlainLucky: title: "Just Plain Lucky" - description: "Has a chance to be obtained with a probability of 0.01% every 10 seconds" + description: "Has a chance to be obtained with a probability of 0.005% every 10 seconds" _setNameToSyuilo: title: "God Complex" description: "Set your name to \"syuilo\"" @@ -1215,7 +1355,7 @@ _role: isConditionalRole: "This is a conditional role." isPublic: "Public role" descriptionOfIsPublic: "Anyone will be able to view a list of users assigned to this role. In addition, this role will be displayed in the profiles of assigned users." - options: "Role options" + options: "Options" policies: "Policies" baseRole: "Role template" useBaseValue: "Use role template value" @@ -1223,6 +1363,10 @@ _role: iconUrl: "Icon URL" asBadge: "Show as badge" descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on." + isExplorable: "Role timeline is public" + descriptionOfIsExplorable: "This role's timeline will become publicly accessible if enabled. Timelines of non-public roles will not be made public even if set." + displayOrder: "Position" + descriptionOfDisplayOrder: "The higher the number, the higher its UI position." canEditMembersByModerator: "Allow moderators to edit the list of members for this role" descriptionOfCanEditMembersByModerator: "When turned on, moderators as well as administrators will be able to assign and unassign users to this role. When turned off, only administrators will be able to assign users." priority: "Priority" @@ -1237,6 +1381,7 @@ _role: canInvite: "Can create instance invite codes" canManageCustomEmojis: "Can manage custom emojis" driveCapacity: "Drive capacity" + alwaysMarkNsfw: "Always mark files as NSFW" pinMax: "Maximum number of pinned notes" antennaMax: "Maximum number of antennas" wordMuteMax: "Maximum number of characters allowed in word mutes" @@ -1248,6 +1393,7 @@ _role: rateLimitFactor: "Rate limit" descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. " canHideAds: "Can hide ads" + canSearchNotes: "Usage of note search" _condition: isLocal: "Local user" isRemote: "Remote user" @@ -1257,6 +1403,8 @@ _role: followersMoreThanOrEq: "Has X or more followers" followingLessThanOrEq: "Follows X or fewer accounts" followingMoreThanOrEq: "Follows X or more accounts" + notesLessThanOrEq: "Post count is less than/equal to" + notesMoreThanOrEq: "Post count is greater than/equal to" and: "AND-Condition" or: "OR-Condition" not: "NOT-Condition" @@ -1446,6 +1594,8 @@ _channel: following: "Followed" usersCount: "{n} Participants" notesCount: "{n} Notes" + nameAndDescription: "Name and description" + nameOnly: "Name only" _menuDisplay: sideFull: "Side" sideIcon: "Side (Icons)" @@ -1557,47 +1707,47 @@ _ago: weeksAgo: "{n}w ago" monthsAgo: "{n}mo ago" yearsAgo: "{n}y ago" + invalid: "None" _time: second: "Second(s)" minute: "Minute(s)" hour: "Hour(s)" day: "Day(s)" -_tutorial: +_timelineTutorial: title: "How to use CherryPick" - step1_1: "Welcome!" - step1_2: "This page is called the \"timeline\". It shows chronologically ordered \"notes\" of people who you \"follow\"." - step1_3: "Your timeline is currently empty, since you have not posted any notes or followed anyone yet." - step2_1: "Let's finish setting up your profile before writing a note or following anyone." - step2_2: "Providing some information about who you are will make it easier for others to tell if they want to see your notes or follow you." - step3_1: "Finished setting up your profile?" - step3_2: "Then let's try posting a note next. You can do so by pressing the button with a pencil icon on the screen." - step3_3: "Fill in the modal and press the button on the top right to post." - step3_4: "Have nothing to say? Try \"just setting up my msky\"!" - step4_1: "Finished posting your first note?" - step4_2: "Hurray! Now your first note should be displayed on your timeline." - step5_1: "Now, let's try making your timeline more lively by following other people." - step5_2: "{featured} will show you popular notes in this instance. {explore} will let you find popular users. Try finding people you'd like to follow there!" - step5_3: "To follow other users, click on their icon and press the \"Follow\" button on their profile." - step5_4: "If the other user has a lock icon next to their name, it may take some time for that user to manually approve your follow request." - step6_1: "You should be able to see other users' notes on your timeline now." - step6_2: "You can also put \"reactions\" on other people's notes to quickly respond to them." - step6_3: "To attach a \"reaction\", press the \"+\" mark on another user's note and choose an emoji you'd like to react with." - step7_1: "Congratulations! You have now finished CherryPick's basic tutorial." - step7_2: "If you would like to learn more about CherryPick, try the {help} section." - step7_3: "Now then, have fun with CherryPick! 🚀" - step8_1: "Lastly, would you like to enable push notifications?" - step8_2: "Enabling these will allow you to receive notifications for mentions, reactions, follows, etc. even when CherryPick is not opened." - step8_3: "You can always change this setting later." + step1_1: "This is the \"timeline\". All \"notes\" submitted on {name} will be chronologically displayed here." + step1_2: "There are a few different timelines. For example, the \"Home timeline\" will contain notes of users you follow, and the \"Local timeline\" will contain notes from all users of {name}." + step2_1: "Let's try posting a note next. You can do so by pressing the button with a pencil icon." + step2_2: "How about writing a self-introduction, or just \"Hello {name}!\" if you don't feel like it?" + step3_1: "Finished posting your first note?" + step3_2: "Your first note should now be displayed on your timeline." + step4_1: "You can also attach \"Reactions\" to notes." + step4_2: "To attach a reaction, press the \"+\" mark on a note and choose an emoji you'd like to react with." _2fa: alreadyRegistered: "You have already registered a 2-factor authentication device." - registerDevice: "Register a new device" - registerKey: "Register a security key" + registerTOTP: "Register authenticator app" + passwordToTOTP: "Enter your password" step1: "First, install an authentication app (such as {a} or {b}) on your device." step2: "Then, scan the QR code displayed on this screen." + step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app." step2Url: "You can also enter this URL if you're using a desktop program:" + step3Title: "Enter an authentication code" step3: "Enter the token provided by your app to finish setup." step4: "From now on, any future login attempts will ask for such a login token." + securityKeyNotSupported: "Your browser does not support security keys." + registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key." securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup authentication via hardware security keys that support FIDO2 to further secure your account." + chromePasskeyNotSupported: "Chrome passkeys are currently not supported." + registerSecurityKey: "Register a security or pass key" + securityKeyName: "Enter a key name" + tapSecurityKey: "Please follow your browser to register the security or pass key" + removeKey: "Remove security key" + removeKeyConfirm: "Really delete the {name} key?" + whyTOTPOnlyRenew: "The authenticator app cannot be removed as long as a security key is registered." + renewTOTP: "Reconfigure authenticator app" + renewTOTPConfirm: "This will cause verification codes from your previous app to stop working" + renewTOTPOk: "Reconfigure" + renewTOTPCancel: "Cancel" _permissions: "read:account": "View your account information" "write:account": "Edit your account information" @@ -1721,8 +1871,8 @@ _visibility: followersDescription: "Make visible to your followers only" specified: "Direct" specifiedDescription: "Make visible for specified users only" - localOnly: "Local only" - localOnlyDescription: "Not visible to remote users" + disableFederation: "Defederate" + disableFederationDescription: "Don't transmit to other instances" _postForm: replyPlaceholder: "Reply to this note..." quotePlaceholder: "Quote this note..." @@ -1882,6 +2032,7 @@ _notification: receiveFollowRequest: "Received follow requests" followRequestAccepted: "Accepted follow requests" groupInvited: "Group invitations" + achievementEarned: "Achievement unlocked" app: "Notifications from linked apps" _actions: followBack: "followed you back" @@ -1896,7 +2047,7 @@ _deck: swapRight: "Swap with the right column" swapUp: "Swap with the above column" swapDown: "Swap with the below column" - stackLeft: "Stack with the left column" + stackLeft: "Stack on left column" popRight: "Pop column to the right" profile: "Profile" newProfile: "New profile" @@ -1914,3 +2065,27 @@ _deck: channel: "Channel" mentions: "Mentions" direct: "Direct notes" + roleTimeline: "Role Timeline" +_dialog: + charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." + charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." +_disabledTimeline: + title: "Timeline disabled" + description: "You cannot use this timeline under your current roles." +_drivecleaner: + orderBySizeDesc: "Descending Filesizes" + orderByCreatedAtAsc: "Ascending Dates" +_webhookSettings: + createWebhook: "Create Webhook" + name: "Name" + secret: "Secret" + events: "Webhook Events" + active: "Enabled" + _events: + follow: "When following a user" + followed: "When being followed" + note: "When posting a note" + reply: "When receiving a reply" + renote: "When renoted" + reaction: "When receiving a reaction" + mention: "When being mentioned" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index ebda6252a8..fd4083f10b 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -103,6 +103,8 @@ renoted: "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" pinnedNote: "Nota fijada" pinned: "Fijar al perfil" you: "Tú" @@ -120,6 +122,8 @@ unmarkAsSensitive: "Desmarcar como sensible" enterFileName: "Ingrese el nombre del archivo" mute: "Silenciar" unmute: "Dejar de silenciar" +renoteMute: "Silenciar renota" +renoteUnmute: "Desilenciar renota" block: "Bloquear" unblock: "Dejar de bloquear" suspend: "Suspender" @@ -151,6 +155,7 @@ flagShowTimelineReplies: "Mostrar respuestas a las notas en la biografía" flagShowTimelineRepliesDescription: "Cuando se marca, la línea de tiempo muestra respuestas a otras notas además de las notas del usuario" autoAcceptFollowed: "Aceptar automáticamente las solicitudes de seguimiento de los usuarios que sigues" addAccount: "Agregar Cuenta" +reloadAccountsList: "Recargar lista de cuentas" loginFailed: "Error al iniciar sesión." showOnRemote: "Ver en una instancia remota" general: "General" @@ -259,12 +264,12 @@ nUsersRead: "Leído por {n} personas" agreeTo: "De acuerdo con {0}" agreeBelow: "Estoy de acuerdo con lo siguiente" basicNotesBeforeCreateAccount: "Notas básicas" -tos: "Términos de uso" start: "Comenzar" home: "Inicio" remoteUserCaution: "Para el usuario remoto, la información está incompleta" activity: "Actividad" images: "Imágenes" +image: "Imágenes" birthday: "Fecha de nacimiento" yearsOld: "{age} años" registeredDate: "Fecha de registro" @@ -391,16 +396,19 @@ about: "Información" aboutMisskey: "Sobre CherryPick" administrator: "Administrador" token: "Token" -twoStepAuthentication: "Autenticación de dos factores" +2fa: "Autenticación de doble factor" +totp: "Aplicación autentícadora" +totpDescription: "Ingresa una contaseña de un sólo uso usando la aplicación autenticadora" moderator: "Moderador" moderation: "Moderación" nUsersMentioned: "{n} usuarios mencionados" +securityKeyAndPasskey: "Clave de seguridad / clave de paso" securityKey: "Clave de seguridad" -securityKeyName: "Nombre de la Clave" -registerSecurityKey: "Registrar clave de seguridad" lastUsed: "Última vez usado" +lastUsedAt: "Último uso: {t}" unregister: "Cancelar registro" passwordLessLogin: "Iniciar sesión sin contraseña" +passwordLessLoginDescription: "Iniciar sesión con sólo una clave se seguridad / de paso sin usar una contraseña" resetPassword: "Resetear contraseña" newPasswordIs: "La nueva contraseña es \"{password}\"" reduceUiAnimation: "Reducir la animación de la UI" @@ -454,7 +462,6 @@ passwordMatched: "Correcto" passwordNotMatched: "Las contraseñas no son las mismas" signinWith: "Inicie sesión con {x}" signinFailed: "Autenticación fallida. Asegúrate de haber usado el nombre de usuario y contraseña correctos." -tapSecurityKey: "Toque la clave de seguridad" or: "O" language: "Idioma" uiLanguage: "Idioma de visualización de la interfaz" @@ -465,8 +472,11 @@ native: "Nativo" disableDrawer: "No mostrar los menús en cajones" youHaveNoGroups: "Sin grupos" joinOrCreateGroup: "Obtenga una invitación para unirse al grupos o puede crear su propio grupo." +showNoteActionsOnlyHover: "Mostrar acciones de la nota sólo al pasar el cursor" noHistory: "No hay datos en el historial" signinHistory: "Historial de ingresos" +enableAdvancedMfm: "Habilitar MFM avanzado" +enableAnimatedMfm: "Habilitar MFM con movimiento" doing: "Voy en camino" category: "Categoría" tags: "Etiqueta" @@ -508,9 +518,11 @@ objectStorageUseSSLDesc: "Desactive esto si no va a usar HTTPS para la conexión objectStorageUseProxy: "Conectarse a través de Proxy" objectStorageUseProxyDesc: "Desactive esto si no va a usar Proxy para la conexión de Almacenamiento de objetos" objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir " +s3ForcePathStyleDesc: "Si s3ForcePathStyle esta habilitado el nombre del bucket debe ser especificado como parte de la URL en lugar del nombre de host en la URL. Puede ser necesario activar esta opción cuando se utilice, por ejemplo, Minio en un servidor propio." serverLogs: "Registros del servidor" deleteAll: "Eliminar todos" showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo" +showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)" newNoteRecived: "Tienes una nota nueva" sounds: "Sonidos" sound: "Sonidos" @@ -548,6 +560,10 @@ userSuspended: "Este usuario ha sido suspendido." userSilenced: "Este usuario ha sido silenciado." yourAccountSuspendedTitle: "Esta cuenta ha sido suspendida" yourAccountSuspendedDescription: "Esta cuenta ha sido suspendida debido a violaciones de los términos de servicio del servidor y otras razones. Para más información, póngase en contacto con el administrador. Por favor, no cree una nueva cuenta." +tokenRevoked: "Token inválido" +tokenRevokedDescription: "Este token expiró, vuelve a iniciar sesión." +accountDeleted: "Cuenta borrada" +accountDeletedDescription: "Esta cuenta ha sido borrada." menu: "Menú" divider: "Divisor" addItem: "Agregar elemento" @@ -591,7 +607,6 @@ tokenRequested: "Permiso de acceso a la cuenta" pluginTokenRequestedDescription: "Este plugin podrá usar los permisos descritos aquí" notificationType: "Tipo de notificación" edit: "Editar" -useStarForReactionFallback: "En caso de que los emojis de reacciones no sean claros, usar en su lugar una estrella" emailServer: "Servidor de correo" enableEmail: "Activar el envío de correos electrónicos" emailConfigInfo: "Usar en caso de validación de correo electrónico y pedido de contraseña" @@ -785,6 +800,7 @@ popularPosts: "Más vistos" shareWithNote: "Compartir con una nota" ads: "Anuncios" expiration: "Termina el" +startingperiod: "periodo de inicio" memo: "Notas" priority: "Prioridad" high: "Alta" @@ -817,6 +833,7 @@ lastCommunication: "Última comunicación" resolved: "Resuelto" unresolved: "Sin resolver" breakFollow: "Dejar de seguir" +breakFollowConfirm: "¿Quieres dejar de seguir?" itsOn: "¡Está encendido!" itsOff: "¡Está apagado!" emailRequiredForSignup: "Se requere una dirección de correo electrónico para el registro de la cuenta" @@ -853,16 +870,20 @@ instanceDefaultLightTheme: "Tema claro por defecto de la instancia" instanceDefaultDarkTheme: "Tema oscuro por defecto de la instancia" instanceDefaultThemeDescription: "Ingrese el código del tema en formato objeto" mutePeriod: "Período de silenciamiento" +period: "Termina el" indefinitely: "Sin límite de tiempo" tenMinutes: "10 minutos" oneHour: "1 hora" oneDay: "1 día" oneWeek: "1 semana" +oneMonth: "1 mes" reflectMayTakeTime: "Puede pasar un tiempo hasta que se reflejen los cambios" failedToFetchAccountInformation: "No se pudo obtener información de la cuenta" rateLimitExceeded: "Se excedió el límite de peticiones" cropImage: "Recortar imágen" cropImageAsk: "¿Desea recortar la imagen?" +cropYes: "Recortar" +cropNo: "Usar como está" file: "Archivos" recentNHours: "Últimas {n} horas" recentNDays: "Últimos {n} días" @@ -913,6 +934,7 @@ pushNotificationNotSupported: "El navegador o la instancia no admiten notificaci sendPushNotificationReadMessage: "Eliminar las notificaciones push después de leer las notificaciones y los mensajes" sendPushNotificationReadMessageCaption: "La notificación \"{emptyPushNotificationMessage}\" aparecerá momentáneamente. Esto puede aumentar el consumo de batería del dispositivo." windowMaximize: "Maximizar" +windowMinimize: "Minimizar" windowRestore: "Regresar" caption: "Pie de foto" loggedInAsBot: "Inicio sesión como cuenta bot." @@ -943,6 +965,58 @@ selectFromPresets: "Escoger desde predefinidos" achievements: "Logros" gotInvalidResponseError: "Respuesta del servidor inválida" gotInvalidResponseErrorDescription: "Puede que el servidor esté caído o en mantenimiento. Favor de intentar más tarde" +thisPostMayBeAnnoying: "Ésta publicación puede resultar molesta." +thisPostMayBeAnnoyingHome: "Publicar en línea de tiempo 'Inicio'" +thisPostMayBeAnnoyingCancel: "detener" +thisPostMayBeAnnoyingIgnore: "Publicar de todos modos" +collapseRenotes: "Colapsar renotas que ya hayas visto" +internalServerError: "Error interno del servidor" +internalServerErrorDescription: "El servidor tuvo un error inesperado." +copyErrorInfo: "Copiar detalles del error" +joinThisServer: "Registrarse en esta instancia" +exploreOtherServers: "Buscar otra instancia" +letsLookAtTimeline: "Mirar la línea de tiempo local" +disableFederationConfirm: "¿Estas seguro que quieres desactivar la federación?" +disableFederationConfirmWarn: "Aunque no exista federación los posts no serán marcados como privados. En la mayoría de los casos, no es necesario hacer los posts no federar." +disableFederationOk: "Desactivar." +invitationRequiredToRegister: "Esta instancia está configurada sólo por invitación, tienes que ingresar un código de invitación válido." +emailNotSupported: "Esta instancia no soporta el envío de correo electrónico" +postToTheChannel: "Publicar en el canal" +cannotBeChangedLater: "Esto no podrá ser cambiado después." +reactionAcceptance: "Aceptación de reacciones" +likeOnly: "Sólo 'me gusta'" +likeOnlyForRemote: "Sólo reacciones de instancias remotas" +rolesAssignedToMe: "Roles asignados a mí" +resetPasswordConfirm: "¿Realmente quieres cambiar la contraseña?" +sensitiveWords: "Palabras sensibles" +sensitiveWordsDescription: "La visibilidad de todas las notas que contienen cualquiera de las palabras configuradas serán puestas en \"Inicio\" automáticamente. Puedes enumerás varias separándolas con saltos de línea" +notesSearchNotAvailable: "No se puede buscar una nota" +license: "Licencia" +unfavoriteConfirm: "¿Desea quitar de favoritos?" +myClips: "Mis clips" +drivecleaner: "Limpiador del Drive" +retryAllQueuesNow: "Reintentar inmediatamente todas las colas" +retryAllQueuesConfirmTitle: "Desea ¿reintentar inmediatamente todas las colas?" +retryAllQueuesConfirmText: "La carga del servidor está incrementándose temporalmente " +enableChartsForRemoteUser: "Generar gráficas de usuarios remotos." +enableChartsForFederatedInstances: "Generar gráficos de servidores remotos" +showClipButtonInNoteFooter: "Añadir \"Clip\" al menú de notas" +largeNoteReactions: "Agrandar las reacciones de las notas" +noteIdOrUrl: "ID o URL de la nota" +accountMigration: "Migración de cuenta" +accountMoved: "Este usuario se ha mudado a una nueva cuenta:" +horizontal: "Horizontal" +youFollowing: "Siguiendo" +options: "Opción" +_accountMigration: + moveFrom: "Trasladar de otra cuenta a ésta" + moveFromLabel: "Cuenta desde la que se realiza el traslado:" + moveFromDescription: "Si quieres transferir seguidores de otra cuenta a esta cuenta y trasladarlos, tendrás que crear un alias aquí. Asegúrate de crearlo antes de realizar el traslado. Introduce la cuenta desde la que estás moviendo los seguidores así: @person@instance.com" + moveTo: "Mover esta cuenta a una nueva" + moveToLabel: "Cuenta destino:" + moveAccountDescription: "Esta operación no puede deshacerse. En primer lugar, asegúrese de haber creado un alias para esta cuenta en la cuenta a la que se va a trasladar. Después de crear el alias, introduzca la cuenta a la que se está trasladando de la siguiente manera: @person@instance.com" + migrationConfirm: "¿Estás seguro de que quieres mover esta cuenta a {account}? Una vez trasladada, no podrás deshacer el traslado y no podrás volver a utilizar la cuenta original.\n\nAdemás, compruebe que ha configurado un alias en el destino del traslado." + movedTo: "Cuenta destino:" _achievements: earnedAt: "Desbloqueado el" _types: @@ -1202,6 +1276,8 @@ _role: iconUrl: "URL del ícono" asBadge: "Mostrar como emblema" descriptionOfAsBadge: "Este ícono de rol se mostrará a lado del nombre de usuario cuando este rol se encuentre activo." + displayOrder: "Posición" + descriptionOfDisplayOrder: "Entre más alto el número, mayor es la posición en la interfaz." canEditMembersByModerator: "Permitir a los moderadores editar los miembros" descriptionOfCanEditMembersByModerator: "Si se activa, los moderadores, al igual que los administradores, serán capaces de asignar/quitar usuarios a éste rol. Si se desactiva, sólo los administradores podrán hacerlo." priority: "Prioridad" @@ -1227,6 +1303,7 @@ _role: rateLimitFactor: "Limitador" descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos" canHideAds: "Puede ocultar anuncios" + canSearchNotes: "Uso de la búsqueda de notas" _condition: isLocal: "Usuario local" isRemote: "Usuario remoto" @@ -1236,6 +1313,8 @@ _role: followersMoreThanOrEq: "Tiene X o más seguidores" followingLessThanOrEq: "Sigue X o menos cuentas" followingMoreThanOrEq: "Sigue X o más cuentas" + notesLessThanOrEq: "El número de notas es inferior o igual a" + notesMoreThanOrEq: "El número de notas es superior o igual a" and: "Condicional AND" or: "Condicional OR" not: "Condicional NOT" @@ -1522,47 +1601,37 @@ _ago: weeksAgo: "Hace {n} semanas" monthsAgo: "Hace {n} meses" yearsAgo: "Hace {n} años" + invalid: "No hay nada que ver aqui" _time: second: "Segundos" minute: "Minutos" hour: "Horas" day: "Días" -_tutorial: - title: "Cómo usar CherryPick" - step1_1: "Bienvenido" - step1_2: "Esta imagen se llama \"Linea de tiempo\" y muestra en orden cronológico las \"notas\" tuyas y de la gente que \"sigues\"" - step1_3: "Si no estás escribiendo ninguna nota y no estás siguiendo a nadie, es esperable que no se muestre nada en la linea de tiempo" - step2_1: "Antes de crear notas y seguir a alguien, primero vamos a crear tu perfil" - step2_2: "Si provees información sobre quien eres, será más fácil para que otros usuarios te sigan" - step3_1: "¿Has podido crear tu perfil sin problemas?" - step3_2: "Con esto, prueba hacer una nota. Aprieta el botón con forma de lápiz que está arriba de la imagen y abre el formulario." - step3_3: "Si has escrito el contenido, aprieta el botón que está arriba a la derecha del formulario para postear." - step3_4: "¿No se te ocurre un contenido? Prueba con decir \"Empecé a usar CherryPick\"" - step4_1: "¿Has posteado?" - step4_2: "Si tu nota puede verse en la linea de tiempo, fue todo un éxito." - step5_1: "Luego, ponte a seguir a otra gente y haz que tu linea de tiempo esté más animada." - step5_2: "Puedes ver las notas destacadas en {featured} y desde allí seguir a usuarios que te importan. También puedes buscar usuario destacados en {explore}." - step5_3: "Para seguir a un usuario, haz click en su avatar para ver su página de usuario y allí apretar el botón \"seguir\"" - step5_4: "De esa manera, puede pasar un tiempo hasta que el usuario apruebe al seguidor." - step6_1: "Si puedes ver en la linea de tiempo las notas de otros usuarios, fue todo un éxito." - step6_2: "En las notas de otros usuarios puedes añadir una \"reacción\", para poder responder rápidamente." - step6_3: "Para añadir una reacción, haz click en el botón \"+\" de la nota y elige la reacción que prefieras." - step7_1: "Así terminó la explicación del funcionamiento básico de CherryPick. Eso fue todo." - step7_2: "Si quieres conocer más sobre CherryPick, prueba con la sección {help}." - step7_3: "Así, disfruta de CherryPick 🚀" - step8_1: "Por último, ¿por qué no activar las notificaciones emergentes?" - step8_2: "Al recibir notificaciones emergentes, estarás al tanto de reacciones, seguimientos y menciones incluso cuando CherryPick no esté abierto." - step8_3: "La configuración de las notificaciones puede modificarse posteriormente." _2fa: alreadyRegistered: "Ya has completado la configuración." - registerDevice: "Registrar dispositivo" - registerKey: "Registrar clave" + registerTOTP: "Registrar aplicación autenticadora" + passwordToTOTP: "Ingresa tu contraseña" step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o {b} u otra." step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla." + step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app.\nTocar este código QR te permitirá registrar la autenticación 2FA a tu llave de seguridad o aplicación autenticadora." step2Url: "En una aplicación de escritorio se puede ingresar la siguiente URL:" + step3Title: "Ingresa un código de autenticación" step3: "Para terminar, ingrese el token mostrado en la aplicación." step4: "Ahora cuando inicie sesión, ingrese el mismo token" + securityKeyNotSupported: "Tu navegador no soporta claves de autenticación." + registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key.\npor favor. configura una aplicación de autenticación para registrar una llave de seguridad." securityKeyInfo: "Se puede configurar el inicio de sesión usando una clave de seguridad de hardware que soporte FIDO2 o con un certificado de huella digital o con un PIN" + chromePasskeyNotSupported: "Las llaves de seguridad de Chrome no son soportadas por el momento." + registerSecurityKey: "Registrar una llave de seguridad" + securityKeyName: "Ingresa un nombre para la clave" + tapSecurityKey: "Por favor, sigue tu navegador para registrar una llave de seguridad" + removeKey: "Quitar la llave de seguridad" + removeKeyConfirm: "¿Borrar el respaldo \"{name}\"?" + whyTOTPOnlyRenew: "The authenticator app cannot be removed as long as a security key is registered.\nLa aplicación autenticadora no puede ser eliminada mientras la llave de seguridad se encuentre registrada." + renewTOTP: "Reconfigurar la aplicación autenticadora" + renewTOTPConfirm: "This will cause verification codes from your previous app to stop working\nEsto hará que los códigos de verificación de la aplicación anterior dejen de funcionar" + renewTOTPOk: "Reconfigurar" + renewTOTPCancel: "No gracias" _permissions: "read:account": "Ver información de la cuenta" "write:account": "Editar información de la cuenta" @@ -1686,8 +1755,8 @@ _visibility: followersDescription: "Visible sólo para tus seguidores" specified: "Mensaje directo" specifiedDescription: "Visible sólo para los usuarios elegidos" - localOnly: "Solo local" - localOnlyDescription: "Oculto para usuarios remotos" + disableFederation: "No federado" + disableFederationDescription: "No enviar a otras instancias" _postForm: replyPlaceholder: "Responder a esta nota" quotePlaceholder: "Citar esta nota" @@ -1847,6 +1916,7 @@ _notification: receiveFollowRequest: "Recibió una solicitud de seguimiento" followRequestAccepted: "El seguimiento fue aceptado" groupInvited: "Invitado al grupo" + achievementEarned: "Logro desbloqueado" app: "Notificaciones desde aplicaciones" _actions: followBack: "Te sigue de vuelta" @@ -1879,3 +1949,26 @@ _deck: channel: "Canal" mentions: "Menciones" direct: "Mensaje directo" +_dialog: + charactersExceeded: "¡Has excedido el límite de caracteres! Actualmente {current} de {max}." + charactersBelow: "¡Estás por debajo del límite de caracteres! Actualmente {current} de {min}." +_disabledTimeline: + title: "Línea de tiempo deshabilitada" + description: "No puedes usar esta línea de tiempo con tus roles actuales." +_drivecleaner: + orderBySizeDesc: "Más grandes" + orderByCreatedAtAsc: "Más antiguos" +_webhookSettings: + createWebhook: "Crear Webhook" + name: "Nombre" + secret: "Secreto" + events: "Eventos de webhook" + active: "Activado" + _events: + follow: "Cuando se sigue a alguien" + followed: "Cuando se es seguido" + note: "Cuando se publica una nota" + reply: "Cuando se recibe una respuesta" + renote: "Cuando reciba un \"re-note\"" + reaction: "Cuando se recibe una reacción" + mention: "Cuando hay una mención" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 9e5588de80..4196c6053c 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -20,6 +20,7 @@ noNotes: "Aucune note" noNotifications: "Aucune notification" instance: "Instance" settings: "Paramètres" +notificationSettings: "Paramètres des notifications " basicSettings: "Paramètres généraux" otherSettings: "Paramètres avancés" openInWindow: "Ouvrir dans une nouvelle fenêtre" @@ -103,6 +104,8 @@ renoted: "Renoté !" cantRenote: "Ce message ne peut pas être renoté." cantReRenote: "Impossible de renoter une Renote." quote: "Citer" +inChannelRenote: "Renoter dans le canal" +inChannelQuote: "Citer dans le canal" pinnedNote: "Note épinglée" pinned: "Épingler sur le profil" you: "Vous" @@ -129,6 +132,7 @@ unblockConfirm: "Êtes-vous sûr·e de vouloir débloquer ce compte ?" suspendConfirm: "Êtes-vous sûr·e de vouloir suspendre ce compte ?" unsuspendConfirm: "Êtes-vous sûr·e de vouloir annuler la suspension de ce compte ?" selectList: "Sélectionner une liste" +selectChannel: "Sélectionner un canal" selectAntenna: "Sélectionner une antenne" selectWidget: "Sélectionner un widget" editWidgets: "Modifier les widgets" @@ -256,12 +260,12 @@ noMoreHistory: "Il n’y a plus d’historique" startMessaging: "Commencer à discuter" nUsersRead: "Lu par {n} personnes" agreeTo: "J’accepte {0}" -tos: "les conditions d’utilisation" start: "Commencer" home: "Principal" remoteUserCaution: "Les informations de ce compte risqueraient d’être incomplètes du fait que l’utilisateur·rice provient d’une instance distante." activity: "Activité" images: "Images" +image: "Images" birthday: "Date de naissance" yearsOld: "{age} ans" registeredDate: "Inscrit le" @@ -388,13 +392,10 @@ about: "Informations" aboutMisskey: "À propos de CherryPick" administrator: "Administrateur" token: "Jeton" -twoStepAuthentication: "Authentification à deux facteurs" moderator: "Modérateur·rice·s" moderation: "Modérations" nUsersMentioned: "{n} utilisateur·rice·s mentionné·e·s" securityKey: "Clé de sécurité" -securityKeyName: "Nom de la clé" -registerSecurityKey: "Enregistrer une clé de sécurité" lastUsed: "Dernier utilisé" unregister: "Se désinscrire" passwordLessLogin: "Se connecter sans mot de passe" @@ -451,7 +452,6 @@ passwordMatched: "Les mots de passe correspondent" passwordNotMatched: "Les mots de passe ne correspondent pas" signinWith: "Se connecter avec {x}" signinFailed: "Échec d’authentification. Veuillez vérifier que votre nom d’utilisateur et mot de passe sont corrects." -tapSecurityKey: "Appuyez sur votre clé de sécurité" or: "OU" language: "Langue" uiLanguage: "Langue d’affichage de l’interface" @@ -464,6 +464,7 @@ youHaveNoGroups: "Vous n’avez aucun groupe" joinOrCreateGroup: "Vous pouvez être invité·e à rejoindre des groupes existants ou créer votre propre nouveau groupe." noHistory: "Pas d'historique" signinHistory: "Historique de connexion" +enableAdvancedMfm: "Activer la MFM avancée" doing: "En cours..." category: "Catégorie" tags: "Étiquettes" @@ -540,7 +541,7 @@ updateRemoteUser: "Mettre à jour les informations de l’utilisateur·rice dist deleteAllFiles: "Supprimer tous les fichiers" deleteAllFilesConfirm: "Êtes-vous sûr·e de vouloir supprimer tous les fichiers ?" removeAllFollowing: "Retenir tous les abonnements" -removeAllFollowingDescription: "Se désabonner de tous les comptes de {host}. Veuillez lancer cette action uniquement si l’instance n’existe plus." +removeAllFollowingDescription: "Se désabonner de tous les comptes de {host}. Veuillez lancer cette action dans les cas où l’instance n’existe plus, etc." userSuspended: "Cet·te utilisateur·rice a été suspendu·e." userSilenced: "Cette utilisateur·trice a été mis·e en sourdine." yourAccountSuspendedTitle: "Ce compte est suspendu" @@ -588,7 +589,6 @@ tokenRequested: "Autoriser l'accès au compte" pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies ici." notificationType: "Type de notifications" edit: "Editer" -useStarForReactionFallback: "Utiliser ★ comme alternative si l’émoji de réaction est inconnu" emailServer: "Serveur mail" enableEmail: "Activer la distribution de courriel" emailConfigInfo: "Utilisé pour confirmer votre adresse de courriel et la réinitialisation de votre mot de passe en cas d’oubli." @@ -848,18 +848,23 @@ instanceDefaultLightTheme: "Thème clair par défaut sur toute l’instance" instanceDefaultDarkTheme: "Thème sombre par défaut sur toute l’instance" instanceDefaultThemeDescription: "Saisissez le code du thème en format objet." mutePeriod: "Durée de mise en sourdine" +period: "Fin du sondage" indefinitely: "Illimité" tenMinutes: "10 minutes" oneHour: "1 heure" oneDay: "1 jour" oneWeek: "1 semaine" +oneMonth: "Un mois" 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" cropImage: "Recadrer l'image" cropImageAsk: "Voulez-vous recadrer cette image ?" +cropYes: "Rogner" +cropNo: "Utiliser en l'état" file: "Fichiers" recentNHours: "Dernières {n} heures" +recentNDays: "Derniers {n} jours" noEmailServerWarning: "Serveur de courrier non configuré." thereIsUnresolvedAbuseReportWarning: "Il n’y a aucun rapport non résolu." recommended: "Recommandé" @@ -915,13 +920,94 @@ numberOfLikes: "Favoris" show: "Affichage" neverShow: "Ne plus afficher" remindMeLater: "Peut-être plus tard" +roles: "Rôles" +role: "Rôles" +normalUser: "Simple utilisateur·rice" +assign: "Attribuer" color: "Couleur" +manageCustomEmojis: "Gestion des émojis personnalisés" +preset: "Préréglage" +selectFromPresets: "Sélectionner à partir des préréglages" +thisPostMayBeAnnoyingCancel: "Annuler" +license: "Licence" +video: "Vidéo" +videos: "Vidéos" +dataSaver: "Économiseur de données" +accountMigration: "Migration de compte" +accountMoved: "Cet·te utilisateur·rice a migré son compte vers :" +notificationDisplay: "Style des notifications" +leftTop: "En haut à gauche" +rightTop: "En haut à droite" +leftBottom: "En bas à gauche" +rightBottom: "En bas à droite" +vertical: "Vertical" +horizontal: "Latéral" +serverRules: "Règles du serveur" +youFollowing: "Abonné·e" +_achievements: + _types: + _notes1: + description: "Publiez votre première note" + flavor: "Passez un bon moment avec CherryPick !" + _notes100000: + title: "ALL YOUR NOTE ARE BELONG TO US" + _login3: + title: "Débutant Ⅰ" + description: "Se connecter pour un total de 3 jours" + _login7: + title: "Débutant Ⅱ" + description: "Se connecter pour un total de 7 jours" + _login15: + title: "Débutant Ⅲ" + description: "Se connecter pour un total de 15 jours" + _login30: + description: "Se connecter pour un total de 30 jours" + _login60: + description: "Se connecter pour un total de 60 jours" + _login100: + description: "Se connecter pour un total de 100 jours" + _login200: + description: "Se connecter pour un total de 200 jours" + _login300: + description: "Se connecter pour un total de 300 jours" + _login400: + description: "Se connecter pour un total de 400 jours" + _login500: + description: "Se connecter pour un total de 500 jours" + _login600: + description: "Se connecter pour un total de 600 jours" + _login700: + description: "Se connecter pour un total de 700 jours" + _login800: + description: "Se connecter pour un total de 800 jours" + _login900: + description: "Se connecter pour un total de 900 jours" + _login1000: + flavor: "Merci d'utiliser CherryPick !" + _markedAsCat: + title: "Je suis un chat" + flavor: "Je n'ai pas encore de nom" + _following50: + title: "Beaucoup d'amis" + _followers10: + title: "Abonnez-moi !" + _iLoveMisskey: + title: "J’adore Misskey" + _viewInstanceChart: + title: "Analyste" + _loggedInOnBirthday: + title: "Joyeux Anniversaire !" + _loggedInOnNewYearsDay: + title: "Bonne année !" _role: + assignTarget: "Attribuer" priority: "Priorité" _priority: low: "Basse" middle: "Moyen" high: "Haute" + _options: + canManageCustomEmojis: "Gestion des émojis personnalisés" _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" @@ -1205,46 +1291,22 @@ _ago: weeksAgo: "Il y a {n} semaines" monthsAgo: "Il y a {n} mois" yearsAgo: "Il y a {n} ans" + invalid: "Il n'y a rien à voir ici" _time: second: "s" minute: "min" hour: "h" day: "j" -_tutorial: - title: "Comment utiliser CherryPick" - step1_1: "Bienvenue," - step1_2: "Cette page est appelée « un fil ». Elle affiche les « notes » des personnes auxquelles vous êtes abonné dans un ordre chronologique." - step1_3: "Votre fil est actuellement vide vu que vous ne suivez aucun compte et que vous n’avez publié aucune note, pour l’instant." - step2_1: "Procédons d’abord à la préparation de votre profil avant d’écrire une note et/ou de vous abonner à un compte." - step2_2: "En fournissant quelques informations sur vous, il sera plus facile pour les autres de s’abonner à votre compte." - step3_1: "Vous avez fini de créer votre profil ?" - step3_2: "L’étape suivante consiste à créer une note. Vous pouvez commencer en cliquant sur l’icône crayon sur l’écran." - step3_3: "Remplissez le cadran et cliquez sur le bouton en haut à droite pour envoyer." - step3_4: "Vous n’avez rien à dire ? Essayez d’écrire « J’ai commencé à utiliser CherryPick »." - step4_1: "Avez-vous publié votre première note ?" - step4_2: "Youpi ! Celle-ci est maintenant affichée sur votre fil d’actualité." - step5_1: "Maintenant, essayons de nous abonner à d’autres personnes afin de rendre votre fil plus vivant." - step5_2: "La page {featured} affiche les notes en tendance sur la présente instance et {explore} vous permet de trouver des utilisateur·rice·s en tendance. Essayez de vous abonner aux gens que vous aimez !" - step5_3: "Pour pouvoir suivre d’autres utilisateur·rice, cliquez sur leur avatar afin d’afficher la page du profil utilisateur ensuite appuyez sur le bouton « S’abonner »." - step5_4: "Si l’autre utilisateur possède une icône sous forme d’un cadenas à côté de son nom, il devra accepter votre demande d’abonnement manuellement." - step6_1: "Maintenant, vous êtes en mesure de voir s’afficher les notes des autres utilisateur·rice·s sur votre propre fil." - step6_2: "Vous avez également la possibilité d’intéragir rapidement avec les notes des autres utilisateur·rice·s en ajoutant des « réactions »." - step6_3: "Pour ajouter une réaction à une note, cliquez sur le signe « + » de celle-ci et sélectionnez l’émoji souhaité." - step7_1: "Félicitations ! Vous avez atteint la fin du tutoriel de base pour l’utilisation de CherryPick." - step7_2: "Si vous désirez en savoir plus sur CherryPick, jetez un œil sur la section {help}." - step7_3: "Bon courage et amusez-vous bien sur CherryPick ! 🚀" - step8_1: "Enfin, souhaitez-vous activer les notifications push ?" - step8_2: "En les activant, vous recevrez des notifications pour les mentions, les réactions, les suivis, etc., même lorsque CherryPick n'est pas ouvert." _2fa: alreadyRegistered: "Configuration déjà achevée." - registerDevice: "Ajouter un nouvel appareil" - registerKey: "Enregistrer une clef" step1: "Tout d'abord, installez une application d'authentification, telle que {a} ou {b}, sur votre appareil." step2: "Ensuite, scannez le code QR affiché sur l’écran." step2Url: "Vous pouvez également saisir cette URL si vous utilisez un programme de bureau :" step3: "Entrez le jeton affiché sur votre application pour compléter la configuration." step4: "À partir de maintenant, ce même jeton vous sera demandé à chacune de vos connexions." securityKeyInfo: "Vous pouvez configurer l'authentification WebAuthN pour sécuriser davantage le processus de connexion grâce à une clé de sécurité matérielle qui prend en charge FIDO2, ou bien en configurant l'authentification par empreinte digitale ou par code PIN sur votre appareil." + removeKeyConfirm: "Voulez-vous supprimer {name} ?" + renewTOTPCancel: "Pas maintenant" _permissions: "read:account": "Afficher les informations du compte" "write:account": "Mettre à jour les informations de votre compte" @@ -1362,8 +1424,6 @@ _visibility: followersDescription: "Publier à vos abonné·e·s uniquement" specified: "Direct" specifiedDescription: "Publier uniquement aux utilisateur·rice·s mentionné·e·s" - localOnly: "Local seulement" - localOnlyDescription: "Caché pour les utilisateurs distant" _postForm: replyPlaceholder: "Répondre à cette note ..." quotePlaceholder: "Citez cette note ..." @@ -1543,3 +1603,6 @@ _deck: channel: "Canaux" mentions: "Mentions" direct: "Direct" +_webhookSettings: + name: "Nom" + active: "Activé" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 17d7c472d6..cb1343db3e 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -84,7 +84,7 @@ error: "Galat" somethingHappened: "Terjadi kesalahan" retry: "Coba lagi" pageLoadError: "Gagal memuat halaman." -pageLoadErrorDescription: "Umumnya disebabkan jaringan atau tembolok perambah. Cobalah bersihkan tembolok peramban lalu tunggu sesaat sebelum mencoba kembali." +pageLoadErrorDescription: "Umumnya disebabkan jaringan atau tembolok peramban. Cobalah bersihkan tembolok peramban lalu tunggu sesaat sebelum mencoba kembali." serverIsDead: "Tidak ada respon dari peladen. Mohon tunggu dan coba beberapa saat lagi." youShouldUpgradeClient: "Untuk melihat halaman ini, mohon muat ulang untuk memutakhirkan klienmu." enterListName: "Masukkan nama daftar" @@ -103,6 +103,8 @@ renoted: "Telah direnote" cantRenote: "Postingan ini tidak dapat direnote" cantReRenote: "Renote tidak dapat direnote" quote: "Kutip" +inChannelRenote: "Hanya renote dalam kanal" +inChannelQuote: "Hanya kutip dalam kanal" pinnedNote: "Catatan yang disematkan" pinned: "Sematkan ke profil" you: "Kamu" @@ -120,6 +122,8 @@ unmarkAsSensitive: "Hapus tanda konten sensitif" enterFileName: "Masukkan nama berkas" mute: "Bisukan" unmute: "Hapus bisukan" +renoteMute: "Matikan renote" +renoteUnmute: "Batal mematikan renote" block: "Blokir" unblock: "Buka blokir" suspend: "Bekukan" @@ -129,6 +133,7 @@ unblockConfirm: "Apakah kamu yakin ingin membuka blokir akun ini?" suspendConfirm: "Apakah kamu yakin ingin membekukan akun ini?" unsuspendConfirm: "Apakah kamu yakin ingin membuka pembekuan akun ini?" selectList: "Pilih daftar" +selectChannel: "Pilih kanal" selectAntenna: "Pilih Antena" selectWidget: "Pilih gawit" editWidgets: "Sunting gawit" @@ -256,12 +261,14 @@ noMoreHistory: "Tidak ada sejarah lagi" startMessaging: "Mulai mengirim pesan" nUsersRead: "Dibaca oleh {n}" agreeTo: "Saya setuju kepada {0}" -tos: "Syarat dan ketentuan" +agreeBelow: "Saya setuju dengan di bawah ini" +basicNotesBeforeCreateAccount: "Catatan penting" start: "Mulai" home: "Beranda" remoteUserCaution: "Informasi ini mungkin tidak mutakhir, karena pengguna ini berasal dari instansi luar." activity: "Aktivitas" images: "Gambar" +image: "Gambar" birthday: "Tanggal lahir" yearsOld: "{age} tahun" registeredDate: "Bergabung pada" @@ -388,14 +395,15 @@ about: "Informasi" aboutMisskey: "Tentang CherryPick" administrator: "Admin" token: "Token" -twoStepAuthentication: "Otentikasi dua faktor" +totp: "Aplikasi autentikator" +totpDescription: "Gunakan aplikasi autentikator untuk mendapatkan kata sandi sekali pakai" moderator: "Moderator" moderation: "Moderasi" nUsersMentioned: "{n} pengguna disebut" +securityKeyAndPasskey: "Security key dan passkey" securityKey: "Kunci keamanan" -securityKeyName: "Nama kunci" -registerSecurityKey: "Daftarkan kunci keamanan" lastUsed: "Terakhir digunakan" +lastUsedAt: "Penggunaan terakhir: {t}" unregister: "Batalkan pendaftaran" passwordLessLogin: "Setel login tanpa kata sandi" resetPassword: "Atur ulang kata sandi" @@ -451,7 +459,6 @@ passwordMatched: "Kata sandi sama" passwordNotMatched: "Kata sandi tidak sama" signinWith: "Masuk dengan {x}" signinFailed: "Tidak dapat masuk. Nama pengguna atau kata sandi yang kamu masukkan salah." -tapSecurityKey: "Ketuk kunci keamanan kamu" or: "atau" language: "Bahasa" uiLanguage: "Bahasa antarmuka pengguna" @@ -464,6 +471,8 @@ youHaveNoGroups: "Kamu tidak memiliki grup" joinOrCreateGroup: "Bergabunglah dengan grup atau kamu dapat membuat grupmu sendiri." noHistory: "Tidak ada riwayat" signinHistory: "Riwayat masuk" +enableAdvancedMfm: "Nyalakan MFM tingkat lanjut" +enableAnimatedMfm: "Nyalakan animasi MFM" doing: "Sedang berkerja..." category: "Kategori" tags: "Tandai" @@ -588,7 +597,6 @@ tokenRequested: "Berikan ijin akses ke akun" pluginTokenRequestedDescription: "Plugin ini dapat menggunakan setelan ijin disini." notificationType: "Jenis pemberitahuan" edit: "Sunting" -useStarForReactionFallback: "Gunakan ★ sebagai fallback jika reaksi emoji tidak diketahui" emailServer: "Peladen surel" enableEmail: "Nyalakan distribusi surel" emailConfigInfo: "Digunakan untuk mengonfirmasi surel kamu disaat mendaftar dan lupa kata sandi" @@ -850,16 +858,20 @@ instanceDefaultLightTheme: "Bawaan instan tema terang" instanceDefaultDarkTheme: "Bawaan instan tema gelap" instanceDefaultThemeDescription: "Masukkan kode tema di format obyek." mutePeriod: "Batas waktu bisu" +period: "Batas akhir" indefinitely: "Selamanya" tenMinutes: "10 Menit" oneHour: "1 Jam" oneDay: "1 Hari" oneWeek: "1 Bulan" +oneMonth: "satu bulan" reflectMayTakeTime: "Mungkin perlu beberapa saat untuk dicerminkan." failedToFetchAccountInformation: "Gagal untuk mendapatkan informasi akun" rateLimitExceeded: "Batas sudah terlampaui" cropImage: "potong gambar" cropImageAsk: "Ingin memotong gambar?" +cropYes: "Potong" +cropNo: "Gunakan apa adanya" file: "Berkas" recentNHours: "{n} jam terakhir" recentNDays: "{n} hari terakhir" @@ -910,6 +922,7 @@ pushNotificationNotSupported: "Browser atau instansi kamu tidak mendukung pember sendPushNotificationReadMessage: "Hapus pemberitahuan push ketika pemberitahuan relevan atau pesan telah dibaca" sendPushNotificationReadMessageCaption: "Pemberitahuan berisi teks「{emptyPushNotificationMessage}」akan ditampilkan dalam waktu pendek. Ini mungkin dapat menambah pemakaian baterai pada perangkat kamu." windowMaximize: "Maksimalkan" +windowMinimize: "Minimalkan" windowRestore: "Kembalikan" caption: "Keterangan" loggedInAsBot: "Sedang login sebagai bot" @@ -926,10 +939,96 @@ didYouLikeMisskey: "Apakah kamu mulai menyukai CherryPick?" pleaseDonate: "{host} menggunakan perangkat lunak bebas yaitu CherryPick. Kami sangat mengapresiasi sekali donasi dari kamu agar pengembangan CherryPick tetap dapat berlanjut!" roles: "Peran" role: "Peran" +normalUser: "Pengguna umum" +undefined: "Tak terdefinisi" +assign: "Tetapkan\n" +unassign: "Batalkan penetapan" color: "Warna" +manageCustomEmojis: "Kelola Emoji Kustom" +youCannotCreateAnymore: "Kamu melewati batas pembuatan." +cannotPerformTemporary: "Sementara Tidak Tersedia" +cannotPerformTemporaryDescription: "Aksi ini tidak dapat dilakukan sementara karena melewati batas eksekusi. Mohon tunggu sejenak dan coba lagi." +preset: "Prasetel" +selectFromPresets: "Pilih dari prasetel" +achievements: "Pencapaian" +gotInvalidResponseError: "Respon peladen tidak valid" +gotInvalidResponseErrorDescription: "Peladen tidak dapat dijangkau atau sedang dalam perawatan. Mohon coba lagi nanti." +thisPostMayBeAnnoying: "Catatan ini mungkin dapat mengganggu orang lain." +thisPostMayBeAnnoyingHome: "Catat ke linimasa beranda" +thisPostMayBeAnnoyingCancel: "Batalkan" +thisPostMayBeAnnoyingIgnore: "Tetap catat" +collapseRenotes: "Tutup renote yang sudah kamu lihat" +internalServerError: "Kesalahan internal peladen" +internalServerErrorDescription: "Peladen sedang mengalami galat tak terduga" +copyErrorInfo: "Salin detil galat" +joinThisServer: "Gabung server ini" +exploreOtherServers: "Cari server lain" +letsLookAtTimeline: "LIhat timeline" +disableFederationConfirm: "Matikan federasi?" +disableFederationConfirmWarn: "Mematikan federasi tidak membuat kiriman menjadi privat. Umumnya, mematikan federasi tidak diperlukan." +disableFederationOk: "Matikan federasi" +horizontal: "Horisontal" +youFollowing: "Mengikuti" +options: "Opsi peran" _achievements: + earnedAt: "Terbuka pada" _types: + _notes1: + title: "Cus, baru gabung CherryPick nih!" + description: "Catat catatan pertama kamu" + flavor: "Selamat bersenang-senang dengan CherryPick!" + _notes10: + title: "Beberapa catatan" + description: "Catat 10 catatan" + _notes100: + title: "Banyak catatan" + description: "Catat 100 catatan" + _notes500: + title: "Tertumpuk catatan" + description: "Catat 500 catatan" + _notes1000: + title: "Gunung catatan" + description: "Catat 1000 catatan" + _notes5000: + title: "Luapan catatan" + description: "Catat 5000 catatan" + _notes10000: + title: "Catatan super" + description: "Catat 10 ribu catatan" + _notes20000: + title: "Butuh... banyak... catatan..." + description: "Catat 20 ribu catatan" + _notes30000: + title: "Catat, catat, catat !" + description: "Catat 30 ribu catatan" + _notes40000: + title: "Pabrik catatan" + description: "Catat 40 ribu catatan" + _notes50000: + title: "Planet catatan" + description: "Catat 50 ribu catatan" + _notes60000: + title: "Kuasar catatan" + description: "Catat 60 ribu catatan" + _notes70000: + title: "Lubang hitam catatan" + description: "Catat 70 ribu catatan" + _notes80000: + title: "Galaksi catatan" + description: "Catat 80 ribu catatan" + _notes90000: + title: "Semesta catatan" + description: "Catat 90 ribu catatan" + _notes100000: + title: "ALL YOUR NOTE ARE BELONG TO US" + description: "Catat 100 ribu catatan" + flavor: "Banyak bacot ya kamu." + _login3: + title: "Pemula I" + description: "Login selama 3 hari" + flavor: "Mulai hari ini, panggil gue Misskist" _login7: + title: "Pemula II" description: "Login selama 7 hari" flavor: "Sudah mulai terbiasa?" _login15: @@ -1002,7 +1101,80 @@ _achievements: _following100: title: "100 Teman" description: "Ikuti 100 pengguna lain" + _following300: + title: "Kelebihan teman" + description: "Mengikuti 300 pengguna lain" + _followers1: + title: "Pengikut pertama" + description: "Dapatkan 1 pengikut" + _followers10: + title: "Ikuti aku!" + description: "Dapatkan 10 pengikut" + _followers50: + title: "Rame-rame" + description: "Dapatkan 50 pengikut" + _followers100: + title: "Terkenal" + description: "Dapatkan 100 pengikut" + _followers300: + title: "Mohon antri satu baris" + description: "Dapatkan 300 pengikut" + _followers500: + title: "Stasiun Informasi" + description: "Dapatkan 500 pengikut" + _followers1000: + title: "Influencer" + description: "Dapatkan 1000 pengikut" + _collectAchievements30: + title: "Kolektor pencapaian" + description: "Dapatkan 30 pencapaian" + _viewAchievements3min: + title: "Suka Pencapaian" + description: "Lugat daftar pencapaianmu setidaknya 3 menit" + _iLoveMisskey: + title: "I Love CherryPick" + description: "Catat \"I ❤ #CherryPick\"" + flavor: "Tim pengembang cherrypick sangat mengapresiasi dukungan kamu!" + _foundTreasure: + title: "Berburu Harta Karun" + description: "Kamu telah menemukan harta karun tersembunyi" + _client30min: + title: "Istirahat pendek" + description: "Habiskan waktu 30 menit di CherryPick" + _noteDeletedWithin1min: + title: "Eh, salah coy!" + description: "Hapus catatan kurang dari semenit kamu catat" + _postedAtLateNight: + title: "Nokturnal" + description: "Catat catatan di tengah malam hari" + flavor: "Udah waktunya boboq." + _postedAt0min0sec: + title: "Jam ngomong" + description: "Catat catatan di jam 00.00" + flavor: "Tik Tok Tik Toeeeng" + _selfQuote: + title: "Rujukan mandiri" + description: "Kutip catatanmu sendiri" + _htl20npm: + title: "Linimasa mengalir" + description: "Memiliki linimasa beranda dengan kecepatan melebihi 20 cpm (catatan per menit)" + _viewInstanceChart: + title: "Analis" + description: "Lihat bagan instansimu" + _outputHelloWorldOnScratchpad: + title: "Halo, dunia!" + description: "Munculkan \"hello world\" di Scratchpad" + _open3windows: + title: "Jendela ganda" + description: "Memiliki setidaknya 3 jendela yang terbuka secara bersamaan" + _driveFolderCircularReference: + title: "Referensi Siklus" + description: "Mencoba membuat folder bersarang rekursif di Drive" + _reactWithoutRead: + title: "Beneran udah dibaca?" + description: "Mereaksi catatan dengan 100 karakter panjangnya dalam 3 detik setelah dicatat" _clickedClickHere: + title: "Klik di sini" description: "Kamu telah mengeklik disini" _justPlainLucky: title: "Lagi Beruntung" @@ -1025,6 +1197,7 @@ _achievements: _loggedInOnNewYearsDay: title: "Selamat Tahun Baru!" description: "Login di hari pertama tahun baru" + flavor: "Untuk tahun baru yang berkah bagi instansi ini" _cookieClicked: title: "Permainan dimana kamu mengeklik kue" description: "Mengeklik kue" @@ -1053,6 +1226,9 @@ _role: baseRole: "Templat peran" useBaseValue: "Gunakan nilai templat peran" chooseRoleToAssign: "Pilih peran yang ditugaskan" + iconUrl: "URL ikon" + asBadge: "Tampilkan sebagai lencana" + descriptionOfAsBadge: "Ikon peran ini akan ditampilkan bersebelahan dengan username pengguna yang memiliki peran ini jika dinyalakan." canEditMembersByModerator: "Perbolehkan moderator untuk menyunting daftar anggota untuk peran ini" descriptionOfCanEditMembersByModerator: "Ketika dinyalakan, moderator beserta administrator dapat menugaskan ataupun mencabut pengguna ke peran ini. Ketika dimatikan, hanya administrator saja yang dapat menugaskan pengguna ke peran ini." priority: "Prioritas" @@ -1068,6 +1244,36 @@ _role: canManageCustomEmojis: "Dapat mengelola Emoji kustom" driveCapacity: "Kapasitas Drive" pinMax: "Jumlah maksimal catatan yang disematkan" + antennaMax: "Jumlah maksimum antena" + wordMuteMax: "Jumlah maksimum karakter yang diperbolehkan dalam membisukan kata" + webhookMax: "Jumlah maksimum Webhook" + clipMax: "Jumlah maksimum Klip" + noteEachClipsMax: "Jumlah maksimum catatan di dalam Klip" + userListMax: "Jumlah maksimum daftar pengguna" + userEachUserListsMax: "Jumlah maksimum pengguna dalam dsftar pengguna" + rateLimitFactor: "Batas kecepatan" + descriptionOfRateLimitFactor: "Batas kecepatan yang rendah tidak begitu membatasi, batas kecepatan tinggi lebih membatasi. " + canHideAds: "Dapat menyembunyikan iklan" + _condition: + isLocal: "Pengguna lokal" + isRemote: "Pengguna remote" + createdLessThan: "Telah berlalu kurang dari X sejak pembuatan akun" + createdMoreThan: "Telah berlalu lebih dari X sejak pembuatan akun" + followersLessThanOrEq: "Memiliki pengikut X atau kurang dari tersebut" + followersMoreThanOrEq: "Memiliki pengikut X atau lebih dari tersebut" + followingLessThanOrEq: "Mengikuti X pengguna atau kurang dari itu" + followingMoreThanOrEq: "Mengikuti X pengguna atau lebih dari itu" + and: "Kondisi-AND" + or: "Kondisi-OR" + not: "Kondisi-NOT" +_sensitiveMediaDetection: + description: "Mengurangi usaha moderasi server dengan mengenali media NSFW srcara otomatis menggunakan Machine Learning. Fungsi ini akan sedikit menaikkan beban peladen." + sensitivity: "Sensitivitas deteksi" + sensitivityDescription: "Mengurangi sensitivitas akan mengurangi misdeteksi (false positive) sedangkan meningkatkannya akan menambah misdeteksi (false positive)." + setSensitiveFlagAutomatically: "Tandai sebagai NSFW" + setSensitiveFlagAutomaticallyDescription: "Hasil dari deteksi internal akan dipertahankan meskipun fungsi ini dimatikan." + analyzeVideos: "Nyalakan analisis terhadap video" + analyzeVideosDescription: "Analisa video sebagai tambahan dari gambar. Ini akan sedikit meningkatkan beban ke peladen." _emailUnavailable: used: "Alamat surel ini telah digunakan" format: "Format tidak valid." @@ -1111,6 +1317,24 @@ _plugin: install: "Memasang plugin" installWarn: "Mohon jangan memasang plugin yang tidak dapat dipercayai." manage: "Manajemen plugin" +_preferencesBackups: + list: "Cadangan yang dibuat" + saveNew: "Simpan cadangan baru" + loadFile: "Muat dari berkas" + apply: "Terapkan pada perangkat ini" + save: "Simpan perubahan" + inputName: "Mohon masukkan nama untuk cadangan ini" + cannotSave: "Gagal menyimpan" + nameAlreadyExists: "Cadangan bernama \"{name}\" sudah ada. Mohon gunakan nama lain." + applyConfirm: "Apakah kamu yakin untuk menerapkan cadangan \"{name}\" ke perangkat ini? Pengaturan yang sudah ada di perangkat ini nantinya akan ditimpa." + saveConfirm: "Simpan cadangan sebagai {name}?" + deleteConfirm: "Hapus cadangan {name}?" + renameConfirm: "Ganti cadangan ini dari \"{old}\" ke \"{new}\"?" + noBackups: "Tidak ada cadangan. Kamu dapat mencadangkan pengaturanmu di peladen ini dengan menggunakan \"Buat cadangan baru\"." + createdAt: "Dibuat pada: {date} {time}" + updatedAt: "Diperbarui pada: {date} {time}" + cannotLoad: "Gagal memuat" + invalidFile: "Format berkas tidak valid" _registry: scope: "Lingkup" key: "Kunci" @@ -1323,45 +1547,22 @@ _ago: weeksAgo: "{n} minggu lalu" monthsAgo: "{n} bulan lalu" yearsAgo: "{n} tahun lalu" + invalid: "Tidak ada sama sekali disini" _time: second: "detik" minute: "menit" hour: "jam" day: "hari" -_tutorial: - title: "Cara menggunakan CherryPick" - step1_1: "Selamat datang!" - step1_2: "Halaman ini disebut \"linimasa\". Halaman ini menampilkan \"catatan\" yang diurutkan secara kronologis dari orang-orang yang kamu \"ikuti\"." - step1_3: "Linimasa kamu kosong, karena kamu belum mencatat catatan apapun atau mengikuti siapapun." - step2_1: "Selesaikan menyetel profilmu sebelum menulis sebuah catatan atau mengikuti seseorang." - step2_2: "Menyediakan beberapa informasi tentang siapa kamu akan membuat orang lain mudah untuk mengikutimu kembali." - step3_1: "Selesai menyetel profil kamu?" - step3_2: "Langkah selanjutnya adalah membuat catatan. Kamu bisa lakukan ini dengan mengklik ikon pensil pada layar kamu." - step3_3: "Isilah di dalam modal dan tekan tombol pada atas kanan untuk memcatat catatan kamu." - step3_4: "Bingung tidak berpikiran untuk mengatakan sesuatu? Coba saja \"baru aja ikutan bikin akun CherryPick punyaku\"!" - step4_1: "Selesai mencatat catatan pertamamu?" - step4_2: "Horee! Sekarang catatan pertamamu sudah ditampilkan di linimasa milikmu." - step5_1: "Sekarang, mari mencoba untuk membuat linimasamu lebih hidup dengan mengikuti orang lain." - step5_2: "{featured} akan memperlihatkan catatan yang sedang tren saat ini untuk kamu. {explore} akan membantumu untuk mencari pengguna yang sedang tren juga saat ini. Coba ikuti seseorang yang kamu suka!" - step5_3: "Untuk mengikuti pengguna lain, klik pada ikon mereka dan tekan tombol follow pada profil mereka." - step5_4: "Jika pengguna lain memiliki ikon gembok di sebelah nama mereka, maka pengguna rersebut harus menyetujui permintaan mengikuti dari kamu secara manual." - step6_1: "Sekarang kamu dapat melihat catatan pengguna lain pada linimasamu." - step6_2: "Kamu juga bisa memberikan \"reaksi\" ke catatan orang lain untuk merespon dengan cepat." - step6_3: "Untuk memberikan \"reaksi\", tekan tanda \"+\" pada catatan pengguna lain dan pilih emoji yang kamu suka untuk memberikan reaksimu kepada mereka." - step7_1: "Yay, Selamat! Kamu sudah menyelesaikan tutorial dasar CherryPick." - step7_2: "Jika kamu ingin mempelajari lebih lanjut tentang CherryPick, cobalah berkunjung ke bagian {help}." - step7_3: "Semoga berhasil dan bersenang-senanglah! 🚀" - step8_3: "Kamu dapat mengganti pengaturan ini nanti." _2fa: alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor." - registerDevice: "Daftarkan perangkat baru" - registerKey: "Daftarkan kunci keamanan baru" step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat kamu." step2: "Lalu, pindai kode QR yang ada di layar." step2Url: "Di aplikasi desktop, masukkan URL berikut:" step3: "Masukkan token yang telah disediakan oleh aplikasimu untuk menyelesaikan pemasangan." step4: "Mulai sekarang, upaya login apapun akan meminta token login dari aplikasi otentikasi kamu." securityKeyInfo: "Kamu dapat memasang otentikasi WebAuthN untuk mengamankan proses login lebih lanjut dengan tidak hanya perangkat keras kunci keamanan yang mendukung FIDO2, namun juga sidik jari atau otentikasi PIN pada perangkatmu." + removeKeyConfirm: "Hapus cadangan {name}?" + renewTOTPCancel: "Tidak sekarang." _permissions: "read:account": "Lihat informasi akun" "write:account": "Sunting informasi akun" @@ -1396,12 +1597,15 @@ _permissions: "read:gallery-likes": "Lihat daftar postingan galeri yang disukai" "write:gallery-likes": "Sunting daftar postingan galeri yang disukai" _auth: + shareAccessTitle: "Mendapatkan ijin akses aplikasi" shareAccess: "Apakah kamu ingin mengijinkan \"{name}\" untuk mengakses akun ini?" shareAccessAsk: "Apakah kamu ingin mengijinkan aplikasi ini untuk mengakses akun kamu?" + permission: "{name} meminta ijin berikut" permissionAsk: "Aplikasi ini membutuhkan beberapa ijin, yaitu:" pleaseGoBack: "Mohon kembali ke aplikasi kamu" callback: "Mengembalikan kamu ke aplikasi" denied: "Akses ditolak" + pleaseLogin: "Mohon masuk untuk otorisasi aplikasi." _antennaSources: all: "Semua catatan" homeTimeline: "Catatan dari pengguna yang diikuti" @@ -1482,8 +1686,6 @@ _visibility: followersDescription: "Catat ke pengikut saja" specified: "Langsung" specifiedDescription: "Catat ke pengguna yang ditentukan saja" - localOnly: "Hanya lokal" - localOnlyDescription: "Hanya dapat dilihat di instansi lokal" _postForm: replyPlaceholder: "Balas ke catatan ini..." quotePlaceholder: "Kutip catatan ini..." @@ -1675,3 +1877,6 @@ _deck: channel: "Kanal" mentions: "Sebutan" direct: "Langsung" +_webhookSettings: + name: "Nama" + active: "Aktif" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 1f1a4a0531..aa13c61cd2 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -11,7 +11,7 @@ password: "Password" forgotPassword: "Hai dimenticato la password?" fetchingAsApObject: "Recuperando dal Fediverso..." ok: "OK" -gotIt: "Ho capito" +gotIt: "ok!" cancel: "Annulla" noThankYou: "No grazie" enterUsername: "Inserisci un nome utente" @@ -20,6 +20,7 @@ noNotes: "Nessuna nota!" noNotifications: "Nessuna notifica" instance: "Istanza" settings: "Impostazioni" +notificationSettings: "Preferenze di notifica" basicSettings: "Impostazioni generali" otherSettings: "Altre impostazioni" openInWindow: "Apri in una finestra" @@ -46,7 +47,7 @@ copyContent: "Copia il contenuto" copyLink: "Copia il link" delete: "Elimina" deleteAndEdit: "Elimina e modifica" -deleteAndEditConfirm: "Vuoi davvero cancellare questa nota e scriverla di nuovo? Verrano eliminate anche tutte le reazioni, Rinote e risposte collegate." +deleteAndEditConfirm: "Vuoi davvero cancellare questa nota e scriverla di nuovo? Verranno eliminate anche tutte le reazioni, rinote e risposte collegate." addToList: "Aggiungi alla lista" sendMessage: "Invia messaggio" copyRSS: "Copia RSS" @@ -54,8 +55,8 @@ copyUsername: "Copia nome utente" searchUser: "Cerca utente" reply: "Rispondi" loadMore: "Mostra di più" -showMore: "Mostra di più" -showLess: "Chiudi" +showMore: "Espandi" +showLess: "Comprimi" youGotNewFollower: "Ha iniziato a seguirti" receiveFollowRequest: "Hai ricevuto una richiesta di follow" followRequestAccepted: "Richiesta di follow accettata" @@ -75,8 +76,8 @@ lists: "Liste" noLists: "Nessuna lista" note: "Nota" notes: "Note" -following: "Follows" -followers: "Followers" +following: "Follow" +followers: "Follower" followsYou: "Ti segue" createList: "Aggiungi una nuova lista" manageLists: "Gestisci liste" @@ -84,17 +85,17 @@ error: "Errore" somethingHappened: "Si è verificato un problema" retry: "Riprova" pageLoadError: "Caricamento pagina non riuscito. " -pageLoadErrorDescription: "Questo viene normalmente causato dalla rete o dalla cache del browser. Si prega di pulire la cache, o di attendere e riprovare più tardi." +pageLoadErrorDescription: "Questo problema viene normalmente causato da errori di rete o dalla cache del browser. Si prega di pulire la cache, o di attendere e riprovare più tardi." serverIsDead: "Il server non risponde. Si prega di attendere e riprovare più tardi." youShouldUpgradeClient: "Per visualizzare la pagina è necessario aggiornare il client alla nuova versione e ricaricare." enterListName: "Nome della lista" privacy: "Privacy" -makeFollowManuallyApprove: "Richiedi di approvare i follower manualmente" +makeFollowManuallyApprove: "Approva i follower manualmente" defaultNoteVisibility: "Privacy predefinita delle note" follow: "Segui" followRequest: "Richiesta di follow" followRequests: "Richieste di follow" -unfollow: "Smetti di seguire" +unfollow: "Non seguire" followRequestPending: "Richiesta in approvazione" enterEmoji: "Inserisci emoji" renote: "Rinota" @@ -103,6 +104,8 @@ renoted: "Rinotato!" cantRenote: "È impossibile rinotare questa nota." cantReRenote: "È impossibile rinotare una Rinota." quote: "Cita" +inChannelRenote: "Rinota nel canale" +inChannelQuote: "Cita nel canale" pinnedNote: "Nota fissata" pinned: "Fissa sul profilo" you: "Tu" @@ -119,7 +122,9 @@ markAsSensitive: "Segna come sensibile" unmarkAsSensitive: "Segna come non sensibile" enterFileName: "Nome del file" mute: "Silenzia" -unmute: "Riattiva" +unmute: "Riattiva l'audio" +renoteMute: "Silenzia i Rinota" +renoteUnmute: "Non silenziare i Rinota" block: "Blocca" unblock: "Sblocca" suspend: "Sospendi" @@ -129,6 +134,7 @@ unblockConfirm: "Vuoi davvero sbloccare il profilo?" suspendConfirm: "Vuoi sospendere questo profilo?" unsuspendConfirm: "Vuoi revocare la sospensione si questo profilo?" selectList: "Seleziona una lista" +selectChannel: "Seleziona canale" selectAntenna: "Scegli un'antenna" selectWidget: "Seleziona il riquadro" editWidgets: "Modifica i riquadri" @@ -140,7 +146,7 @@ emojiName: "Nome dell'emoji" emojiUrl: "URL dell'emoji" addEmoji: "Aggiungi un emoji" settingGuide: "Configurazione suggerita" -cacheRemoteFiles: "Memorizzazione nella cache dei file remoti" +cacheRemoteFiles: "Memorizza i file remoti nella cache" cacheRemoteFilesDescription: "Disabilitando questa opzione, i file remoti verranno linkati direttamente senza essere memorizzati nella cache. Sarà possibile risparmiare spazio di archiviazione sul server, ma il traffico aumenterà in quanto non verranno generate anteprime." flagAsBot: "Io sono un robot" flagAsBotDescription: "Attiva questo campo se il profilo esegue principalmente operazioni automatiche. L'attivazione segnala agli altri sviluppatori come comportarsi per evitare catene d’interazione infinite con altri bot. I sistemi interni di CherryPick si adegueranno al fine di trattare questo profilo come bot." @@ -150,21 +156,22 @@ flagShowTimelineReplies: "Mostra le risposte alle note sulla timeline." flagShowTimelineRepliesDescription: "Se è attiva, la timeline mostra le risposte alle altre note dell'utente oltre a quelle dell'utente stesso." autoAcceptFollowed: "Accetta automaticamente le richieste di follow da utenti che già segui" addAccount: "Aggiungi profilo" +reloadAccountsList: "Ricarica l'elenco dei profili" loginFailed: "Accesso non riuscito" -showOnRemote: "Sfoglia sull'istanza remota" +showOnRemote: "Leggi sull'istanza remota" general: "Generali" wallpaper: "Sfondo" setWallpaper: "Imposta sfondo" removeWallpaper: "Elimina lo sfondo" searchWith: "Cerca: {q}" youHaveNoLists: "Non hai ancora creato nessuna lista" -followConfirm: "Sei sicur@ di voler seguire {name}?" +followConfirm: "Vuoi seguire {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: "Server remoto" selectUser: "Seleziona profilo" recipient: "Destinatario" -annotation: "Descrizione" +annotation: "Annotazione preventiva" federation: "Federazione" instances: "Istanza" registeredAt: "Registrato presso" @@ -213,12 +220,12 @@ noJobs: "Nessun lavoro" federating: "Federazione" blocked: "Bloccato" suspended: "Sospensione" -all: "Tutti" +all: "Tutte" subscribing: "Iscrizione" publishing: "Pubblicazione" notResponding: "Nessuna risposta" instanceFollowing: "Seguiti dall'istanza" -instanceFollowers: "Followers dell'istanza" +instanceFollowers: "Follower dell'istanza" instanceUsers: "Utenti dell'istanza" changePassword: "Aggiorna Password" security: "Sicurezza" @@ -231,14 +238,14 @@ more: "Di più!" featured: "Tendenze" usernameOrUserId: "Nome utente o ID utente" noSuchUser: "Nessun utente trovato" -lookup: "Cercare" +lookup: "Ricerca remota" announcements: "Annunci" imageUrl: "URL dell'immagine" remove: "Elimina" removed: "Eliminato con successo" removeAreYouSure: "Vuoi davvero eliminare \"{x}\"?" deleteAreYouSure: "Eliminare \"{x}\"?" -resetAreYouSure: "Reimposta" +resetAreYouSure: "Ripristinare?" saved: "Salvato" messaging: "Messaggi" upload: "Carica" @@ -256,12 +263,16 @@ noMoreHistory: "Non c'è più cronologia da visualizzare" startMessaging: "Nuovo messaggio" nUsersRead: "Letto da {n} persone" agreeTo: "Sono d'accordo con {0}" -tos: "Termini di servizio" +agree: "D'accordo" +agreeBelow: "Accetto quanto riportato sotto" +basicNotesBeforeCreateAccount: "Note importanti" +termsOfService: "Informativa Privacy" start: "Inizia!" home: "Home" remoteUserCaution: "Può darsi che le informazioni siano incomplete perché questo è un utente remoto." activity: "Attività" images: "Immagini" +image: "Immagini" birthday: "Compleanno" yearsOld: "{age} anni" registeredDate: "Iscrizione a.." @@ -283,7 +294,7 @@ selectFolders: "Seleziona cartella" renameFile: "Rinomina file" folderName: "Nome della cartella" createFolder: "Nuova cartella" -renameFolder: "Rinominare cartella" +renameFolder: "Rinomina cartella" deleteFolder: "Elimina cartella" addFile: "Allega" emptyDrive: "Il Drive è vuoto" @@ -300,7 +311,7 @@ avatar: "Foto del profilo" banner: "Intestazione" nsfw: "Contenuti sensibili" whenServerDisconnected: "Quando la connessione col server è persa" -disconnectedFromServer: "Disconness@ dal server" +disconnectedFromServer: "Il server si è disconnesso" reload: "Ricarica" doNothing: "Nessun'azione" reloadConfirm: "Vuoi ricaricare?" @@ -311,9 +322,9 @@ reject: "Rifiuta" normal: "Normale" instanceName: "Nome dell'istanza" instanceDescription: "Descrizione dell'istanza" -maintainerName: "Nome dell'Amministratore" -maintainerEmail: "Indirizzo e-mail dell'Amministratore" -tosUrl: "Termini di servizio URL" +maintainerName: "Nome dell'amministratore" +maintainerEmail: "Indirizzo e-mail dell'amministratore" +tosUrl: "URL dei termini del servizio e della privacy" thisYear: "Anno" thisMonth: "Mese" today: "Oggi" @@ -322,13 +333,13 @@ monthX: "{month}" yearX: "{year}" pages: "Pagine" integration: "App collegate" -connectService: "Connessione" -disconnectService: "Disconnessione " -enableLocalTimeline: "Abilita Timeline locale" -enableGlobalTimeline: "Abilita Timeline federata" +connectService: "Connetti" +disconnectService: "Disconnetti" +enableLocalTimeline: "Abilita la timeline locale" +enableGlobalTimeline: "Abilita la timeline federata" disablingTimelinesInfo: "Anche disabilitandole, gli Amministratori e i Moderatori potranno comunque accedervi." registration: "Iscriviti" -enableRegistration: "Permettere nuove registrazioni" +enableRegistration: "Consenti a chiunque di registrarsi" invite: "Invita" driveCapacityPerLocalAccount: "Capienza del Drive per profilo locale" driveCapacityPerRemoteAccount: "Capienza del Drive per profilo remoto" @@ -388,17 +399,20 @@ about: "Informazioni" aboutMisskey: "Informazioni di CherryPick" administrator: "Amministratore" token: "Token" -twoStepAuthentication: "Autenticazione a due fattori" +2fa: "Autenticazione a due fattori" +totp: "App di autenticazione" +totpDescription: "Inserisci un codice OTP tramite un'app di autenticazione" moderator: "Moderatore" moderation: "moderazione" nUsersMentioned: "{n} profili menzionati" +securityKeyAndPasskey: "Chiave di sicurezza e accesso" securityKey: "Chiave di sicurezza" -securityKeyName: "Nome della chiave" -registerSecurityKey: "Registra una chiave di sicurezza" lastUsed: "Ultima attività" +lastUsedAt: "Uso più recente: {t}" unregister: "Annulla l'iscrizione" passwordLessLogin: "Accedi senza password" -resetPassword: "Reimposta password" +passwordLessLoginDescription: "Accedi senza password, usando la chiave di sicurezza" +resetPassword: "Ripristina la password" newPasswordIs: "La tua nuova password è「{password}」" reduceUiAnimation: "Ridurre le animazioni dell'interfaccia" share: "Condividi" @@ -451,7 +465,6 @@ passwordMatched: "Corretta" passwordNotMatched: "Le password non corrispondono." signinWith: "Accedi con {x}" signinFailed: "Autenticazione non riuscita. Controlla la tua password e nome utente." -tapSecurityKey: "Premi la chiave di sicurezza" or: "oppure" language: "Lingua" uiLanguage: "Lingua di visualizzazione dell'interfaccia" @@ -462,8 +475,11 @@ native: "Nativo" disableDrawer: "Non mostrare il menù sul drawer" youHaveNoGroups: "Nessun gruppo" joinOrCreateGroup: "Puoi creare il tuo gruppo o essere invitat@ a gruppi che già esistono." +showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mouse" noHistory: "Nessuna cronologia" signinHistory: "Storico degli accessi al profilo" +enableAdvancedMfm: "Attiva MFM avanzati" +enableAnimatedMfm: "Attiva MFM animati" doing: "In corso..." category: "Categoria" tags: "Tag" @@ -472,8 +488,10 @@ createAccount: "Crea il tuo profilo" existingAccount: "Profilo esistente" 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" -openImageInNewTab: "Aprire immagini in una nuova scheda" +openImageInNewTab: "Apri le immagini in un nuovo tab" dashboard: "Pannello di controllo" local: "Locale" remote: "Remoto" @@ -505,9 +523,11 @@ objectStorageUseSSLDesc: "Disabilita quest'opzione se non utilizzi HTTPS per le objectStorageUseProxy: "Usa proxy" objectStorageUseProxyDesc: "Disabilita quest'opzione se non usi proxy per la connessione API." objectStorageSetPublicRead: "Imposta \"visibilità pubblica\" al momento di caricare" +s3ForcePathStyleDesc: "L'attivazione di s3ForcePathStyle impone di specificare il nome del bucket come parte del percorso nell'URL anziché del nome host. Potrebbe tornare utile quando si utilizzano applicazioni come Minio." serverLogs: "Log del server" deleteAll: "Cancella cronologia" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" +showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline" newNoteRecived: "Vedi le nuove note" sounds: "Impostazioni suoni" sound: "Impostazioni suoni" @@ -545,6 +565,10 @@ userSuspended: "L'utente è in sospensione" userSilenced: "L'utente è silenziat@." yourAccountSuspendedTitle: "Questo profilo è sospeso" yourAccountSuspendedDescription: "Questo profilo è stato sospeso a causa di una violazione del regolamento. Per informazioni, contattare l'amministrazione. Si prega di non creare un nuovo account." +tokenRevoked: "Il token non è valido" +tokenRevokedDescription: "Il token di accesso è scaduto. Per favore, accedi nuovamente." +accountDeleted: "Profilo eliminato" +accountDeletedDescription: "Questo profilo è stato eliminato." menu: "Menù" divider: "Linea di separazione" addItem: "Aggiungi elemento" @@ -558,9 +582,9 @@ invisibleNote: "Nota invisibile" enableInfiniteScroll: "Abilita scorrimento infinito" visibility: "Visibilità" poll: "Sondaggio" -useCw: "Nascondere media" -enablePlayer: "Apri in lettore video" -disablePlayer: "Chiudi lettore video" +useCw: "Content Warning" +enablePlayer: "Visualizza" +disablePlayer: "Chiudi" expandTweet: "Espandi tweet" themeEditor: "Editor di temi" description: "Descrizione" @@ -573,7 +597,7 @@ plugins: "Estensioni" preferencesBackups: "Backup delle impostazioni" deck: "Deck" undeck: "Esci dal deck" -useBlurEffectForModal: "Utilizza effetto sfocatura per i modali" +useBlurEffectForModal: "Utilizza effetto sfocatura per le finestre modali" useFullReactionPicker: "Usa la totalità del pannello di reazioni" width: "Larghezza" height: "Altezza" @@ -588,10 +612,9 @@ tokenRequested: "Autorizza accesso al profilo" pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate qui." notificationType: "Tipo di notifiche" edit: "Modifica" -useStarForReactionFallback: "Se è sconosciuto l'emoji di reazione, usare la ★ come alternativa." emailServer: "Server email" enableEmail: "Abilita consegna email" -emailConfigInfo: "Utilizzato per verificare il tuo indirizzo di posta elettronica e per reimpostare la tua password" +emailConfigInfo: "Utilizzato per verificare il tuo indirizzo di posta elettronica e per ripristinare la password" email: "Email" emailAddress: "Indirizzo di posta elettronica" smtpConfig: "Impostazioni del server SMTP" @@ -602,7 +625,7 @@ smtpPass: "Password" emptyToDisableSmtpAuth: "Lasciare il nome utente e la password vuoti per disabilitare la verifica SMTP" smtpSecure: "Usare la porta SSL/TLS implicito per le connessioni SMTP" smtpSecureInfo: "Disabilitare quando è attivo STARTTLS." -testEmail: "Testare la consegna di posta elettronica" +testEmail: "Testa la consegna di posta elettronica" wordMute: "Filtri parole" regexpError: "errore regex" regexpErrorDescription: "Si è verificato un errore nell'espressione regolare alla riga {line} della parola muta {tab}:" @@ -615,7 +638,7 @@ metrics: "Statistiche" overview: "Anteprima" logs: "Log" delayed: "Ritardo" -database: "Base di dati" +database: "Base dati" channel: "Canale" create: "Crea" notificationSetting: "Impostazioni notifiche" @@ -704,7 +727,7 @@ narrow: "Stretto" reloadToApplySetting: "Le tue preferenze verranno impostate dopo il ricaricamento della pagina. Vuoi ricaricare adesso?" needReloadToApply: "È necessario riavviare per rendere effettive le modifiche." showTitlebar: "Visualizza la barra del titolo" -clearCache: "Svuota cache" +clearCache: "Svuota la cache" onlineUsersCount: "{n} utenti online" nUsers: "{n} utenti" nNotes: "{n}Note" @@ -729,9 +752,9 @@ currentVersion: "Versione attuale" latestVersion: "Ultima versione" youAreRunningUpToDateClient: "Stai usando la versione più recente del client." newVersionOfClientAvailable: "Una nuova versione del tuo client è disponibile." -usageAmount: "In utilizzo" +usageAmount: "In uso" capacity: "Capacità" -inUse: "In utilizzo" +inUse: "In uso" editCode: "Modifica codice" apply: "Applica" receiveAnnouncementFromInstance: "Ricevi i messaggi informativi dall'istanza" @@ -780,8 +803,9 @@ gallery: "Galleria" recentPosts: "Le più recenti" popularPosts: "Le più visualizzate" shareWithNote: "Condividere in nota" -ads: "Pubblicità" +ads: "Banner" expiration: "Scadenza" +startingperiod: "Periodo di inizio" memo: "Promemoria" priority: "Priorità" high: "Alta" @@ -808,16 +832,17 @@ translatedFrom: "Tradotto 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" -keepCw: "Mantieni il CW" +keepCw: "Mantieni il Content Warning" pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" resolved: "Risolto" unresolved: "Non risolto" -breakFollow: "Smetti di seguire" +breakFollow: "Non seguire" +breakFollowConfirm: "Vuoi davvero togliere follower?" itsOn: "Abilitato" itsOff: "Disabilitato" emailRequiredForSignup: "L'ndirizzo e-mail è obbligatorio per registrarsi" -unread: "Non letto" +unread: "Non lette" filter: "Filtri" controlPanel: "Pannello di controllo" manageAccounts: "Gestisci i profili" @@ -850,16 +875,20 @@ instanceDefaultLightTheme: "Istanza, tema luminoso predefinito." instanceDefaultDarkTheme: "Istanza, tema scuro predefinito." instanceDefaultThemeDescription: "Compilare il codice del tema nel modulo dell'oggetto." mutePeriod: "Durata del mute" +period: "Scadenza" indefinitely: "Non scade" tenMinutes: "10 minuti" oneHour: "1 ora" oneDay: "1 giorno" oneWeek: "1 settimana" +oneMonth: "Un mese" reflectMayTakeTime: "Potrebbe essere necessario un po' di tempo perché ciò abbia effetto." failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul profilo" -rateLimitExceeded: "Superato il limite di velocità." -cropImage: "Ritaglio dell'immagine" -cropImageAsk: "Si desidera ritagliare l'immagine?" +rateLimitExceeded: "Superato il limite di richieste." +cropImage: "Ritaglia l'immagine" +cropImageAsk: "Vuoi ritagliare l'immagine?" +cropYes: "Ritaglia" +cropNo: "Non ritagliare" file: "Allegati" recentNHours: "Ultime {n} ore" recentNDays: "Ultimi {n} giorni" @@ -910,6 +939,7 @@ pushNotificationNotSupported: "Il client o il server non supporta le notifiche p sendPushNotificationReadMessage: "Elimina le notifiche push dopo la relativa lettura" sendPushNotificationReadMessageCaption: "Se possibile, verrà mostrata brevemente una notifica con il testo \"{emptyPushNotificationMessage}\". Potrebbe influire negativamente sulla durata della batteria." windowMaximize: "Ingrandisci" +windowMinimize: "Contrai finestra" windowRestore: "Ripristina" caption: "Didascalia" loggedInAsBot: "Connessione come Bot" @@ -935,9 +965,87 @@ manageCustomEmojis: "Gestisci le emoji personalizzate" youCannotCreateAnymore: "Non puoi creare, hai raggiunto il limite." cannotPerformTemporary: "Indisponibilità temporanea" cannotPerformTemporaryDescription: "L'attività non può essere svolta, poiché si è raggiunto il limite di esecuzioni possibili. Per favore, riprova più tardi." +invalidParamError: "Errore, parametro non valido" +invalidParamErrorDescription: "Riscontrato un errore nei parametri della richiesta. Solitamente potrebbe essere un malfunzionamento, ma a volte potrebbe essere causato dalla quantità eccessiva di caratteri nel parametro." +permissionDeniedError: "Errore, attività non autorizzata" +permissionDeniedErrorDescription: "Non si dispone dell'autorizzazione per eseguire questa operazione." preset: "Preimpostato" selectFromPresets: "Seleziona preimpostato" achievements: "Obiettivi raggiunti" +gotInvalidResponseError: "Risposta del server non valida" +gotInvalidResponseErrorDescription: "Il server potrebbe essere irraggiungibile o in manutenzione. Riprova più tardi." +thisPostMayBeAnnoying: "Questa nota potrebbe essere offensiva" +thisPostMayBeAnnoyingHome: "Pubblica sulla timeline principale" +thisPostMayBeAnnoyingCancel: "Annulla" +thisPostMayBeAnnoyingIgnore: "Pubblica lo stesso" +collapseRenotes: "Comprimi i Rinota già letti" +internalServerError: "Errore interno del server" +internalServerErrorDescription: "Si è verificato un errore imprevisto all'interno del server" +copyErrorInfo: "Copia le informazioni sull'errore" +joinThisServer: "Registrati su questa istanza" +exploreOtherServers: "Trova altre istanze" +letsLookAtTimeline: "Sbircia la timeline" +disableFederationConfirm: "Vuoi davvero disattivare la federazione?" +disableFederationConfirmWarn: "Anche se defederate, le Note continueranno ad essere pubbliche, se non diversamente specificato. Di solito, non è necessario far questo." +disableFederationOk: "Disabilita federazione" +invitationRequiredToRegister: "L'accesso a questa istanza è solo ad invito. Può registrarsi solo chi ha un codice fornito dall'amministrazione." +emailNotSupported: "L'istanza non supporta l'invio di email" +postToTheChannel: "Pubblica nel canale" +cannotBeChangedLater: "Non sarà più modificabile" +reactionAcceptance: "Reazioni consentite" +likeOnly: "Solo i Like" +likeOnlyForRemote: "Solo Like remoti" +rolesAssignedToMe: "I miei ruoli" +resetPasswordConfirm: "Vuoi davvero ripristinare la password?" +sensitiveWords: "Parole sensibili" +sensitiveWordsDescription: "Imposta automaticamente \"Home\" alla visibilità delle Note che contengono una qualsiasi parola tra queste configurate. Puoi separarle per riga." +notesSearchNotAvailable: "Non è possibile cercare tra le Note." +license: "Licenza" +unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?" +myClips: "Le mie Clip" +drivecleaner: "Drive cleaner" +retryAllQueuesNow: "Ritenta di consumare tutte le code" +retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?" +retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente." +enableChartsForRemoteUser: "Abilita i grafici per i profili remoti" +enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate" +showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note" +largeNoteReactions: "Ingrandisci le reazioni" +noteIdOrUrl: "ID della Nota o URL" +video: "Video" +videos: "Video" +dataSaver: "Risparmia dati" +accountMigration: "Migrazione del profilo" +accountMoved: "Questo profilo ha migrato altrove:" +forceShowAds: "Mostra sempre i banner" +addMemo: "Aggiungi Memo" +editMemo: "Modifica Memo" +notificationDisplay: "Stile delle notifiche" +leftTop: "In alto a sinistra" +rightTop: "In alto a destra" +leftBottom: "In basso a sinistra" +rightBottom: "In basso a destra" +stackAxis: "Allineamento" +vertical: "Verticale" +horizontal: "Laterale" +position: "Posizione" +serverRules: "Regolamento" +pleaseConfirmBelowBeforeSignup: "Ai sensi del regolamento EU 679/2016 GDPR, autorizzo il trattamento dati personali come descritto nella informativa Privacy." +pleaseAgreeAllToContinue: "Per continuare, occorre selezionare ed essere d'accordo su tutto." +continue: "Continua" +youFollowing: "Seguiti" +options: "Opzioni del ruolo" +_serverRules: + description: "In Europa è necessario mostrare l'informativa sul trattamento dei dati personali, prima della registrazione al servizio." +_accountMigration: + moveFrom: "Migra un altro profilo dentro a questo" + moveFromLabel: "Profilo da cui migrare:" + 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" + moveTo: "Migrare questo profilo verso un un altro" + moveToLabel: "Profilo verso cui migrare" + moveAccountDescription: "Questa attività è irreversibile! Innanzitutto, assicurati di aver creato, nella istanza di destinazione, un alias con l'indirizzo di questo profilo. Successivamente, indica qui il profilo di destinazione in questo modo: @persona@istanza.it" + 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." + movedTo: "Profilo verso cui migrare" _achievements: earnedAt: "Data di conseguimento" _types: @@ -993,53 +1101,53 @@ _achievements: flavor: "Hai molto da scrivere?" _login3: title: "Principiante I" - description: "Accedi per un totale di 3 giorni" + description: "Hai totalizzato 3 accessi!" flavor: "Da oggi, chiamatemi Misskist" _login7: title: "Principiante II" - description: "Accedi per un totale di 7 giorni" + description: "Hai totalizzato 7 accessi!" flavor: "Ti sembra di avere la situazione sotto controllo?" _login15: title: "Principiante III" - description: "Accedi per un totale di 15 giorni" + description: "Hai totalizzato 15 accessi!" _login30: title: "Misskist I" - description: "Accedi per un totale di 30 giorni" + description: "Hai totalizzato 30 accessi!" _login60: title: "Misskeist II" - description: "Accedi per un totale di 60 giorni" + description: "Hai totalizzato 60 accessi!" _login100: title: "Misskeist III" - description: "Accedi per un totale di 100 giorni" + description: "Hai totalizzato 100 accessi!" flavor: "Violent Misskeist" _login200: - title: "Regolare I" - description: "Accedi per un totale di 200 giorni" + title: "Regolare I livello" + description: "Hai totalizzato 200 accessi!" _login300: - title: "Regolare II" - description: "Accedi per un totale di 300 giorni" + title: "Regolare II livello" + description: "Hai totalizzato 300 accessi!" _login400: - title: "Regolare III" - description: "Accedi per un totale di 400 giorni" + title: "Regolare III livello" + description: "Hai totalizzato 400 accessi!" _login500: - title: "Professionista I" - description: "Accedi per un totale di 500 giorni" + title: "Professionista I livello" + description: "Hai totalizzato 500 accessi!" flavor: "Amici cari, mi piacciono le Note" _login600: - title: "Professionista II" - description: "Accedi per un totale di 600 giorni" + title: "Professionista II livello" + description: "Hai totalizzato 600 accessi!" _login700: - title: "Professionista III" - description: "Accedi per un totale di 700 giorni" + title: "Professionista III livello" + description: "Hai totalizzato 700 accessi!" _login800: - title: "Maestro di Note I" - description: "Accedi per un totale di 800 giorni" + title: "Maestro di Note I livello" + description: "Hai totalizzato 800 accessi!" _login900: - title: "Maestro di Note II" - description: "Accedi per un totale di 900 giorni" + title: "Maestro di Note II livello" + description: "Hai totalizzato 900 accessi!" _login1000: - title: "Maestro di Note III" - description: "Accedi per un totale di 1.000 giorni" + title: "Maestro di Note III livello" + description: "Hai totalizzato 1000 accessi!" flavor: "Grazie per aver usato CherryPick!" _noteClipped1: title: "Devo clippare!" @@ -1051,7 +1159,7 @@ _achievements: title: "Fornitura stelline" description: "Qualcuno ha preferito una delle tue Note" _profileFilled: - title: "Perfettamente" + title: "Preparazione perfetta!" description: "Imposta il tuo profilo" _markedAsCat: title: "Io sono un gatto" @@ -1074,7 +1182,7 @@ _achievements: description: "Hai seguito 300 profili" _followers1: title: "Il primo profilo tuo Follower" - description: "Hai ottenuto il tuo primo Follower" + description: "Hai ottenuto il tuo primo profilo Follower" _followers10: title: "Follow me!" description: "Hai ottenuto 10 profili Follower" @@ -1109,6 +1217,9 @@ _achievements: _client30min: title: "Piccola grande pausa" description: "Hai passato più di 30 minuti su CherryPick" + _client60min: + title: "CherryPick negli occhi" + description: "Hai letto CherryPick almeno per un'ora" _noteDeletedWithin1min: title: "Ooops!" description: "Hai eliminato una nota entro un minuto dalla sua pubblicazione" @@ -1197,6 +1308,10 @@ _role: iconUrl: "URL dell'icona" asBadge: "Mostra come badge" descriptionOfAsBadge: "Se indicato, accanto al nome utente viene visualizzata l'icona del ruolo." + isExplorable: "La timeline del ruolo è pubblica" + descriptionOfIsExplorable: "Selezionandolo, la timeline del ruolo diventerà accessibile pubblicamente. Tranne se il ruolo non è pubblico." + displayOrder: "Ordine di visualizzazione" + descriptionOfDisplayOrder: "I valori più alti vengono visualizzati per primi" canEditMembersByModerator: "Anche i Moderatori assegnano profili a questo ruolo" descriptionOfCanEditMembersByModerator: "Se disattivo, potranno farlo solamente gli Amministratori." priority: "Priorità" @@ -1222,6 +1337,7 @@ _role: rateLimitFactor: "Limite del rapporto" descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più." canHideAds: "Può nascondere i banner" + canSearchNotes: "Ricercare nelle Note" _condition: isLocal: "Profilo locale" isRemote: "Profilo remoto" @@ -1231,6 +1347,8 @@ _role: followersMoreThanOrEq: "Ha più di N follower" followingLessThanOrEq: "Segue N profili o meno" followingMoreThanOrEq: "Segue N profili o più" + notesLessThanOrEq: "Conteggio Note inferiore o uguale a" + notesMoreThanOrEq: "Conteggio Note maggiore o uguale a" and: "E" or: "O" not: "NON" @@ -1269,8 +1387,8 @@ _ad: hide: "Nascondi" _forgotPassword: enterEmail: "Inserisci l'indirizzo di posta elettronica che hai registrato nel tuo profilo. Il collegamento necessario per ripristinare la password verrà inviato a questo indirizzo." - ifNoEmail: "Se nessun indirizzo e-mail è stato registrato, si prega di contattare l'amministratore·trice dell'istanza." - contactAdmin: "Poiché questa istanza non permette l'utilizzo di una mail, si prega di contattare l'amministratore·trice dell'istanza per poter ripristinare la password." + ifNoEmail: "Se il tuo indirizzo email non risulta registrato, contatta l'amministrazione dell'istanza." + contactAdmin: "Poiché questa istanza non permette di impostare l'indirizzo mail, contatta l'amministrazione per ripristinare la password.\n" _gallery: my: "Le mie pubblicazioni" liked: "Pubblicazioni che mi piacciono" @@ -1406,6 +1524,8 @@ _channel: following: "Seguiti" usersCount: "{n} partecipanti" notesCount: "{n} note" + nameAndDescription: "Nome e descrizione" + nameOnly: "Solo il nome" _menuDisplay: sideFull: "Laterale" sideIcon: "Laterale (solo icone)" @@ -1517,58 +1637,48 @@ _ago: weeksAgo: "{n} sett. fa" monthsAgo: "{n} mesi fa" yearsAgo: "{n} anni fa" + invalid: "Niente da visualizzare" _time: second: "s" minute: "min" hour: "ore" day: "giorni" -_tutorial: - title: "Come usare CherryPick" - step1_1: "Eccoci!" - step1_2: "Questa pagina si chiama una \" Timeline \". Mostra in ordine cronologico le \" note \" delle persone che segui." - step1_3: "Attualmente la tua Timeline è vuota perché non segui alcun profilo e non hai pubblicato alcuna nota ancora." - step2_1: "Prima di scrivere una «Nota» o di seguire altri profili, prepara il tuo profilo!" - step2_2: "Aggiungere qualche informazione su di te aumenterà le tue possibilità di essere seguit@ da altre persone. " - step3_1: "Hai finito di impostare il tuo profilo?" - step3_2: "Ora puoi pubblicare una «Nota». Proviamo subito! Premi il bottone con l'icona «penna» per iniziare a scrivere in una finestra di dialogo. " - step3_3: "Scritto il testo della nota, puoi pubblicarla premendo il pulsante nella parte superiore destra della finestra di dialogo." - step3_4: "Non ti viene niente in mente? Perché non scrivi semplicemente \"Ho appena iniziato a usare CherryPick\"?" - step4_1: "Hai pubblicato qualcosa?" - step4_2: "Se puoi visualizzare la tua nota sulla timeline, ce l'hai fatta!" - step5_1: "Adesso, cerca di seguire altre persone per vivacizzare la tua timeline. " - step5_2: "La pagina {featured} mostra le note di tendenza su questa istanza, magari ti aiuterà a trovare profili che ti piacciono e che vorrai seguire. Altrimenti potrai trovare utenti popolari anche usando {explore}." - step5_3: "Clicca l'immagine per aprire il profilo e premere il bottone \"Seguire\"" - step5_4: "Se l'altro profilo ha un lucchetto vicino al nome, significa che occorre un po' di tempo prima che approvi manualmente la tua richiesta di follow." - step6_1: "Adesso, dovresti essere in grado di vedere le note dagli altri profili sulla tua timeline." - step6_2: "Puoi anche rispondere alle note con un click, scegliendo le reazioni immediate." - step6_3: "Per inviare una reazione, premi l'icona + della nota e scegli l'emoji che vuoi mandare." - step7_1: "Congratulazioni! Hai completato l'esercitazione iniziale su come usare CherryPick." - step7_2: "Se vuoi saperne di più su CherryPick, puoi dare un'occhiata alla sezione {help}." - step7_3: "Da ultimo, buon divertimento su CherryPick! 🚀" - step8_1: "Per concludere, vuoi attivare le notifiche push?" - step8_2: "Attivandole, otterrai notifiche di follow, reazioni e menzioni anche quando CherryPick è chiuso." - step8_3: "Puoi anche modificare questa impostazione successivamente." _2fa: alreadyRegistered: "La configurazione è stata già completata." - registerDevice: "Aggiungi dispositivo" - registerKey: "Chiave di registro." + registerTOTP: "Registra un'app di autenticazione" + passwordToTOTP: "Inserire la password" step1: "Innanzitutto, installare sul dispositivo un'applicazione di autenticazione come {a} o {b}." step2: "Quindi, scansionare il codice QR visualizzato con l'app." + step2Click: "Cliccando sul codice QR, puoi registrarlo con l'app di autenticazione o il portachiavi installato sul tuo dispositivo." step2Url: "Nell'applicazione desktop inserire il seguente URL: " + step3Title: "Inserisci il codice di verifica" step3: "Inserite il token visualizzato nell'app e il gioco è fatto." step4: "D'ora in poi, quando si accede, si inserisce il token nello stesso modo." + securityKeyNotSupported: "Il tuo browser non supporta le chiavi di sicurezza." + registerTOTPBeforeKey: "Ti occorre un'app di autenticazione con OTP, prima di registrare la chiave di sicurezza." securityKeyInfo: "È possibile impostare il dispositivo per accedere utilizzando una chiave di sicurezza hardware che supporta FIDO2 o un'impronta digitale o un PIN sul dispositivo." + chromePasskeyNotSupported: "Le passkey di Chrome non sono attualmente supportate." + registerSecurityKey: "Registra la chiave di sicurezza" + securityKeyName: "Inserisci il nome della chiave" + tapSecurityKey: "Segui le istruzioni del browser e registra la chiave di sicurezza." + removeKey: "Elimina la chiave di sicurezza" + removeKeyConfirm: "Vuoi davvero eliminare \"{name}\"?" + whyTOTPOnlyRenew: "Se c'è una chiave di sicurezza attiva, non è possibile rimuovere l'app di autenticazione." + renewTOTP: "Riconfigura l'app di autenticazione" + renewTOTPConfirm: "I codici di verifica nelle app di autenticazione esistenti smetteranno di funzionare" + renewTOTPOk: "Ripristina" + renewTOTPCancel: "No grazie" _permissions: - "read:account": "Visualizzare le informazioni sul profilo" - "write:account": "Modificare le informazioni sul profilo" + "read:account": "Visualizza le informazioni sul profilo" + "write:account": "Modifica le informazioni sul profilo" "read:blocks": "Visualizza i profili bloccati" "write:blocks": "Gestisci i profili bloccati" - "read:drive": "Aprire il Drive" - "write:drive": "Gestire il Drive" + "read:drive": "Apri il Drive" + "write:drive": "Gestisci il Drive" "read:favorites": "Visualizza i tuoi preferiti" "write:favorites": "Gestisci i tuoi preferiti" "read:following": "Vedi le informazioni di follow" - "write:following": "Seguiti/ Smetti di seguire" + "write:following": "Seguire / Non seguire altri profili" "read:messaging": "Visualizzare la chat" "write:messaging": "Gestire la chat" "read:mutes": "Vedi i profili silenziati" @@ -1592,12 +1702,15 @@ _permissions: "read:gallery-likes": "Visualizza i contenuti della galleria." "write:gallery-likes": "Manipolazione dei \"Mi piace\" della galleria." _auth: + shareAccessTitle: "Permessi dell'applicazione" shareAccess: "Vuoi autorizzare {name} ad accedere al tuo profilo?" shareAccessAsk: "Vuoi autorizzare questa App ad accedere al tuo profilo?" + permission: "{name} richiede i permessi seguenti" permissionAsk: "Questa app richiede le seguenti autorizzazioni:" pleaseGoBack: "Si prega di ritornare sulla app" callback: "Ritornando sulla app" denied: "Accesso negato" + pleaseLogin: "Per favore accedi al tuo account per cambiare i permessi dell'applicazione" _antennaSources: all: "Tutte le note" homeTimeline: "Note dagli utenti che segui" @@ -1644,7 +1757,7 @@ _widgets: clicker: "Cliccaggio" _cw: hide: "Nascondere" - show: "Mostra di più" + show: "Apri..." chars: "{count} caratteri" files: "{count} file" _poll: @@ -1673,24 +1786,24 @@ _visibility: public: "Pubblica" publicDescription: "Visibile per tutti sul Fediverso" home: "Home" - homeDescription: "Visibile solo sulla timeline \"Home\"" - followers: "Followers" - followersDescription: "Visibile solo per i tuoi followers" - specified: "Diretta" + homeDescription: "Visibile solo sulla timeline locale" + followers: "Follower" + followersDescription: "Visibile solo ai tuoi follower" + specified: "Nota diretta" specifiedDescription: "Visibile solo ai profili menzionati" - localOnly: "Soltanto locale" - localOnlyDescription: "Non visibile ai profili remoti" + disableFederation: "Federazione disabilitata" + disableFederationDescription: "Non spedire attività alle altre istanze remote" _postForm: - replyPlaceholder: "Nota la tua risposta.." - quotePlaceholder: "Cita Nota..." - channelPlaceholder: "Pubblica in canale" + replyPlaceholder: "Rispondi a questa nota..." + quotePlaceholder: "Cita questa nota..." + channelPlaceholder: "Pubblica sul canale..." _placeholders: - a: "Che succede?" - b: "È successo qualcosa?" - c: "Che cos'hai in mente?" + a: "Come va?" + b: "Hai qualcosa da raccontare? Inizia pure..." + c: "Stai pensando a qualcosa?" d: "Vuoi dire qualcosa?" - e: "Scrivi qualcosa qui" - f: "Aspettando che scriva..." + e: "Puoi scrivere qui..." + f: "Inizia pure a scrivere..." _profile: name: "Nome" username: "Nome utente" @@ -1706,7 +1819,7 @@ _profile: _exportOrImport: allNotes: "Tutte le note" favoritedNotes: "Note preferite" - followingList: "Follows" + followingList: "Follow" muteList: "Elenco profili silenziati" blockingList: "Elenco profili bloccati" userLists: "Liste" @@ -1721,9 +1834,9 @@ _charts: notesIncDec: "Variazione del numero di note" localNotesIncDec: "Variazione del numero di note locali" remoteNotesIncDec: "Variazione del numero di note distanti" - notesTotal: "Conteggio totale di note" + notesTotal: "Numero di note in totale" filesIncDec: "Variazione del numero dei file" - filesTotal: "Numero totale di file" + filesTotal: "Numero di file in totale" storageUsageIncDec: "Variazione dell'utilizzo dell'immagazzinamento" storageUsageTotal: "Utilizzo totale dell'immagazzinamento" _instanceCharts: @@ -1815,7 +1928,7 @@ _notification: fileUploaded: "File caricato correttamente" youGotMention: "{name} ti ha menzionato" youGotReply: "{name} ti ha risposto" - youGotQuote: "{name} ha citato il tuo Nota e ha detto" + youGotQuote: "{name} ha citato la tua Nota e ha detto" youRenoted: "{name} ha rinotato" youGotMessagingMessageFromUser: "{name} ti ha mandato un messaggio" youGotMessagingMessageFromGroup: "{name} ti ha mandato un messaggio nella chat" @@ -1839,6 +1952,7 @@ _notification: receiveFollowRequest: "Richiesta di follow ricevuta" followRequestAccepted: "Richiesta di follow accettata" groupInvited: "Invito a un gruppo" + achievementEarned: "Risultato raggiunto" app: "Notifiche da applicazioni" _actions: followBack: "Segui" @@ -1871,3 +1985,27 @@ _deck: channel: "Canale" mentions: "Menzioni" direct: "Diretta" + roleTimeline: "Timeline Ruolo" +_dialog: + charactersExceeded: "Hai superato il limite di {max} caratteri! ({corrente})" + charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({corrente})" +_disabledTimeline: + title: "Timeline disabilitata" + description: "Il ruolo in cui sei non ti permette di leggere questa timeline" +_drivecleaner: + orderBySizeDesc: "Dal più grande al più piccolo" + orderByCreatedAtAsc: "Dal più vecchio al più recente" +_webhookSettings: + createWebhook: "Creazione Webhook" + name: "Nome" + secret: "Segreto" + events: "Quando eseguire il Webhook" + active: "Attivo" + _events: + follow: "Quando segui un profilo" + followed: "Quando ti segue un profilo" + note: "Quando pubblichi una Nota" + reply: "Quando rispondono ad una Nota" + renote: "Quando la Nota è Rinotata" + reaction: "Quando ricevo una reazione" + mention: "Quando mi menzionano" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1ec87be63e..aa7cee4087 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -11,7 +11,7 @@ cherrypickUpdatedCacheClear: "テーマや色、ロケールなどの変更が cherrypickUpdatedCacheClearLater: "あとでキャッシュをクリアするには、設定 - キャッシュをクリアでできます!" headlineMisskey: "ノートでつながるネットワーク" introMisskey: "ようこそ!CherryPickは、オープンソースの分散型マイクロブログサービスです。\n「ノート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀" -poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームCherryPickを使ったサービス(CherryPickインスタンスと呼ばれます)のひとつです。" +poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームCherryPickのサーバーのひとつです。" monthAndDay: "{month}月 {day}日" search: "検索" notifications: "通知" @@ -27,8 +27,9 @@ enterUsername: "ユーザー名を入力" renotedBy: "{user}がRenote" noNotes: "ノートはありません" noNotifications: "通知はありません" -instance: "インスタンス" +instance: "サーバー" settings: "設定" +notificationSettings: "通知の設定" basicSettings: "基本設定" otherSettings: "その他の設定" openInWindow: "ウィンドウで開く" @@ -80,7 +81,7 @@ import: "インポート" export: "エクスポート" files: "ファイル" download: "ダウンロード" -driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを添付したノートも消えます。" +driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを使用した全てのコンテンツからも削除されます。" unfollowConfirm: "{name}のフォローを解除しますか?" exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。" importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。" @@ -135,6 +136,8 @@ unmarkAsSensitive: "閲覧注意を解除する" enterFileName: "ファイル名を入力" mute: "ミュート" unmute: "ミュート解除" +renoteMute: "リノートをミュート" +renoteUnmute: "リノートのミュートを解除" block: "ブロック" unblock: "ブロック解除" suspend: "凍結" @@ -166,6 +169,7 @@ flagShowTimelineReplies: "タイムラインにノートへの返信を表示す flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" addAccount: "アカウントを追加" +reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗しました" showOnRemote: "リモートで表示" general: "全般" @@ -176,13 +180,13 @@ searchWith: "検索: {q}" youHaveNoLists: "リストがありません" followConfirm: "{name}をフォローしますか?" proxyAccount: "プロキシアカウント" -proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがインスタンスに配達されないため、代わりにプロキシアカウントがフォローするようにします。" +proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがサーバーに配達されないため、代わりにプロキシアカウントがフォローするようにします。" host: "ホスト" selectUser: "ユーザーを選択" recipient: "宛先" annotation: "注釈" federation: "連合" -instances: "インスタンス" +instances: "サーバー" registeredAt: "初観測" latestRequestReceivedAt: "直近のリクエスト受信" latestStatus: "直近のステータス" @@ -191,7 +195,7 @@ charts: "チャート" perHour: "1時間ごと" perDay: "1日ごと" stopActivityDelivery: "アクティビティの配送を停止" -blockThisInstance: "このインスタンスをブロック" +blockThisInstance: "このサーバーをブロック" operations: "操作" software: "ソフトウェア" version: "バージョン" @@ -202,15 +206,15 @@ jobQueue: "ジョブキュー" cpuAndMemory: "CPUとメモリ" network: "ネットワーク" disk: "ディスク" -instanceInfo: "インスタンス情報" +instanceInfo: "サーバー情報" statistics: "統計" clearQueue: "キューをクリア" clearQueueConfirmTitle: "キューをクリアしますか?" clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。" clearCachedFiles: "キャッシュをクリア" clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?" -blockedInstances: "ブロックしたインスタンス" -blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。サブドメインもブロックされます。" +blockedInstances: "ブロックしたサーバー" +blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定します。ブロックされたサーバーは、このサーバーとやり取りできなくなります。サブドメインもブロックされます。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" @@ -233,9 +237,9 @@ all: "全て" subscribing: "購読中" publishing: "配信中" notResponding: "応答なし" -instanceFollowing: "インスタンスのフォロー" -instanceFollowers: "インスタンスのフォロワー" -instanceUsers: "インスタンスのユーザー" +instanceFollowing: "サーバーのフォロー" +instanceFollowers: "サーバーのフォロワー" +instanceUsers: "サーバーのユーザー" changePassword: "パスワードを変更" security: "セキュリティ" retypedNotMatch: "入力が一致しません。" @@ -272,14 +276,16 @@ noMoreHistory: "これより過去の履歴はありません" startMessaging: "チャットを開始" nUsersRead: "{n}人が読みました" agreeTo: "{0}に同意" +agree: "同意する" agreeBelow: "下記に同意する" basicNotesBeforeCreateAccount: "基本的な注意事項" -tos: "利用規約" +termsOfService: "利用規約" start: "始める" home: "ホーム" remoteUserCaution: "リモートユーザーのため、情報が不完全です。" activity: "アクティビティ" images: "画像" +image: "画像" birthday: "誕生日" yearsOld: "{age}歳" registeredDate: "登録日" @@ -327,8 +333,8 @@ unwatch: "ウォッチ解除" accept: "許可" reject: "拒否" normal: "正常" -instanceName: "インスタンス名" -instanceDescription: "インスタンスの紹介" +instanceName: "サーバー名" +instanceDescription: "サーバーの紹介" maintainerName: "管理者の名前" maintainerEmail: "管理者のメールアドレス" tosUrl: "利用規約URL" @@ -358,7 +364,7 @@ basicInfo: "基本情報" pinnedUsers: "ピン留めユーザー" pinnedUsersDescription: "「みつける」ページなどにピン留めしたいユーザーを改行で区切って記述します。" pinnedPages: "ピン留めページ" -pinnedPagesDescription: "インスタンスのトップページにピン留めしたいページのパスを改行で区切って記述します。" +pinnedPagesDescription: "サーバーのトップページにピン留めしたいページのパスを改行で区切って記述します。" pinnedClipId: "ピン留めするクリップのID" pinnedNotes: "ピン留めされたノート" hcaptcha: "hCaptcha" @@ -405,17 +411,20 @@ userList: "リスト" about: "情報" aboutMisskey: "CherryPickについて" administrator: "管理者" -token: "トークン" -twoStepAuthentication: "二段階認証" +token: "確認コード" +2fa: "二要素認証" +totp: "認証アプリ" +totpDescription: "認証アプリを使ってワンタイムパスワードを入力" moderator: "モデレーター" moderation: "モデレーション" nUsersMentioned: "{n}人が投稿" +securityKeyAndPasskey: "セキュリティキー・パスキー" securityKey: "セキュリティキー" -securityKeyName: "キーの名前" -registerSecurityKey: "セキュリティキーを登録する" lastUsed: "最後の使用" +lastUsedAt: "最後の使用: {t}" unregister: "登録を解除" -passwordLessLogin: "パスワード無しログイン" +passwordLessLogin: "パスワードレスログイン" +passwordLessLoginDescription: "パスワードを使用せず、セキュリティキーやパスキーなどのみでログインします" resetPassword: "パスワードをリセット" newPasswordIs: "新しいパスワードは「{password}」です" reduceUiAnimation: "UIのアニメーションを減らす" @@ -469,7 +478,6 @@ passwordMatched: "一致しました" passwordNotMatched: "一致していません" signinWith: "{x}でログイン" signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" -tapSecurityKey: "セキュリティキーにタッチ" or: "もしくは" language: "言語" uiLanguage: "UIの表示言語" @@ -480,6 +488,7 @@ native: "ネイティブ" disableDrawer: "メニューをドロワーで表示しない" youHaveNoGroups: "グループがありません" joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループを作成してください。" +showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する" noHistory: "履歴はありません" signinHistory: "ログイン履歴" enableAdvancedMfm: "高度なMFMを有効にする" @@ -492,6 +501,8 @@ createAccount: "アカウントを作成" existingAccount: "既存のアカウント" regenerate: "再生成" fontSize: "フォントサイズ" +mediaListWithOneImageAppearance: "画像が1枚のみのメディアリストの高さ" +limitTo: "{x}を上限に" noFollowRequests: "フォロー申請はありません" openImageInNewTab: "画像を新しいタブで開く" dashboard: "ダッシュボード" @@ -519,15 +530,17 @@ objectStoragePrefixDesc: "このprefixのディレクトリ下に格納されま objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "S3の場合は空、それ以外の場合は各サービスのendpointを指定してください。''または':'のように指定します。" objectStorageRegion: "Region" -objectStorageRegionDesc: "'xx-east-1'のようなregionを指定してください。使用サービスにregionの概念がない場合は、空または'us-east-1'にしてください。" +objectStorageRegionDesc: "'xx-east-1'のようなregionを指定してください。使用サービスにregionの概念がない場合は'us-east-1'にしてください。AWS設定ファイルまたは環境変数を参照する場合は空にしてください。" objectStorageUseSSL: "SSLを使用する" objectStorageUseSSLDesc: "API接続にhttpsを使用しない場合はオフにしてください" objectStorageUseProxy: "Proxyを利用する" objectStorageUseProxyDesc: "API接続にproxyを利用しない場合はオフにしてください" objectStorageSetPublicRead: "アップロード時に'public-read'を設定する" +s3ForcePathStyleDesc: "s3ForcePathStyleを有効にすると、バケット名をURLのホスト名ではなくパスの一部として指定することを強制します。セルフホストされたMinioなどの使用時に有効にする必要がある場合があります。" serverLogs: "サーバーログ" deleteAll: "全て削除" showFixedPostForm: "タイムライン上部に投稿フォームを表示する" +showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" newNoteRecived: "新しいノートがあります" sounds: "サウンド" sound: "サウンド" @@ -560,14 +573,19 @@ updateRemoteUser: "リモートユーザー情報の更新" deleteAllFiles: "すべてのファイルを削除" deleteAllFilesConfirm: "すべてのファイルを削除しますか?" removeAllFollowing: "フォローを全解除" -removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのインスタンスがもう存在しなくなった場合などに実行してください。" +removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのサーバーがもう存在しなくなった場合などに実行してください。" userSuspended: "このユーザーは凍結されています。" userSilenced: "このユーザーはサイレンスされています。" yourAccountSuspendedTitle: "アカウントが凍結されています" yourAccountSuspendedDescription: "このアカウントは、サーバーの利用規約に違反したなどの理由により、凍結されています。詳細については管理者までお問い合わせください。新しいアカウントを作らないでください。" +tokenRevoked: "トークンが無効です" +tokenRevokedDescription: "ログイントークンが失効しています。ログインし直してください。" +accountDeleted: "アカウントは削除されています" +accountDeletedDescription: "このアカウントは削除されています。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" +rearrange: "並び替え" relays: "リレー" addRelay: "リレーの追加" inboxUrl: "inboxのURL" @@ -608,7 +626,6 @@ tokenRequested: "アカウントへのアクセス許可" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" notificationType: "通知の種類" edit: "編集" -useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う" emailServer: "メールサーバー" enableEmail: "メール配信機能を有効化する" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" @@ -626,7 +643,7 @@ testEmail: "配信テスト" wordMute: "ワードミュート" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" -instanceMute: "インスタンスミュート" +instanceMute: "サーバーミュート" userSaysSomething: "{name}が何かを言いました" makeActive: "アクティブにする" display: "表示" @@ -657,15 +674,15 @@ abuseReported: "内容が送信されました。ご報告ありがとうござ reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートインスタンスに通報を転送する" -forwardReportIsAnonymous: "リモートインスタンスからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。" +forwardReport: "リモートサーバーに通報を転送する" +forwardReportIsAnonymous: "リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。" send: "送信" abuseMarkAsResolved: "対応済みにする" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。" -instanceTicker: "ノートのインスタンス情報" +instanceTicker: "ノートのサーバー情報" waitingFor: "{x}を待っています" random: "ランダム" system: "システム" @@ -697,7 +714,7 @@ no: "いいえ" driveFilesCount: "ドライブのファイル数" driveUsage: "ドライブ使用量" noCrawle: "クローラーによるインデックスを拒否" -noCrawleDescription: "検索エンジンにあなたのユーザーページ、ノート、Pagesなどのコンテンツを登録(インデックス)しないよう要請します。" +noCrawleDescription: "外部の検索エンジンにあなたのユーザーページ、ノート、Pagesなどのコンテンツを登録(インデックス)しないよう要求します。" lockedAccountInfo: "フォローを承認制にしても、ノートの公開範囲を「フォロワー」にしない限り、誰でもあなたのノートを見ることができます。" alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にする" loadRawImages: "添付画像のサムネイルをオリジナル画質にする" @@ -712,6 +729,8 @@ contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使う" clips: "クリップ" experimentalFeatures: "実験的機能" +experimental: "実験的" +thisIsExperimentalFeature: "これは実験的な機能です。仕様が変更されたり、正常に動作しなかったりする可能性があります。" developer: "開発者" makeExplorable: "アカウントを見つけやすくする" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。" @@ -754,7 +773,7 @@ capacity: "容量" inUse: "使用中" editCode: "コードを編集" apply: "適用" -receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" +receiveAnnouncementFromInstance: "サーバーからのお知らせを受け取る" emailNotification: "メール通知" publish: "公開" inChannelSearch: "チャンネル内検索" @@ -782,7 +801,7 @@ active: "アクティブ" offline: "オフライン" notRecommended: "非推奨" botProtection: "Botプロテクション" -instanceBlocking: "インスタンスブロック" +instanceBlocking: "サーバーブロック" selectAccount: "アカウントを選択" switchAccount: "アカウントを切り替え" enabled: "有効" @@ -835,8 +854,11 @@ lastCommunication: "直近の通信" resolved: "解決済み" unresolved: "未解決" breakFollow: "フォロワーを解除" +breakFollowConfirm: "フォロワー解除しますか?" itsOn: "オンになっています" itsOff: "オフになっています" +on: "オン" +off: "オフ" emailRequiredForSignup: "アカウント登録にメールアドレスを必須にする" unread: "未読" filter: "フィルタ" @@ -867,15 +889,17 @@ themeColor: "テーマカラー" size: "サイズ" numberOfColumn: "列の数" searchByGoogle: "検索" -instanceDefaultLightTheme: "インスタンスデフォルトのライトテーマ" -instanceDefaultDarkTheme: "インスタンスデフォルトのダークテーマ" +instanceDefaultLightTheme: "サーバーデフォルトのライトテーマ" +instanceDefaultDarkTheme: "サーバーデフォルトのダークテーマ" instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入します。" mutePeriod: "ミュートする期限" +period: "期限" indefinitely: "無期限" tenMinutes: "10分" oneHour: "1時間" oneDay: "1日" oneWeek: "1週間" +oneMonth: "1ヶ月" reflectMayTakeTime: "反映されるまで時間がかかる場合があります。" failedToFetchAccountInformation: "アカウント情報の取得に失敗しました" rateLimitExceeded: "レート制限を超えました" @@ -917,9 +941,10 @@ remoteOnly: "リモートのみ" failedToUpload: "アップロード失敗" cannotUploadBecauseInappropriate: "不適切な内容を含む可能性があると判定されたためアップロードできません。" cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" +cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。" beta: "ベータ" enableAutoSensitive: "自動NSFW判定" -enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、インスタンスによっては自動で設定されることがあります。" +enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。" navbar: "ナビゲーションバー" shuffle: "シャッフル" @@ -929,10 +954,11 @@ pushNotification: "プッシュ通知" subscribePushNotification: "プッシュ通知を有効化" unsubscribePushNotification: "プッシュ通知を停止する" pushNotificationAlreadySubscribed: "プッシュ通知は有効です" -pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に非対応" -sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" -sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" +pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応" +sendPushNotificationReadMessage: "通知が既読になったらプッシュ通知を削除する" +sendPushNotificationReadMessageCaption: "端末の電池消費量が増加する可能性があります。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "元に戻す" caption: "キャプション" loggedInAsBot: "Botアカウントでログイン中" @@ -949,6 +975,7 @@ didYouLikeMisskey: "CherryPickを気に入っていただけましたか?" pleaseDonate: "CherryPickは{host}が使用している無料のソフトウェアです。これからも開発を続けられるように、ぜひ寄付をお願いします!" roles: "ロール" role: "ロール" +noRole: "ロールはありません" normalUser: "一般ユーザー" undefined: "未定義" assign: "アサイン" @@ -958,6 +985,10 @@ manageCustomEmojis: "カスタム絵文字の管理" youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" +invalidParamError: "パラメータエラー" +invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。" +permissionDeniedError: "操作が拒否されました" +permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。" preset: "プリセット" selectFromPresets: "プリセットから選択" achievements: "実績" @@ -971,6 +1002,110 @@ collapseRenotes: "見たことのあるRenoteを省略して表示" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。" copyErrorInfo: "エラー情報をコピー" +joinThisServer: "このサーバーに登録する" +exploreOtherServers: "他のサーバーを探す" +letsLookAtTimeline: "タイムラインを見てみる" +disableFederationConfirm: "連合なしにしますか?" +disableFederationConfirmWarn: "連合なしにしても投稿は非公開になりません。ほとんどの場合、連合なしにする必要はありません。" +disableFederationOk: "連合なしにする" +invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。" +emailNotSupported: "このサーバーではメール配信はサポートされていません" +postToTheChannel: "チャンネルに投稿" +cannotBeChangedLater: "後から変更できません。" +reactionAcceptance: "リアクションの受け入れ" +likeOnly: "いいねのみ" +likeOnlyForRemote: "リモートからはいいねのみ" +rolesAssignedToMe: "自分に割り当てられたロール" +resetPasswordConfirm: "パスワードリセットしますか?" +sensitiveWords: "センシティブワード" +sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" +sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" +notesSearchNotAvailable: "ノート検索は利用できません。" +license: "ライセンス" +unfavoriteConfirm: "お気に入り解除しますか?" +myClips: "自分のクリップ" +drivecleaner: "ドライブクリーナー" +retryAllQueuesNow: "すべてのキューを今すぐ再試行" +retryAllQueuesConfirmTitle: "今すぐ再試行しますか?" +retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" +enableChartsForRemoteUser: "リモートユーザーのチャートを生成" +enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" +showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" +largeNoteReactions: "ノートのリアクションを大きく表示" +noteIdOrUrl: "ノートIDまたはURL" +video: "動画" +videos: "動画" +dataSaver: "データセーバー" +accountMigration: "アカウントの移行" +accountMoved: "このユーザーは新しいアカウントに移行しました:" +accountMovedShort: "このアカウントは移行されています" +operationForbidden: "この操作はできません" +forceShowAds: "常に広告を表示する" +addMemo: "メモを追加" +editMemo: "メモを編集" +reactionsList: "リアクション一覧" +renotesList: "Renote一覧" +notificationDisplay: "通知の表示" +leftTop: "左上" +rightTop: "右上" +leftBottom: "左下" +rightBottom: "右下" +stackAxis: "スタック方向" +vertical: "縦" +horizontal: "横" +position: "位置" +serverRules: "サーバールール" +pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。" +pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。" +continue: "続ける" +preservedUsernames: "予約ユーザー名" +preservedUsernamesDescription: "予約するユーザー名を改行で列挙します。ここで指定されたユーザー名はアカウント作成時に使えなくなりますが、管理者によるアカウント作成時はこの制限を受けません。また、既に存在するアカウントも影響を受けません。" +createNoteFromTheFile: "このファイルからノートを作成" +archive: "アーカイブ" +channelArchiveConfirmTitle: "{name}をアーカイブしますか?" +channelArchiveConfirmDescription: "アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。" +thisChannelArchived: "このチャンネルはアーカイブされています。" +displayOfNote: "ノートの表示" +initialAccountSetting: "初期設定" +youFollowing: "フォロー中" +preventAiLearning: "生成AIによる学習を拒否" +preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対して、投稿したノートや画像などのコンテンツを学習の対象にしないように要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されますが、この要求に従うかはそのAI次第であるため、学習を完全に防止するものではありません。" +options: "オプション" +specifyUser: "ユーザー指定" + +_initialAccountSetting: + accountCreated: "アカウントの作成が完了しました!" + letsStartAccountSetup: "アカウントの初期設定を行いましょう。" + letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。" + profileSetting: "プロフィール設定" + privacySetting: "プライバシー設定" + theseSettingsCanEditLater: "これらの設定は後から変更できます。" + youCanEditMoreSettingsInSettingsPageLater: "この他にも様々な設定を「設定」ページから行えます。ぜひ後で確認してみてください。" + followUsers: "タイムラインを構築するため、気になるユーザーをフォローしてみましょう。" + pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。" + initialAccountSettingCompleted: "初期設定が完了しました!" + haveFun: "{name}をお楽しみください!" + ifYouNeedLearnMore: "{name}(CherryPick)の使い方などを詳しく知るには{link}をご覧ください。" + skipAreYouSure: "初期設定をスキップしますか?" + +_serverRules: + description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" + +_accountMigration: + moveFrom: "別のアカウントからこのアカウントに移行" + moveFromSub: "別のアカウントへエイリアスを作成" + moveFromLabel: "移行元のアカウント #{n}" + moveFromDescription: "別のアカウントからこのアカウントに移行したい場合、ここでエイリアスを作成しておく必要があります。\n移行元のアカウントをこのように入力してください: @username@server.example.com\n削除するには、入力欄を空にして保存します(非推奨)。" + moveTo: "このアカウントを新しいアカウントへ移行" + moveToLabel: "移行先のアカウント:" + moveCannotBeUndone: "アカウントを移行すると、取り消すことはできません。" + moveAccountDescription: "新しいアカウントへ移行します。\n ・フォロワーが新しいアカウントを自動でフォローします\n ・このアカウントからのフォローは全て解除されます\n ・このアカウントではノートの作成などができなくなります\n\nフォロワーの移行は自動ですが、フォローの移行は手動で行う必要があります。移行前にこのアカウントでフォローエクスポートし、移行後すぐに移行先アカウントでインポートを行なってください。\nリスト・ミュート・ブロックについても同様ですので、手動で移行する必要があります。\n\n(この説明はこのサーバー(CherryPick v13.12.0以降)の仕様です。Mastodonなどの他のActivityPubソフトウェアでは挙動が異なる場合があります。)" + moveAccountHowTo: "アカウントの移行には、まずは移行先のアカウントでこのアカウントに対しエイリアスを作成します。\nエイリアス作成後、移行先のアカウントを次のように入力してください: @username@server.example.com" + startMigration: "移行する" + migrationConfirm: "本当にこのアカウントを {account} に移行しますか?一度移行すると取り消せず、二度とこのアカウントを元の状態で使用できなくなります。" + movedAndCannotBeUndone: "\nアカウントは移行されています。\n移行を取り消すことはできません。" + postMigrationNote: "このアカウントからのフォロー解除は移行操作から24時間後に実行されます。\nこのアカウントのフォロー・フォロワー数は0になっています。フォロワーの解除はされないため、あなたのフォロワーはこのアカウントのフォロワー向け投稿を引き続き閲覧できます。" + movedTo: "移行先のアカウント:" _achievements: earnedAt: "獲得日時" @@ -1143,6 +1278,9 @@ _achievements: _client30min: title: "ひとやすみ" description: "クライアントを起動してから30分以上経過した" + _client60min: + title: "CherryPickの見すぎ" + description: "クライアントを起動してから60分以上経過した" _noteDeletedWithin1min: title: "いまのなし" description: "投稿してから1分以内にその投稿を削除した" @@ -1162,7 +1300,7 @@ _achievements: description: "ホームタイムラインの流速が20npmを越す" _viewInstanceChart: title: "アナリスト" - description: "インスタンスのチャートを表示した" + description: "サーバーのチャートを表示した" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "スクラッチパッドで hello world を出力した" @@ -1199,7 +1337,7 @@ _achievements: _loggedInOnNewYearsDay: title: "あけましておめでとうございます" description: "元日にログインした" - flavor: "今年も弊インスタンスをよろしくお願いします" + flavor: "今年も弊サーバーをよろしくお願いします" _cookieClicked: title: "クッキーをクリックするゲーム" description: "クッキーをクリックした" @@ -1215,7 +1353,7 @@ _role: name: "ロール名" description: "ロールの説明" permission: "ロールの権限" - descriptionOfPermission: "モデレーターは基本的なモデレーションに関する操作を行えます。\n管理者はインスタンスの全ての設定を変更できます。" + descriptionOfPermission: "モデレーターは基本的なモデレーションに関する操作を行えます。\n管理者はサーバーの全ての設定を変更できます。" assignTarget: "アサイン" descriptionOfAssignTarget: "マニュアルは誰がこのロールに含まれるかを手動で管理します。\nコンディショナルは条件を設定し、それに合致するユーザーが自動で含まれるようになります。" manual: "マニュアル" @@ -1232,6 +1370,10 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして表示" descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。" + isExplorable: "ロールタイムラインを公開" + descriptionOfIsExplorable: "オンにすると、ロールのタイムラインを公開します。ロールの公開がオフの場合、タイムラインの公開はされません。" + displayOrder: "表示順" + descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" priority: "優先度" @@ -1243,9 +1385,10 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" - canInvite: "インスタンス招待コードの発行" + canInvite: "サーバー招待コードの発行" canManageCustomEmojis: "カスタム絵文字の管理" driveCapacity: "ドライブ容量" + alwaysMarkNsfw: "ファイルにNSFWを常に付与" pinMax: "ノートのピン留めの最大数" antennaMax: "アンテナの作成可能数" wordMuteMax: "ワードミュートの最大文字数" @@ -1257,6 +1400,7 @@ _role: rateLimitFactor: "レートリミット" descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。" canHideAds: "広告の非表示" + canSearchNotes: "ノート検索の利用可否" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1266,6 +1410,8 @@ _role: followersMoreThanOrEq: "フォロワー数が~以上" followingLessThanOrEq: "フォロー数が~以下" followingMoreThanOrEq: "フォロー数が~以上" + notesLessThanOrEq: "投稿数が~以下" + notesMoreThanOrEq: "投稿数が~以上" and: "~かつ~" or: "~または~" not: "~ではない" @@ -1317,7 +1463,7 @@ _ad: _forgotPassword: enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。" ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。" - contactAdmin: "このインスタンスではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。" + contactAdmin: "このサーバーではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。" _gallery: my: "自分の投稿" @@ -1474,6 +1620,8 @@ _channel: following: "フォロー中" usersCount: "{n}人が参加中" notesCount: "{n}投稿があります" + nameAndDescription: "名前と説明" + nameOnly: "名前のみ" _menuDisplay: sideFull: "横" @@ -1492,10 +1640,10 @@ _wordMute: mutedNotes: "ミュートされたノート" _instanceMute: - instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートします。" + instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのノートとRenoteをミュートします。" instanceMuteDescription2: "改行で区切って設定します" - title: "設定したインスタンスのノートを隠します。" - heading: "ミュートするインスタンス" + title: "設定したサーバーのノートを隠します。" + heading: "ミュートするサーバー" _theme: explore: "テーマを探す" @@ -1592,6 +1740,7 @@ _ago: weeksAgo: "{n}週間前" monthsAgo: "{n}ヶ月前" yearsAgo: "{n}年前" + invalid: "ありません" _time: second: "秒" @@ -1599,43 +1748,42 @@ _time: hour: "時間" day: "日" -_tutorial: +_timelineTutorial: title: "CherryPickの使い方" - step1_1: "ようこそ。" - step1_2: "この画面は「タイムライン」と呼ばれ、あなたや、あなたが「フォロー」する人の「ノート」が時系列で表示されます。" - step1_3: "あなたはまだ何もノートを投稿しておらず、誰もフォローしていないので、タイムラインには何も表示されていないはずです。" - step2_1: "ノートを作成したり誰かをフォローしたりする前に、まずあなたのプロフィールを完成させましょう。" - step2_2: "あなたがどんな人かわかると、多くの人にノートを見てもらえたり、フォローしてもらいやすくなります。" - step3_1: "プロフィール設定はうまくできましたか?" - step3_2: "では試しに、何かノートを投稿してみてください。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" - step3_3: "内容を書いたら、フォーム右上のボタンを押すと投稿できます。" - step3_4: "内容が思いつかない?「CherryPick始めました」というのはいかがでしょう。" - step4_1: "投稿できましたか?" - step4_2: "あなたのノートがタイムラインに表示されていれば成功です。" - step5_1: "次は、他の人をフォローしてタイムラインを賑やかにしたいところです。" - step5_2: "{featured}で人気のノートが見れるので、その中から気になった人を選んでフォローしたり、{explore}で人気のユーザーを探すこともできます。" - step5_3: "ユーザーをフォローするには、ユーザーのアイコンをクリックしてユーザーページを表示し、「フォロー」ボタンを押します。" - step5_4: "ユーザーによっては、フォローが承認されるまで時間がかかる場合があります。" - step6_1: "タイムラインに他のユーザーのノートが表示されていれば成功です。" - step6_2: "他の人のノートには、「リアクション」を付けることができ、簡単にあなたの反応を伝えられます。" - step6_3: "リアクションを付けるには、ノートの「+」マークをクリックして、好きなリアクションを選択します。" - step7_1: "これで、CherryPickの基本的な使い方の説明は終わりました。お疲れ様でした。" - step7_2: "もっとCherryPickについて知りたいときは、{help}を見てみてください。" - step7_3: "では、CherryPickをお楽しみください🚀" - step8_1: "最後に、プッシュ通知を有効化してみませんか?" - step8_2: "プッシュ通知を受け取ることで、CherryPickを開いていない時にもリアクションやフォロー、メンションなどに気づけます。" - step8_3: "通知の設定は後から変更できます。" + step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。" + step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。" + step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" + step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。" + step3_1: "投稿できましたか?" + step3_2: "あなたのノートがタイムラインに表示されていれば成功です。" + step4_1: "ノートには、「リアクション」を付けることができます。" + step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。" _2fa: alreadyRegistered: "既に設定は完了しています。" - registerDevice: "デバイスを登録" - registerKey: "キーを登録" + registerTOTP: "認証アプリの設定を開始" + passwordToTOTP: "パスワードを入力してください" step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" step2: "次に、表示されているQRコードをアプリでスキャンします。" - step2Url: "デスクトップアプリでは次のURLを入力します:" - step3: "アプリに表示されているトークンを入力して完了です。" - step4: "これからログインするときも、同じようにトークンを入力します。" - securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。" + step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。" + step2Url: "デスクトップアプリでは次のURIを入力します:" + step3Title: "確認コードを入力" + step3: "アプリに表示されている確認コード(トークン)を入力して完了です。" + step4: "これからログインするときも、同じように確認コードを入力します。" + securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。" + registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。" + securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。" + chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。" + registerSecurityKey: "セキュリティキー・パスキーを登録する" + securityKeyName: "キーの名前を入力" + tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください" + removeKey: "セキュリティキーを削除" + removeKeyConfirm: "{name}を削除しますか?" + whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。" + renewTOTP: "認証アプリを再設定" + renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります" + renewTOTPOk: "再設定する" + renewTOTPCancel: "やめておく" _permissions: "read:account": "アカウントの情報を見る" @@ -1700,7 +1848,7 @@ _weekday: _widgets: profile: "プロフィール" - instanceInfo: "インスタンス情報" + instanceInfo: "サーバー情報" memo: "付箋" notifications: "通知" timeline: "タイムライン" @@ -1714,7 +1862,7 @@ _widgets: digitalClock: "デジタル時計" unixClock: "UNIX時計" federation: "連合" - instanceCloud: "インスタンスクラウド" + instanceCloud: "サーバークラウド" postForm: "投稿フォーム" slideshow: "スライドショー" button: "ボタン" @@ -1767,8 +1915,8 @@ _visibility: followersDescription: "自分のフォロワーのみに公開" specified: "ダイレクト" specifiedDescription: "指定したユーザーのみに公開" - localOnly: "ローカルのみ" - localOnlyDescription: "リモートユーザーには非公開" + disableFederation: "連合なし" + disableFederationDescription: "他サーバーへの配信を行いません" _postForm: replyPlaceholder: "このノートに返信..." @@ -1792,7 +1940,7 @@ _profile: metadataDescription: "プロフィールに表として追加情報を表示することができます。" metadataLabel: "ラベル" metadataContent: "内容" - changeAvatar: "アバター画像を変更" + changeAvatar: "アイコン画像を変更" changeBanner: "バナー画像を変更" _exportOrImport: @@ -1940,6 +2088,7 @@ _notification: receiveFollowRequest: "フォロー申請を受け取った" followRequestAccepted: "フォローが受理された" groupInvited: "グループに招待された" + achievementEarned: "実績の獲得" app: "連携アプリからの通知" _actions: @@ -1975,3 +2124,31 @@ _deck: channel: "チャンネル" mentions: "あなた宛て" direct: "ダイレクト" + roleTimeline: "ロールタイムライン" + +_dialog: + charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" + charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}" + +_disabledTimeline: + title: "無効化されたタイムライン" + description: "現在のロールでは、このタイムラインを使用することはできません。" + +_drivecleaner: + orderBySizeDesc: "サイズが大きい順" + orderByCreatedAtAsc: "追加日が古い順" + +_webhookSettings: + createWebhook: "Webhookを作成" + name: "名前" + secret: "シークレット" + events: "Webhookを実行するタイミング" + active: "有効" + _events: + follow: "フォローしたとき" + followed: "フォローされたとき" + note: "ノートを投稿したとき" + reply: "返信されたとき" + renote: "Renoteされたとき" + reaction: "リアクションがあったとき" + mention: "メンションされたとき" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 8bb7ae96b7..c176904a18 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1,8 +1,8 @@ --- _lang_: "日本語 (関西弁)" headlineMisskey: "ノートでつながるネットワーク" -introMisskey: "ようお越し!CherryPickは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな新しい世界を探検しよか🚀" -poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームCherryPickを使ったサービス(CherryPickインスタンスと呼ばれるやつや)のひとつやで。" +introMisskey: "ようお越し!CherryPickは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「ツッコミ」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな新しい世界を探検しよか🚀" +poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームCherryPickのサーバーのひとつなんやで。" monthAndDay: "{month}月 {day}日" search: "探す" notifications: "通知" @@ -15,13 +15,14 @@ gotIt: "ほい" cancel: "やめとく" noThankYou: "やめとく" enterUsername: "ユーザー名を入れてや" -renotedBy: "{user}がRenote" +renotedBy: "{user}がRenoteしたで" noNotes: "ノートはあらへん" noNotifications: "通知はあらへん" -instance: "インスタンス" +instance: "サーバー" settings: "設定" +notificationSettings: "通知の設定" basicSettings: "基本設定" -otherSettings: "その他の設定" +otherSettings: "ほかの設定" openInWindow: "ウィンドウで開くで" profile: "プロフィール" timeline: "タイムライン" @@ -31,15 +32,15 @@ loggingIn: "ログインしよるで" logout: "ログアウト" signup: "新規登録" uploading: "アップロードしとるで" -save: "保存" +save: "とっとく" users: "ユーザー" addUser: "ユーザーを追加や" favorite: "お気に入り" favorites: "お気に入り" unfavorite: "やっぱ気に入らん" -favorited: "お気に入りに登録したで" +favorited: "お気に入りに入れたで" alreadyFavorited: "もうお気に入りに入れとるがな。" -cantFavorite: "アカン、お気に入り登録できへんかったで。" +cantFavorite: "アカン、お気に入りに入れれんかったわ。" pin: "ピン留めしとく" unpin: "やっぱピン留めせん" copyContent: "内容をコピー" @@ -55,19 +56,19 @@ searchUser: "ユーザーを検索" reply: "返事" loadMore: "まだまだあるで!" showMore: "まだまだあるで!" -showLess: "閉じる" +showLess: "さいなら" youGotNewFollower: "フォローされたで" receiveFollowRequest: "フォローリクエストされたで" followRequestAccepted: "フォローが承認されたで" mention: "メンション" -mentions: "うち宛て" +mentions: "自分宛て" directNotes: "ダイレクト投稿" importAndExport: "インポートとエクスポート" import: "インポート" export: "エクスポート" files: "ファイル" download: "ダウンロード" -driveFileDeleteConfirm: "ファイル「{name}」を消してしもうてええか?このファイルを添付したノートも消えてまうで。" +driveFileDeleteConfirm: "ファイル「{name}」をほかしてええか?このファイルを添付したノートも消えてまうで。" unfollowConfirm: "{name}のフォローを解除してもええんか?" exportRequested: "エクスポートしてな、ってリクエストしたけど、これ多分めっちゃ時間かかるで。エクスポート終わったら「ドライブ」に突っ込んどくで。" importRequested: "インポートしてな、ってリクエストしたけど、これ多分めっちゃ時間かかるで。" @@ -81,15 +82,15 @@ followsYou: "フォローされとるで" createList: "リスト作る" manageLists: "リストの管理" error: "エラー" -somethingHappened: "なんかアカンことが起こったで" +somethingHappened: "なんかあかんわ" retry: "もっぺんやる?" -pageLoadError: "ページの読み込みに失敗してしもうたで…" -pageLoadErrorDescription: "これは普通、ネットワークかブラウザキャッシュが原因やからね。キャッシュをクリアするか、もうちっとだけ待ってくれへんか?" +pageLoadError: "ページが読み込めんかったわ。" +pageLoadErrorDescription: "これは普通ならネットワークかブラウザキャッシュが悪さしてるんよ。キャッシュをほかすか、もうちょっとだけ待ってくれへん?" serverIsDead: "サーバーからの応答がないで。もうちょい待ってから試してみてな。" youShouldUpgradeClient: "このページを表示するには、リロードして新しいバージョンのクライアントを使ってなー。" enterListName: "リスト名を入れてや" privacy: "プライバシー" -makeFollowManuallyApprove: "他人のフォローは許可してからや!" +makeFollowManuallyApprove: "ええって言わなフォローできへんようにする" defaultNoteVisibility: "もとからの公開範囲" follow: "フォロー" followRequest: "フォローを頼む" @@ -103,15 +104,17 @@ renoted: "Renoteしたで。" cantRenote: "この投稿はRenoteできへんらしい。" cantReRenote: "Renote自体はRenoteできへんで。" quote: "引用" +inChannelRenote: "チャンネル内Renote" +inChannelQuote: "チャンネル内引用" pinnedNote: "ピン留めされとるノート" pinned: "ピン留めしとく" you: "あんた" clickToShow: "押したら見えるで" -sensitive: "ちょっとアカンやつやで" +sensitive: "気いつけて見いや" add: "増やす" -reaction: "リアクション" -reactions: "リアクション" -reactionSetting: "Reaction that will be displayed in Picker. " +reaction: "ツッコミ" +reactions: "ツッコミ" +reactionSetting: "ピッカーに出しとくツッコミ" reactionSettingDescription2: "ドラッグで並び替え、クリックで削除、+を押して追加やで。" rememberNoteVisibility: "公開範囲覚えといて" attachCancel: "のっけるのやめる" @@ -120,6 +123,8 @@ unmarkAsSensitive: "そこまでアカンことないやろ" enterFileName: "ファイル名を入れてや" mute: "ミュート" unmute: "ミュートやめたる" +renoteMute: "Renoteは見いひん" +renoteUnmute: "Renoteもやっぱ見るわ" block: "ブロック" unblock: "ブロックやめたる" suspend: "凍結" @@ -137,26 +142,27 @@ editWidgetsExit: "編集終ったで" customEmojis: "カスタム絵文字" emoji: "絵文字" emojis: "絵文字" -emojiName: "絵文字名" +emojiName: "絵文字はんの名前" emojiUrl: "絵文字画像URL" addEmoji: "絵文字を追加" settingGuide: "ええ感じの設定" cacheRemoteFiles: "リモートのファイルをキャッシュする" -cacheRemoteFilesDescription: "この設定を切っとくと、リモートファイルをキャッシュせず直リンクするようになるで。サーバーの容量は節約できるけど、サムネイルが作られんくなるから通信量が増えるで。" -flagAsBot: "Botやで" -flagAsBotDescription: "もしこのアカウントがプログラムによって運用されるんやったら、このフラグをオンにしてたのむで。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、CherryPickのシステム上での扱いがBotに合ったもんになるんやで。" +cacheRemoteFilesDescription: "この設定を切っとったら、リモートファイルをキャッシュせんと直リンクするようになるで。サーバーの容量は節約できるけど、サムネイルを作らんなるから通信量が増えるで。" +flagAsBot: "Botにするで" +flagAsBotDescription: "もしこのアカウントをプログラム使うて運用するんやったら、このフラグをオンにしてや。オンにすれば、反応がバーッて連鎖せんように開発者が使うたり、CherryPickのシステム上での扱いがBotに合ったもんになるからな。" flagAsCat: "Catやで" flagAsCatDescription: "ワレ、猫ちゃんならこのフラグをつけてみ?" flagShowTimelineReplies: "タイムラインにノートへの返信を表示するで" flagShowTimelineRepliesDescription: "オンにしたら、タイムラインにユーザーのノートの他にもそのユーザーの他のノートへの返信を表示するで。" autoAcceptFollowed: "フォローしとるユーザーからのフォローリクエストを勝手に許可しとく" addAccount: "アカウントを追加" -loginFailed: "ログインに失敗してしもうた…" +reloadAccountsList: "アカウントリストの情報を更新" +loginFailed: "ログインに失敗してもうた…" showOnRemote: "リモートで見る" general: "全般" wallpaper: "壁紙" setWallpaper: "壁紙を設定" -removeWallpaper: "壁紙を削除" +removeWallpaper: "壁紙ほかす" searchWith: "検索: {q}" youHaveNoLists: "リストがあらへんで?" followConfirm: "{name}をフォローしてええか?" @@ -167,7 +173,7 @@ selectUser: "ユーザーを選ぶ" recipient: "宛先" annotation: "注釈" federation: "連合" -instances: "インスタンス" +instances: "サーバー" registeredAt: "初観測" latestRequestReceivedAt: "ちょっと前のリクエスト受信" latestStatus: "ちょっと前のステータス" @@ -176,7 +182,7 @@ charts: "チャート" perHour: "1時間ごと" perDay: "1日ごと" stopActivityDelivery: "アクティビティの配送をやめる" -blockThisInstance: "このインスタンスをブロック" +blockThisInstance: "このサーバーをブロックすんで" operations: "操作" software: "ソフトウェア" version: "バージョン" @@ -187,28 +193,28 @@ jobQueue: "ジョブキュー" cpuAndMemory: "CPUとメモリ" network: "ネットワーク" disk: "ディスク" -instanceInfo: "インスタンス情報" +instanceInfo: "サーバー情報" statistics: "統計" -clearQueue: "キューにさいなら" -clearQueueConfirmTitle: "キューをクリアしまっか?" -clearQueueConfirmText: "未配達の投稿は配送されなくなるで。通常この操作を行う必要はあらへんや。" -clearCachedFiles: "キャッシュにさいなら" +clearQueue: "キューをほかす" +clearQueueConfirmTitle: "キューをほかしとこか?" +clearQueueConfirmText: "未配達の投稿は配送されんなるで。ふつうこの操作を行う必要は無いんやけどな。" +clearCachedFiles: "キャッシュをほかす" clearCachedFilesConfirm: "キャッシュされとるリモートファイルをみんなほかしてええか?" -blockedInstances: "インスタンスブロック" -blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定してな。ブロックされてもうたインスタンスとはもう金輪際やり取りできひんくなるで。" +blockedInstances: "ブロックしたサーバー" +blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定してな。ブロックされてもうたサーバーとはもう金輪際やり取りできひんくなるで。ついでにそのサブドメインもブロックするで。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" -noUsers: "ユーザーはおらへん" +noUsers: "ユーザーはおらん" editProfile: "プロフィールをいじる" -noteDeleteConfirm: "このノートを削除しまっか?" +noteDeleteConfirm: "このノートをほかしてええか?" pinLimitExceeded: "これ以上ピン留めできひん" -intro: "CherryPickのインストールが完了してん!管理者アカウントを作ってや。" +intro: "CherryPickのインストールが完了したで!管理者アカウントを作ってや。" done: "でけた" processing: "処理しとる" preview: "プレビュー" default: "デフォルト" -defaultValueIs: "デフォルト" +defaultValueIs: "デフォルト: {value}" noCustomEmojis: "絵文字はあらへん" noJobs: "ジョブはあらへん" federating: "連合しとる" @@ -218,17 +224,17 @@ all: "みんな" subscribing: "購読しとる" publishing: "配信しとる" notResponding: "応答してへんで" -instanceFollowing: "インスタンスのフォロー" -instanceFollowers: "インスタンスのフォロワー\n" -instanceUsers: "インスタンスのユーザー" -changePassword: "パスワード変える" +instanceFollowing: "サーバーのフォロー" +instanceFollowers: "サーバーのフォロワー\n" +instanceUsers: "サーバーのユーザー" +changePassword: "パスワードをいじる" security: "セキュリティ" -retypedNotMatch: "そやないねん。" +retypedNotMatch: "入れたやつ合うてへんわ。" currentPassword: "今のパスワード" -newPassword: "今度のパスワード" +newPassword: "次のパスワード" newPasswordRetype: "今度のパスワード(もっぺん入れて)" attachFile: "ファイルのっける" -more: "他のやつ!" +more: "他のん" featured: "ハイライト" usernameOrUserId: "ユーザー名かユーザーID" noSuchUser: "ユーザーが見つからへんで" @@ -236,15 +242,15 @@ lookup: "見てきて" announcements: "お知らせ" imageUrl: "画像URL" remove: "ほかす" -removed: "削除したで!" +removed: "ほかしたで!" removeAreYouSure: "「{x}」はほかしてええか?" deleteAreYouSure: "「{x}」はほかしてええか?" resetAreYouSure: "リセットしてええん?" saved: "保存したで!" messaging: "チャット" upload: "アップロード" -keepOriginalUploading: "オリジナル画像を保持するで" -keepOriginalUploadingDescription: "画像を上げるときにオリジナル版を保持するで。オフにしたら上げたときにブラウザでWeb公開用の画像を生成するで。 " +keepOriginalUploading: "オリジナル画像のまんま" +keepOriginalUploadingDescription: "画像を上げるときにオリジナル版のまんまにするで。オフにしたら、上げたときにブラウザでWeb公開用の画像を生成するで。 " fromDrive: "ドライブから" fromUrl: "URLから" uploadFromUrl: "URLアップロード" @@ -253,25 +259,27 @@ uploadFromUrlRequested: "アップロードしたい言うといたで" uploadFromUrlMayTakeTime: "アップロード終わるんにちょい時間かかるかもしれへんわ。" explore: "みつける" messageRead: "もう読んだ" -noMoreHistory: "これより過去の履歴はあらへんで" +noMoreHistory: "これより昔のんはあらへんで" startMessaging: "チャットやるで" nUsersRead: "{n}人が読んでもうた" agreeTo: "{0}に同意したで" +agree: "せやな" agreeBelow: "下記に同意したる" basicNotesBeforeCreateAccount: "よう読んでやってや" -tos: "利用規約" +termsOfService: "使うための決め事" start: "始める" home: "ホーム" remoteUserCaution: "リモートユーザーやから、足りひん情報あるかもしれへん。" activity: "アクティビティ" images: "画像" +image: "画像" birthday: "生まれた日" yearsOld: "{age}歳" registeredDate: "始めた日" location: "場所" theme: "テーマ" -themeForLightMode: "ライトモードではこのテーマつこて" -themeForDarkMode: "ダークモードではこのテーマつこて" +themeForLightMode: "ライトモードではこのテーマ使うて" +themeForDarkMode: "ダークモードではこのテーマ使うて" light: "ライト" dark: "ダーク" lightThemes: "デイゲーム" @@ -287,22 +295,22 @@ renameFile: "ファイル名をいらう" folderName: "フォルダー名" createFolder: "フォルダー作る" renameFolder: "フォルダー名を変える" -deleteFolder: "フォルダーを消してまう" +deleteFolder: "フォルダーをほかす" addFile: "ファイルを追加" -emptyDrive: "ドライブにはなんも残っとらん" -emptyFolder: "ふぉろだーにはなんも残っとらん" -unableToDelete: "消そうおもってんけどな、あかんかったわ" +emptyDrive: "ドライブは空っぽや" +emptyFolder: "このフォルダーは空や" +unableToDelete: "消せんかったわ" inputNewFileName: "今度のファイル名は何にするん?" -inputNewDescription: "新しいキャプションを入力しましょ" +inputNewDescription: "新しいキャプションを入れてや" inputNewFolderName: "今度のフォルダ名は何にするん?" circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーや。" -hasChildFilesOrFolders: "このフォルダ、まだなんか入っとるから消されへん" +hasChildFilesOrFolders: "このフォルダは空っぽちゃうから消されへん" copyUrl: "URLをコピー" rename: "名前を変えるで" avatar: "アイコン" banner: "バナー" -nsfw: "閲覧注意" -whenServerDisconnected: "サーバーとの接続が切れたとき" +nsfw: "見るんは気いつけてな" +whenServerDisconnected: "サーバーとの接続が失くなってしもうたとき" disconnectedFromServer: "サーバーが機嫌悪いねん" reload: "リロード" doNothing: "何もせんとく" @@ -312,11 +320,11 @@ unwatch: "ウォッチやめる" accept: "ええで" reject: "あかん" normal: "ええ感じ" -instanceName: "インスタンス名" -instanceDescription: "インスタンスの紹介" -maintainerName: "管理者の名前" -maintainerEmail: "管理者のメールアドレス" -tosUrl: "利用規約のURL" +instanceName: "サーバー名" +instanceDescription: "サーバーの紹介" +maintainerName: "管理者はんの名前" +maintainerEmail: "管理者はんのメールアドレス" +tosUrl: "使うための決め事のURL" thisYear: "今年" thisMonth: "今月" today: "今日" @@ -327,23 +335,23 @@ pages: "ページ" integration: "連携" connectService: "つなげるで" disconnectService: "切るで" -enableLocalTimeline: "ローカルタイムラインを使えるようにする" -enableGlobalTimeline: "グローバルタイムラインを使えるようにする" +enableLocalTimeline: "ローカルタイムラインを使えるようにするわ" +enableGlobalTimeline: "グローバルタイムラインを使えるようにするわ" disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。" registration: "登録" enableRegistration: "一見さんでも誰でもいらっしゃ~い" invite: "来てや" -driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" -driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量" +driveCapacityPerLocalAccount: "ローカルユーザーはんひとりあたりのドライブ容量" +driveCapacityPerRemoteAccount: "リモートユーザーはんひとりあたりのドライブ容量" inMb: "メガバイト単位" iconUrl: "アイコン画像のURL" bannerUrl: "バナー画像のURL" backgroundImageUrl: "背景画像のURL" basicInfo: "基本情報" pinnedUsers: "ピン留めしたユーザー" -pinnedUsersDescription: "「みつける」ページとかにピン留めしたいユーザーをここに書けばええんやで。他ん人との名前は改行で区切ればええんやで。" +pinnedUsersDescription: "「みつける」ページとかにピン留めしたいユーザーをここに書けばええんやで。ユーザー毎に改行してや。" pinnedPages: "ピン留めページ" -pinnedPagesDescription: "インスタンスのいっちゃん上にピン留めしたいページのパスを改行で区切って記述してな" +pinnedPagesDescription: "サーバーのいっちゃん上にピン留めしたいページのパスを改行で区切って記述してな" pinnedClipId: "ピン留めするクリップのID" pinnedNotes: "ピン留めされとるノート" hcaptcha: "hCaptcha(キャプチャ)" @@ -368,7 +376,7 @@ antennaExcludeKeywords: "除外キーワード" antennaKeywordsDescription: "スペースで区切ったるとAND指定で、改行で区切ったるとOR指定や" notifyAntenna: "新しいノートを通知すんで" withFileAntenna: "なんか添付されたノートだけ" -enableServiceworker: "ServiceWorkerをつこて" +enableServiceworker: "ブラウザにプッシュ通知が行くようにする" antennaUsersDescription: "ユーザー名を改行で区切ったってな" caseSensitive: "大文字と小文字は別もんや" withReplies: "返信も入れたって" @@ -391,22 +399,25 @@ about: "情報" aboutMisskey: "CherryPickってなんや?" administrator: "管理者" token: "トークン" -twoStepAuthentication: "二段階認証" +2fa: "二要素認証" +totp: "認証アプリ" +totpDescription: "認証アプリ使うてワンタイムパスワードを入れる" moderator: "モデレーター" moderation: "モデレーション" nUsersMentioned: "{n}人が投稿" +securityKeyAndPasskey: "セキュリティキー・パスキー" securityKey: "セキュリティキー" -securityKeyName: "キーの名前" -registerSecurityKey: "セキュリティキーを登録するで" lastUsed: "最後につこうた日" +lastUsedAt: "最後に使うたんは: {t}" unregister: "登録やめる" passwordLessLogin: "パスワード無くてもログインできるようにする" +passwordLessLoginDescription: "パスワードなんかいらん、セキュリティキーとかパスキーだけでログインするわ" resetPassword: "パスワードをリセット" newPasswordIs: "今度のパスワードは「{password}」や" -reduceUiAnimation: "UIの動きやアニメーションを減らす" +reduceUiAnimation: "UIの動きやアニメーションを少なする" share: "わけわけ" notFound: "見つからへんね" -notFoundDescription: "指定されたURLに該当するページはあらへんやった。" +notFoundDescription: "言われたURLにはまるページはなかったで。" uploadFolder: "とりあえずアップロードしたやつ置いとく所" cacheClear: "キャッシュをほかす" markAsReadAllNotifications: "通知はもう全て読んだわっ" @@ -414,7 +425,7 @@ markAsReadAllUnreadNotes: "投稿は全て読んだわっ" markAsReadAllTalkMessages: "チャットはもうぜんぶ読んだわっ" help: "ヘルプ" inputMessageHere: "ここにメッセージ書いてや" -close: "閉じる" +close: "さいなら" group: "グループ" groups: "グループ" createGroup: "グループを作るで" @@ -422,7 +433,7 @@ ownedGroups: "所有しとるグループ" joinedGroups: "参加しとるグループ" invites: "来てや" groupName: "グループ名" -members: "メンバー" +members: "メンバーはん" transfer: "譲渡" messagingWithUser: "ユーザーとチャット" messagingWithGroup: "グループでチャット" @@ -431,30 +442,29 @@ text: "テキスト" enable: "有効にするで" next: "次" retype: "もっかい入力" -noteOf: "{user}のノート" +noteOf: "{user}はんのノート" inviteToGroup: "グループに招く" quoteAttached: "引用付いとるで" quoteQuestion: "引用として添付してもええか?" noMessagesYet: "まだチャットはあらへんで" newMessageExists: "新しいメッセージがきたで" -onlyOneFileCanBeAttached: "すまん、メッセージに添付できるファイルはひとつだけなんや。" +onlyOneFileCanBeAttached: "ごめんな、メッセージに添付できるファイルはひとつだけなんよ。" signinRequired: "ログインしてくれへん?" invitations: "来てや" invitationCode: "招待コード" checking: "確認しとるで" -available: "利用できる\n" +available: "使えるで" unavailable: "利用できん" usernameInvalidFormat: "a~z、A~Z、0~9、_が使えるで" tooShort: "短すぎやろ!" tooLong: "長すぎやろ!" weakPassword: "へぼいパスワード" -normalPassword: "普通のパスワード" +normalPassword: "ぼちぼちのパスワード" strongPassword: "ええ感じのパスワード" passwordMatched: "よし!一致や!" passwordNotMatched: "一致しとらんで?" signinWith: "{x}でログイン" -signinFailed: "ログインできんかったで。もっかいユーザー名とパスワードを確認してみてな。" -tapSecurityKey: "セキュリティキーにタッチしてな" +signinFailed: "ログインできんかったで。もっかいユーザー名とパスワードを確認してみてや。" or: "それか" language: "言語" uiLanguage: "UIの表示言語" @@ -462,10 +472,11 @@ groupInvited: "グループに招待されとるで" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" -disableDrawer: "メニューをドロワーで表示せぇへん" +disableDrawer: "メニューをドロワーで表示せえへん" youHaveNoGroups: "グループがあらへんねぇ。" joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループ作ってからやってな" -noHistory: "履歴はあらへんねぇ。" +showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで" +noHistory: "履歴はないわ。" signinHistory: "ログイン履歴" enableAdvancedMfm: "ややこしいMFMもありにする" enableAnimatedMfm: "動きがやかましいMFMも許したる" @@ -473,12 +484,14 @@ doing: "やっとるがな" category: "カテゴリ" tags: "タグ" docSource: "このドキュメントのソース" -createAccount: "アカウントを作成" -existingAccount: "既存のアカウント" -regenerate: "再生成" -fontSize: "フォントサイズ" +createAccount: "アカウントを作るで" +existingAccount: "前に作ったアカウント" +regenerate: "もっぺん生成するで" +fontSize: "字の大きさ" +mediaListWithOneImageAppearance: "画像が1枚のみのメディアリストの高さ" +limitTo: "{x}をいっぱいに" noFollowRequests: "フォロー申請はあらへんで" -openImageInNewTab: "画像を新しいタブで開く" +openImageInNewTab: "画像を新しいタブで開くで" dashboard: "ダッシュボード" local: "ローカル" remote: "リモート" @@ -510,9 +523,11 @@ objectStorageUseSSLDesc: "API接続にhttpsを使わん場合はオフにする objectStorageUseProxy: "Proxyを使う" objectStorageUseProxyDesc: "API接続にproxy使わんのやったら切ってくれへん?" objectStorageSetPublicRead: "アップロードした時に'public-read'を設定してや" +s3ForcePathStyleDesc: "s3ForcePathStyleを使たらバケット名をURLのホスト名やなくてパスの一部として必ず指定させるようになるで。セルフホストされたMinioとかを使うてるんやったら有効にせなあかん場合があるで。" serverLogs: "サーバーログ" -deleteAll: "全て削除してや" +deleteAll: "全部ほかす" showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" +showFixedPostFormInChannel: "タイムラインの上の方で投稿できるようにするわ(チャンネル)" newNoteRecived: "新しいノートがあるで" sounds: "サウンド" sound: "サウンド" @@ -520,11 +535,11 @@ listen: "聴く" none: "なし" showInPage: "ページで表示" popout: "ポップアウト" -volume: "音量" -masterVolume: "全体の音量" +volume: "やかましさ" +masterVolume: "全体のやかましさ" details: "もっと" chooseEmoji: "絵文字を選ぶ" -unableToProcess: "なんか作業が止まってしまったようやね" +unableToProcess: "なんか奥の方で詰まってもうた" recentUsed: "最近使ったやつ" install: "インストール" uninstall: "アンインストール" @@ -542,17 +557,22 @@ output: "出力" script: "スクリプト" disablePagesScript: "Pagesのスクリプトを無効にしてや" updateRemoteUser: "リモートユーザー情報の更新してくれん?" -deleteAllFiles: "すべてのファイルを削除" -deleteAllFilesConfirm: "ホンマにすべてのファイルを削除するん?消したもんはもう戻ってこんのやで?" +deleteAllFiles: "ファイルを全部ほかす" +deleteAllFilesConfirm: "ホンマにファイル全部ほかすんか?消したもんはもう戻ってこんのやで?" removeAllFollowing: "フォローを全解除" removeAllFollowingDescription: "{host}からのフォローをすべて解除するで。そのインスタンスが消えて無くなった時とかには便利な機能やで。" userSuspended: "このユーザーは...凍結されとる。" userSilenced: "このユーザーは...サイレンスされとる。" yourAccountSuspendedTitle: "あんたのアカウント凍結されとるで" -yourAccountSuspendedDescription: "あんたのアカウントは、サーバーの利用規約に違反したとかの理由で、凍結されとるで。細かいことは管理者までお問い合わせたってなー。絶対に新しいアカウント作ったらあかんで。絶対やで。" +yourAccountSuspendedDescription: "あんたのアカウントは、サーバーの使うための決め事に違反したとかの理由で、凍結されとるで。細かいことは管理者までお問い合わせたってなー。絶対に新しいアカウント作ったらあかんで。絶対やで。" +tokenRevoked: "トークンが無効やで" +tokenRevokedDescription: "ログイントークンが失効しとるで。もっかいログインしてもろてもええか?" +accountDeleted: "アカウントは削除されとるで" +accountDeletedDescription: "このアカウントは削除されとるで。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" +rearrange: "並び替え" relays: "リレー" addRelay: "リレーの追加" inboxUrl: "inboxのURL" @@ -572,14 +592,14 @@ description: "説明" describeFile: "キャプションを付ける" enterFileDescription: "キャプションを入力" author: "作者" -leaveConfirm: "未保存の変更があるで!ほかしてええか?" +leaveConfirm: "あんた、いじったのにまだ保存してないで!ほかしてええか?" manage: "管理" plugins: "プラグイン" preferencesBackups: "設定のバックアップ" deck: "デッキ" undeck: "デッキ解除" useBlurEffectForModal: "モーダルにぼかし効果を使用" -useFullReactionPicker: "フル機能にリアクションピッカーを使用" +useFullReactionPicker: "フル機能の突っ込みピッカーを使用" width: "幅" height: "高さ" large: "大" @@ -589,11 +609,10 @@ generateAccessToken: "アクセストークンの発行" permission: "権限" enableAll: "全部使えるようにする" disableAll: "全部使えへんようにする" -tokenRequested: "アカウントへのアクセス許可" +tokenRequested: "アカウントへのアクセス許してやったらどうや" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を使えるようになるで。" notificationType: "通知の種類" edit: "編集" -useStarForReactionFallback: "リアクションがようわからん場合、★を使う" emailServer: "メールサーバー" enableEmail: "メール配信を受け取る" emailConfigInfo: "メールアドレスの確認とかパスワードリセットの時に使うで" @@ -606,12 +625,12 @@ smtpUser: "ユーザー名" smtpPass: "パスワード" emptyToDisableSmtpAuth: "ユーザー名とパスワードになんも入れんかったら、SMTP認証を無効化するで" smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" -smtpSecureInfo: "STARTTLS使っとる時はオフにするで。" +smtpSecureInfo: "STARTTLS使っとる時はオフにしてや。" testEmail: "配信テスト" wordMute: "ワードミュート" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが出てきたで:" -instanceMute: "インスタンスミュート" +instanceMute: "サーバーミュート" userSaysSomething: "{name}が何か言うとるわ" makeActive: "使うで" display: "表示" @@ -630,7 +649,7 @@ useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使 other: "その他" regenerateLoginToken: "ログイントークンを再生成" regenerateLoginTokenDescription: "ログインに使われる内部トークンをもっかい作るで。いつもならこれをやる必要はないで。もっかい作ると、全部のデバイスでログアウトされるで気ぃつけてなー。" -setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できるで。" +setMultipleBySeparatingWithSpace: "スペースで区切って何個でも設定できるで。" fileIdOrUrl: "ファイルIDかURL" behavior: "動作" sample: "サンプル" @@ -642,15 +661,15 @@ abuseReported: "無事内容が送信されたみたいやで。おおきに〜 reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートインスタンスに通報を転送するで" -forwardReportIsAnonymous: "リモートインスタンスからはあんたの情報は見れへんくって、匿名のシステムアカウントとして表示されるで。" +forwardReport: "リモートサーバーに通報を転送するで" +forwardReportIsAnonymous: "リモートサーバーからはあんたの情報は見えんなって、匿名のシステムアカウントとして表示されるで。" send: "送信" abuseMarkAsResolved: "対応したで" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" editTheseSettingsMayBreakAccount: "このへんの設定をようわからんままイジるとアカウントが壊れて使えんくなるかも知れへんで?" -instanceTicker: "ノートのインスタンス情報" +instanceTicker: "ノートのサーバー情報" waitingFor: "{x}を待っとるで" random: "ランダム" system: "システム" @@ -661,7 +680,7 @@ createNew: "新しく作るで" optional: "任意" createNewClip: "新しいクリップを作るで" unclip: "クリップ解除するで" -confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外したる?" +confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外しよか?" public: "パブリック" i18nInfo: "CherryPickは有志によっていろんな言語に翻訳されとるで。{link}で翻訳に協力したってやー。" manageAccessTokens: "アクセストークンの管理" @@ -673,12 +692,12 @@ repliedCount: "返信された数やで" renotedCount: "Renoteされた数やで" followingCount: "フォロー数やで" followersCount: "フォロワー数やで" -sentReactionsCount: "リアクションした数やで" -receivedReactionsCount: "リアクションされた数" +sentReactionsCount: "ツッコんだ数" +receivedReactionsCount: "ツッコまれた数" pollVotesCount: "アンケートに投票した数" pollVotedCount: "アンケートに投票された数" yes: "ええで" -no: "あかんで" +no: "あかん" driveFilesCount: "ドライブのファイル数" driveUsage: "ドライブ使用量やで" noCrawle: "クローラーによるインデックスを拒否するで" @@ -686,7 +705,7 @@ noCrawleDescription: "検索エンジンにあんたのユーザーページ、 lockedAccountInfo: "フォローを承認制にしとっても、ノートの公開範囲を「フォロワー」にせぇへん限り、誰でもあんたのノートを見れるで。" alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にするで" loadRawImages: "添付画像のサムネイルをオリジナル画質にするで" -disableShowingAnimatedImages: "アニメーション画像を再生しやへんで" +disableShowingAnimatedImages: "アニメーション画像を再生せんとくで" verificationEmailSent: "無事確認のメールを送れたで。メールに書いてあるリンクにアクセスして、設定を完了してなー。" notSet: "未設定" emailVerified: "メールアドレスは確認されたで" @@ -696,14 +715,16 @@ pageLikedCount: "Pageにええやんと思ってくれた数" contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使うで" clips: "クリップ" -experimentalFeatures: "実験的機能やで" +experimentalFeatures: "おためし機能やで" +experimental: "実験的" +thisIsExperimentalFeature: "これは実験的な機能やで。仕様が変更になったりちゃんと動かなかったりするかもやで。" developer: "開発者やで" makeExplorable: "アカウントを見つけやすくするで" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。" showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示するで" duplicate: "複製" left: "左" -center: "中央" +center: "真ん中" wide: "広い" narrow: "狭い" reloadToApplySetting: "設定はページリロード後に反映されるで。今リロードしとくか?" @@ -714,7 +735,7 @@ onlineUsersCount: "{n}人が起きとるで" nUsers: "{n}ユーザー" nNotes: "{n}ノート" sendErrorReports: "エラーリポートを送る" -sendErrorReportsDescription: "オンにしたら、なんか変なことが起きたときにエラーの詳細がCherryPickに共有されて、ソフトウェアの品質向上に役立てられるんや。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれるで。" +sendErrorReportsDescription: "オンにしたら、変なことが起きたときにエラーの詳細がCherryPickに送られて、ソフトウェアの品質向上に使えるようになるで。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴なんかが含まれるで。" myTheme: "マイテーマ" backgroundColor: "背景" accentColor: "アクセント" @@ -739,11 +760,11 @@ capacity: "容量" inUse: "使用中" editCode: "コードを編集" apply: "適用" -receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" +receiveAnnouncementFromInstance: "サーバーからのお知らせを受け取る" emailNotification: "メール通知" publish: "公開" inChannelSearch: "チャンネル内検索" -useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開くようにする" +useReactionPickerForContextMenu: "右クリックでツッコミピッカーを開くようにする" typingUsers: "{users}が今書きよるで" jumpToSpecifiedDate: "特定の日付にジャンプ" showingPastTimeline: "過去のタイムラインを表示してるで" @@ -767,7 +788,7 @@ active: "アクティブ" offline: "オフライン" notRecommended: "あんま推奨しやんで" botProtection: "Botプロテクション" -instanceBlocking: "インスタンスブロック" +instanceBlocking: "サーバーブロック" selectAccount: "アカウントを選んでなー" switchAccount: "アカウントを変えるで" enabled: "有効" @@ -787,6 +808,7 @@ popularPosts: "人気の投稿" shareWithNote: "ノートで共有" ads: "広告" expiration: "期限" +startingperiod: "始めた期間" memo: "メモ" priority: "優先度" high: "高い" @@ -819,6 +841,7 @@ lastCommunication: "直近の通信" resolved: "解決したで" unresolved: "まだ解決してないで" breakFollow: "フォロワーを解除するで" +breakFollowConfirm: "フォロワー解除してもええか?" itsOn: "オンになっとるよ" itsOff: "オフになってるで" emailRequiredForSignup: "アカウント登録にメールアドレスを必須にするで" @@ -826,8 +849,8 @@ unread: "未読" filter: "フィルタ" controlPanel: "コントロールパネル" manageAccounts: "アカウントを管理" -makeReactionsPublic: "リアクション一覧を公開するで" -makeReactionsPublicDescription: "あんたがしたリアクション一覧を誰でも見れるようにするで。" +makeReactionsPublic: "ツッコミ一覧を公開するで" +makeReactionsPublicDescription: "あんたがしたツッコミ一覧を誰でも見れるようにするで。" classic: "クラシック" muteThread: "スレッドをミュート" unmuteThread: "スレッドのミュートを解除" @@ -841,7 +864,7 @@ hide: "隠す" leaveGroup: "グループから抜けるで" leaveGroupConfirm: "「{name}」から抜けるん?" useDrawerReactionPickerForMobile: "ケータイとかのときドロワーで表示するで" -welcomeBackWithName: "まいど、{name}さん" +welcomeBackWithName: "まいど、{name}はん" clickToFinishEmailVerification: "[{ok}]を押してメアドの確認を終わらせてなー" overridedDeviceKind: "デバイスタイプ" smartphone: "スマホ" @@ -851,20 +874,22 @@ themeColor: "テーマカラー" size: "大きさ" numberOfColumn: "列の数" searchByGoogle: "探す" -instanceDefaultLightTheme: "インスタンスの最初の明るいテーマ" -instanceDefaultDarkTheme: "インスタンスの最初の暗いテーマ" +instanceDefaultLightTheme: "サーバーおすすめの明るいテーマ" +instanceDefaultDarkTheme: "サーバーおすすめのの暗いテーマ" instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入するで。" mutePeriod: "ミュートする期間" +period: "期限" indefinitely: "無期限" tenMinutes: "10分" oneHour: "1時間" oneDay: "1日" oneWeek: "1週間" +oneMonth: "1ヶ月" reflectMayTakeTime: "反映されるまで時間がかかることがあるで" failedToFetchAccountInformation: "アカウントの取得に失敗したみたいや…" rateLimitExceeded: "レート制限が超えたみたいやで" cropImage: "画像のクロップ" -cropImageAsk: "画像をクロップしたってええか?" +cropImageAsk: "画像をクロップしてもええか?" cropYes: "切り抜いたる" cropNo: "切り抜かへん" file: "ファイル" @@ -881,7 +906,7 @@ isSystemAccount: "システムが自動で作成・管理しとるアカウン typeToConfirm: "この操作をやるんなら {x} と入力してなー" deleteAccount: "アカウント削除するで" document: "ドキュメント" -numberOfPageCache: "ページキャッシュ数やで" +numberOfPageCache: "ページ、どんだけキャッシュすんの?" numberOfPageCacheDescription: "増やすと使いやすくなる、負荷とメモリ使用量が増えてくで。一長一短やな。" logoutConfirm: "ログアウトしまっか?" lastActiveDate: "最後に使った日時" @@ -898,12 +923,13 @@ fast: "速い" sensitiveMediaDetection: "センシティブなメディアの検出" localOnly: "ローカルのみ" remoteOnly: "リモートのみ" -failedToUpload: "アップロードに失敗したで" -cannotUploadBecauseInappropriate: "不適切な内容を含むかもしれへんって判定されたでアップロードできまへん。" -cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いでアップロードできまへん。" +failedToUpload: "アップロードに失敗してもうたわ…" +cannotUploadBecauseInappropriate: "不適切な内容を含むかもしれへんって判定されたからアップロードできへんわ。" +cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いからアップロードできへんわ。" +cannotUploadBecauseExceedsFileSizeLimit: "ファイルが思うたよりも大きいさかいアップロードできへんでこれ。" beta: "ベータ" enableAutoSensitive: "自動NSFW判定" -enableAutoSensitiveDescription: "使える時は、機械学習を使って自動でメディアにNSFWフラグを設定するで。この機能をオフにしても、インスタンスによっては自動で設定されることがあるで。" +enableAutoSensitiveDescription: "使える時は、機械学習を使って自動でメディアにNSFWフラグを設定するで。この機能をオフにしても、サーバーによっては自動で設定されることがあるで。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかとかを判定して積極的に行うで。オフにすると単に文字列として正しいかどうかだけチェックするで。" navbar: "ナビゲーションバー" shuffle: "シャッフルするで" @@ -913,10 +939,11 @@ pushNotification: "プッシュ通知" subscribePushNotification: "プッシュ通知をオンにするで" unsubscribePushNotification: "プッシュ通知を止めるで" pushNotificationAlreadySubscribed: "プッシュ通知はオンになってるで" -pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に対応してないみたいやで。" -sendPushNotificationReadMessage: "通知やメッセージが既読担ったらプッシュ通知を消すで" +pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に対応してないみたいやで。" +sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を消すで" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」っていう表示が一瞬表示されるようになるで。端末の電池使用量が増える可能性があるで。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "元に戻す" caption: "キャプション" loggedInAsBot: "Botアカウントでログイン中やで" @@ -929,62 +956,385 @@ numberOfLikes: "いいね数" show: "表示" neverShow: "今後表示しない" remindMeLater: "また後で" -didYouLikeMisskey: "CherryPickを気に入っとっただけましたん?" +didYouLikeMisskey: "CherryPick気に入ってくれた?" pleaseDonate: "CherryPickは{host}が使用している無料のソフトウェアやで。これからも開発を続けれるように、寄付したってな~。" roles: "ロール" role: "ロール" +noRole: "ロールはありまへん" normalUser: "一般ユーザー" undefined: "未定義" assign: "アサイン" unassign: "アサインを解除" color: "色" manageCustomEmojis: "カスタム絵文字の管理" -youCannotCreateAnymore: "これ以上作れなさそうや" +youCannotCreateAnymore: "これ以上作れなさそうやわ" cannotPerformTemporary: "一時的に利用できへんで" cannotPerformTemporaryDescription: "操作回数が制限を超えたから一時的に利用できへんくなったで。ちょっと時間置いてからもう一回やってやー。" +invalidParamError: "パラメータがエラー言うとりますわ" +invalidParamErrorDescription: "リクエストパラメータに問題があんねん。普通はバグやねんけど、もしかすると入力した文字数が多すぎるとかの可能性もあるから確認してや〜" +permissionDeniedError: "操作が拒否されてもうた。" +permissionDeniedErrorDescription: "自分のアカウントにはこの操作を行う権限があらへんねん" preset: "プリセット" selectFromPresets: "プリセットから選ぶ" achievements: "実績" gotInvalidResponseError: "サーバー黙っとるわ、知らんけど" gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。" thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。" -collapseRenotes: "見たことあるRenoteは省略やで" +thisPostMayBeAnnoyingHome: "ホームに投稿" +thisPostMayBeAnnoyingCancel: "やめとく" +thisPostMayBeAnnoyingIgnore: "このまま投稿" +collapseRenotes: "見たことあるRenoteは飛ばして表示するで" +internalServerError: "サーバー内部エラー" +internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ" +copyErrorInfo: "エラー情報をコピー" +joinThisServer: "このサーバーに登録するわ" +exploreOtherServers: "他のサーバー見てみる" +letsLookAtTimeline: "タイムライン見てみーや" +disableFederationConfirm: "連合なしにしとくか?" +disableFederationConfirmWarn: "連合なしにしても投稿は非公開にはならへんで。大体の場合は連合なしにする必要はないで。" +disableFederationOk: "連合なしにしとく" +invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。" +emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ" +postToTheChannel: "チャンネルに投稿" +cannotBeChangedLater: "後からは変えられへんで。" +reactionAcceptance: "ツッコミの受け入れ" +likeOnly: "いいねだけ" +likeOnlyForRemote: "リモートからはいいねだけな" +rolesAssignedToMe: "自分に割り当てられたロール" +resetPasswordConfirm: "パスワード作り直すんでええな?" +sensitiveWords: "けったいな単語" +sensitiveWordsDescription: "設定した単語が入っとるノートの公開範囲をホームにしたるわ。改行で区切ったら複数設定できるで。" +sensitiveWordsDescription2: "スペースで区切るとAND指定、キーワードをスラッシュで囲んだら正規表現や。" +notesSearchNotAvailable: "ノート検索は使われへんで。" +license: "ライセンス" +unfavoriteConfirm: "ほんまに気に入らんの?" +myClips: "自分のクリップ" +drivecleaner: "ドライブキレイキレイ" +retryAllQueuesNow: "キューを全部もっかいやり直す" +retryAllQueuesConfirmTitle: "もっかいやってみるか?" +retryAllQueuesConfirmText: "一時的にサーバー重なるかもしれへんで。" +enableChartsForRemoteUser: "リモートユーザーのチャートを作る" +enableChartsForFederatedInstances: "リモートサーバーのチャートを作る" +showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" +largeNoteReactions: "ノートのツッコミを大きする" +noteIdOrUrl: "ノートIDかURL" +video: "動画" +videos: "動画" +dataSaver: "データケチケチ" +accountMigration: "アカウントのお引っ越し" +accountMoved: "このユーザーはさらのアカウントに引っ越したで:" +accountMovedShort: "このアカウントは移行されとるで" +operationForbidden: "この操作はできまへん" +forceShowAds: "常に広告を表示しとく" +addMemo: "メモを足す" +editMemo: "メモをいらう" +reactionsList: "ツッコミ一覧" +renotesList: "Renote一覧" +notificationDisplay: "通知見せる" +leftTop: "左上" +rightTop: "右上" +leftBottom: "左下" +rightBottom: "右下" +stackAxis: "重ねる方向" +vertical: "縦" +horizontal: "横" +position: "位置" +serverRules: "サーバールール" +pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、下に書いてること確認してな。" +pleaseAgreeAllToContinue: "続けるんやったら、全ての「せやな」にチェック入れてる必要があるで。" +continue: "続けるで" +preservedUsernames: "予約ユーザー名" +preservedUsernamesDescription: "予約しとくユーザー名を行ごとに挙げるで。ここで指定されたユーザー名はアカウント作るときに使えへんくなるけど、管理者は例外や。あと、もうあるアカウントも例外やな。" +createNoteFromTheFile: "このファイル使うてノート作るで" +archive: "アーカイブ" +channelArchiveConfirmTitle: "{name}をアーカイブしてええか?" +channelArchiveConfirmDescription: "アーカイブしたら、チャンネル一覧とか検索結果からなくなるし、新しく書き込みもできへんなるで。" +thisChannelArchived: "このチャンネル、アーカイブされとるで。" +displayOfNote: "ノートの表示" +initialAccountSetting: "初期設定" +youFollowing: "フォロー中やで" +preventAiLearning: "生成AIの学習に使わんといて" +preventAiLearningDescription: "他の文章生成AIとか画像生成AIに、投稿したノートとか画像なんかを勝手に使わんように頼むで。具体的にはnoaiフラグをHTMLレスポンスに含めるんやけど、これ聞いてくれるんはAIの気分次第やから、使われる可能性もちょっとはあるな。" +options: "オプション" +specifyUser: "ユーザー指定" +_initialAccountSetting: + accountCreated: "アカウント作り終わったで。" + letsStartAccountSetup: "アカウントの初期設定をしよか。" + letsFillYourProfile: "最初はあんたのプロフィールを設定しよか。" + profileSetting: "プロフィール設定" + privacySetting: "プライバシー設定" + theseSettingsCanEditLater: "この設定はあとから変えれるで。" + youCanEditMoreSettingsInSettingsPageLater: "これ以外にもいろんな設定を「設定」ページからできるで。後で確認してみてな。" + followUsers: "タイムラインを構築するために、気になるユーザーをフォローしてみ。" + pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をあんたのデバイスで受け取れるで。" + initialAccountSettingCompleted: "初期設定が終わったで。" + haveFun: "{name}、楽しんでな~" + ifYouNeedLearnMore: "{name}(CherryPick)の使い方とかをよー知りたいんやったら{link}をみてな。" + skipAreYouSure: "初期設定飛ばすか?" +_serverRules: + description: "新規登録前に見せる、サーバーの簡潔なルールを設定すんで。内容は使うための決め事の要約とすることを推奨するわ。" +_accountMigration: + moveFrom: "別のアカウントからこのアカウントに引っ越す" + moveFromSub: "別のアカウントへエイリアスを作る" + moveFromLabel: "引っ越し元のアカウント:" + moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したかったら、ここでエイリアスを作っとく必要があるで。必ずお引っ越しを実行する前に作っとかなあかんで!引っ越し元のアカウントをこんな風に入力してくれへんか?:@person@instance.com" + moveTo: "このアカウントをさらのアカウントに引っ越すで" + moveToLabel: "引っ越し先のアカウント:" + moveCannotBeUndone: "アカウントを移行すると、取り消すことはできへんくなります。" + moveAccountDescription: "おニューのアカウントに移行すんで。\n ・フォロワーがおニューの方を勝手にフォローすんで。\n ・このアカウントからのフォローはまるまる全部解除されんで。\n ・このアカウントでノート作れへんようになるで。\n\nフォロワーの移行は勝手にこっちでやっとくけど、フォローの移行は自分でしてや。移行前にこのアカウントでフォローエクスポートして、移行したあとすぐにおニューのところでインポートしてくれな。\nリストとかミュート、あとブロックもおんなじや。自分で移行してな。\n\n(この説明はこのサーバー、つまりMisskey v13.12.0から後の仕様や。Mastodonとか他のActivityPubソフトやとちょっと挙動が違うこともあんで。)" + moveAccountHowTo: "アカウントの引っ越しには、まず引っ越し先のアカウントで自分のアカウントに対しエイリアスを作成しなはれや。\nエイリアス作成した後、引っ越し先のアカウントを次のように入力してくれへんか?:@username@server.example.com" + startMigration: "引っ越しする" + migrationConfirm: "ほんまにこのアカウントを {account} に引っ越すんか?一回引っ越してもうたら取り消されへんし、二度とこのアカウントを元に戻されへんくなるで。\nそれと、引っ越し先のアカウントでエイリアスが作れたかちゃ~んと確認しーや?" + movedAndCannotBeUndone: "\nアカウントはもう引っ越されてます。\n引っ越しを取り消すことはできまへん。" + postMigrationNote: "このアカウントからのフォロー解除は移行操作から丸一日経ったら実行されんで。\nこのアカウントのフォロー・フォロワー数はどっちも0や。フォローの解除はされへんから、あんたのフォロワーはこのアカウントのフォロワー向けの投稿をこの後も見れるで。" + movedTo: "引っ越し先のアカウント:" _achievements: earnedAt: "貰った日ぃ" _types: _notes1: title: "まいど!" description: "初めてノート投稿したった" + flavor: "CherryPickを楽しんでな~" _notes10: title: "ノートの天保山" + description: "ノートを10回投稿した" _notes100: title: "ノートの真田山" + description: "ノートを100回投稿した" _notes500: title: "ノートの生駒山" + description: "ノートを500回投稿した" + _notes1000: + title: "ノートの六甲山" + description: "ノートを1,000回投稿した" _notes5000: title: "箕面の滝からノート" + description: "ノートを5,000回投稿した" + _notes10000: + title: "スーパーノート" + description: "ノートを10,000回投稿した" + _notes20000: + title: "ニードモアノート" + description: "ノートを20,000回投稿した" + _notes30000: + title: "ノートノートノート" + description: "ノートを30,000回投稿した" + _notes40000: + title: "ノート工場" + description: "ノートを40,000回投稿した" + _notes50000: + title: "ノートの惑星" + description: "ノートを50,000回投稿した" + _notes60000: + title: "ノートクエーサー" + description: "ノートを60,000回投稿した" + _notes70000: + title: "ブラックノートホール" + description: "ノートを70,000回投稿した" + _notes80000: + title: "ノートギャラクシー" + description: "ノートを80,000回投稿した" + _notes90000: + title: "ノートバース" + description: "ノートを90,000回投稿した" + _notes100000: + title: "ALL YOUR NOTE ARE BELONG TO US" + description: "ノートを100,000回投稿した" + flavor: "そんなに書くことあるんか?" _login3: + title: "ビギナーⅠ" + description: "通算3日ログインした" flavor: "今日からワシはミスキストやで" + _login7: + title: "ビギナーⅡ" + description: "通算7日ログインした" + flavor: "慣れてきたんとちゃう?" + _login15: + title: "ビギナーⅢ" + description: "通算15日ログインした" + _login30: + title: "ミスキストⅠ" + description: "通算30日ログインした" + _login60: + title: "ミスキストⅡ" + description: "通算60日ログインした" + _login100: + title: "ミスキストⅢ" + description: "通算100日ログインした" + flavor: "そのユーザー、ミスキストにつき" + _login200: + title: "常連さんⅠ" + description: "通算200日ログインした" + _login300: + title: "常連さんⅡ" + description: "通算300日ログインした" + _login400: + title: "常連さんⅢ" + description: "通算400日ログインした" + _login500: + title: "ベテランさんⅠ" + description: "通算500日ログインした" + flavor: "あんたら、うちはノートが好きや" + _login600: + title: "ベテランさんⅡ" + description: "通算600日ログインした" + _login700: + title: "ベテランさんⅢ" + description: "通算700日ログインした" + _login800: + title: "ノートマイスターⅠ" + description: "通算800日ログインした" + _login900: + title: "ノートマイスターⅡ" + description: "通算900日ログインした" + _login1000: + title: "ノートマイスターⅢ" + description: "通算1,000日ログインした" + flavor: "CherryPickようさん使てもろておおきにな!" + _noteClipped1: + title: "アカンどれもクリップしたいわ" + description: "初めてノートをクリップした" + _noteFavorited1: + title: "星ぃみるひと" + description: "初めてノートをお気に入りに登録した" + _myNoteFavorited1: + title: "星ぃ欲しい" + description: "ワレのノートが他のひとにお気に入り登録されたで" + _profileFilled: + title: "準備万端や" + description: "プロフィールを設定した" + _markedAsCat: + title: "吾輩は猫やねん" + description: "アカウントをCatにしたった" + flavor: "名前はまだないねん。" + _following1: + title: "はじめてのフォロー" + description: "初めてフォローした" + _following10: + title: "ついてく、ついてく" + description: "フォローが10人超えた" + _following50: + title: "友達ぎょうさん" + description: "フォローが50人超えた" + _following100: + title: "友達100人" + description: "フォローが100人超えた" + _following300: + title: "いや友達多すぎやろ" + description: "フォローが300人超えた" + _followers1: + title: "はじめてのフォロワー" + description: "初めてフォローされた" + _followers10: + title: "フォローみぃ!" + description: "フォロワーが10人超えた" + _followers50: + title: "ぞろぞろ" + description: "フォロワーが50人超えた" + _followers100: + title: "人気もん" + description: "フォロワーが100人超えた" + _followers300: + title: "ほらそこ一列に並んで!" + description: "フォロワーが300人超えた" + _followers500: + title: "基地局" + description: "フォロワーが500人超えた" + _followers1000: + title: "インフルエンサー" + description: "フォロワーが1,000人超えた" + _collectAchievements30: + title: "実績コレクター" + description: "実績を30個以上獲得した" + _viewAchievements3min: + title: "実績好き" + description: "実績一覧を3分以上眺め続けた" _iLoveMisskey: - title: "Misskey好きやねん" + title: "CherryPick好きやねん" + description: "\"I ❤ #CherryPick\"を投稿した" + flavor: "CherryPickを使ってくれておおきにな~ by 開発チーム" _foundTreasure: title: "なんでも鑑定団" + description: "隠されたお宝を発見した" _client30min: title: "ねんね" + description: "クライアントを起動してから30分以上経過した" + _client60min: + title: "CherryPickの見過ぎや!" + description: "クライアント付けてから1時間経ってもうたで。" _noteDeletedWithin1min: title: "*おおっと*" + description: "投稿してから1分以内にその投稿を消した" + _postedAtLateNight: + title: "夜行性" + description: "深夜にノートを投稿した" + flavor: "そろそろ寝よか" + _postedAt0min0sec: + title: "時報" + description: "0分0秒にノートを投稿した" + flavor: "ポッ ポッ ポッ ピーン" + _selfQuote: + title: "自己言及" + description: "自分のノートを引用した" + _htl20npm: + title: "流れるTL" + description: "ホームタイムラインの流速が20npmを超す" + _viewInstanceChart: + title: "アナリスト" + description: "サーバーのチャートを表示した" + _outputHelloWorldOnScratchpad: + title: "Hello, world!" + description: "スクラッチパッドで hello worldを出力した" _open3windows: title: "マド開けすぎ" + description: "ウィンドウを3つ以上開いた状態にした" _driveFolderCircularReference: title: "環状線" + description: "ドライブのフォルダを再帰的な入れ子にしようとした" + _reactWithoutRead: + title: "ちゃんと読んだんか?" + description: "100文字以上のテキストを含むノートに投稿されてから3秒以内にツッコんだ" + _clickedClickHere: + title: "ここをクリック" + description: "ここをクリックした" + _justPlainLucky: + title: "単なるラッキー" + description: "10秒ごとに0.005%の確率で獲得" + _setNameToSyuilo: + title: "神様コンプレックス" + description: "名前を syuilo に設定した" + _passedSinceAccountCreated1: + title: "一周年" + description: "アカウント作成から1年経過した" + _passedSinceAccountCreated2: + title: "二周年" + description: "アカウント作成から2年経過した" + _passedSinceAccountCreated3: + title: "三周年" + description: "アカウント作成から3年経過した" + _loggedInOnBirthday: + title: "ハッピーバースデー!" + description: "誕生日にログインした" + _loggedInOnNewYearsDay: + title: "あけましておめでとうございます!" + description: "元旦にログインした" + flavor: "今年も弊サーバーをよろしゅう頼みますわ" + _cookieClicked: + title: "クッキー叩くやつ" + description: "クッキー叩いてもうた" + flavor: "兄ちゃんソフト間違っとんで" + _brainDiver: + title: "Brain Diver" + description: "Brain Diverへのリンクを投稿したった" + flavor: "CherryPick-CherryPick La-Tu-Ma" _role: new: "ロールの作成" edit: "ロールの編集" name: "ロール名" description: "ロールの説明" permission: "ロールの権限" - descriptionOfPermission: "モデレーターは基本的なモデレーションに関わる操作を行えるで。\n管理者はインスタンスの全ての設定を変更できるで。" - assignTarget: "アサインターゲット" + descriptionOfPermission: "モデレーターは基本的なモデレーションに関わる操作を行えるで。\n管理者はサーバーの全ての設定を変更できるで。" + assignTarget: "アサイン" descriptionOfAssignTarget: "マニュアルは誰がこのロールに含まれてるかを手動で管理するで。\nコンディショナルは条件を設定して、それに合うユーザーが自動で含まれるようになるで。" manual: "マニュアル" conditional: "コンディショナル" @@ -997,6 +1347,13 @@ _role: baseRole: "ベースロール" useBaseValue: "ベースロールの値を使用" chooseRoleToAssign: "アサインするロールを選択" + iconUrl: "アイコン画像のURL" + asBadge: "バッジとして見せる" + descriptionOfAsBadge: "オンにすると、ユーザー名の横んとこにロールのアイコンが表示されるで。" + isExplorable: "ロールタイムラインを公開するで〜" + descriptionOfIsExplorable: "オンにしたらロールのタイムラインを公開するで〜。でもロールの公開をオフにしたら公開されへんよ。" + displayOrder: "表示順" + descriptionOfDisplayOrder: "数がでかいほど、UI上で先に表示されるで。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになるで。オフにすると管理者のみが行えるで。" priority: "優先度" @@ -1008,9 +1365,10 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" - canInvite: "インスタンス招待コードの発行" + canInvite: "サーバー招待コードの発行" canManageCustomEmojis: "カスタム絵文字の管理" driveCapacity: "ドライブ容量" + alwaysMarkNsfw: "勝手にファイルにNSFWをくっつける" pinMax: "ノートのピン留めの最大数" antennaMax: "アンテナの作成可能数" wordMuteMax: "ワードミュートの最大文字数" @@ -1020,8 +1378,9 @@ _role: userListMax: "ユーザーリストの作成可能数" userEachUserListsMax: "ユーザーリスト内のユーザーの最大数" rateLimitFactor: "レートリミット" - descriptionOfRateLimitFactor: "ちっちゃいほど制限が緩くなって、大きいほど制限されるで。" + descriptionOfRateLimitFactor: "ちっちゃいほど制限が緩なって、大きいほど制限されるで。" canHideAds: "広告を表示させへん" + canSearchNotes: "ノート検索を使わすかどうか" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1031,6 +1390,8 @@ _role: followersMoreThanOrEq: "フォロワー数が~以上" followingLessThanOrEq: "フォロー数が~以下" followingMoreThanOrEq: "フォロー数が~以上" + notesLessThanOrEq: "投稿数が~以下しかない" + notesMoreThanOrEq: "投稿を~以上しとる" and: "~かつ~" or: "~または~" not: "~ではない" @@ -1070,7 +1431,7 @@ _ad: _forgotPassword: enterEmail: "アカウントに登録したメールアドレスをここに入力してや。そのアドレス宛に、パスワードリセット用のリンクが送られるから待っててな~。" ifNoEmail: "メールアドレスを登録してへんのやったら、管理者まで教えてな~。" - contactAdmin: "このインスタンスはメールに対応してへんから、パスワードリセットをしたいときは管理者まで教えてな~。" + contactAdmin: "このサーバーはメールに対応してへんから、パスワードリセットをしたいときは管理者まで教えてな~。" _gallery: my: "あんたの投稿" liked: "いいねした投稿" @@ -1206,6 +1567,8 @@ _channel: following: "フォロー中やで" usersCount: "{n}人が参加中やで" notesCount: "{n}こ投稿があるで" + nameAndDescription: "名前と説明" + nameOnly: "名前だけ" _menuDisplay: sideFull: "横" sideIcon: "横(アイコン)" @@ -1221,10 +1584,10 @@ _wordMute: hard: "ハード" mutedNotes: "ミュートされたノート" _instanceMute: - instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。" - instanceMuteDescription2: "改行で区切って設定するで" - title: "設定したインスタンスのノートを隠すで。" - heading: "ミュートするインスタンス" + instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。" + instanceMuteDescription2: "改行で区切って設定するんやで" + title: "設定したサーバーのノートを隠すで。" + heading: "ミュートするサーバー" _theme: explore: "テーマを探す" install: "テーマのインストール" @@ -1309,7 +1672,7 @@ _sfx: channel: "チャンネル通知" _ago: future: "未来" - justNow: "たった今" + justNow: "ついさっき" secondsAgo: "{n}秒前" minutesAgo: "{n}分前" hoursAgo: "{n}時間前" @@ -1317,47 +1680,47 @@ _ago: weeksAgo: "{n}週間前" monthsAgo: "{n}ヶ月前" yearsAgo: "{n}年前" + invalid: "あらへん" _time: second: "秒" minute: "分" hour: "時間" day: "日" -_tutorial: - title: "CherryPickの使い方" - step1_1: "よう来たなあ" - step1_2: "この画面は「タイムライン」って言って、あんたや、あんたが「フォロー」する人の「ノート」が時系列で表示されるんやで。" - step1_3: "あんたはまだ何もノートを投稿してなくて、誰もフォローしてへんから、タイムラインには何も表示されてないはずやで。" - step2_1: "ノートを作ったり誰かをフォローしたりする前に、まずあんたのプロフィールを完成させよか。" - step2_2: "あんたがどんな人かわかると、多くの人にノートを見てもらえたり、フォローしてもらいやすくなるで。" - step3_1: "プロフィール設定はええ感じにできたか?" - step3_2: "ほな試しに、何かノートを投稿してみてやー。画面上にある鉛筆マークのボタンを押すとフォームが開くはずやで。" - step3_3: "内容を書いたら、フォーム右上のボタンを押すと投稿できるで。" - step3_4: "内容が思いつかへん?ほな「関西人なら面白いこと言うてえ〜や〜」とかどうやろか。" - step4_1: "投稿できたん?" - step4_2: "あんたのノートがタイムラインに表示されていれば成功やで" - step5_1: "次は、ほかの人をフォローしてタイムラインを賑やかにしよか" - step5_2: "{featured}で人気のノートが見れるから、その中から気になった人を選んでフォローしたり、{explore}で人気のユーザーを探すこともできるで。" - step5_3: "ユーザーをフォローしたかったら、ユーザーのアイコンをクリックしてユーザーページを表示して、「フォロー」ボタンを押すんやで。" - step5_4: "ユーザーによっては、フォローが承認されるまでちょっと時間がかかることがあるで。" - step6_1: "タイムラインに他のユーザーのノートが表示されていれば成功やで。" - step6_2: "他の人のノートには、「リアクション」を付けることができて、簡単にあんたの反応を伝えられるで。" - step6_3: "リアクションを付けるんやったら、ノートの「+」マークをクリックして、好きなリアクションを選択してな。" - step7_1: "これで、CherryPickの基本的な使い方の説明は終わりやで。お疲れさん。" - step7_2: "もっとCherryPickについて知りたいときは、{help}を見るとええかもな。" - step7_3: "ほな、CherryPickを楽しんでなー🚀" - step8_1: "最後に、プッシュ通知を有効化してみやん?" - step8_2: "プッシュ通知を受け取ることで、CherryPickを開いていない時でもリアクションやフォロー、メンションとかに気づけるで。" - step8_3: "通知の設定はあとから変更できるで" +_timelineTutorial: + title: "CherryPickってなんや?" + step1_1: "これは「タイムライン」や。{name}に投稿された「ノート」が順番に表示されるで。" + step1_2: "タイムラインには何個か種類があってな、例えば「ホームタイムライン」だったらあんたのフォローしてる人のノート、「ローカルタイムライン」には{name}全部のノートが流れてくるで。" + step2_1: "試しに、何かノートを投稿してみ。画面の鉛筆マークのボタンでフォームが開くで。" + step2_2: "最初のノートは、自己紹介とか「{name}始めてみたんや」とかがええと思うで。" + step3_1: "投稿できた?" + step3_2: "あんたのノートがタイムラインに出てきたら成功や。" + step4_1: "ノートには、「リアクション」を付けれるで。" + step4_2: "ツッコむんやったら、ノートの「+」マークを押して、好きな絵文字を選ぶで。" _2fa: alreadyRegistered: "もう設定終わっとるわ。" - registerDevice: "デバイスを登録するで" - registerKey: "キーを登録するで" + registerTOTP: "認証アプリの設定はじめる" + passwordToTOTP: "パスワードを入れてーや" step1: "ほんなら、{a}や{b}とかの認証アプリを使っとるデバイスにインストールしてな。" step2: "次に、ここにあるQRコードをアプリでスキャンしてな~。" + step2Click: "QRコードをクリックすると、今使とる端末に入っとる認証アプリとかキーリングに登録できるで。" step2Url: "デスクトップアプリやったら次のURLを入力してや:" + step3Title: "確認コードを入れてーや" step3: "アプリに表示されているトークンを入力して終わりや。" step4: "これからログインするときも、同じようにトークンを入力するんやで" + securityKeyNotSupported: "今使とるブラウザはセキュリティキーに対応してへんのやってさ。" + registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するんやったら、まず認証アプリを設定してーな。" securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーか端末の指紋認証やPINを使ってログインするように設定できるで。" + chromePasskeyNotSupported: "Chromeのパスキーは今んとこ対応してないねん。" + registerSecurityKey: "セキュリティキー・パスキーを登録するわ" + securityKeyName: "キーの名前を入れてーや" + tapSecurityKey: "ブラウザが言うこと聞いて、セキュリティキーとかパスキー登録しといでや" + removeKey: "セキュリティキーをほかす" + removeKeyConfirm: "{name}を消すん?" + whyTOTPOnlyRenew: "セキュリティキーが登録されとったら、認証アプリの設定は解除できへんで。" + renewTOTP: "認証アプリをもっかい設定" + renewTOTPConfirm: "今までの人称アプリの確認コードは使えんくなるけどええか?" + renewTOTPOk: "もっかい設定する" + renewTOTPCancel: "やめとく" _permissions: "read:account": "アカウントの情報を見るで" "write:account": "アカウントの情報を変更するで" @@ -1376,8 +1739,8 @@ _permissions: "write:notes": "ノートを作成・削除するで" "read:notifications": "通知を見るで" "write:notifications": "通知を操作するで" - "read:reactions": "リアクションを見る" - "write:reactions": "リアクションを操作するで" + "read:reactions": "ツッコミを見る" + "write:reactions": "ツッコミを操作するで" "write:votes": "投票する" "read:pages": "ページを見る" "write:pages": "ページを操作するで" @@ -1392,6 +1755,7 @@ _permissions: "read:gallery-likes": "ギャラリーのいいねを見るで" "write:gallery-likes": "ギャラリーのいいねを操作するで" _auth: + shareAccessTitle: "アプリへのアクセス許してやったらどうや" shareAccess: "「{name}」がアカウントにアクセスすることを許可してええか?" shareAccessAsk: "アカウントのアクセスを許可してもええか?" permission: "{name}に次の権限つけたってやって" @@ -1416,7 +1780,7 @@ _weekday: saturday: "土曜日" _widgets: profile: "プロフィール" - instanceInfo: "インスタンス情報" + instanceInfo: "サーバー情報" memo: "付箋" notifications: "通知" timeline: "タイムライン" @@ -1430,7 +1794,7 @@ _widgets: digitalClock: "デジタル時計" unixClock: "UNIX時計" federation: "連合" - instanceCloud: "インスタンスクラウド" + instanceCloud: "サーバークラウド" postForm: "投稿フォーム" slideshow: "スライドショー" button: "ボタン" @@ -1480,16 +1844,16 @@ _visibility: followersDescription: "自分のフォロワーのみに公開するで" specified: "ダイレクト" specifiedDescription: "選んだユーザーのみに公開するで" - localOnly: "ローカルのみ" - localOnlyDescription: "リモートユーザーには非公開にするで" + disableFederation: "連合なし" + disableFederationDescription: "他サーバーへは送らんとくわ" _postForm: replyPlaceholder: "このノートに返信..." quotePlaceholder: "このノートを引用..." channelPlaceholder: "チャンネルに投稿..." _placeholders: - a: "いまどうしとるん?" + a: "いまどないしとるん?" b: "何かあったん?" - c: "何を考えとるん?" + c: "何か考えとるん?" d: "何か言いたいことあるん?" e: "ここに書いてーなー" f: "あんたが書くの待っとるで" @@ -1519,7 +1883,7 @@ _charts: apRequest: "リクエスト" usersIncDec: "ユーザーの増減" usersTotal: "ユーザーの合計" - activeUsers: "アクティブユーザー数" + activeUsers: "いまおるユーザー数" notesIncDec: "ノートの増減" localNotesIncDec: "ローカルのノートの増減" remoteNotesIncDec: "リモートのノートの増減" @@ -1576,7 +1940,7 @@ _pages: viewPage: "ページを見る" like: "ええやん" unlike: "良くないわ" - my: "人気のページ" + my: "自分のページ" liked: "ええと思ったページ" featured: "人気" inspector: "インスペクター" @@ -1636,11 +2000,12 @@ _notification: reply: "リプライ" renote: "Renote" quote: "引用" - reaction: "リアクション" + reaction: "ツッコミ" pollEnded: "アンケートが終了したで" receiveFollowRequest: "フォロー許可してほしいみたいやで" followRequestAccepted: "フォローが受理されたで" groupInvited: "グループに招待されたで" + achievementEarned: "実績の獲得" app: "連携アプリからの通知や" _actions: followBack: "フォローバック" @@ -1673,3 +2038,27 @@ _deck: channel: "チャンネル" mentions: "あんた宛て" direct: "ダイレクト" + roleTimeline: "ロールタイムライン" +_dialog: + charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" + charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" +_disabledTimeline: + title: "使われへんタイムライン" + description: "あんたの今のロールやったら、このタイムラインは使われへんで。" +_drivecleaner: + orderBySizeDesc: "サイズのでかい順" + orderByCreatedAtAsc: "追加日の古い順" +_webhookSettings: + createWebhook: "Webhookをつくる" + name: "名前" + secret: "シークレット" + events: "Webhookを投げるタイミング" + active: "有効" + _events: + follow: "フォローしたとき~!" + followed: "フォローもらったとき~!" + note: "ノートを投稿したとき~!" + reply: "返信があるとき~!" + renote: "Renoteされるとき~!" + reaction: "ツッコミがあるとき~!" + mention: "メンションがあるとき~!" diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml index fcd490db12..18fd8f5a58 100644 --- a/locales/kab-KAB.yml +++ b/locales/kab-KAB.yml @@ -39,10 +39,8 @@ remove: "Kkes" connectService: "Qqen" userList: "Tibdarin" securityKey: "Tasarutt n tɣellist" -securityKeyName: "Isem n tsarutt" signinRequired: "Ttxil jerred" signinWith: "Tuqqna s {x}" -tapSecurityKey: "Sekcem tasarutt-ik·im n tɣellist" uiLanguage: "Tutlayt n wegrudem" accountSettings: "Iɣewwaṛen n umiḍan" plugins: "Izegrar" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 4979bcb186..a7c700a1c8 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -11,7 +11,7 @@ cherrypickUpdatedCacheClear: "테마 및 색상, 로케일 등의 변경 사항 cherrypickUpdatedCacheClearLater: "나중에 캐시를 지우려면 설정 - 캐시 지우기에서 할 수 있어요!" headlineMisskey: "노트로 연결되는 네트워크" introMisskey: "어서오세요! CherryPick은 오픈 소스 분산형 마이크로 블로그 서비스에요.\n\"노트\" 를 작성해서, 지금 일어나고 있는 일을 공유하거나, 당신만의 이야기를 모두에게 발신할 수 있어요📡\n\"리액션\" 기능으로, 친구의 노트에 총알같이 반응을 추가할 수도 있어요👍\n새로운 세계를 탐험해 보세요🚀" -poweredByMisskeyDescription: "{name}은(는) 오픈소스 플랫폼 CherryPick을 사용한 서비스(CherryPick 인스턴스라고 불려요) 중 하나예요." +poweredByMisskeyDescription: "{name}은(는) 오픈소스 플랫폼 CherryPick을 사용한 서버 중 하나예요." monthAndDay: "{month}월 {day}일" search: "검색" notifications: "알림" @@ -29,6 +29,7 @@ noNotes: "노트가 없어요" noNotifications: "표시할 알림이 없어요" instance: "인스턴스" settings: "설정" +notificationSettings: "알림 설정" basicSettings: "기본 설정" otherSettings: "기타 설정" openInWindow: "창으로 열기" @@ -116,6 +117,8 @@ renoted: "리노트 했어요!" cantRenote: "이 게시물은 리노트 할 수 없어요." cantReRenote: "리노트된 노트는 리노트 할 수 없어요." quote: "인용" +inChannelRenote: "채널 내 리노트" +inChannelQuote: "채널 내 인용" pinnedNote: "고정해놓은 노트" pinned: "프로필에 고정" you: "나" @@ -133,6 +136,8 @@ unmarkAsSensitive: "열람주의 해제" enterFileName: "파일명 입력" mute: "뮤트" unmute: "뮤트 해제" +renoteMute: "리노트 뮤트" +renoteUnmute: "리노트 뮤트 해제" block: "차단" unblock: "차단 해제" suspend: "정지" @@ -164,6 +169,7 @@ flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기" flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시해요." autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" addAccount: "계정 추가" +reloadAccountsList: "계정 리스트 정보 갱신" loginFailed: "로그인에 실패했어요.." showOnRemote: "리모트에서 보기" general: "일반" @@ -174,13 +180,13 @@ searchWith: "검색: {q}" youHaveNoLists: "리스트가 없어요" followConfirm: "{name}님을 팔로우 하시겠어요?" proxyAccount: "프록시 계정" -proxyAccountDescription: "프록시 계정은 유저의 리모트 팔로우를 대행하는 계정이에요. 예를 들면, 리모트 유저를 리스트에 넣었을 때, 리스트에 들어간 유저를 아무도 팔로우한 적이 없다면 액티비티가 인스턴스로 배달되지 않기 때문에 대신 프록시 계정이 해당 유저를 팔로우해서 문제를 해결해요." +proxyAccountDescription: "프록시 계정은 유저의 리모트 팔로우를 대행하는 계정이에요. 예를 들면, 리모트 유저를 리스트에 넣었을 때, 리스트에 들어간 유저를 아무도 팔로우한 적이 없다면 액티비티가 서버로 배달되지 않기 때문에 대신 프록시 계정이 해당 유저를 팔로우해서 문제를 해결해요." host: "호스트" selectUser: "유저 선택" recipient: "수신인" annotation: "내용에 대한 주석" federation: "연합" -instances: "인스턴스" +instances: "서버" registeredAt: "등록 날짜" latestRequestReceivedAt: "마지막으로 요청을 받은 시간" latestStatus: "마지막 상태" @@ -189,7 +195,7 @@ charts: "차트" perHour: "1시간마다" perDay: "1일마다" stopActivityDelivery: "액티비티 보내지 않기" -blockThisInstance: "이 인스턴스를 차단" +blockThisInstance: "이 서버를 차단" operations: "작업" software: "소프트웨어" version: "버전" @@ -200,15 +206,15 @@ jobQueue: "작업 대기열" cpuAndMemory: "CPU 및 메모리" network: "네트워크" disk: "디스크" -instanceInfo: "인스턴스 정보" +instanceInfo: "서버 정보" statistics: "통계" clearQueue: "대기열 비우기" clearQueueConfirmTitle: "대기열을 비울까요?" clearQueueConfirmText: "아직 대기열에 남아 있는 노트는 연합우주에 전송되지 않게 돼요. 보통은 이 작업이 필요하지 않아요." clearCachedFiles: "캐시 비우기" clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제할까요?" -blockedInstances: "차단된 인스턴스" -blockedInstancesDescription: "차단하려는 인스턴스의 호스트 이름을 줄바꿈으로 구분하여 설정해요. 차단된 인스턴스는 이 인스턴스와 통신할 수 없게 돼요." +blockedInstances: "차단된 서버" +blockedInstancesDescription: "차단하려는 서버의 호스트 이름을 줄바꿈으로 구분하여 설정해요. 차단된 인스턴스는 이 인스턴스와 통신할 수 없게 돼요." muteAndBlock: "뮤트 및 차단" mutedUsers: "뮤트한 유저" blockedUsers: "차단한 유저" @@ -231,9 +237,9 @@ all: "전체" subscribing: "구독 중" publishing: "배포 중" notResponding: "응답 없음" -instanceFollowing: "인스턴스의 팔로잉" -instanceFollowers: "인스턴스의 팔로워" -instanceUsers: "인스턴스의 유저" +instanceFollowing: "서버의 팔로잉" +instanceFollowers: "서버의 팔로워" +instanceUsers: "서버의 유저" changePassword: "비밀번호 변경" security: "보안" retypedNotMatch: "비밀번호가 일치하지 않아요." @@ -270,14 +276,16 @@ noMoreHistory: "타임머신이 더 이상은 돌아갈 수 없대요!" startMessaging: "대화 시작하기" nUsersRead: "{n}명이 읽음" agreeTo: "{0}에 동의" +agree: "동의합니다" agreeBelow: "아래 내용에 동의합니다" basicNotesBeforeCreateAccount: "기본적인 주의사항" -tos: "이용 약관" +termsOfService: "이용 약관" start: "시작하기" home: "홈" remoteUserCaution: "리모트 유저에요! 인스턴스와 정보가 일치하지 않을 수 있어요." activity: "활동" images: "이미지" +image: "이미지" birthday: "생일" yearsOld: "{age}세" registeredDate: "등록일" @@ -325,8 +333,8 @@ unwatch: "지켜보기 해제" accept: "허가" reject: "거부" normal: "정상" -instanceName: "인스턴스 이름" -instanceDescription: "인스턴스 소개" +instanceName: "서버 이름" +instanceDescription: "서버 소개" maintainerName: "관리자 이름" maintainerEmail: "관리자 이메일" tosUrl: "이용약관 URL" @@ -356,7 +364,7 @@ basicInfo: "기본 정보" pinnedUsers: "고정된 유저" pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적어주세요." pinnedPages: "고정한 페이지" -pinnedPagesDescription: "인스턴스의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적어주세요." +pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적어주세요." pinnedClipId: "고정할 클립의 ID" pinnedNotes: "고정해놓은 노트" hcaptcha: "hCaptcha" @@ -404,16 +412,19 @@ about: "정보" aboutMisskey: "CherryPick에 대하여" administrator: "관리자" token: "토큰" -twoStepAuthentication: "2단계 인증" +2fa: "2단계 인증" +totp: "인증 앱" +totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" moderator: "모더레이터" moderation: "모더레이션" nUsersMentioned: "{n}명이 언급함" +securityKeyAndPasskey: "보안 키 또는 패스 키" securityKey: "보안 키" -securityKeyName: "키 이름" -registerSecurityKey: "보안 키 등록" lastUsed: "마지막 사용" +lastUsedAt: "마지막 사용: {t}" unregister: "등록 해제" passwordLessLogin: "비밀번호 없이 로그인" +passwordLessLoginDescription: "비밀번호를 사용하지 않고 보안 키 또는 패스 키 등으로만 로그인합니다." resetPassword: "비밀번호 재설정" newPasswordIs: "새로운 비밀번호는 \"{password}\" 에요!" reduceUiAnimation: "UI 애니메이션 줄이기" @@ -467,7 +478,6 @@ passwordMatched: "똑같아요!" passwordNotMatched: "어라? 비밀번호가 다른 것 같아요" signinWith: "{x}로 로그인" signinFailed: "로그인할 수 없었어요. 사용자명과 비밀번호를 다시 확인해 주세요." -tapSecurityKey: "보안 키를 터치" or: "혹은" language: "언어" uiLanguage: "UI 표시 언어" @@ -478,6 +488,7 @@ native: "네이티브" disableDrawer: "드로어 메뉴를 사용하지 않기" youHaveNoGroups: "그룹이 없어요" joinOrCreateGroup: "다른 그룹의 초대를 받거나, 직접 새 그룹을 만들 수 있어요!" +showNoteActionsOnlyHover: "노트 액션 버튼을 마우스를 올렸을 때에만 표시" noHistory: "기록이 없어요" signinHistory: "로그인 기록" enableAdvancedMfm: "고급 MFM을 활성화" @@ -490,6 +501,8 @@ createAccount: "계정 만들기" existingAccount: "기존 계정" regenerate: "재생성" fontSize: "글자 크기" +mediaListWithOneImageAppearance: "이미지가 1개 뿐인 미디어 목록의 높이" +limitTo: "{x}로 제한" noFollowRequests: "아직은 팔로우 요청이 없어요" openImageInNewTab: "새 탭에서 이미지 열기" dashboard: "대시보드" @@ -523,9 +536,11 @@ objectStorageUseSSLDesc: "API 호출시 HTTPS 를 사용하지 않는 경우 OFF objectStorageUseProxy: "연결에 프록시 사용" objectStorageUseProxyDesc: "오브젝트 스토리지 API 호출시 프록시를 사용하지 않는 경우 OFF로 설정해 주세요" objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" +s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급해요. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해야 할 수 있어요." serverLogs: "서버 로그" deleteAll: "모두 삭제" showFixedPostForm: "타임라인 상단에 글 작성란 표시" +showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란 표시" newNoteRecived: "새 노트가 있어요!" newNoteRecivedCount: "{n}개의 새 노트가 있어요!" sounds: "소리" @@ -559,14 +574,19 @@ updateRemoteUser: "리모트 유저 정보 갱신" deleteAllFiles: "모든 파일 삭제" deleteAllFilesConfirm: "모든 파일을 삭제할까요?" removeAllFollowing: "모든 팔로잉 해제" -removeAllFollowingDescription: "{host}(으)로부터 모든 팔로잉을 해제할 거예요. 해당 인스턴스가 더 이상 존재하지 않게 된 경우에만 실행해 주세요." +removeAllFollowingDescription: "{host}(으)로부터 모든 팔로잉을 해제합니다. 해당 서버가 더 이상 존재하지 않게 된 경우 등에 실행해 주세요." userSuspended: "이 계정은 정지된 상태에요." userSilenced: "이 계정은 사일런스된 상태에요." yourAccountSuspendedTitle: "계정이 정지되어 있어요" yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반했거나, 기타 다른 이유로 인해 정지되었어요. 자세한 사항은 관리자에게 문의해 주시고, 계정을 새로 생성하지 마세요." +tokenRevoked: "유효하지 않은 토큰이에요" +tokenRevokedDescription: "로그인 토큰이 비활성화되었어요. 다시 로그인해 주세요." +accountDeleted: "계정이 정지되었어요" +accountDeletedDescription: "이 계정은 삭제되었어요." menu: "메뉴" divider: "구분선" addItem: "항목 추가" +rearrange: "정렬" relays: "릴레이" addRelay: "릴레이 추가" inboxUrl: "Inbox 주소" @@ -607,7 +627,6 @@ tokenRequested: "계정 접근 허용" pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있어요." notificationType: "알림 유형" edit: "편집" -useStarForReactionFallback: "알 수 없는 리액션 이모지 대신 ★ 사용" emailServer: "메일 서버" enableEmail: "이메일 송신 기능 활성화" emailConfigInfo: "가입할 때 메일 주소를 확인하거나, 비밀번호를 재설정할 때 사용해요." @@ -625,7 +644,7 @@ testEmail: "이메일 전송 테스트" wordMute: "단어 뮤트" regexpError: "정규 표현식 오류" regexpErrorDescription: "{tab}단어 뮤트 {line}행의 정규 표현식에 오류가 발생했어요:" -instanceMute: "인스턴스 뮤트" +instanceMute: "서버 뮤트" userSaysSomething: "{name}님이 뭔가 말했어요!" makeActive: "활성화" display: "표시" @@ -656,15 +675,15 @@ abuseReported: "신고를 보냈어요! 신고해 주셔서 감사합니다." reporter: "신고자" reporteeOrigin: "피신고자" reporterOrigin: "신고자" -forwardReport: "리모트 인스턴스에도 신고 내용 보내기" -forwardReportIsAnonymous: "리모트 인스턴스에서는 내 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시돼요." +forwardReport: "리모트 서버에도 신고 내용 보내기" +forwardReportIsAnonymous: "리모트 서버에서는 내 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시돼요." send: "전송" abuseMarkAsResolved: "해결됨으로 표시" openInNewTab: "새 탭에서 열기" openInSideView: "사이드뷰로 열기" defaultNavigationBehaviour: "기본 탐색 동작" editTheseSettingsMayBreakAccount: "이 설정을 변경하면 계정이 손상될 수 있으니 주의해 주세요!" -instanceTicker: "노트의 인스턴스 정보" +instanceTicker: "노트의 서버 정보" waitingFor: "{x}을(를) 기다리고 있어요" random: "랜덤" system: "시스템" @@ -711,6 +730,8 @@ contact: "연락처" useSystemFont: "시스템 기본 글꼴 사용" clips: "클립" experimentalFeatures: "실험실" +experimental: "실험실" +thisIsExperimentalFeature: "이 기능은 실험적인 기능입니다. 사양이 변경되거나 정상적으로 동작하지 않을 가능성이 있습니다." developer: "개발자" makeExplorable: "\"발견하기\"에 내 계정 보이기" makeExplorableDescription: "비활성화하면 \"발견하기\"에 내 계정이 표시되지 않아요." @@ -753,7 +774,7 @@ capacity: "용량" inUse: "사용 중" editCode: "코드 수정" apply: "적용" -receiveAnnouncementFromInstance: "이 인스턴스의 알림을 이메일로 수신하고 싶어요" +receiveAnnouncementFromInstance: "이 서버의 알림을 이메일로 수신하고 싶어요" emailNotification: "메일 알림" publish: "게시" inChannelSearch: "채널에서 검색" @@ -781,7 +802,7 @@ active: "최근에 활동함" offline: "오프라인" notRecommended: "추천하지 않음" botProtection: "봇 방어" -instanceBlocking: "인스턴스 차단" +instanceBlocking: "서버 차단" selectAccount: "계정 선택" switchAccount: "계정 바꾸기" enabled: "활성화" @@ -801,6 +822,7 @@ popularPosts: "인기 포스트" shareWithNote: "노트로 공유" ads: "광고" expiration: "기한" +startingperiod: "시작 기간" memo: "메모" priority: "우선순위" high: "높음" @@ -833,6 +855,7 @@ lastCommunication: "마지막 통신" resolved: "해결됨" unresolved: "해결되지 않음" breakFollow: "팔로워 해제" +breakFollowConfirm: "이 계정이 더 이상 나를 팔로우 하지 않도록 하시겠어요?" itsOn: "켜짐" itsOff: "꺼짐" emailRequiredForSignup: "가입할 때 이메일 주소를 필수로 입력" @@ -865,15 +888,17 @@ themeColor: "테마 컬러" size: "크기" numberOfColumn: "한 줄에 보일 리액션의 수" searchByGoogle: "검색" -instanceDefaultLightTheme: "인스턴스 기본 라이트 테마" -instanceDefaultDarkTheme: "인스턴스 기본 다크 테마" +instanceDefaultLightTheme: "서버 기본 라이트 테마" +instanceDefaultDarkTheme: "서버 기본 다크 테마" instanceDefaultThemeDescription: "객체 형식의 테마 코드를 입력해 주세요." mutePeriod: "뮤트할 기간" +period: "투표 기한" indefinitely: "무기한" tenMinutes: "10분" oneHour: "1시간" oneDay: "1일" oneWeek: "일주일" +oneMonth: "1개월" reflectMayTakeTime: "반영되기까지 시간이 걸릴 수 있어요." failedToFetchAccountInformation: "계정 정보를 가져오지 못했어요" rateLimitExceeded: "요청 제한 횟수를 초과했어요! 나중에 다시 시도해 주세요." @@ -915,9 +940,10 @@ remoteOnly: "리모트만" failedToUpload: "업로드 실패" cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없어요!" cannotUploadBecauseNoFreeSpace: "드라이브에 용량이 부족해서 업로드할 수 없었어요.." +cannotUploadBecauseExceedsFileSizeLimit: "파일 크기가 너무 크기 때문에 업로드할 수 없어요!" beta: "베타" enableAutoSensitive: "자동으로 NSFW 탐지" -enableAutoSensitiveDescription: "사용 가능한 경우, 기계학습을 통해 자동으로 미디어에 NSFW를 설정할 거예요. 이 기능을 해제하더라도, 인스턴스 정책에 따라 자동으로 설정될 수 있어요." +enableAutoSensitiveDescription: "사용 가능한 경우, 기계학습을 통해 자동으로 미디어에 NSFW를 설정할 거예요. 이 기능을 해제하더라도, 서버 정책에 따라 자동으로 설정될 수 있어요." activeEmailValidationDescription: "유저가 입력한 메일 주소가 일회용 메일인지, 실제로 통신할 수 있는 지 엄격하게 검사해요. 해제하면 이메일 형식에 대해서만 검사해요." navbar: "내비게이션 바" shuffle: "셔플" @@ -927,10 +953,11 @@ pushNotification: "푸시 알림" subscribePushNotification: "푸시 알림 켜기" unsubscribePushNotification: "푸시 알림 끄기" pushNotificationAlreadySubscribed: "푸시 알림이 이미 켜져 있어요!" -pushNotificationNotSupported: "브라우저나 인스턴스에서 푸시 알림을 지원하지 않아요.." +pushNotificationNotSupported: "브라우저나 서버에서 푸시 알림을 지원하지 않아요.." sendPushNotificationReadMessage: "푸시 알림이나 메시지를 읽은 뒤 푸시 알림을 삭제하기" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」이라는 알림이 잠깐 표시돼요. 기기의 전력 소비량이 증가할 수 있어요." windowMaximize: "최대화" +windowMinimize: "최소화" windowRestore: "복구" caption: "캡션" loggedInAsBot: "봇 계정으로 로그인 중" @@ -947,6 +974,7 @@ didYouLikeMisskey: "CherryPick이 마음에 드시나요?" pleaseDonate: "{host}은(는) 무료 소프트웨어 CherryPick을 사용하고 있어요. 후원을 통해 저희의 개발이 이어질 수 있게 도와주세요!" roles: "역할" role: "역할" +noRole: "역할이 없어요" normalUser: "일반 사용자" undefined: "정의되지 않음" assign: "할당" @@ -956,6 +984,10 @@ manageCustomEmojis: "커스텀 이모지 관리" youCannotCreateAnymore: "더 이상 생성할 수 없어요." cannotPerformTemporary: "일시적으로 사용할 수 없음" cannotPerformTemporaryDescription: "조작 횟수 제한을 초과하여 일시적으로 사용할 수 없어요. 잠시 후 다시 시도해 주세요." +invalidParamError: "매개변수 오류" +invalidParamErrorDescription: "요청 매개변수에 문제가 있어요. 대부분의 경우 CherryPick의 버그가 원인이지만, 입력 문자수가 너무 많았을 가능성 등도 있어요." +permissionDeniedError: "작업이 거부되었어요" +permissionDeniedErrorDescription: "이 작업을 수행할 권한이 없어요." preset: "프리셋" selectFromPresets: "프리셋에서 선택" achievements: "도전 과제" @@ -965,6 +997,106 @@ thisPostMayBeAnnoying: "이 게시물은 다른 유저에게 피해를 줄 가 thisPostMayBeAnnoyingHome: "홈에 게시" thisPostMayBeAnnoyingCancel: "그만두기" thisPostMayBeAnnoyingIgnore: "이대로 게시" +collapseRenotes: "이미 본 리노트를 간략화하기" +internalServerError: "내부 서버 오류" +internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했어요." +copyErrorInfo: "오류 정보 복사" +joinThisServer: "이 서버에 가입" +exploreOtherServers: "다른 서버 둘러보기" +letsLookAtTimeline: "타임라인 구경하기" +disableFederationConfirm: "정말로 연합을 비활성화 할까요?" +disableFederationConfirmWarn: "연합을 끄더라도 게시물이 비공개로 전환되는 것은 아니예요. 대부분의 경우 연합을 비활성화할 필요가 없어요." +disableFederationOk: "연합 비활성화 하기" +invitationRequiredToRegister: "이 서버는 현재 비공개 상태예요. 회원가입을 하려면 초대 코드가 필요해요." +emailNotSupported: "이 서버에서는 메일 전송을 지원하지 않아요" +postToTheChannel: "채널에 게시하기" +cannotBeChangedLater: "나중에 변경할 수 없어요." +reactionAcceptance: "리액션 수신" +likeOnly: "좋아요만 받기" +likeOnlyForRemote: "리모트에서는 좋아요만 받기" +rolesAssignedToMe: "나에게 할당된 역할" +resetPasswordConfirm: "비밀번호를 재설정할까요?" +sensitiveWords: "민감한 단어" +sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제해요. 개행으로 구분하여 여러 개를 지정할 수 있어요." +notesSearchNotAvailable: "노트 검색을 현재 이용할 수 없어요." +license: "라이선스" +unfavoriteConfirm: "즐겨찾기를 해제할까요?" +myClips: "내 클립" +drivecleaner: "드라이브 정리" +retryAllQueuesNow: "모든 큐를 다시 시도" +retryAllQueuesConfirmTitle: "지금 다시 시도할까요?" +retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있어요!" +enableChartsForRemoteUser: "리모트 유저의 차트를 생성" +enableChartsForFederatedInstances: "리모트 서버의 차트를 생성" +showClipButtonInNoteFooter: "노트 동작에 클립을 추가" +largeNoteReactions: "노트의 리액션을 크게 표시" +noteIdOrUrl: "노트 ID 및 URL" +video: "동영상" +videos: "동영상" +dataSaver: "데이터 절약 모드" +accountMigration: "계정 이동" +accountMoved: "이 사용자는 다음 계정으로 이사했어요:" +accountMovedShort: "이사한 계정이에요" +operationForbidden: "사용할 수 없어요" +forceShowAds: "광고를 항상 표시" +addMemo: "메모 추가" +editMemo: "메모 편집" +reactionsList: "리액션 목록" +renotesList: "Renote 목록" +notificationDisplay: "알림 표시" +leftTop: "왼쪽 상단" +rightTop: "오른쪽 상단" +leftBottom: "왼쪽 하단" +rightBottom: "오른쪽 하단" +stackAxis: "나열 방향" +vertical: "세로" +horizontal: "가로" +position: "위치" +serverRules: "서버 규칙" +pleaseConfirmBelowBeforeSignup: "이 서버에 가입하기 전에 아래 사항을 확인해 주세요." +pleaseAgreeAllToContinue: "계속하시려면 모든 항목에 동의해야 해요." +continue: "계속" +preservedUsernames: "예약된 사용자명" +preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력해 주세요. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 돼요. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않아요." +createNoteFromTheFile: "이 파일로 노트를 작성" +archive: "아카이브" +channelArchiveConfirmTitle: "{name} 을(를) 아카이브할까요?" +channelArchiveConfirmDescription: "아카이브한 채널은 채널 목록과 검색 결과에 표시되지 않으며, 채널에 새로운 노트를 작성할 수 없게 돼요." +thisChannelArchived: "이 채널은 아카이브 되었어요." +displayOfNote: "노트 표시" +initialAccountSetting: "초기 설정" +youFollowing: "팔로잉" +options: "옵션" +_initialAccountSetting: + accountCreated: "계정 생성이 완료되었어요!" + letsStartAccountSetup: "계정의 초기 설정을 진행해 볼까요?" + letsFillYourProfile: "우선 나의 프로필을 설정해 보아요." + profileSetting: "프로필 설정" + theseSettingsCanEditLater: "이 설정들은 나중에도 변경할 수 있어요." + youCanEditMoreSettingsInSettingsPageLater: "이 외에도 '설정' 페이지에서 다양한 설정을 나의 입맛에 맞게 조절할 수 있으니 꼭 확인해 보세요!" + followUsers: "관심사가 맞는 유저를 팔로우하여 타임라인을 가꾸어 보세요." + pushNotificationDescription: "푸시 알림을 활성화하면 {name}의 알림을 나의 기기에서 받아볼 수 있어요." + initialAccountSettingCompleted: "초기 설정을 모두 완료했어요!" + haveFun: "{name}와 함께 즐거운 시간 보내세요!" + ifYouNeedLearnMore: "{name}(CherryPick)의 사용 방법에 대해 자세히 알아보려면 {link}를 참고해 주세요." + skipAreYouSure: "초기 설정을 건너뛸까요?" +_serverRules: + description: "회원 가입 이전에 간단하게 표시할 서버 규칙이에요. 이용 약관의 요약으로 구성하는 것을 추천해요." +_accountMigration: + moveFrom: "다른 계정에서 이 계정으로 이사" + moveFromSub: "다른 계정에 대한 별칭을 생성" + moveFromLabel: "기존 계정:" + moveFromDescription: "다른 계정에서 이 계정으로 팔로워를 가져오려면, 우선 여기에서 별칭을 지정해야 해요.\n반드시 이사하기 전에 지정해야 해요! 기존 계정을 다음과 같은 형식으로 입력해 주세요: @person@instance.com" + moveTo: "이 계정에서 다른 계정으로 이사" + moveToLabel: "이사할 계정:" + moveCannotBeUndone: "한 번 이사하면, 두 번 다시 되돌릴 수 없으니 신중하게 결정해 주세요!" + moveAccountDescription: "이 작업은 취소할 수 없어요! 먼저 이사할 계정에서 이 계정에 대한 별칭을 지정했는지 다시 한 번 확인해 주세요. 별칭을 지정한 다음, 이사할 계정을 다음과 같은 형식으로 입력해 주세요: @person@instance.com" + moveAccountHowTo: "계정을 이사하려면 우선 이사갈 계정에서 이 계정에 대한 별칭을 지정해야 해요.\n별칭을 작성한 다음, 이사갈 계정을 다음과 같이 입력해 주세요:\n@username@server.example.com" + startMigration: "이사하기" + migrationConfirm: "정말로 이 계정을 {account} 으로 이전할까요? 작업을 시작한 이후에는 취소할 수 없으며, 두 번 다시 원래 상태로 복구할 수 없어요.\n이사할 계정에서 계정 별칭을 지정하였는지 다시 한 번 확인해 주세요." + movedAndCannotBeUndone: "\n이사한 계정이에요.\n이사는 취소할 수 없어요." + postMigrationNote: "이 계정의 팔로잉 해제는 이사 후 24시간 뒤에 실행돼요.\n이 계정의 팔로우 및 팔로워 수는 0으로 표시돼요. 팔로워 해제는 이루어지지 않으므로, 당신의 팔로워는 이 계정의 팔로워 한정 게시물을 계속해서 열람할 수 있어요." + movedTo: "이사할 계정:" _achievements: earnedAt: "달성 일시" _types: @@ -1136,6 +1268,9 @@ _achievements: _client30min: title: "잠깐 쉬어" description: "클라이언트를 시작한 지 30분이 경과했어요. 과도한 SNS 이용은 정상적인 일상생활에 지장을 줄 수 있어요." + _client60min: + title: "No \"Miss\" in CherryPick" + description: "클라이언트를 시작하고 60분이 경과했어요. 어서 현생으로 돌아가세요!!" _noteDeletedWithin1min: title: "있었는데요, 없었습니다" description: "노트를 포스트한 후 1분 이내에 삭제했어요" @@ -1155,7 +1290,7 @@ _achievements: description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성됐어요" _viewInstanceChart: title: "애널리스트" - description: "인스턴스의 차트를 열었어요" + description: "서버의 차트를 열었어요" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "스크래치 패드에서 hello world를 출력했어요!" @@ -1192,7 +1327,7 @@ _achievements: _loggedInOnNewYearsDay: title: "새해 복 많이 받으세요" description: "새해 첫 날에 로그인했어요" - flavor: "올해에도 저희 인스턴스에 관심을 가져 주셔서 감사합니다!" + flavor: "올해에도 저희 서버에 관심을 가져 주셔서 감사합니다!" _cookieClicked: title: "쿠키를 클릭하는 게임" description: "쿠키를 클릭했어요" @@ -1207,7 +1342,7 @@ _role: name: "역할 이름" description: "역할 설명" permission: "역할 권한" - descriptionOfPermission: "모더레이터는 기본적인 중재와 관련된 작업을 수행할 수 있어요.\n관리자는 인스턴스의 모든 설정을 변경할 수 있어요." + descriptionOfPermission: "모더레이터는 기본적인 중재와 관련된 작업을 수행할 수 있어요.\n관리자는 서버의 모든 설정을 변경할 수 있어요." assignTarget: "할당 대상" descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있어요.\n조건부를 선택하면 조건을 설정해 일치하는 사용자를 자동으로 포함시킬 수 있어요." manual: "수동" @@ -1224,6 +1359,10 @@ _role: iconUrl: "아이콘 URL" asBadge: "배지로 표시" descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시돼요." + isExplorable: "역할 타임라인 공개" + descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개해요. 비활성화하면 타임라인이 공개되지 않아요." + displayOrder: "표시 순서" + descriptionOfDisplayOrder: "값이 클 수록 UI에서 먼저 표시돼요." canEditMembersByModerator: "모더레이터의 역할 수정 허용" descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있어요. 꺼져 있으면 관리자만 사용자를 할당할 수 있어요." priority: "우선순위" @@ -1235,9 +1374,10 @@ _role: gtlAvailable: "글로벌 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기" canPublicNote: "공개 노트 허용" - canInvite: "인스턴스 초대 코드 발행" + canInvite: "서버 초대 코드 발행" canManageCustomEmojis: "커스텀 이모지 관리" driveCapacity: "드라이브 용량" + alwaysMarkNsfw: "파일을 항상 NSFW로 지정" pinMax: "고정할 수 있는 노트 수" antennaMax: "최대 안테나 생성 허용 수" wordMuteMax: "단어 뮤트할 수 있는 문자 수" @@ -1246,18 +1386,21 @@ _role: noteEachClipsMax: "각 클립에 추가할 수 있는 노트 수" userListMax: "생성할 수 있는 유저 리스트 수" userEachUserListsMax: "유저 리스트당 최대 사용자 수" - rateLimitFactor: "속도 제한" - descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 강화돼요." + rateLimitFactor: "요청 빈도 제한" + descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화돼요." canHideAds: "광고 숨기기" + canSearchNotes: "노트 검색 이용 가능 여부" _condition: isLocal: "로컬 사용자" isRemote: "리모트 사용자" - createdLessThan: "가압한 지 다음 일수 이내인 유저" + createdLessThan: "가입한 지 다음 일수 이내인 유저" createdMoreThan: "가입한 지 다음 일수 이상인 유저" followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" followersMoreThanOrEq: "팔로워 수가 다음 이상인 유저" followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저" followingMoreThanOrEq: "팔로잉 수가 다음 이상인 유저" + notesLessThanOrEq: "노트 수가 다음 이하인 유저" + notesMoreThanOrEq: "노트 수가 다음 이상인 유저" and: "다음을 모두 만족" or: "다음을 하나라도 만족" not: "다음을 만족하지 않음" @@ -1301,7 +1444,7 @@ _ad: _forgotPassword: enterEmail: "여기에 계정에 등록한 메일 주소를 입력해 주세요. 입력한 메일 주소로 비밀번호를 재설정할 수 있는 링크를 보내드릴 거예요." ifNoEmail: "만약 메일 주소를 등록하지 않았다면 관리자에게 문의해 주세요." - contactAdmin: "이 인스턴스에서는 메일 기능을 지원하지 않기 때문에 비밀번호를 재설정하려면 관리자에게 문의해 주세요." + contactAdmin: "이 서버에서는 메일 기능을 지원하지 않기 때문에 비밀번호를 재설정하려면 관리자에게 문의해 주세요." _gallery: my: "내 갤러리" liked: "좋아요 한 갤러리" @@ -1447,6 +1590,8 @@ _channel: following: "팔로잉" usersCount: "{n}명이 참여 중" notesCount: "{n}노트" + nameAndDescription: "이름과 설명" + nameOnly: "이름만" _menuDisplay: sideFull: "가로" sideIcon: "가로(아이콘)" @@ -1462,10 +1607,10 @@ _wordMute: hard: "보다 높은 수준" mutedNotes: "뮤트된 노트" _instanceMute: - instanceMuteDescription: "뮤트한 인스턴스에서 오는 답글을 포함한 모든 노트와 리노트를 뮤트해요." + instanceMuteDescription: "뮤트한 서버에서 오는 답글을 포함한 모든 노트와 리노트를 뮤트해요." instanceMuteDescription2: "한 줄에 하나씩 입력해 주세요" - title: "지정한 인스턴스의 노트가 숨겨져요." - heading: "뮤트할 인스턴스" + title: "지정한 서버의 노트가 숨겨져요." + heading: "뮤트할 서버" _theme: explore: "테마 찾아보기" install: "테마 설치" @@ -1558,47 +1703,47 @@ _ago: weeksAgo: "{n}주 전" monthsAgo: "{n}개월 전" yearsAgo: "{n}년 전" + invalid: "없음" _time: second: "초" minute: "분" hour: "시간" day: "일" -_tutorial: +_timelineTutorial: title: "CherryPick의 사용 방법" - step1_1: "어서오세요!" - step1_2: "이 페이지는 \"타임라인\"이라고 불러요. 내가 \"팔로우\"하고 있는 사람들의 \"노트\"가 시간순으로 나타나요." - step1_3: "아직 아무도 팔로우하지 않았기 때문에 타임라인이 비어 있을 거에요." - step2_1: "새 노트를 작성하거나 다른 사람을 팔로우하기 전에, 먼저 프로필을 완성해볼까요?" - step2_2: "나를 소개한다면, 다른 사람들이 나를 팔로우할 확률이 올라갈 거에요!" - step3_1: "프로필 설정은 잘 끝내셨나요?" - step3_2: "그럼 시험삼아 노트를 작성해 볼까요? 화면에 있는 연필 버튼을 누르면 노트를 작성할 수 있어요!" - step3_3: "내용을 작성한 후, 오른쪽 상단의 버튼을 눌러 노트를 타임라인에 올릴 수 있어요." - step3_4: "어떤 내용을 써야할 지 모르겠다구요? \"CherryPick 시작했어요!\" 같은 건 어떨까요? ☺️" - step4_1: "노트 작성을 끝내셨나요?" - step4_2: "내가 올린 노트가 타임라인에 표시되어 있다면 성공이에요!" - step5_1: "이제, 다른 사람을 팔로우해서 타임라인을 활기차게 만들어보세요!" - step5_2: "{featured}에서 이 인스턴스의 인기 노트를 볼 수 있어요. {explore}에서는 인기 사용자를 찾을 수 있구요. 마음에 드는 사람을 골라 팔로우해 보세요!" - step5_3: "다른 유저를 팔로우하려면 해당 유저의 아이콘을 클릭하여 프로필 페이지를 띄운 후, 팔로우 버튼을 눌러 주세요." - step5_4: "사용자에 따라 팔로우가 승인될 때까지 시간이 걸릴 수 있어요." - step6_1: "타임라인에 다른 사용자의 노트가 나타난다면 성공이에요!" - step6_2: "다른 유저의 노트에 \"리액션\"을 붙여 간단하게 반응을 전달할 수도 있어요." - step6_3: "리액션을 붙이려면, 노트의 \"+\" 버튼을 클릭하고 원하는 이모지를 선택하기만 하면 돼요!" - step7_1: "이것으로 CherryPick의 기본 튜토리얼이 끝났어요. 수고하셨습니다!" - step7_2: "CherryPick에 대해 더 알고 싶으시다면 {help}를 참고해 주세요." - step7_3: "그럼 CherryPick을 즐겨주세요! 🚀" - step8_1: "마지막으로, 푸시 알림을 활성화해 보지 않으실래요?" - step8_2: "푸시 알림을 활성화하면, CherryPick을 열지 않았을 때에도 리액션이나 팔로우, 멘션 등을 확인할 수 있어요." - step8_3: "알림 설정은 나중에도 변경할 수 있어요." + step1_1: "이것은 '타임라인'이에요. {name}에 게시된 '노트'가 시간순으로 나타나요." + step1_2: "타임라인은 몇 가지 종류로 나뉘게 되는데, 그 중에 '홈 타임라인'은 내가 팔로우한 사람의 노트가 표시되며, '로컬 타임라인'에는 {name} 의 모든 노트가 표시돼요." + step2_1: "그럼 시험삼아 노트를 작성해 볼까요? 화면에 있는 연필 버튼을 누르면 노트를 작성할 수 있어요!" + step2_2: "첫 노트이니까 자기소개, 혹은 가볍게 \"안녕 {name}\"라고 올려 보는 건 어떨까요?" + step3_1: "노트 작성을 끝내셨나요?" + step3_2: "내가 올린 노트가 타임라인에 표시되어 있다면 성공이에요!" + step4_1: "노트에는 '리액션'을 붙일 수 있어요." + step4_2: "리액션을 붙이려면, 노트의 \"+\" 버튼을 클릭하고 원하는 이모지를 선택하기만 하면 돼요!" _2fa: alreadyRegistered: "이미 설정되어 있어요!" - registerDevice: "디바이스 등록" - registerKey: "키 등록" + registerTOTP: "인증 앱 설정 시작" + passwordToTOTP: "비밀번호를 입력하세요." step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치해 주세요." step2: "인증 앱을 설치했다면, 표시되어 있는 QR 코드를 앱으로 스캔해 주세요." + step2Click: "QR 코드를 클릭하면 기기에 설치된 인증 앱에 등록할 수 있어요." step2Url: "데스크톱 앱에서는 다음 URL을 입력해 주세요:" + step3Title: "인증 코드 입력" step3: "앱에 표시된 토큰을 입력하면 완료돼요!" step4: "다음 로그인부터는 토큰을 입력해야 로그인할 수 있어요." + securityKeyNotSupported: "이 브라우저는 보안 키를 지원하지 않는 것 같아요." + registerTOTPBeforeKey: "보안 키 또는 패스키를 등록하려면 먼저 인증 앱을 등록해 주세요." securityKeyInfo: "FIDO2를 지원하는 하드웨어 보안 키 혹은 디바이스의 지문인식이나 화면잠금 PIN을 이용해서 로그인하도록 설정할 수 있어요." + chromePasskeyNotSupported: "현재 Chrome 에서의 패스키는 아직 지원되지 않아요." + registerSecurityKey: "보안 키 또는 패스키 등록" + securityKeyName: "키 이름 입력" + tapSecurityKey: "브라우저에 안내되는 지시에 따라 보안 키 또는 패스키를 등록해 주세요" + removeKey: "보안 키 삭제" + removeKeyConfirm: "{name} 을(를) 삭제할까요?" + whyTOTPOnlyRenew: "보안 키가 등록되어 있는 경우 인증 앱을 해제할 수 없어요." + renewTOTP: "인증 앱 재설정" + renewTOTPConfirm: "기존에 등록되어 있던 인증 키는 더 이상 사용할 수 없게 돼요." + renewTOTPOk: "재설정" + renewTOTPCancel: "취소" _permissions: "read:account": "계정의 정보를 조회합니다" "write:account": "계정의 정보를 변경합니다" @@ -1658,7 +1803,7 @@ _weekday: saturday: "토요일" _widgets: profile: "프로필" - instanceInfo: "인스턴스 정보" + instanceInfo: "서버 정보" memo: "스티커 메모" notifications: "알림" timeline: "타임라인" @@ -1672,7 +1817,7 @@ _widgets: digitalClock: "디지털 시계" unixClock: "UNIX 시계" federation: "연합" - instanceCloud: "인스턴스 구름" + instanceCloud: "서버 구름" postForm: "글 입력란" slideshow: "슬라이드 쇼" button: "버튼" @@ -1722,8 +1867,8 @@ _visibility: followersDescription: "팔로워에게만 공개" specified: "다이렉트" specifiedDescription: "지정한 유저에게만 공개" - localOnly: "로컬에만" - localOnlyDescription: "리모트 유저에게 보이지 않기" + disableFederation: "연합에 보내지 않기" + disableFederationDescription: "다른 서버로 보내지 않을래요" _postForm: replyPlaceholder: "이 노트에 답글..." quotePlaceholder: "이 노트를 인용..." @@ -1883,6 +2028,7 @@ _notification: receiveFollowRequest: "팔로우 요청을 받았을 때" followRequestAccepted: "팔로우 요청이 승인되었을 때" groupInvited: "그룹에 초대되었을 때" + achievementEarned: "도전 과제 획득" app: "연동된 앱을 통한 알림" _actions: followBack: "팔로우" @@ -1915,3 +2061,27 @@ _deck: channel: "채널" mentions: "받은 멘션" direct: "다이렉트" + roleTimeline: "역할 타임라인" +_dialog: + charactersExceeded: "최대 글자수를 초과했어요! 현재 {current} / 최대 {min}" + charactersBelow: "최소 글자수 미만이에요! 현재 {current} / 최소 {min}" +_disabledTimeline: + title: "비활성화된 타임라인" + description: "현재 역할에서는 이 타임라인을 이용할 수 없어요." +_drivecleaner: + orderBySizeDesc: "크기가 큰 순" + orderByCreatedAtAsc: "등록일이 오래된 순" +_webhookSettings: + createWebhook: "Webhook 생성" + name: "이름" + secret: "시크릿" + events: "Webhook을 실행할 타이밍" + active: "활성화" + _events: + follow: "누군가를 팔로우 했을 때" + followed: "누군가 나를 팔로우 했을 때" + note: "노트를 게시할 때" + reply: "답글을 받았을 때" + renote: "누군가 내 글을 리노트 했을 때" + reaction: "누군가 내 노트에 리액션 했을 때" + mention: "누군가 나를 멘션 했을 때" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 263a7eb18c..f686b04257 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -136,17 +136,45 @@ loginFailed: "ການເຂົ້າສູ່ລະບົບບໍ່ສຳເ general: "ທົ່ວໄປ" wallpaper: "ພາບພື້ນຫລັງ" setWallpaper: "ຕັ້ງເປັນພາບພື້ນຫຼັງ" +searchWith: "ຊອກຫາ: {q}" +proxyAccount: "ບັນຊີພຣັອກຊີ" +host: "ໂຮດສ" +selectUser: "ເລືອກຜູ້ໃຊ້" +recipient: "ເຖິງ" +annotation: "ຄຳເຫັນ" +federation: "ສະຫະພັນ" instances: "ອີນສະແຕນ" +registeredAt: "ລົງທະບຽນຢູ່" +storageUsage: "ບ່ອນ​ຈັດ​ເກັບ​ຂໍ້​ມູນທີ່ໃຊ້" +charts: "ອັນດັບເພງ" +perHour: "ຕໍ່ຊົ່ວໂມງ" +perDay: "ຕໍ່​ມື້" +stopActivityDelivery: "ຢຸດເຊົາການສົ່ງກິດຈະກໍາ" +blockThisInstance: "ຂັດຂວາງຕົວຢ່າງນີ້" +operations: "ການດຳເນີນງານ" +software: "ຊອບແວ" +version: "ສະບັບ" +metadata: "Metadata" +monitor: "ຈໍພາບ" +cpuAndMemory: "CPU ແລະ ຫນ່ວຍຄວາມຈໍາ" +network: "ເຄືອຂ່າຍ" +disk: "ດິສກ໌" instanceInfo: "ອີນສະແຕນ" statistics: "ສະຖິຕິ" clearQueue: "ລ້າງຄິວ" clearCachedFiles: "ລຶບລ້າງແຄສ" +noUsers: "ບໍ່ພົບຜູ້ໃຊ້" editProfile: "ແກ້ໄຂໂປຣໄຟລ໌" done: "ສຳເລັດ" processing: "ກຳລັງປະມວນຜົນ" preview: "ສະແດງເປັນຕົວຢ່າງ" default: "ຄ່າເລີ່ມຕົ້ນ" +defaultValueIs: "ຄ່າເລີ່ມຕົ້ນ: {value}" +noCustomEmojis: "ບໍ່ມີອີໂມຈິ" +noJobs: "ບໍ່ມີຊິ້ນວຽກ" +federating: "ສະຫະພັນ" blocked: "ບລັອກແລ້ວ " +suspended: "ໂຈະ" all: "ທັງໝົດ" subscribing: "ສະໝັກສະມາຊິກແລັວ" publishing: "ການ​ພິມ​ເຜີຍ​ແຜ່" @@ -155,32 +183,144 @@ instanceFollowing: "ກຳລັງຕິດຕາມສຸດຕົວຢ່າ instanceFollowers: "ຜູ້ຕິດຕາມຕົວຢ່າງ" instanceUsers: "ຜູ້​ຊົມ​ໃຊ້​ຂອງ​ຕົວ​ຢ່າງ​ນີ້​" changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ" +security: "ຄວາມປອດໄພ" +retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ" +currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ" +newPassword: "ລະຫັດຜ່ານໃໝ່" +newPasswordRetype: "ໃສ່ລະຫັດຜ່ານໃໝ່ອີກເທື່ອໜຶ່ງ" +attachFile: "ແນບໄຟລ໌" +more: "ເພີ່ມເຕີມ!" featured: "ໄຮໄລທ໌" +usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້" +noSuchUser: "ບໍ່ພົບຜູ້ໃຊ້" +lookup: "ຄົ້ນ​ຫາ" announcements: "ປະກາດ" +imageUrl: "URL ຮູບພາບ" remove: "ລຶບ" +removed: "ລຶບແລ້ວ" +resetAreYouSure: "ຣີ​ເຊັດບໍ?" +saved: "ບັນທຶກແລ້ວ" messaging: "ແຊ໋ດ" -tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" +upload: "ອັບໂຫຼດ" +keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" +fromDrive: "ຈາກ Drive" +fromUrl: "ຈາກ URL" +uploadFromUrl: "ອັບໂຫຼດຈາກ URL" +uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ" +uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດ" +messageRead: "ອ່ານແລ້ວ" +startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" +nUsersRead: "ອ່ານໂດຍ {n}" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" home: "ໜ້າຫຼັກ" +activity: "ກິດຈະກຳ" images: "ຮູບພາບ" +image: "ຮູບພາບ" birthday: "ວັນເກີດ" +yearsOld: "{age} ປີ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" location: "ທີ່ຕັ້ງ" theme: "ແທ໋ມ" +themeForLightMode: "ຮູບແບບສີສັນເພື່ອໃຊ້ໃນໂໝດແສງ" +themeForDarkMode: "ຮູບແບບສີສັນທີ່ຈະໃຊ້ຢູ່ໃນໂໝດມືດ" light: "ສະຫວ່າງ" dark: "ມືດ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" darkThemes: "ຮູບແບບສີສັນມືດ" +syncDeviceDarkMode: "ຊິງຄ໌ໂໝດມືດກັບການຕັ້ງຄ່າທົ່ວອຸປະກອນ" +drive: "ຂັບ" fileName: "ຊື່ໄຟລ໌" selectFile: "ເລືອກໄຟລ໌" selectFiles: "ເລືອກໄຟລ໌" +selectFolder: "ເລືອກໂຟລເດີ" +selectFolders: "ເລືອກໂຟລເດີ" +renameFile: "ປ່ຽນຊື່ໄຟລ໌" +folderName: "ຊື່ໂຟນເດີ" +createFolder: "​ສ້າງ​ໂຟ​ລ​ເດີ" +renameFolder: "ປ່ຽນຊື່ໂຟນເດີນີ້" +deleteFolder: "ລົບໂຟ​ລ​ເດີ​" +addFile: "ເພີ່ມໄຟລ໌" +emptyDrive: "Drive ຂອງທ່ານຫວ່າງເປົ່າ" +emptyFolder: "ໂຟນເດີນີ້ເປົ່າຫວ່າງ" +unableToDelete: "ບໍ່​ສາ​ມາດລົບໄດ້" +inputNewFileName: "ໃສ່ຊື່ໄຟລ໌ໃໝ່" +inputNewDescription: "ໃສ່ຄຳບັນຍາຍໃໝ່" +inputNewFolderName: "ໃສ່ຊື່ໂຟນເດີໃໝ່" +circularReferenceFolder: "ໂຟນເດີປາຍທາງແມ່ນໂຟນເດີຍ່ອຍຂອງໂຟນເດີທີ່ທ່ານຕ້ອງການຍ້າຍ" +rename: "ປ່ຽນຊື່" nsfw: "NSFW" +watch: "ເບິ່ງ" +unwatch: "ຢຸດເບິ່ງ" accept: "ອະນຸຍາດ" +reject: "ປະຕິເສດ" +normal: "ປົກກະຕິ" +instanceName: "ຊື່ເຊີເວີ້" +instanceDescription: "ຄໍາອະທິບາຍຕົວຢ່າງ" +maintainerName: "ຜູ້ດູແລ" +maintainerEmail: "ອີເມວ admin" +tosUrl: "ເງື່ອນໄຂການໃຫ້ບໍລິການ URL" +thisYear: "ປີນີ້" +thisMonth: "ເດືອນນີ້" +today: "ມື້ນີ້" +dayX: "ວັນ {day}" +monthX: "ເດືອນ {month}" +yearX: "ປີ {year}" +pages: "ໜ້າ" +integration: "ຄວາມສຳພັນຂອງ" +connectService: "ເຊື່ອມຕໍ່" +disconnectService: "ຕັດການເຊື່ອມຕໍ່" +enableLocalTimeline: "ເປີດໃຊ້ທາມລາຍທ້ອງຖິ່ນ" +enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ" +disablingTimelinesInfo: "ຜູ້ເບິ່ງແຍງລະບົບ ແລະຜູ້ຄວບຄຸມຈະມີການເຂົ້າເຖິງທຸກກຳນົດເວລາ, ເຖິງແມ່ນວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍຕາມ" +registration: "ລົງທະບຽນ" +enableRegistration: "ເປີດໃຊ້ການລົງທະບຽນຜູ້ໃຊ້ໃໝ່" +invite: "ເຊີນ" +driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" +driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ" pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" +turnstileSiteKey: "ກະແຈໄຊທ໌" +turnstileSecretKey: "ກະແຈລັບ" +name: "ຊື່" userList: "ລາຍການ" +about: "ກ່ຽວກັບ" +aboutMisskey: "ກ່ຽວກັບ Misskey" +administrator: "ຜູ້ບໍລິຫານ" +share: "ແບ່ງປັນ" +notFound: "ບໍ່ພົບ" +cacheClear: "ລຶບລ້າງແຄສ" +invites: "ເຊີນ" +title: "ຫົວຂໍ້" +text: "ຂໍ້ຄວາມ" +enable: "ເປີດໃຊ້" +next: "ຕໍ່ໄປ" +invitations: "ເຊີນ" +language: "ພາສາ" +native: "ພາ​ສາ​ແມ່" +category: "ຫມວດຫມູ່" +tags: "ແທ໋ກ" +createAccount: "ສ້າງບັນຊີ" +existingAccount: "ທີ່ມີຢູ່" +dashboard: "ໜ້າປັດ" +local: "ທ້ອງຖິ່ນ" +objectStorageRegion: "ພາກ​ພື້ນ" +sounds: "ສຽງ" +sound: "ສຽງ" +none: "ບໍ່ມີ" +volume: "ລະດັບສຽງ" +details: "ລາຍລະອຽດ" +install: "ຕິດຕັ້ງ" +uninstall: "ຖອນການຕິດຕັ້ງ" +state: "ສະຖານະ" +sort: "ຈັດຮຽງໂດຍ" +ascendingOrder: "ນ້ອຍໄປຫາໃຫຍ່" +descendingOrder: "ໃຫຍ່ຫານ້ອຍ" +output: "ຜົນຜະລິດ" +script: "ບົດ​ຄວາມ" +smtpHost: "ໂຮດສ" smtpUser: "ຊື່ຜູ້ໃຊ້" smtpPass: "ລະຫັດຜ່ານ" clearCache: "ລຶບລ້າງແຄສ" +info: "ກ່ຽວກັບ" user: "ຜູ້ໃຊ້ຕ່າງໆ" searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" @@ -195,11 +335,15 @@ _sfx: note: "ບັນທຶກ" notification: "ການແຈ້ງເຕືອນ" chat: "ແຊ໋ດ" +_2fa: + renewTOTPCancel: "ບໍ່​ແມ່ນ​ຕອນ​ນີ້" _widgets: profile: "ໂພຼຟາຍ" instanceInfo: "ອີນສະແຕນ" notifications: "ການແຈ້ງເຕືອນ" timeline: "​ເສັ້ນກຳ​ນົດ​ເວ​ລາ​" + activity: "ກິດຈະກຳ" + federation: "ສະຫະພັນ" _userList: chooseList: "ເລືອກບັນຊີລາຍການ" _cw: @@ -208,14 +352,19 @@ _visibility: home: "ໜ້າຫຼັກ" followers: "ຜູ້ຕິດຕາມ" _profile: + name: "ຊື່" username: "ຊື່ຜູ້ໃຊ້" _exportOrImport: followingList: "ກຳລັງຕິດຕາມ" muteList: "ປີດສຽງ" blockingList: "ບ໋ອກ" userLists: "ລາຍການ" +_charts: + federation: "ສະຫະພັນ" _timelines: home: "ໜ້າຫຼັກ" +_play: + script: "ບົດ​ຄວາມ" _pages: blocks: image: "ຮູບພາບ" @@ -237,3 +386,5 @@ _deck: list: "ລາຍການ" channel: "ຊ່ອງ" mentions: "ກ່າວເຖິງ" +_webhookSettings: + name: "ຊື່" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 4925792e35..e35d0861b5 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -254,12 +254,12 @@ noMoreHistory: "Er is geen verdere geschiedenis" startMessaging: "Start een gesprek" nUsersRead: "gelezen door {n}" agreeTo: "Ik stem in met {0}" -tos: "Gebruiksvoorwaarden" start: "Aan de slag" home: "Startpagina" remoteUserCaution: "Aangezien deze gebruiker van een externe server afkomstig is, kan de weergegeven informatie onvolledig zijn." activity: "Activiteit" images: "Afbeeldingen" +image: "Afbeeldingen" birthday: "Geboortedatum" yearsOld: "{age} jaar" registeredDate: "Inschrijvingsdatum" @@ -376,13 +376,10 @@ about: "Over" aboutMisskey: "Over CherryPick" administrator: "Beheerder" token: "Token" -twoStepAuthentication: "Tweestapsverificatie" moderator: "Moderator" moderation: "Moderatie" nUsersMentioned: "Vermeld door {n} gebruikers" securityKey: "Beveiligingssleutel" -securityKeyName: "Sleutelnaam" -registerSecurityKey: "Zekerheids-Sleutel registreren" lastUsed: "Laatst gebruikt" unregister: "Uitschrijven" passwordLessLogin: "Inloggen zonder wachtwoord" @@ -399,8 +396,6 @@ markAsReadAllTalkMessages: "Markeer alle berichten als gelezen" help: "Help" inputMessageHere: "Voer hier je bericht in" close: "Sluiten" -group: "Groep" -groups: "Groepen" invites: "Uitnodigen" invitations: "Uitnodigen" sound: "Geluid" @@ -435,6 +430,8 @@ _sfx: note: "Notities" notification: "Meldingen" chat: "Chat" +_2fa: + renewTOTPCancel: "Nee, bedankt" _widgets: profile: "Profiel" instanceInfo: "Serverinformatie" @@ -486,3 +483,5 @@ _deck: antenna: "Antennes" list: "Lijsten" mentions: "Vermeldingen" +_webhookSettings: + name: "Naam" diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 83e189b9cf..36c29295f6 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -1,2 +1,484 @@ --- _lang_: "Norsk Bokmål" +search: "Søk" +notifications: "Varsler" +username: "Brukernavn" +password: "Passord" +forgotPassword: "Glemt passord" +ok: "OK" +gotIt: "Skjønner" +cancel: "Avbryt" +noThankYou: "Avbryt" +noNotifications: "Ingen varsler" +instance: "Server" +settings: "Innstillinger" +notificationSettings: "Varslingsinnstillinger" +otherSettings: "Andre innstillinger" +profile: "Profil" +timeline: "Tidslinje" +login: "Logg inn" +loggingIn: "Logget inn" +logout: "Logg ut" +signup: "Bli med" +uploading: "Laster opp" +save: "Lagre" +users: "Brukere" +addUser: "Legg til bruker" +favorite: "Favoritt" +favorites: "Favoritter" +unfavorite: "Fjern favoritt" +pin: "Fest" +unpin: "Opphev festing" +copyContent: "Kopier innhold" +copyLink: "Kopier lenke" +delete: "Slett" +deleteAndEdit: "Slett og rediger" +addToList: "Legg til i liste" +sendMessage: "Send melding" +copyRSS: "Kopier RSS" +copyUsername: "Kopier brukernavn" +searchUser: "Søk brukere" +reply: "Svar" +loadMore: "Vis mer" +showMore: "Vis mer" +showLess: "Lukk" +files: "Filer" +download: "Nedlastinger" +lists: "Lister" +noLists: "Ingen lister" +following: "Følg" +followers: "Følgere" +followsYou: "Følger deg" +createList: "Opprett liste" +error: "Feil" +retry: "Prøv igjen" +pageLoadError: "Kunne ikke hente side." +privacy: "Personvern" +follow: "Følg" +followRequest: "Følgeforespørsel" +followRequests: "Følgeforespørsel" +unfollow: "Avfølg" +followRequestPending: "Venter på godkjenning" +quote: "Sitat" +pinned: "Fest" +you: "Du" +clickToShow: "Klikk for å vise" +add: "Legg til" +reaction: "Reaksjon" +reactions: "Reaksjoner" +mute: "Skjul" +unmute: "Vis" +block: "Blokker" +unblock: "Opphev blokkering" +blockConfirm: "Blokker?" +selectList: "Velg liste" +selectChannel: "Velg kanal" +emoji: "Emoji" +emojis: "Emojier" +addEmoji: "Legg til emoji" +addAccount: "Legg til konto" +selectUser: "Velg bruker" +instances: "Server" +registeredAt: "Registrerte seg" +perHour: "Per time" +perDay: "Per dag" +blockThisInstance: "Blokker denne serveren" +version: "Versjon" +statistics: "Statistikk" +clearQueue: "Tøm kø" +clearQueueConfirmTitle: "Vil du tømme kø?" +blockedInstances: "Blokkerte severe" +muteAndBlock: "Skjul og blokker" +mutedUsers: "Skjulte brukere" +blockedUsers: "Blokkerte brukere" +editProfile: "Rediger profil" +pinLimitExceeded: "Du kan ikke feste flere." +noCustomEmojis: "Ingen emoji" +blocked: "Blokkert" +all: "Alle" +changePassword: "Endre passord" +security: "Sikkerhet" +newPassword: "Nytt passord" +newPasswordRetype: "Nytt passord (gjenta)" +more: "Mer!" +remove: "Slett" +removed: "Slettet" +saved: "Lagret" +upload: "Laste opp" +explore: "Utforsk" +messageRead: "Lest" +agree: "Jeg godtar" +home: "Hjem" +images: "Bilder" +image: "Bilder" +birthday: "Bursdag" +yearsOld: "{age} år gammel" +light: "Lys" +dark: "Mørk" +fileName: "Filnavn" +selectFile: "Velg fil" +selectFiles: "Velg fil" +selectFolder: "Velg mappe" +selectFolders: "Velg mappe" +renameFile: "Endre filnavn" +folderName: "Mappenavn" +createFolder: "Opprett mappe" +renameFolder: "Endre mappenavn" +deleteFolder: "Slett mappe" +addFile: "Legg til fil" +emptyFolder: "Denne mappen er tom" +copyUrl: "Kopier URL" +rename: "Endre navn" +doNothing: "Gjør ingenting" +accept: "Tillatt" +reject: "Avslå" +instanceName: "Servernavn" +thisYear: "I år" +today: "I dag" +pages: "Sider" +pinnedUsers: "Festete brukrere" +pinnedPages: "Festete sider" +hcaptcha: "hCaptcha" +recaptcha: "reCAPTCHA" +name: "Navn" +popularUsers: "Populære brukere" +exploreUsersCount: "Det finnes {count} brukere" +userList: "Lister" +about: "Infomasjon" +aboutMisskey: "Om Misskey" +share: "Del" +help: "Hjelp" +close: "Lukk" +members: "Medlemmer" +text: "Tekst" +next: "Neste" +retype: "Gjenta" +available: "Tilgjengelig" +unavailable: "Utilgjengelig" +tooShort: "For kort" +tooLong: "For langt" +or: "eller" +language: "Språk" +aboutX: "Om {x}" +category: "Kategorier" +createAccount: "Opprett konto" +objectStorageRegion: "Region" +objectStorageUseSSL: "Bruk SSL" +objectStorageUseProxy: "Bruk Proxy" +deleteAll: "Slett alt" +listen: "Lytt" +none: "Ingen" +chooseEmoji: "Velg emoji" +recentUsed: "Sist brukte" +install: "Installer" +nothing: "Ingenting" +deleteAllFiles: "Slett alle filer" +deleteAllFilesConfirm: "Vil du slette alle filer?" +accountDeleted: "Kontoen blir slettet" +accountDeletedDescription: "Denne kontoen blir slettet" +menu: "Meny" +poll: "Avstemning" +description: "Beskrivelse" +author: "Forfatter" +height: "Høyde" +large: "Stor" +small: "Liten" +notificationType: "Varseltype" +edit: "Rediger" +email: "E-post" +smtpUser: "Brukernavn" +smtpPass: "Passord" +userSaysSomething: "{name} sa noe" +copy: "Kopier" +channel: "Kanaler" +create: "Opprett" +notificationSetting: "Varslingsinnstillinger" +other: "Andre" +behavior: "Oppførsel" +sample: "Eksempel" +abuseReports: "Rappoter" +reportAbuse: "Rappoter" +send: "Send" +openInNewTab: "Åpne i ny fane" +waitingFor: "Venter på {x}" +system: "System" +followingCount: "Følger" +followersCount: "Følgere" +yes: "Ja" +no: "Nei" +left: "Venstre" +saveAs: "Lagre som" +value: "Verdi" +deleteConfirm: "Vil du slette?" +invalidValue: "Verdien er ugyldig." +emailNotification: "E-postvarsler" +inChannelSearch: "Søk i kanal" +clear: "Tøm" +markAllAsRead: "Merk alt som lest" +addDescription: "Legg til beskrivelse" +info: "Infomasjon" +unknown: "Ukjent" +selectAccount: "Velg konto" +user: "Brukere" +accounts: "Kontoer" +switch: "Bytt" +gallery: "Galleri" +ads: "Annonser" +high: "Høy" +low: "Lav" +sent: "Send" +learnMore: "Les mer" +translate: "Oversett" +unread: "Ulest" +manageAccounts: "Administrer konto" +classic: "Klassisk" +muteThread: "Skjul denne tråden" +unmuteThread: "Vis denne tråden" +tablet: "Nettbrett" +auto: "Automatisk" +size: "Størrelse" +searchByGoogle: "Søk" +tenMinutes: "10 minutter" +oneHour: "1 time" +oneDay: "1 dag" +oneWeek: "1 uke" +oneMonth: "1 måned" +file: "Filer" +recommended: "Anbefalt" +check: "Sjekk" +deleteAccount: "Slett konto" +document: "Dokumenter" +logoutConfirm: "Vil du logge ut?" +pleaseSelect: "Vennligst velg" +type: "Type" +beta: "Beta" +account: "Kontoer" +move: "Flytt" +pushNotification: "Push-varsler" +tools: "Verktøy" +like: "Liker!" +unlike: "Liker ikke" +numberOfLikes: "Likerklikk" +show: "Vis" +neverShow: "Ikke vis igjen" +remindMeLater: "Kanskje senere" +didYouLikeMisskey: "Likte du Misskey?" +roles: "Roller" +role: "Rolle" +color: "Farge" +youCannotCreateAnymore: "Du kan ikke opprette flere." +cannotPerformTemporary: "Midlertidig utilgjengelig" +thisPostMayBeAnnoyingCancel: "Avbryt" +exploreOtherServers: "Utforsk andre severe" +letsLookAtTimeline: "La oss se på tidslinje" +cannotBeChangedLater: "Du kan ikke endre senere." +likeOnly: "Bare liker" +retryAllQueuesConfirmTitle: "Vil du prøve igjen akkurat nå?" +video: "Video" +videos: "Videoer" +continue: "Fortsett" +youFollowing: "Følger" +options: "Alternativ" +_initialAccountSetting: + theseSettingsCanEditLater: "Du kan endre disse innstillingene senere." +_achievements: + _types: + _notes100000: + flavor: "Du har jammen mye å si." + _noteFavorited1: + title: "Stjernekikker" + _myNoteFavorited1: + title: "Jeg vil gjerne få en stjerne" + _following50: + title: "Mange venner" + _following100: + title: "100 venner" + _following300: + title: "For mange venner" + _followers10: + title: "Følg meg!" + _followers100: + title: "Populær" + _postedAtLateNight: + flavor: "Det er på tide å gå til sengs." + _driveFolderCircularReference: + title: "Rundskrivreferanse" + _reactWithoutRead: + title: "Leste du det virkelig?" + _clickedClickHere: + title: "Klikk her" + description: "Du har klikket her" + _justPlainLucky: + title: "Rett og slett heldig" + _setNameToSyuilo: + description: "Du har satt navnet ditt til \"syuilo\"" + _loggedInOnBirthday: + title: "Gratulerer med dagen" + _loggedInOnNewYearsDay: + title: "Godt nytt år" + _brainDiver: + title: "Brain Diver" + flavor: "Misskey-Misskey La-Tu-Ma" +_role: + options: "Alternativ" + _priority: + low: "Lav" + high: "Høy" +_emailUnavailable: + used: "Allerede brukt" +_accountDelete: + accountDelete: "Slett konto" +_ad: + hide: "Ikke vis" +_gallery: + like: "Liker!" + unlike: "Liker ikke" +_preferencesBackups: + saveNew: "Lagre som ny" + cannotSave: "Kunne ikke lagre" +_registry: + key: "Nøkkel" + keys: "Nøkler" +_aboutMisskey: + about: "Misskey er programvare med åpen kildekode som har blitt utviklet av syuilo siden 2014." + translation: "Oversett Misskey" +_instanceTicker: + none: "Ikke vis" + always: "Alltid vis" +_channel: + create: "Opprett kanal" + edit: "Rediger kanal" + featured: "Populært" + following: "Følger" + nameAndDescription: "Navn og beskrivelse" +_wordMute: + soft: "Myk" + hard: "Hard" +_theme: + description: "Beskrivelse" + color: "Farge" + key: "Nøkkel" + keys: + link: "Lenke" +_sfx: + notification: "Varsler" +_ago: + future: "Fremitid" + justNow: "Akkurat nå" + secondsAgo: "{n} sekunder siden" + minutesAgo: "{n} minutter siden" + hoursAgo: "{n} timer siden" + daysAgo: "{n} dager siden" + weeksAgo: "{n} uker siden" + monthsAgo: "{n} måneder siden" + yearsAgo: "{n} år siden" + invalid: "Ingenting" +_time: + second: "Sekunder" + minute: "Minutter" + hour: "Timer" + day: "Dager" +_timelineTutorial: + title: "Hvordan bruke Misskey" +_2fa: + renewTOTPCancel: "Avbryt" +_weekday: + sunday: "Søndag" + monday: "Mandag" + tuesday: "Tirsdag" + wednesday: "Onsdag" + thursday: "Torsdag" + friday: "Fredag" + saturday: "Lørdag" +_widgets: + profile: "Profil" + notifications: "Varsler" + timeline: "Tidslinje" + calendar: "Kalender" + trends: "Populært" + clock: "Klokke" + photos: "Bilder" + button: "Knapp" + aiscriptApp: "AiScript App" + userList: "Brukerliste" + _userList: + chooseList: "Velg liste" +_cw: + show: "Vis mer" +_poll: + noOnlyOneChoice: "Trenger minst to valger." + choiceN: "Valg {n}" + noMore: "Du kan ikke legge til flere." + deadlineTime: "Timer" + votesCount: "{n} stemmer" + vote: "Stem" + showResult: "Vis resultatet" + voted: "Stemt" + closed: "Avsluttet" +_visibility: + home: "Hjem" + followers: "Følgere" +_postForm: + _placeholders: + a: "Hva skjer?" +_profile: + name: "Navn" + username: "Brukernavn" + metadataContent: "Innhold" +_exportOrImport: + followingList: "Følg" + muteList: "Skjul" + blockingList: "Blokker" + userLists: "Lister" +_charts: + filesIncDec: "Forskjell på antall filer" +_instanceCharts: + users: "Forskjell på antall brukere" + ff: "Forskjell på antall Følg/Følgere" + files: "Forskjell på antall filer" +_timelines: + home: "Hjem" +_play: + new: "Opprett Play" + edit: "Rediger Play" + featured: "Populært" + summary: "Beskrivelse" +_pages: + like: "Liker" + unlike: "Liker ikke" + my: "Mine sider" + featured: "Populært" + contents: "Innhold" + url: "Side URL" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + selectType: "Velg type" + blocks: + text: "Tekst" + section: "Seksjon" + image: "Bilde" + button: "Knapp" +_notification: + _types: + follow: "Følg" + reply: "Svar" + quote: "Sitat" + reaction: "Reaksjon" + _actions: + reply: "Svar" +_deck: + swapLeft: "Flytt til venstre" + swapRight: "Flytt til høyre" + swapUp: "Flytt opp" + swapDown: "Flytt ned" + profile: "Profil" + newProfile: "Ny profil" + deleteProfile: "Slett profil" + _columns: + notifications: "Varsler" + tl: "Tidslinje" + list: "Lister" + channel: "Kanaler" + direct: "Direkte" +_webhookSettings: + name: "Navn" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index f0ee7913d6..5ba0dedffc 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -129,6 +129,7 @@ unblockConfirm: "Czy na pewno chcesz odblokować to konto?" suspendConfirm: "Czy na pewno chcesz zawiesić to konto?" unsuspendConfirm: "Czy na pewno chcesz cofnąć zawieszenie tego konta?" selectList: "Wybierz listę" +selectChannel: "Wybierz kanał" selectAntenna: "Wybierz Antennę" selectWidget: "Wybierz widżet" editWidgets: "Edytuj widżety" @@ -149,6 +150,7 @@ flagAsCatDescription: "Przełącz tę opcję, aby konto było oznaczone jako kot flagShowTimelineReplies: "Pokazuj odpowiedzi na osi czasu" autoAcceptFollowed: "Automatycznie przyjmuj prośby o możliwość obserwacji od użytkowników, których obserwujesz" addAccount: "Dodaj konto" +reloadAccountsList: "Odśwież listę kont" loginFailed: "Nie udało się zalogować" showOnRemote: "Zobacz na zdalnej instancji" general: "Ogólne" @@ -159,6 +161,7 @@ searchWith: "Szukaj: {q}" youHaveNoLists: "Nie masz żadnej listy" followConfirm: "Czy na pewno chcesz zaobserwować {name}?" proxyAccount: "Konto proxy" +proxyAccountDescription: "Opis konta pełnomocniczego" host: "Host" selectUser: "Wybierz użytkownika" recipient: "Odbiorca" @@ -253,12 +256,13 @@ noMoreHistory: "Nie ma dalszej historii" startMessaging: "Rozpocznij czat" nUsersRead: "przeczytano przez {n}" agreeTo: "Wyrażam zgodę na {0}" -tos: "Regulamin" +agreeBelow: "Zaakceptuj poniżej" start: "Rozpocznij" home: "Strona główna" remoteUserCaution: "Te informacje mogą nie być aktualne, ponieważ użytkownik pochodzi ze zdalnej instancji." activity: "Aktywność" images: "Zdjęcia" +image: "Zdjęcia" birthday: "Data urodzenia" yearsOld: "{age} lat" registeredDate: "Zarejestrowano" @@ -385,16 +389,19 @@ about: "Informacje" aboutMisskey: "O CherryPick" administrator: "Admin" token: "Token" -twoStepAuthentication: "Uwierzytelnianie dwuskładnikowe" +2fa: "Klucz 2FA " +totp: "Klucz aplikacji uwierzytelniającej (totp)" +totpDescription: "Opis klucza czasowego" moderator: "Moderator" moderation: "Moderacja" nUsersMentioned: "{n} wspomnianych użytkowników" +securityKeyAndPasskey: "Klucz bezpieczeństwa i klucze Passkey" securityKey: "Klucz bezpieczeństwa" -securityKeyName: "Nazwa klucza" -registerSecurityKey: "Zarejestruj klucz bezpieczeństwa" lastUsed: "Ostatnio używane" +lastUsedAt: "Ostatnio używane w" unregister: "Cofnij rejestrację" passwordLessLogin: "Skonfiguruj logowanie bez użycia hasła" +passwordLessLoginDescription: "Opis logowania bez użycia hasła" resetPassword: "Zresetuj hasło" newPasswordIs: "Nowe hasło to „{password}”" reduceUiAnimation: "Ogranicz animacje w UI" @@ -448,7 +455,6 @@ passwordMatched: "Pasuje" passwordNotMatched: "Hasła nie pasują do siebie" signinWith: "Zaloguj się z {x}" signinFailed: "Nie udało się zalogować. Wprowadzona nazwa użytkownika lub hasło są nieprawidłowe." -tapSecurityKey: "Wybierz swój klucz bezpieczeństwa" or: "Lub" language: "Język" uiLanguage: "Język wyświetlania UI" @@ -534,11 +540,16 @@ disablePagesScript: "Wyłącz AiScript na Stronach" updateRemoteUser: "Aktualizuj zdalne dane o użytkowniku" deleteAllFiles: "Usuń wszystkie pliki" deleteAllFilesConfirm: "Czy na pewno chcesz usunąć wszystkie pliki?" +removeAllFollowing: "Przestań obserwować" removeAllFollowingDescription: "Przestań obserwować wszystkie konta z {host}. Wykonaj to, jeżeli instancja już nie istnieje." userSuspended: "To konto zostało zawieszone." userSilenced: "Ten użytkownik został wyciszony." yourAccountSuspendedTitle: "To konto jest zawieszone" yourAccountSuspendedDescription: "To konto zostało zawieszone z powodu złamania regulaminu serwera lub innych podobnych. Skontaktuj się z administratorem, jeśli chciałbyś poznać bardziej szczegółowy powód. Proszę nie zakładać nowego konta." +tokenRevoked: "Token odrzucony" +tokenRevokedDescription: "Opis odrzuconego tokena" +accountDeleted: "Konto usunięte" +accountDeletedDescription: "Opis konta usuniętego" menu: "Menu" divider: "Rozdzielacz" addItem: "Dodaj element" @@ -564,7 +575,9 @@ author: "Autor" leaveConfirm: "Są niezapisane zmiany. Czy chcesz je odrzucić?" manage: "Zarządzanie" plugins: "Wtyczki" +preferencesBackups: "Kopia zapasowa ustawień" deck: "Tablica" +undeck: "oddkouj" useBlurEffectForModal: "Używaj efektu rozmycia w modalach" useFullReactionPicker: "Używaj pełnowymiarowego wybornika reakcji" width: "Szerokość" @@ -580,7 +593,6 @@ tokenRequested: "Przydziel dostęp do konta" pluginTokenRequestedDescription: "Ta wtyczka będzie mogła korzystać z ustawionych tu uprawnień." notificationType: "Rodzaj powiadomień" edit: "Edytuj" -useStarForReactionFallback: "Użyj ★ jako zapasowego emoji, gdy emoji reakcji jest nieznane" emailServer: "Serwer poczty e-mail" enableEmail: "Włącz dostarczanie wiadomości e-mail" emailConfigInfo: "Wykorzystywany do potwierdzenia adresu e-mail w trakcie rejestracji, lub gdy zapomnisz hasła" @@ -828,11 +840,14 @@ auto: "Automatycznie" size: "Rozmiar" numberOfColumn: "Liczba kolumn" searchByGoogle: "Szukaj" +period: "Ankieta kończy się" indefinitely: "Nigdy" tenMinutes: "10 minut" oneHour: "1 godzina" oneDay: "1 dzień" oneWeek: "1 tydzień" +oneMonth: "jeden miesiąc" +failedToFetchAccountInformation: "Nie udało się uzyskać informacji o koncie" file: "Pliki" recommended: "Zalecane" check: "Zweryfikuj" @@ -869,6 +884,7 @@ loggedInAsBot: "Jesteś obecnie zalogowany/a jako bot" like: "Polub" show: "Wyświetlanie" color: "Kolor" +youFollowing: "Śledzeni" _role: priority: "Priorytet" _priority: @@ -1141,45 +1157,20 @@ _ago: weeksAgo: "{n} tyg. temu" monthsAgo: "{n} mies. temu" yearsAgo: "{n} lat temu" + invalid: "Nie ma tu niczego" _time: second: "sekunda" minute: "minuta" hour: "godz." day: "dzień" -_tutorial: - title: "Jak korzystać z CherryPick" - step1_1: "Witaj!" - step1_2: "Ta strona nazywa się „oś czasu”. Pokazuje chronologicznie uporządkowane wpisy osób, które „śledzisz”." - step1_3: "Twoja oś czasu jest jeszcze pusta, ponieważ nie opublikowałeś(-aś) jeszcze żadnych wpisów i nie obserwujesz jeszcze nikogo." - step2_1: "Ukończmy konfigurację profilu zanim utworzymy wpis lub zaczniemy kogoś obserwować." - step2_2: "Podanie pewnych informacji o tym, kim jesteś, ułatwi innym określenie, czy chcą widzieć Twoje wpisy lub Cię obserwować." - step3_1: "Zakończyłeś(-aś) konfigurację profilu?" - step3_2: "Następnie spróbujmy opublikować wpis. Możesz to zrobić, naciskając przycisk z ikoną ołówka na ekranie." - step3_3: "Wypełnij pole i kliknij przycisk w prawym górnym rogu by wysłać post." - step3_4: "Nie masz nic do powiedzenia? Spróbuj \"ustawiam swój CherryPick\"!" - step4_1: "Zakończyłeś publikowanie pierwszego wpisu?" - step4_2: "Hurra! Teraz Twój pierwszy wpis powinien być wyświetlany na Twojej osi czasu." - step5_1: "Teraz spróbujmy ożywić Twoją oś czasu, przez zaobserwowanie innych ludzi." - step5_2: "{featured} pokaże Ci popularne wpisy na tej instancji. {explore} pozwoli Ci znaleźć popularnych użytkowników. Spróbuj znaleźć tam osoby, które chcesz obserwować!" - step5_3: "Aby obserwować innych użytkowników, kliknij ich ikonę i naciśnij przycisk \"Obserwuj\" na ich profilu." - step5_4: "Jeśli inny użytkownik ma ikonę kłódki obok swojej nazwy, może minąć trochę czasu, zanim ten użytkownik ręcznie zatwierdzi Twoją prośbę o obserwowanie." - step6_1: "Powinieneś teraz widzieć wpisy innych użytkowników na swojej osi czasu." - step6_2: "Możesz także umieścić „reakcje” na wpisach innych osób, aby szybko na nie odpowiedzieć." - step6_3: "Aby dodać \"reakcję\", naciśnij znak \"+\" na wpisie innego użytkownika i wybierz emotikonę, którą chcesz zareagować." - step7_1: "Gratulacje! Ukończyłeś podstawowy samouczek CherryPick." - step7_2: "Jeśli chcesz dowiedzieć się więcej o CherryPick, wypróbuj sekcję {help}." - step7_3: "A teraz powodzenia i baw się dobrze z CherryPick! 🚀" - step8_1: "Na sam koniec, czy nie chciał(a)byś włączyć powiadomień push?" - step8_2: "Włączenie tej opcji pozwoli ci otrzymywać powiadomienia o reakcjach, śledzeniach i wzmiankach nawet wtedy, gdy CherryPick nie będzie otwarty." - step8_3: "Ustawienia powiadomień można zmienić później." _2fa: alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego." - registerDevice: "Zarejestruj nowe urządzenie" - registerKey: "Zarejestruj klucz bezpieczeństwa" step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b}) na swoim urządzeniu." step2: "Następnie, zeskanuje kod QR z ekranu." step3: "Wprowadź token podany w aplikacji, aby ukończyć konfigurację." step4: "Od teraz, przy każdej próbie logowania otrzymasz prośbę o token logowania." + removeKeyConfirm: "Usunąć kopię zapasową {name}?" + renewTOTPCancel: "Nie teraz" _permissions: "read:account": "Wyświetl informacje o swoim koncie" "write:account": "Edytuj swoje informacje o koncie" @@ -1440,3 +1431,6 @@ _deck: channel: "Kanały" mentions: "Wspomnienia" direct: "Bezpośredni" +_webhookSettings: + name: "Nazwa" + active: "Właczono" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 344a795c5d..eb4826f1a7 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -252,12 +252,12 @@ noMoreHistory: "Sem mais história" startMessaging: "Iniciar conversação" nUsersRead: "{n} Pessoas leem" agreeTo: "Eu concordo com {0}" -tos: "Termos de serviço" start: "começar" home: "casa" remoteUserCaution: "As informações estão incompletas porque é um utilizador remoto." activity: "atividade" images: "imagem" +image: "imagem" birthday: "aniversário" yearsOld: "{age} anos" registeredDate: "Data de registro" @@ -382,12 +382,9 @@ about: "Informações" aboutMisskey: "Sobre CherryPick" administrator: "Administrador" token: "Símbolo" -twoStepAuthentication: "Verificação em duas etapas" moderator: "Moderador" nUsersMentioned: "Postado por {n} pessoas" securityKey: "Chave de segurança" -securityKeyName: "Nome chave" -registerSecurityKey: "Registre a chave de segurança" lastUsed: "Último uso" unregister: "Cancelar registro" passwordLessLogin: "Entrar sem senha" @@ -405,10 +402,6 @@ markAsReadAllTalkMessages: "Marcar todas as conversas como lidas" help: "Ajuda" inputMessageHere: "Escrever mensagem aqui" close: "Fechar" -group: "Grupos" -groups: "Grupos" -createGroup: "Criar grupo" -ownedGroups: "Grupo próprio" invites: "Convidar" invitations: "Convidar" tags: "Etiquetas" @@ -522,12 +515,9 @@ _notification: youGotMention: "{name} te mencionou" youGotReply: "{name} te respondeu" youGotQuote: "{name} te citou" - youGotMessagingMessageFromUser: "{name} te mandou uma mensagem de bate-papo" - youGotMessagingMessageFromGroup: "Uma mensagem foi mandada para o grupo {name}" youWereFollowed: "Você tem um novo seguidor" youReceivedFollowRequest: "Você recebeu um pedido de seguimento" yourFollowRequestAccepted: "Seu pedido de seguimento foi aceito" - youWereInvitedToGroup: "{userName} te convidou para um grupo" pollEnded: "Os resultados da enquete agora estão disponíveis" emptyPushNotificationMessage: "As notificações de alerta foram atualizadas" _types: @@ -541,7 +531,6 @@ _notification: pollEnded: "Enquetes terminando" receiveFollowRequest: "Recebeu pedidos de seguimento" followRequestAccepted: "Aceitou pedidos de seguimento" - groupInvited: "Convites de grupo" app: "Notificações de aplicativos conectados" _actions: followBack: "te seguiu de volta" @@ -566,3 +555,5 @@ _deck: list: "Listas" mentions: "Menções" direct: "Notas diretas" +_webhookSettings: + name: "Nome" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index c5ab90245b..88652ebc53 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -252,12 +252,12 @@ noMoreHistory: "Nu există mai mult istoric" startMessaging: "Începe un chat nou" nUsersRead: "citit de {n}" agreeTo: "Sunt de acord cu {0}" -tos: "Termenii de utilizare" start: "Să începem" home: "Acasă" remoteUserCaution: "Deoarece acest utilizator este dintr-o instanță externă, informația afișată poate fi incompletă." activity: "Activitate" images: "Imagini" +image: "Imagini" birthday: "Zi de naștere" yearsOld: "{age} ani" registeredDate: "Data înregistrării" @@ -382,12 +382,9 @@ about: "Despre" aboutMisskey: "Despre CherryPick" administrator: "Administrator" token: "Token" -twoStepAuthentication: "Autentificare în doi pași" moderator: "Moderator" nUsersMentioned: "Menționat de {n} utilizatori" securityKey: "Cheie de securitate" -securityKeyName: "Numele cheii" -registerSecurityKey: "Înregistrează o cheie de securitate" lastUsed: "Ultima utilizată" unregister: "Dezînregistrează" passwordLessLogin: "Autentificare fără parolă" @@ -405,24 +402,15 @@ markAsReadAllTalkMessages: "Marchează toate mesajele drept citit" help: "Ajutor" inputMessageHere: "Introdu un mesaj aici" close: "Închide" -group: "Grup" -groups: "Grupuri" -createGroup: "Crează un grup" -ownedGroups: "Grupuri deținute" -joinedGroups: "Grupuri alăturate" invites: "Invită" -groupName: "Numele grupului" members: "Membri" transfer: "Transferă" -messagingWithUser: "Chat privat" -messagingWithGroup: "Chat de grup" title: "Titlu" text: "Text" enable: "Activează" next: "Următorul" retype: "Introdu din nou" noteOf: "Notă de {user}" -inviteToGroup: "Invită în grup" quoteAttached: "Citat" quoteQuestion: "Vrei să adaugi ca citat?" noMessagesYet: "Niciun mesaj încă" @@ -444,15 +432,11 @@ passwordMatched: "Se potrivește!" passwordNotMatched: "Nu se potrivește" signinWith: "Autentifică-te cu {x}" signinFailed: "Nu se poate autentifica. Numele de utilizator sau parola introduse sunt incorecte." -tapSecurityKey: "Apasă pe cheia ta de securitate." or: "Sau" language: "Limbă" uiLanguage: "Limba interfeței" -groupInvited: "Ai fost invitat într-un grup" aboutX: "Despre {x}" disableDrawer: "Nu folosi meniuri în stil sertar" -youHaveNoGroups: "Nu ai niciun grup" -joinOrCreateGroup: "Primește o invitație într-un grup sau creează unul nou." noHistory: "Nu există istoric" signinHistory: "Istoric autentificări" doing: "Se procesează..." @@ -577,7 +561,6 @@ tokenRequested: "Acordă acces la cont" pluginTokenRequestedDescription: "Acest plugin va putea să folosească permisiunile setate aici." notificationType: "Tipul notificării" edit: "Editează" -useStarForReactionFallback: "Folosește ★ ca fallback dacă emoji-ul este necunoscut" emailServer: "Server email" enableEmail: "Activează distribuția de emailuri" emailConfigInfo: "Folosit pentru a confirma emailul tău în timpul logări dacă îți uiți parola" @@ -664,6 +647,8 @@ _sfx: note: "Note" notification: "Notificări" chat: "Chat" +_ago: + invalid: "Nu e nimic de văzut aici" _widgets: profile: "Profil" instanceInfo: "Informații despre instanță" @@ -699,7 +684,6 @@ _pages: image: "Imagini" _notification: youWereFollowed: "te-a urmărit" - youWereInvitedToGroup: "Ai fost invitat într-un grup" _types: follow: "Urmărești" mention: "Mențiune" @@ -717,3 +701,5 @@ _deck: list: "Liste" channel: "Canale" mentions: "Mențiuni" +_webhookSettings: + name: "Nume" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index aa2d084613..21005be630 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -8,7 +8,7 @@ search: "Поиск" notifications: "Уведомления" username: "Имя пользователя" password: "Пароль" -forgotPassword: "Пароль забыт" +forgotPassword: "Забыли пароль?" fetchingAsApObject: "Приём с других сайтов" ok: "Окей" gotIt: "Ясно!" @@ -20,6 +20,7 @@ noNotes: "Нет ни одной заметки" noNotifications: "Нет ни одного уведомления" instance: "Инстанс" settings: "Настройки" +notificationSettings: "Настройки уведомлений" basicSettings: "Основные настройки" otherSettings: "Прочие настройки" openInWindow: "Открыть в плавающем окне" @@ -103,6 +104,8 @@ renoted: "Репост совершён." cantRenote: "Это нельзя репостить." cantReRenote: "Невозможно репостить репост." quote: "Цитата" +inChannelRenote: "В канале" +inChannelQuote: "Заметки в канале" pinnedNote: "Закреплённая заметка" pinned: "Закрепить в профиле" you: "Вы" @@ -120,6 +123,8 @@ unmarkAsSensitive: "Снять отметку «не для всех»" enterFileName: "Введите имя файла" mute: "Скрыть" unmute: "Отменить скрытие" +renoteMute: "Заглушить репосты" +renoteUnmute: "Включить репосты" block: "Заблокировать" unblock: "Разблокировать" suspend: "Заморозить" @@ -129,6 +134,7 @@ unblockConfirm: "Разблокировать этот аккаунт?" suspendConfirm: "Заморозить этот аккаунт?" unsuspendConfirm: "Разморозить этот аккаунт?" selectList: "Выберите список" +selectChannel: "Выберите канал" selectAntenna: "Выберите антенну" selectWidget: "Выберите виджет" editWidgets: "Редактировать виджеты" @@ -150,6 +156,7 @@ flagShowTimelineReplies: "Показывать ответы на заметки flagShowTimelineRepliesDescription: "Если этот параметр включен, то в ленте, в дополнение к заметкам пользователя, отображаются ответы на другие заметки пользователя." autoAcceptFollowed: "Принимать подписчиков автоматически" addAccount: "Добавить учётную запись" +reloadAccountsList: "Обновить список учётных записей" loginFailed: "Неудачная попытка входа" showOnRemote: "Перейти к оригиналу на сайт" general: "Общее" @@ -256,12 +263,16 @@ noMoreHistory: "История закончилась" startMessaging: "Начать общение" nUsersRead: "Прочитали {n}" agreeTo: "Я соглашаюсь с {0}" -tos: "Пользовательское соглашение" +agree: "Согласиться" +agreeBelow: "Согласен со следующими" +basicNotesBeforeCreateAccount: "Записи, перед созданием аккаунта" +termsOfService: "Условия использования" start: "Начать" home: "Главная" remoteUserCaution: "Это пользователь с другого сайта, поэтому информация может быть неточной." activity: "Активность" images: "Изображения" +image: "Изображения" birthday: "День рождения" yearsOld: "Возраст: {age}" registeredDate: "Дата регистрации" @@ -351,6 +362,8 @@ recaptcha: "reCAPTCHA" enableRecaptcha: "Включить reCAPTCHA" recaptchaSiteKey: "Ключ сайта" recaptchaSecretKey: "Секретный ключ" +turnstile: "Сервис Turnstile" +enableTurnstile: "Включить Turnstile" turnstileSiteKey: "Ключ сайта" turnstileSecretKey: "Секретный ключ" avoidMultiCaptchaConfirm: "Несколько способов проверки могут мешать друг другу. Подтвердите, если хотите отключить другие способы. Или нажмите «Отмена», чтобы оставить их включёнными." @@ -386,16 +399,19 @@ about: "Описание" aboutMisskey: "О CherryPick" administrator: "Администратор" token: "Токен" -twoStepAuthentication: "Двухфакторная аутентификация" +2fa: "2-х факторная аутентификация" +totp: "Приложение-аутентификатор" +totpDescription: "Описание приложения-аутентификатора" moderator: "Модератор" moderation: "Модерация" nUsersMentioned: "Упомянуло пользователей: {n}" +securityKeyAndPasskey: "Ключ безопасности и парольная фраза" securityKey: "Ключ безопасности" -securityKeyName: "Имя ключа" -registerSecurityKey: "Зарегистрировать защитный ключ" lastUsed: "Последнее использование" +lastUsedAt: "Последнее использование: {t}" unregister: "Отписаться" passwordLessLogin: "Настроить вход без пароля" +passwordLessLoginDescription: "Вход без пароля" resetPassword: "Сброс пароля:" newPasswordIs: "Новый пароль — «{password}»." reduceUiAnimation: "Уменьшить анимацию в пользовательском интерфейсе" @@ -449,7 +465,6 @@ passwordMatched: "Совпали" passwordNotMatched: "Не совпадают" signinWith: "Использовать {x} для входа" signinFailed: "Невозможно войти в систему. Введенное вами имя пользователя или пароль неверны." -tapSecurityKey: "Нажмите на свой электронный ключ" or: "или" language: "Язык" uiLanguage: "Язык интерфейса" @@ -460,8 +475,11 @@ native: "Системные" disableDrawer: "Не использовать выдвижные меню" youHaveNoGroups: "У вас нет ни одной группы" joinOrCreateGroup: "Получайте приглашения в группы или создавайте свои собственные" +showNoteActionsOnlyHover: "Показывать кнопки управления заметкой только при наведении" noHistory: "История пока пуста" signinHistory: "Журнал посещений" +enableAdvancedMfm: "Включить расширенный MFM" +enableAnimatedMfm: "Включить анимированную разметку MFM" doing: "В процессе" category: "Категория" tags: "Метки" @@ -470,6 +488,8 @@ createAccount: "Новая учётная запись" existingAccount: "Существующая учётная запись" regenerate: "Создать повторно" fontSize: "Размер шрифта" +mediaListWithOneImageAppearance: "Показывать список медиа только одним изображением" +limitTo: "Обрезать до {x}" noFollowRequests: "Нерассмотренные запросы на подписку отсутствуют" openImageInNewTab: "Открыть изображение в новой вкладке" dashboard: "Панель управления" @@ -503,9 +523,11 @@ objectStorageUseSSLDesc: "Отключите, если не собираетес objectStorageUseProxy: "Использовать прокси" objectStorageUseProxyDesc: "Отключите, если не будете испоьзовать прокси для соединений по протоколу ObjectStorage." objectStorageSetPublicRead: "Устанавливать public-read при загрузке на сервер" +s3ForcePathStyleDesc: "Включение s3ForcePathStyle принудительно указывает имя корзины как часть пути в URL-адресе вместо имени хоста. Может потребоваться активация при использовании таких вещей, как локальный Minio." serverLogs: "Журнал сервера" deleteAll: "Удалить всё" showFixedPostForm: "Показывать поле для ввода новой заметки наверху ленты" +showFixedPostFormInChannel: "Показывать поле для ввода новой заметки наверху ленты (каналы)" newNoteRecived: "Появилась новая заметка" sounds: "Звуки" sound: "Звуки" @@ -543,6 +565,10 @@ userSuspended: "Эта учётная запись заморожена" userSilenced: "Этот пользователь был заглушен" yourAccountSuspendedTitle: "Эта учетная запись заблокирована" yourAccountSuspendedDescription: "Эта учетная запись была заблокирована из-за нарушения условий предоставления услуг сервера. Свяжитесь с администратором, если вы хотите узнать более подробную причину. Пожалуйста, не создавайте новую учетную запись." +tokenRevoked: "Токен недействителен" +tokenRevokedDescription: "Срок действия вашего токена входа истек. Пожалуйста, войдите снова." +accountDeleted: "Эта учетная запись удалена" +accountDeletedDescription: "Эта учетная запись удалена" menu: "Меню" divider: "Линия-разделитель" addItem: "Добавить элемент" @@ -586,7 +612,6 @@ tokenRequested: "Открыть доступ к учётной записи" pluginTokenRequestedDescription: "Это расширение сможет пользоваться разрешениями, установленными здесь." notificationType: "Тип уведомления" edit: "Изменить" -useStarForReactionFallback: "Ставить ★ в качестве реакции вместо неизвестного эмодзи" emailServer: "Сервер электронной почты" enableEmail: "Включить обмен электронной почтой" emailConfigInfo: "Используется для подтверждения адреса электронной почты и сброса пароля." @@ -690,6 +715,8 @@ contact: "Как связаться" useSystemFont: "Использовать шрифт, предлагаемый системой" clips: "Подборки" experimentalFeatures: "Экспериментальные функции" +experimental: "Экспериментальные" +thisIsExperimentalFeature: "Это экспериментальная функция. Технические характеристики могут измениться или он может работать неправильно." developer: "Разработчик" makeExplorable: "Опубликовать профиль в «Обзоре»." makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»." @@ -780,6 +807,7 @@ popularPosts: "Популярные публикации" shareWithNote: "Поделиться заметкой" ads: "Реклама" expiration: "Опрос длится" +startingperiod: "Начальный период" memo: "Памятка" priority: "Приоритет" high: "Высокий" @@ -812,6 +840,7 @@ lastCommunication: "Последнее сообщение" resolved: "Решено" unresolved: "Без решения" breakFollow: "Отписка" +breakFollowConfirm: "Удалить из подписок пользователя ?" itsOn: "Включено" itsOff: "Выключено" emailRequiredForSignup: "Для регистрации учётной записи нужен адрес электронной почты" @@ -848,15 +877,20 @@ instanceDefaultLightTheme: "Светлая тема по умолчанию" instanceDefaultDarkTheme: "Темная тема по умолчанию" instanceDefaultThemeDescription: "Описание темы по умолчанию для инстанса" mutePeriod: "Продолжительность скрытия" +period: "Опрос длится" indefinitely: "вечно" tenMinutes: "10 минут" oneHour: "1 час" oneDay: "1 день" oneWeek: "1 неделя" +oneMonth: "1 месяц" reflectMayTakeTime: "Изменения могут занять время для отображения" failedToFetchAccountInformation: "Не удалось получить информацию об аккаунте" +rateLimitExceeded: "Ограничение скорости превышено" cropImage: "Кадрирование" cropImageAsk: "Нужно ли кадрировать изображение?" +cropYes: "Обрезать" +cropNo: "Не обрезать" file: "Файлы" recentNHours: "Последние {n} ч" recentNDays: "Последние {n} сут" @@ -883,12 +917,15 @@ refreshInterval: "Интервал перезагрузки" label: "Метка" type: "Тип" speed: "Скорость" +slow: "Медленная" +fast: "Быстрая" sensitiveMediaDetection: "Определение содержимого деликатного характера" localOnly: "Локально" remoteOnly: "Только удалённо" failedToUpload: "Сбой выгрузки" cannotUploadBecauseInappropriate: "Файл не может быть загружен, так как было установлено, что он может содержать неприемлемое содержимое." cannotUploadBecauseNoFreeSpace: "Файл не может быть загружен, так как не осталось места на диске" +cannotUploadBecauseExceedsFileSizeLimit: "Файл не может быть загружен, так как он превышает лимит размера файла." beta: "Бета" enableAutoSensitive: "Автоматическое определение NSFW" enableAutoSensitiveDescription: "Если доступно, используйте машинное обучение для автоматической установки флага NSFW на носителе. Даже если эта функция отключена, она может быть установлена ​​автоматически в зависимости от инстанта." @@ -905,6 +942,7 @@ pushNotificationNotSupported: "Push-уведмления не поддержив sendPushNotificationReadMessage: "Удалять push-уведомления когда сообщение или прочитано" sendPushNotificationReadMessageCaption: "На мгновение появится уведомление \"{emptyPushNotificationMessage}\". Расход заряда батареи может увеличиться " windowMaximize: "Развернуть" +windowMinimize: "Свернуть" windowRestore: "Восстановить" caption: "Подпись (Automatic Translation)" loggedInAsBot: "Вы под аккаунтом бота!" @@ -921,6 +959,7 @@ didYouLikeMisskey: "Вам нравится CherryPick?" pleaseDonate: "Сайт {host} работает на CherryPick. Это бесплатное программное обеспечение, и ваши пожертвования очень бы помогли продолжать его разработку!" roles: "Роли" role: "Роль" +noRole: "Нет роли" normalUser: "Обычный пользователь" undefined: "неопределён" assign: "Назначить" @@ -930,9 +969,58 @@ manageCustomEmojis: "Управлять пользовательскими эм youCannotCreateAnymore: "Вы достигли лимита создания." cannotPerformTemporary: "Временно недоступен" cannotPerformTemporaryDescription: "Это действие временно невозможно выполнить из-за превышения лимита выполнения." +invalidParamError: "Ошибка параметра" +invalidParamErrorDescription: "Проблема с параметрами запроса. Обычно это ошибка, но это также может быть связано с тем, что вы набрали слишком много символов." +permissionDeniedError: "Операция запрещена" +permissionDeniedErrorDescription: "У этой учетной записи нет разрешения на выполнение этой операции." preset: "Шаблоны" selectFromPresets: "Выбрать из шаблонов" achievements: "Достижения" +gotInvalidResponseError: "Сервер ответил ошибкой" +gotInvalidResponseErrorDescription: "Сервер временно не доступен. Возможно проводятся технические работы, или сервер отключен." +thisPostMayBeAnnoying: "Это сообщение может быть неприятным." +thisPostMayBeAnnoyingHome: "Этот пост может быть отправлен на главную" +thisPostMayBeAnnoyingCancel: "Этот пост не может быть отменен." +thisPostMayBeAnnoyingIgnore: "Этот пост может быть проигнорирован " +collapseRenotes: "Свернуть репосты" +internalServerError: "Внутренняя ошибка сервера" +internalServerErrorDescription: "Внутри сервера произошла непредвиденная ошибка." +copyErrorInfo: "Скопировать код ошибки" +joinThisServer: "Присоединяйтесь к этому серверу" +exploreOtherServers: "Искать другие сервера" +letsLookAtTimeline: "Давайте посмотрим на ленту" +disableFederationConfirm: "Отключить федерацию?" +disableFederationConfirmWarn: "Дефедерация не делает заметку приватной. В большинстве случаев без федерации не обойтись." +disableFederationOk: "Не федерируется" +invitationRequiredToRegister: "Этот сервер в настоящее время только по приглашению. Зарегистрироваться могут только те, у кого есть код приглашения." +emailNotSupported: "Доставка почты не поддерживается на этом сервере" +postToTheChannel: "Отправить в канал" +cannotBeChangedLater: "Это нельзя изменить позже" +reactionAcceptance: "Принятие реакций" +likeOnly: "Только лайки" +likeOnlyForRemote: "Только лайки с удалённых серверов" +rolesAssignedToMe: "Мои роли" +resetPasswordConfirm: "Сбросить пароль?" +sensitiveWords: "Чувствительные слова" +sensitiveWordsDescription: "Установите общедоступный диапазон заметки, содержащей заданное слово, на домашний. Можно сделать несколько настроек, разделив их переносами строк." +notesSearchNotAvailable: "Поиск заметок недоступен" +license: "Лицензия" +unfavoriteConfirm: "Удалить избранное?" +myClips: "Мои клипы" +drivecleaner: "Очиститель дисков" +retryAllQueuesNow: "Повторить все очереди сейчас" +retryAllQueuesConfirmTitle: "Хотите попробовать ещё раз?" +retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться" +enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей" +enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов" +largeNoteReactions: "Показывать большие реакции на заметки" +noteIdOrUrl: "ID или ссылка на заметку" +video: "Видео" +videos: "Видео" +dataSaver: "Экономия трафика" +horizontal: "Сбоку" +youFollowing: "Подписки" +options: "Настройки ролей" _achievements: earnedAt: "Разблокировано в" _types: @@ -1189,6 +1277,9 @@ _role: baseRole: "Шаблон роли" useBaseValue: "Использовать значение из шаблона" chooseRoleToAssign: "Выберите роль, которую хотите выдать" + iconUrl: "Адрес на иконку роли" + asBadge: "Показывать как значок" + descriptionOfAsBadge: "Описание значка" canEditMembersByModerator: "Могут назначать модераторы" descriptionOfCanEditMembersByModerator: "Если включено, на эту роль могут назначать пользователей как администраторы, так и модераторы. Если выключено, назначать могут только администраторы." priority: "Приоритет" @@ -1206,6 +1297,7 @@ _role: pinMax: "Доступное количество закреплённых заметок" antennaMax: "Доступное количество антенн" wordMuteMax: "Доступное количество знаков в списке скрытия слов" + webhookMax: "Максимум web-хуков" clipMax: "Максимальное количество подборок" noteEachClipsMax: "Максимальное количество заметок в подборке" userListMax: "Максимальное количество списков аккаунтов" @@ -1227,7 +1319,12 @@ _role: not: "Кроме тех, у кого…" _sensitiveMediaDetection: description: "Машинное обучение может быть использовано для автоматического обнаружения чувствительных медиа для модерации. Нагрузка на сервер увеличивается незначительно." + sensitivity: "Чувствительность обнаружения" + sensitivityDescription: "Более низкая чувствительность уменьшает количество ложных срабатываний (false positives). Повышение чувствительности уменьшает утечку при обнаружении (ложноотрицательные результаты)." setSensitiveFlagAutomatically: "Установить флаг NSFW" + setSensitiveFlagAutomaticallyDescription: "Даже если этот параметр отключен, результат оценки сохраняется внутри системы." + analyzeVideos: "Анализировать видео?" + analyzeVideosDescription: "Анализируйте видео в дополнение к неподвижным изображениям. Нагрузка на сервер немного увеличивается." _emailUnavailable: used: "Уже используется" format: "Неверный формат" @@ -1503,47 +1600,37 @@ _ago: weeksAgo: "{n} нед. назад" monthsAgo: "{n} мес. назад" yearsAgo: "{n} г. назад" + invalid: "Ничего нет" _time: second: "с" minute: "мин" hour: "ч" day: "сут" -_tutorial: - title: "Как пользоваться CherryPick" - step1_1: "Добро пожаловать!" - step1_2: "Эта страница называется «лента». Здесь будут появляться «заметки»: ваши личные и тех, на кого вы «подписаны». Они будут располагаться в порядке времени их появления." - step1_3: "Правда, ваша лента пока пуста. Она начнёт заполняться, когда вы будете писать свои заметки и подписываться на других." - step2_1: "Давайте, заполним профиль, прежде чем начать писать заметки и подписываться на других." - step2_2: "То, что вы расскажете в профиле, поможет лучше вас узнать, а значит, многим будет легче присоединиться — вы скорее получите новых подписчиков и читателей." - step3_1: "Успешно заполнили профиль?" - step3_2: "Что ж, теперь самое время опубликовать заметку. Если нажать вверху страницы на изображение карандаша, появится форма для текста." - step3_3: "Напишите в неё, что хотите, и нажмите на кнопку в правом верхнем углу." - step3_4: "Ничего не приходит в голову? Как насчёт: «Я новенький, пока осваиваюсь в CherryPick»?" - step4_1: "С написанием первой заметки покончено?" - step4_2: "Отлично, теперь она должна появиться в вашей ленте." - step5_1: "А теперь самое время немного оживить ленту, подписавшись на других." - step5_2: "На странице «{featured}» собраны популярные сегодня заметки, читая которые, вы можете найти кого-то вам интересного, а на странице «{explore}» можно посмотреть, кто популярен у остальных." - step5_3: "Чтобы подписаться на кого-нибудь, щёлкните по его аватару и в открывшемся профиле нажмите кнопку «Подписаться»." - step5_4: "Некоторые пользователи (около их имени «висит замок») вручную подтверждают чужие подписки. Так что иногда подписка начинает работать не сразу.\n" - step6_1: "Если теперь в ленте видны и чужие заметки, значит у вас получилось." - step6_2: "Здесь можно непринуждённо выразить свои чувства к чьей-то заметке, отметив «реакцию» под ней." - step6_3: "Отмечайте реакции, нажмая на символ «+» под заметкой и выбирая значок по душе." - step7_1: "На этом вводный урок по использованию CherryPick закончен. Спасибо, что прошли его до конца!" - step7_2: "Хотите изучить CherryPick глубже — добро пожаловать в раздел «{help}»." - step7_3: "Приятно вам провести время с CherryPick🚀" - step8_1: "Ах, да, не хотите ли включить push-уведомления?" - step8_2: "С push-уведомлениями вы будете в курсе репостов, ответов, реакций и всего такого, даже когда закрыли CherryPick." - step8_3: "Эту настройку вы всегда сможете поменять" _2fa: alreadyRegistered: "Двухфакторная аутентификация уже настроена." - registerDevice: "Зарегистрируйте ваше устройство" - registerKey: "Зарегистрировать ключ" + registerTOTP: "Начните настраивать приложение-аутентификатор" + passwordToTOTP: "Пожалуйста, введите свой пароль" step1: "Прежде всего, установите на устройство приложение для аутентификации, например, {a} или {b}." step2: "Далее отсканируйте отображаемый QR-код при помощи приложения." + step2Click: "Нажав на QR-код, вы можете зарегистрироваться с помощью приложения для аутентификации или брелка для ключей, установленного на вашем устройстве." step2Url: "Если пользуетесь приложением на компьютере, можете ввести в него эту строку (URL):" + step3Title: "Введите проверочный код" step3: "И наконец, введите код, который покажет приложение." step4: "Теперь при каждом входе на сайт вам нужно будет вводить код из приложения аналогичным образом." + securityKeyNotSupported: "Ваш браузер не поддерживает ключи безопасности." + registerTOTPBeforeKey: "Чтобы зарегистрировать ключ безопасности и пароль, сначала настройте приложение аутентификации." securityKeyInfo: "Вы можете настроить вход с помощью аппаратного ключа безопасности, поддерживающего FIDO2, или отпечатка пальца или PIN-кода на устройстве." + chromePasskeyNotSupported: "В настоящее время Chrome не поддерживает пароль-ключи." + registerSecurityKey: "Зарегистрируйте ключ безопасности ・Passkey" + securityKeyName: "Введите имя для ключа" + tapSecurityKey: "Пожалуйста, следуйте инструкциям в вашем браузере, чтобы зарегистрировать свой ключ безопасности или пароль" + removeKey: "Удалить ключ безопасности" + removeKeyConfirm: "Удалить резервную копию «{name}»?" + whyTOTPOnlyRenew: "Если ключ безопасности зарегистрирован, вы не сможете отключить приложение аутентификации." + renewTOTP: "Перенастроите приложение аутентификации" + renewTOTPConfirm: "Проверочный код предыдущего приложения для аутентификации больше не будет доступен" + renewTOTPOk: "Настроить" + renewTOTPCancel: "Нет, спасибо" _permissions: "read:account": "Просматривать данные учётной записи" "write:account": "Изменять данные учётной записи" @@ -1578,12 +1665,15 @@ _permissions: "read:gallery-likes": "Просмотр списка понравившегося в галерее" "write:gallery-likes": "Изменение списка понравившегося в галерее" _auth: + shareAccessTitle: "Разрешения для приложений" shareAccess: "Дать доступ для «{name}» к вашей учётной записи?" shareAccessAsk: "Уверены, что хотите дать приложению доступ к своей учётной записи?" + permission: "{name} Запрашивает следующие разрешения:" permissionAsk: "Приложение запрашивает следующие разрешения:" pleaseGoBack: "Вернитесь, пожалуйста, в приложение" callback: "Возврат в приложение" denied: "Доступ закрыт" + pleaseLogin: "Вы должны войти в систему, чтобы дать разрешение приложению." _antennaSources: all: "Все заметки" homeTimeline: "Заметки тех на которых вы подписаны" @@ -1664,8 +1754,8 @@ _visibility: followersDescription: "Только вашим подписчикам" specified: "Личное" specifiedDescription: "Тем, кого укажете" - localOnly: "Локально" - localOnlyDescription: "Только для этого сайта" + disableFederation: "Отключить федерацию" + disableFederationDescription: "Не доставляет в другие экземпляры" _postForm: replyPlaceholder: "Ответ на заметку..." quotePlaceholder: "Пояснение к цитате..." @@ -1730,7 +1820,16 @@ _timelines: social: "Социальная" global: "Всеобщая" _play: + new: "Создать приложение " + edit: "Редактировать приложение" + created: "Приложение создано" + updated: "Приложение обновлено" + deleted: "Приложение удалено" + pageSetting: "Настройки приложения" + editThisPage: "Отредактировать страницу" viewSource: "Просмотр исходника" + my: "Мои приложения " + liked: "Понравилось" featured: "Популярные" title: "Заголовок" script: "Скрипт" @@ -1801,6 +1900,7 @@ _notification: yourFollowRequestAccepted: "Ваш запрос на подписку одобрен." youWereInvitedToGroup: "Вы приглашены в группу." pollEnded: "Подведены окончательные итоги опроса" + unreadAntennaNote: "Антенна {name}" emptyPushNotificationMessage: "Обновлены push-уведомления" achievementEarned: "Получено достижение" _types: @@ -1815,6 +1915,7 @@ _notification: receiveFollowRequest: "Получен запрос на подписку" followRequestAccepted: "Запрос на подписку одобрен" groupInvited: "Приглашение в группы" + achievementEarned: "Получение достижений" app: "Уведомления из приложений" _actions: followBack: "отвечает взаимной подпиской" @@ -1847,3 +1948,9 @@ _deck: channel: "Каналы" mentions: "Упоминания" direct: "Личное" +_dialog: + charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}" + charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}" +_webhookSettings: + name: "Название" + active: "Вкл." diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index a22bcb0c02..0c313c32d4 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -103,6 +103,8 @@ renoted: "Preposlané." cantRenote: "Tento príspevok sa nedá preposlať." cantReRenote: "Odpoveď nemôže byť odstránená." quote: "Citovať" +inChannelRenote: "Preposlania v kanáli" +inChannelQuote: "Citácie v kanáli" pinnedNote: "Pripnuté poznámky" pinned: "Pripnúť" you: "Vy" @@ -129,6 +131,7 @@ unblockConfirm: "Naozaj chcete odblokovať tento účet?" suspendConfirm: "Naozaj chcete zmraziť tento účet?" unsuspendConfirm: "Naozaj chcete odmraziť tento účet?" selectList: "Vyberte zoznam" +selectChannel: "Zvoľte kanál" selectAntenna: "Vyberte anténu" selectWidget: "Vyberte widget" editWidgets: "Upraviť widget" @@ -256,12 +259,14 @@ noMoreHistory: "To je všetko" startMessaging: "Začať chat" nUsersRead: "prečítané {n} používateľmi" agreeTo: "Súhlasím s {0}" -tos: "Podmienky používania" +agreeBelow: "Súhlasím s nasledovným" +basicNotesBeforeCreateAccount: "Základné bezpečnostné opatrenia" start: "Začať" home: "Domov" remoteUserCaution: "Tieto informácie nemusia byť aktuálne, keďže používateľ je na vzdialenom serveri." activity: "Aktivita" images: "Obrázky" +image: "Obrázky" birthday: "Dátum narodenia" yearsOld: "{age} rokov" registeredDate: "Dátum registrácie" @@ -388,16 +393,19 @@ about: "Informácie" aboutMisskey: "O CherryPick" administrator: "Administrátor" token: "Token" -twoStepAuthentication: "Dvojfaktorová autentifikácia" +2fa: "Dvojfaktorové overenie (2FA)" +totp: "Overovacia aplikácia" +totpDescription: "Zadajte jednorazové heslo z overovacej aplikácie" moderator: "Moderátor" moderation: "Moderovanie" nUsersMentioned: "{n} používateľov spomenulo" +securityKeyAndPasskey: "Bezpečnostný kľúč/heslo" securityKey: "Bezpečnostný kľúč" -securityKeyName: "Názov kľúča" -registerSecurityKey: "Registrovať bezpečnostný kľúč" lastUsed: "Naposledy použité" +lastUsedAt: "Naposledy použité: {t}" unregister: "Odregistrovať" passwordLessLogin: "Nastaviť bezheslové prihlásenie" +passwordLessLoginDescription: "Prihlásenie bez hesla, len bezpečnostným kľúčom alebo prístupovým kľúčom" resetPassword: "Resetovať heslo" newPasswordIs: "Nové heslo je \"{password}\"" reduceUiAnimation: "Menej UI animácií" @@ -451,7 +459,6 @@ passwordMatched: "Heslá sú rovnaké" passwordNotMatched: "Heslá nie sú rovnaké" signinWith: "Prihlásiť sa použitím {x}" signinFailed: "Nedá sa prihlásiť. Skontrolujte prosím meno používateľa a heslo." -tapSecurityKey: "Ťuknite na bezpečnostný kľúč" or: "Alebo" language: "Jazyk" uiLanguage: "Jazyk používateľského prostredia" @@ -462,8 +469,11 @@ native: "Natívne" disableDrawer: "Nepoužívať šuflíkové menu" youHaveNoGroups: "Nemáte žiadne skupiny" joinOrCreateGroup: "Požiadajte o pozvanie do existujúcej skupiny alebo vytvorte novú." +showNoteActionsOnlyHover: "Ovládacie prvky poznámky sa zobrazujú len po nabehnutí myši" noHistory: "Žiadna história" signinHistory: "História prihlásení" +enableAdvancedMfm: "Povolenie pokročilého MFM" +enableAnimatedMfm: "Povoliť animované MFM" doing: "Pracujem..." category: "Kategórie" tags: "Značky" @@ -572,6 +582,7 @@ manage: "Administrácia" plugins: "Pluginy" preferencesBackups: "Zálohy nastavení" deck: "Deck" +undeck: "Oddokovať" useBlurEffectForModal: "Použiť efekt rozmazania na okná" useFullReactionPicker: "Použiť plnú veľkosť výberu reakcií" width: "Šírka" @@ -587,7 +598,6 @@ tokenRequested: "Povoliť prístup k účtu" pluginTokenRequestedDescription: "Tento plugin bude môcť používať oprávnenia nastavené tu." notificationType: "Typ oznámenia" edit: "Upraviť" -useStarForReactionFallback: "Použiť ★ keď emoji reakcie nie je známe" emailServer: "Email server" enableEmail: "Zapnúť email" emailConfigInfo: "Používa sa na overenie emaily pri registrácii alebo pri zabudnutí hesla" @@ -781,6 +791,7 @@ popularPosts: "Populárne príspevky" shareWithNote: "Zdieľať s poznámkou" ads: "Reklamy" expiration: "Ukončiť hlasovanie" +startingperiod: "Začiatok" memo: "Memo" priority: "Priorita" high: "Vysoká" @@ -849,11 +860,13 @@ instanceDefaultLightTheme: "Predvolená svetlá téma" instanceDefaultDarkTheme: "Predvolená tmavá téma" instanceDefaultThemeDescription: "Vložte kód témy v objektovom formáte" mutePeriod: "Trvanie stíšenia" +period: "Ukončiť hlasovanie" indefinitely: "Navždy" tenMinutes: "10 minút" oneHour: "1 hodina" oneDay: "1 deň" oneWeek: "1 týždeň" +oneMonth: "1 mesiac" reflectMayTakeTime: "Zmeny môžu chvíľu trvať kým sa prejavia." failedToFetchAccountInformation: "Nepodarilo sa načítať informácie o účte." rateLimitExceeded: "Prekročený limit rýchlosti" @@ -918,6 +931,8 @@ remindMeLater: "Pripomenúť neskôr" didYouLikeMisskey: "Páči sa vám CherryPick?" pleaseDonate: "CherryPick je bezplatný softvér, ktorý používa {host}. Prosím, prispejte, aby sme ho mohli ďalej rozvíjať!" color: "Farba" +horizontal: "Strana" +youFollowing: "Sledované" _role: priority: "Priorita" _priority: @@ -1207,47 +1222,22 @@ _ago: weeksAgo: "pred {n} týždňami" monthsAgo: "pred {n} mesiacmi" yearsAgo: "pred {n} rokmi" + invalid: "Nič tu nie je" _time: second: "s" minute: "min" hour: "hod" day: "dní" -_tutorial: - title: "Ako používať CherryPick" - step1_1: "Vitajte!" - step1_2: "Táto stránka sa volá \"časová os\". Zobrazuje chronologicky zoradené \"poznámky\" od ľudí, ktorých sledujete." - step1_3: "Vaša časová os je teraz prázdna pretože ste nepridali žiadne poznámky ani nikoho zatiaľ nesledujete." - step2_1: "Podˇme dokončiť nastavenia vášho profilu pred napísaním poznámky alebo sledovaním niekoho." - step2_2: "Poskytnutím informácií o vás uľahčíte ostatným, či chcú vidieť alebo sledovať vaše poznámky." - step3_1: "Dokončili ste nastavovanie svojho profilu?" - step3_2: "Poďme vyskúšať napísať poznámku. Môžete to spraviť stlačením ikony ceruzky na vrchu obrazovky." - step3_3: "Vyplňte polia a stlačte tlačítko vpravo hore." - step3_4: "Nemáte čo povedať? Skúste \"len si nastavujem môj msky\"!" - step4_1: "Napísali ste svoju prvú poznámku?" - step4_2: "Hurá! Teraz by vaša prvá poznámka mala byť na vašej časovej osi." - step5_1: "Teraz skúsme oživiť časovú os sledovaním nejakých ľudí." - step5_2: "{featured} zobrazí populárne poznámku na tomto serveri. {explore} môžete objavovať populárnych používateľov. Skúste tam nájsť ľudí, ktorých by ste radi sledovali!" - step5_3: "Ak chcete sledovať ďalších používateľov, kliknite na ich ikonu a stlačte tlačidlo \"Sledovať\" na ich profile." - step5_4: "Ak má niektorý používateľ ikonu zámku vedľa svojho mena, znamená to, že môže trvať určitý čas, kým daný používateľ schváli vašu žiadosť o sledovanie." - step6_1: "Teraz by ste mali vidieť poznámky ďalších používateľov na svojej časovej osi." - step6_2: "Môžete dať \"reakcie\" na poznámky ďalších ľudí ako rýchlu odpoveď." - step6_3: "Reakciu pridáte kliknutím na \"+\" niekoho poznámke a vybratím emoji, ktorou chcete reagovať." - step7_1: "Gralujeme! Dokončili ste základného sprievodcu CherryPick." - step7_2: "Ak sa chcete naučiť viac o CherryPick, skúste sekciu {help}." - step7_3: "A teraz, veľa šťastia, bavte sa s CherryPick! 🚀" - step8_1: "A nakoniec, prečo si neaktivovať push oznámenia?" - step8_2: "Vďaka push notifikáciám sa dozviete o reakciách, sledovaniach a zmienkach, aj keď CherryPick nie je otvorené." - step8_3: "Nastavenia notifikácií môžete neskôr zmeniť." _2fa: alreadyRegistered: "Už ste zaregistrovali 2-faktorové autentifikačné zariadenie." - registerDevice: "Registrovať nové zariadenie" - registerKey: "Registrovať bezpečnostný kľúč" step1: "Najprv si nainštalujte autentifikačnú aplikáciu (napríklad {a} alebo {b}) na svoje zariadenie." step2: "Potom, naskenujte QR kód zobrazený na obrazovke." step2Url: "Do aplikácie zadajte nasledujúcu URL adresu:" step3: "Nastavenie dokončíte zadaním tokenu z vašej aplikácie." step4: "Od teraz, všetky ďalšie prihlásenia budú vyžadovať prihlasovací token." securityKeyInfo: "Okrem odtlačku prsta alebo PIN autentifikácie si môžete nastaviť autentifikáciu cez hardvérový bezpečnostný kľúč podporujúci FIDO2 a tak ešte viac zabezpečiť svoj účet." + removeKeyConfirm: "Naozaj chcete odstrániť \"{name}\"?" + renewTOTPCancel: "Nie, ďakujem" _permissions: "read:account": "Vidieť informácie o vašom účte" "write:account": "Upraviť informácie o vašom účte" @@ -1365,8 +1355,6 @@ _visibility: followersDescription: "Viditeľné iba tým, ktorí vás sledujú" specified: "Priame" specifiedDescription: "Viditeľné iba pre konkrétnych používateľov" - localOnly: "Iba lokálne" - localOnlyDescription: "Vzdialený používatelia nebudú vidieť" _postForm: replyPlaceholder: "Odpoveď na túto poznámku..." quotePlaceholder: "Citovanie tejto poznámky..." @@ -1547,3 +1535,6 @@ _deck: channel: "Kanály" mentions: "Zmienky" direct: "Priame poznámky" +_webhookSettings: + name: "Názov" + active: "Zapnuté" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 8eb6041edc..917e0d39ea 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -256,11 +256,11 @@ noMoreHistory: "Det finns ingen mer historik" startMessaging: "Starta en chatt" nUsersRead: "läst av {n}" agreeTo: "Jag accepterar {0}" -tos: "Användarvillkor" home: "Hem" remoteUserCaution: "Då denna användaren kommer från en fjärrinstans, kan informationen visad vara ofullständig." activity: "Aktivitet" images: "Bilder" +image: "Bilder" birthday: "Födelsedag" yearsOld: "{age} år gammal" registeredDate: "Gick med" @@ -343,9 +343,16 @@ recentlyRegisteredUsers: "Nyligen registrerade användare" userList: "Listor" aboutMisskey: "Om CherryPick" administrator: "Administratör" +passwordLessLogin: "Lösenordsfri inloggning" +passwordLessLoginDescription: "Tillåter lösenordsfri inloggning med endast en säkerhetsnyckel eller en passkey." +resetPassword: "Återställ Lösenord" newPasswordIs: "Det nya lösenordet är \"{password}\"" share: "Dela" enable: "Aktivera" +weakPassword: "Svagt Lösenord" +normalPassword: "Medel Lösenord" +strongPassword: "Starkt Lösenord" +signinFailed: "Kan inte logga in. Det angivna användarnamnet eller lösenordet är felaktigt." serviceworkerInfo: "Måste vara aktiverad för pushnotiser." enableInfiniteScroll: "Ladda mer automatiskt" enablePlayer: "Öppna videospelare" @@ -354,11 +361,14 @@ enableEmail: "Aktivera epost-utskick" smtpHost: "Värd" smtpUser: "Användarnamn" smtpPass: "Lösenord" +emptyToDisableSmtpAuth: "Lämna användarnamn och lösenord tomt för att avaktivera SMTP verifiering" clearCache: "Rensa cache" +onlineUsersCount: "{n} användare är online" enabled: "Aktiverad" user: "Användare" global: "Global" squareAvatars: "Visa fyrkantiga profilbilder" +incorrectPassword: "Fel lösenord." searchByGoogle: "Sök" file: "Filer" enableAutoSensitive: "Automatisk NSFW markering" @@ -368,6 +378,15 @@ subscribePushNotification: "Aktivera pushnotiser" unsubscribePushNotification: "Avaktivera pushnotiser" pushNotificationAlreadySubscribed: "Pushnotiser är redan aktiverade" pushNotificationNotSupported: "Din webbläsare eller instans har inte stöd för pushnotiser" +windowMaximize: "Maximera" +windowMinimize: "Minimera" +windowRestore: "Återställ" +resetPasswordConfirm: "Återställ verkligen ditt lösenord?" +_achievements: + _types: + _open3windows: + title: "Flera Fönster" + description: "Ha minst 3 fönster öppna samtidigt" _email: _follow: title: "följde dig" @@ -383,12 +402,14 @@ _sfx: notification: "Notifikationer" chat: "Chatt" antenna: "Antenner" +_2fa: + passwordToTOTP: "Skriv in ditt lösenord" + renewTOTPCancel: "Nej tack" _antennaSources: all: "Alla noter" homeTimeline: "Noter från följda användare" users: "Noter från specifika användare" userList: "Noter från en specificerad lista av användare" - userGroup: "Noter från användare i en specificerad grupp" _widgets: profile: "Profil" instanceInfo: "Instansinformation" @@ -441,3 +462,5 @@ _deck: antenna: "Antenner" list: "Listor" mentions: "Omnämningar" +_webhookSettings: + active: "Aktiverad" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 5c82cebb04..86ac8fe270 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -18,8 +18,9 @@ enterUsername: "ใส่ชื่อผู้ใช้" renotedBy: "รีโน้ตโดย {ผู้ใช้}" noNotes: "ไม่มีโน้ต" noNotifications: "ไม่มีการแจ้งเตือน" -instance: "ตัวอย่าง" +instance: "อินสแตนซ์" settings: "การตั้งค่า" +notificationSettings: "ตั้งค่าการแจ้งเตือน" basicSettings: "การตั้งค่าพื้นฐาน" otherSettings: "การตั้งค่าอื่นๆ" openInWindow: "เปิดในหน้าต่าง" @@ -99,10 +100,12 @@ followRequestPending: "กำลังรอดำเนินการร้อ enterEmoji: "ใส่อีโมจิ" renote: "รีโน้ต" unrenote: "เลิกรีโน้ต" -renoted: "รีโน้ตเอาไว้" +renoted: "รีโน้ตแล้วนะ" cantRenote: "โพสต์นี้ไม่สามารถรีโน้ตไว้ใหม่ได้นะ" cantReRenote: "ไม่สามารถรีโน้ตเอาไว้ใหม่ได้นะ" quote: "อ้างคำพูด" +inChannelRenote: "รีโน้ตช่องแชลแนลเท่านั้น" +inChannelQuote: "อ้างช่องเท่านั้น" pinnedNote: "โน้ตที่ปักหมุดเอาไว้" pinned: "ปักหมุดไปยังโปรไฟล์" you: "ตัวเอง" @@ -120,6 +123,8 @@ unmarkAsSensitive: "ยกเลิกทำเครื่องหมายเ enterFileName: "พิมพ์ชื่อไฟล์" mute: "ปิดเสียง" unmute: "ยกเลิกการปิดเสียง" +renoteMute: "ปิดเสียงรีโน้ต" +renoteUnmute: "เปิดเสียง รีโน้ต" block: "บล็อค" unblock: "เลิกปิดกั้น" suspend: "ถูกระงับ" @@ -151,6 +156,7 @@ flagShowTimelineReplies: "แสดงตอบกลับ ในไทม์ flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้" autoAcceptFollowed: "อนุมัติคำขอติดตามโดยอัตโนมัติทันที จากผู้ใช้งานที่คุณกำลังติดตาม" addAccount: "เพิ่มบัญชี" +reloadAccountsList: "รีโหลดรายการบัญชีใหม่" loginFailed: "การเข้าสู่ระบบไม่สำเร็จ" showOnRemote: "ดูบนอินสแตนซ์ระยะไกล" general: "ทั่วไป" @@ -257,14 +263,18 @@ noMoreHistory: "ในนั้นไม่มีประวัติอีก startMessaging: "เริ่มการสนทนา" nUsersRead: "อ่านโดย {n}" agreeTo: "ฉันยอมรับที่จะ {0}" -tos: "ข้อกำหนดและเงื่อนไข" +agree: "ยอมรับ" +agreeBelow: "ฉันยอมรับถึงด้านล่าง" +basicNotesBeforeCreateAccount: "หมายเหตุสำคัญ" +termsOfService: "เงื่อนไขการให้บริการ" start: "เริ่มต้น​ใช้งาน​" home: "หน้าแรก" remoteUserCaution: "เนื่องจากผู้ใช้งานรายนี้นั้น มาจากอินสแตนซ์ระยะไกล ข้อมูลที่แสดงดังกล่าวนั้นอาจจะไม่สมบูรณ์ก็ได้นะ" activity: "กิจกรรม" images: "รูปภาพ" +image: "รูปภาพ" birthday: "วันเกิด" -yearsOld: "{อายุ} ปี" +yearsOld: "{age} ปี" registeredDate: "วันที่สมัครสมาชิก" location: "ตำแหน่งที่ตั้ง" theme: "ธีม" @@ -389,16 +399,19 @@ about: "เกี่ยวกับ" aboutMisskey: "เกี่ยวกับ CherryPick" administrator: "ผู้ดูแลระบบ" token: "โทเค็น" -twoStepAuthentication: "ยืนยันตัวตน 2 ชั้น" +2fa: "การยืนยันตัวตนแบบสองชั้น" +totp: "แอป Authenticator" +totpDescription: "ใช้แอปยืนยันตัวตนเพื่อป้อนรหัสผ่านแบบใช้ครั้งเดียว" moderator: "ผู้ควบคุม" moderation: "การกลั่นกรอง" nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} รายนี้" +securityKeyAndPasskey: "ความปลอดภัยและรหัสผ่าน" securityKey: "กุญแจความปลอดภัย" -securityKeyName: "ชื่อคีย์" -registerSecurityKey: "ลงทะเบียนรหัสความปลอดภัยคีย์" lastUsed: "ใช้ล่าสุด" +lastUsedAt: "ใช้งานครั้งล่าสุด: {t}" unregister: "เลิกติดตาม" passwordLessLogin: "เข้าสู่ระบบแบบไม่ใช้รหัสผ่าน" +passwordLessLoginDescription: "อนุญาตให้เข้าสู่ระบบโดยไม่ต้องใช้รหัสผ่านโดยใช้รหัสรักษาความปลอดภัยหรือรหัสผ่านเท่านั้น" resetPassword: "รีเซ็ตรหัสผ่าน" newPasswordIs: "รหัสผ่านใหม่คือ \"{password}\"" reduceUiAnimation: "ลดภาพเคลื่อนไหว UI" @@ -452,7 +465,6 @@ passwordMatched: "ถูกต้อง!" passwordNotMatched: "ไม่ถูกต้อง" signinWith: "ลงชื่อเข้าใช้ด้วย {x}" signinFailed: "ไม่สามารถลงชื่อผู้เข้าใช้ได้ เนื่องจาก ชื่อผู้ใช้หรือรหัสผ่านที่คุณป้อนนั้นไม่ถูกต้องนะ" -tapSecurityKey: "แตะคีย์ความปลอดภัย" or: "หรือ" language: "ภาษา" uiLanguage: "ภาษาอินเทอร์เฟซผู้ใช้งาน" @@ -463,16 +475,21 @@ native: "ภาษาแม่" disableDrawer: "อย่าใช้ลิ้นชักสไตล์เมนู" youHaveNoGroups: "คุณยังไม่มีกลุ่ม" joinOrCreateGroup: "รับเชิญเข้าร่วมกลุ่มหรือสร้างกลุ่มของคุณเองเลยนะ" +showNoteActionsOnlyHover: "แสดงการดำเนินการเฉพาะโน้ตเมื่อโฮเวอร์" noHistory: "ไม่มีรายการ" signinHistory: "ประวัติการเข้าสู่ระบบ" +enableAdvancedMfm: "เปิดใช้งาน MFM ขั้นสูง" +enableAnimatedMfm: "เปิดการใช้งาน MFM ด้วยแอนิเมชั่น" doing: "กำลังประมวลผล......" category: "หมวดหมู่" -tags: "แท็ก" +tags: "นามแฝง" docSource: "ที่มาของเอกสารนี้" createAccount: "สร้างบัญชี" existingAccount: "บัญชีที่มีอยู่" regenerate: "สร้างอีกครั้ง" fontSize: "ขนาดตัวอักษร" +mediaListWithOneImageAppearance: "ความสูงของลิสต์สื่อจะต้องมีรูปภาพเดียวเท่านั้น" +limitTo: "จำกัดไว้ที่ {x}" noFollowRequests: "คุณไม่มีคำขอติดตามที่รอดำเนินการ" openImageInNewTab: "เปิดรูปภาพในแท็บใหม่" dashboard: "หน้ากระดานหลัก" @@ -506,9 +523,11 @@ objectStorageUseSSLDesc: "ปิดการทำงานนี้ไว้ objectStorageUseProxy: "เชื่อมต่อผ่านพร็อกซี" objectStorageUseProxyDesc: "ปิดสิ่งนี้ไว้ถ้าหากคุณจะไม่ใช้ Proxy สำหรับการเชื่อมต่อ API" objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในการอัปโหลด" +s3ForcePathStyleDesc: "ถ้าหากเปิดใช้งาน s3ForcePathStyle ชื่อบัคเก็ตนั้นอาจจะต้องรวมอยู่ในเส้นทางของ URL ซึ่งตรงข้ามกับชื่อโฮสต์ของ URL คุณอาจจะต้องเปิดใช้งานการตั้งค่านี้เมื่อใช้บริการต่างๆ เช่น อินสแตนซ์ Minio ที่โฮสต์เองนะ" serverLogs: "บันทึกของเซิร์ฟเวอร์" deleteAll: "ลบทั้งหมด" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" +showFixedPostFormInChannel: "แสดงแบบฟอร์มกำลังโพสต์ที่ด้านบนของไทม์ไลน์ (แชนแนล)" newNoteRecived: "มีโน้ตใหม่" sounds: "เสียง" sound: "เสียง" @@ -546,6 +565,10 @@ userSuspended: "ผู้ใช้รายนี้ถูกระงับก userSilenced: "ผู้ใช้รายนี้กำลังถูกปิดกั้น" yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" +tokenRevoked: "โทเค็นไม่ถูกต้อง" +tokenRevokedDescription: "โทเค็นนี้หมดอายุแล้วนะค่ะกรุณาเข้าสู่ระบบอีกครั้งนะ" +accountDeleted: "ลบบัญชีแล้ว" +accountDeletedDescription: "บัญชีนี้ถูกลบไปแล้วนะ" menu: "เมนู" divider: "ตัวแบ่ง" addItem: "เพิ่มรายการ" @@ -589,7 +612,6 @@ tokenRequested: "ให้สิทธิ์การเข้าถึงบั pluginTokenRequestedDescription: "ปลั๊กอินนี้จะสามารถใช้การอนุญาตที่ตั้งค่าไว้ที่นี่นะ" notificationType: "ประเภทการแจ้งเตือน" edit: "แก้ไข" -useStarForReactionFallback: "ใช้ ★ เป็นทางเลือกแทนถ้าหากไม่ทราบอิโมจิ" emailServer: "อีเมล์เซิร์ฟเวอร์" enableEmail: "เปิดใช้งานการกระจายอีเมล" emailConfigInfo: "ใช้เพื่อยืนยันอีเมลของคุณระหว่างการสมัครหรือถ้าหากคุณลืมรหัสผ่าน" @@ -666,7 +688,7 @@ notesCount: "จำนวนของโน้ต" repliesCount: "จำนวนการตอบกลับที่ส่ง" renotesCount: "จำนวนรีโน้ตที่ส่ง" repliedCount: "จำนวนของการตอบกลับที่ได้รับ" -renotedCount: "จำนวนรีโน้ตที่ได้รับ" +renotedCount: "จำนวนรีโน้ตที่ได้รับแล้ว" followingCount: "จำนวนบัญชีที่ติดตาม" followersCount: "จำนวนผู้ติดตาม" sentReactionsCount: "จำนวนปฏิกิริยาที่ส่ง" @@ -693,6 +715,8 @@ contact: "ติดต่อ" useSystemFont: "ใช้ฟอนต์เริ่มต้นของระบบ" clips: "คลิป" experimentalFeatures: "ฟังก์ชั่นทดสอบ" +experimental: "ทดลอง" +thisIsExperimentalFeature: "นี่คือฟีเจอร์ทดลองนะค่ะ ฟังก์ชันการทำงานบางอย่างอาจเปลี่ยนแปลงได้ และอาจไม่ทำงานหรือไม่เสถียรตามที่ตั้งใจไว้นะ" developer: "สำหรับนักพัฒนา" makeExplorable: "ทำให้บัญชีมองเห็นใน \"สำรวจ\"" makeExplorableDescription: "ถ้าหากคุณปิดการทำงานนี้ บัญชีของคุณนั้นจะไม่แสดงในส่วน \"สำรวจ\" นะ" @@ -783,6 +807,7 @@ popularPosts: "โพสต์ติดอันดับ" shareWithNote: "แบ่งปันด้วยโน้ต" ads: "โฆษณา" expiration: "กำหนดเวลา" +startingperiod: "เริ่ม" memo: "ข้อควรจำ" priority: "ลำดับความสำคัญ" high: "สูง" @@ -815,6 +840,7 @@ lastCommunication: "การสื่อสารครั้งสุดท้ resolved: "คลี่คลายแล้ว" unresolved: "รอการเฉลย" breakFollow: "ลบผู้ติดตาม" +breakFollowConfirm: "ลบผู้ติดตามนี้ออกจริงหรอ?" itsOn: "เปิดใช้งาน" itsOff: "ปิดใช้งาน" emailRequiredForSignup: "จำเป็นต้องการใช้ที่อยู่อีเมลสำหรับการสมัคร" @@ -851,16 +877,20 @@ instanceDefaultLightTheme: "ธีมสว่างค่าเริ่มต instanceDefaultDarkTheme: "ธีมมืดค่าเริ่มต้นอินสแตนซ์" instanceDefaultThemeDescription: "ป้อนรหัสธีมในรูปแบบออบเจ็กต์" mutePeriod: "ระยะเวลาปิดเสียง" +period: "สิ้นสุดการสำรวจความคิดเห็น" indefinitely: "ตลอดไป" tenMinutes: "10 นาที" oneHour: "1 ชั่วโมง" oneDay: "1 วัน" oneWeek: "1 สัปดาห์" +oneMonth: "หนึ่งเดือน" reflectMayTakeTime: "อาจจำเป็นต้องใช้เวลาสักระยะหนึ่งจึงจะเห็นแสดงผลได้นะ" failedToFetchAccountInformation: "ไม่สามารถเรียกดึงข้อมูลบัญชีได้" rateLimitExceeded: "เกินขีดจำกัดอัตรา" cropImage: "ครอบตัดรูปภาพ" cropImageAsk: "คุณต้องการครอบตัดรูปภาพนี้อย่างงั้นหรือ?" +cropYes: "ครอบตัด" +cropNo: "ใช้ตามที่เป็นอยู่" file: "ไฟล์" recentNHours: "ล่าสุด {n} ชั่วโมงที่แล้ว" recentNDays: "ล่าสุด {n} วันที่แล้ว" @@ -895,6 +925,7 @@ remoteOnly: "รีโมทเท่านั้น" failedToUpload: "การอัปโหลดล้มเหลว" cannotUploadBecauseInappropriate: "ไม่สามารถอัปโหลดไฟล์นี้ได้เนื่องจากระบบตรวจพบบางส่วนของไฟล์ว่านี้อาจจะเป็น NSFW" cannotUploadBecauseNoFreeSpace: "การอัปโหลดนั้นล้มเหลวเนื่องจากไม่มีความจุของไดรฟ์" +cannotUploadBecauseExceedsFileSizeLimit: "ไม่สามารถอัปโหลดไฟล์นี้ได้แล้วเนื่องจากเกินขีดจำกัดของขนาดไฟล์แล้ว" beta: "เบต้า" enableAutoSensitive: "ทำเครื่องหมาย NSFW อัตโนมัติ" enableAutoSensitiveDescription: "อนุญาตให้ตรวจหาและทำเครื่องหมายสื่อ NSFW โดยอัตโนมัติผ่านการเรียนรู้ของเครื่องหากเป็นไปได้ แม้ว่าตัวเลือกนี้จะถูกปิดใช้งาน แต่ก็สามารถเปิดใช้งานได้ทั้งอินสแตนซ์นี้" @@ -911,6 +942,7 @@ pushNotificationNotSupported: "เบราว์เซอร์หรืออ sendPushNotificationReadMessage: "ลบการแจ้งเตือนแบบพุชเมื่ออ่านการแจ้งเตือนหรือข้อความที่เกี่ยวข้องแล้ว" sendPushNotificationReadMessageCaption: "การแจ้งเตือนที่มีข้อความ \"{emptyPushNotificationMessage}\" จะแสดงขึ้นมาในช่วงระยะเวลาสั้นๆ การดำเนินการนี้อาจทำให้เพิ่มการใช้งานแบตเตอรี่ของอุปกรณ์ถ้าหากมีนะ" windowMaximize: "ขยายใหญ่สุดแล้ว" +windowMinimize: "ย่อเล็กที่สุด" windowRestore: "เลิกทำ" caption: "รายละเอียด" loggedInAsBot: "ล็อกอินเป็นบอตอยู่ในขณะนี้" @@ -927,6 +959,7 @@ didYouLikeMisskey: "คุณเคยชอบ CherryPick ไหม?" pleaseDonate: "{host} ใช้ซอฟต์แวร์ฟรี CherryPick เราขอขอบคุณการบริจาคของคุณอย่างสูงเพื่อให้การพัฒนา CherryPick สามารถดำเนินต่อไปได้นะ!" roles: "บทบาท" role: "บทบาท" +noRole: "ไม่พบบทบาท" normalUser: "ผู้ใช้มาตรฐาน" undefined: "ไม่ได้กำหนด" assign: "กำหนด" @@ -936,11 +969,100 @@ manageCustomEmojis: "จัดการอีโมจิแบบกำหน youCannotCreateAnymore: "คุณถึงขีดจํากัดการสร้างแล้วนะ" cannotPerformTemporary: "ไม่สามารถใช้การได้ชั่วคราว" cannotPerformTemporaryDescription: "การดําเนินการนี้ไม่สามารถดําเนินการได้ชั่วคราว เนื่องจากเกินขีดจํากัดการดําเนินการ กรุณารอสักครู่แล้วลองใหม่อีกครั้งนะค่ะ" +invalidParamError: "ข้อผิดพลาดพารามิเตอร์" +invalidParamErrorDescription: "คำขอพารามิเตอร์ไม่ถูกต้อง สิ่งนี้มักจะเกิดจากข้อผิดพลาด แต่อาจเกิดจากอินพุตเกินขีดจำกัดของขนาดหรือที่คล้ายกัน" +permissionDeniedError: "การดำเนินถูกปฏิเสธ" +permissionDeniedErrorDescription: "บัญชีนี้ไม่มีสิทธิ์อนุญาตในการดำเนินการนี้" preset: "พรีเซ็ต" selectFromPresets: "เลือกจากการพรีเซ็ต" achievements: "ความสำเร็จ" gotInvalidResponseError: "การตอบสนองเซิร์ฟเวอร์ไม่ถูกต้อง" gotInvalidResponseErrorDescription: "เซิร์ฟเวอร์อาจไม่สามารถเข้าถึงได้หรืออาจจะกำลังอยู่ในระหว่างปรับปรุง กรุณาลองใหม่อีกครั้งในภายหลังนะคะ" +thisPostMayBeAnnoying: "โน้ตนี้อาจจะเป็นการรบกวนผู้อื่นนะคะ" +thisPostMayBeAnnoyingHome: "โพสต์ไปยังบ้านไทม์ไลน์" +thisPostMayBeAnnoyingCancel: "เลิก" +thisPostMayBeAnnoyingIgnore: "โพสต์ยังไงก็แล้วแต่" +collapseRenotes: "ยุบ renotes ที่คุณได้เห็นแล้ว" +internalServerError: "เซิร์ฟเวอร์ภายในเกิดข้อผิดพลาด" +internalServerErrorDescription: "เซิร์ฟเวอร์รันค้นพบข้อผิดพลาดที่ไม่คาดคิด" +copyErrorInfo: "คัดลอกรายละเอียดข้อผิดพลาด" +joinThisServer: "ลงชื่อสมัครใช้ในอินสแตนซ์นี้" +exploreOtherServers: "มองหาอินสแตนซ์อื่น" +letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" +disableFederationConfirm: "ปิดใช้งานสหพันธ์จริงๆหรอแน่ใจแล้วนะ?" +disableFederationConfirmWarn: "แม้ว่าจะถูกยกเลิกเอาไว้โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป เว้นแต่ว่า...จะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องทำตรงนี้หรอกนะค่ะ" +disableFederationOk: "ปิดการใช้งาน" +invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" +emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" +postToTheChannel: "โพสต์ลงช่อง" +cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" +reactionAcceptance: "การยอมรับรีแอคชั่น" +likeOnly: "ที่ชอบเท่านั้น" +likeOnlyForRemote: "ไลค์สำหรับอินสแตนซ์ระยะไกลเท่านั้น" +rolesAssignedToMe: "บทบาทที่ได้รับมอบหมายให้ฉัน" +resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?" +sensitiveWords: "คำที่ละเอียดอ่อน" +sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ" +notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งานนะค่ะ" +license: "ใบอนุญาต" +unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?" +myClips: "คลิปของฉัน" +drivecleaner: "ทำความสะอาดไดรฟ์" +retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหมดอีกครั้ง" +retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?" +retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" +enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล" +enableChartsForFederatedInstances: "สร้างแผนภูมิข้อมูลอินสแตนซ์ระยะไกล" +showClipButtonInNoteFooter: "เพิ่ม \"คลิป\" เพื่อบันทึกเมนูการทำงาน" +largeNoteReactions: "ขยายรีแอคชั่นการแสดงผล" +noteIdOrUrl: "โน้ต ID หรือ URL" +video: "วีดีโอ" +videos: "วีดีโอ" +dataSaver: "ประหยัดข้อมูล" +accountMigration: "การโยกย้ายบัญชี" +accountMoved: "ผู้ใช้รายนี้ได้ย้ายไปยังบัญชีใหม่แล้ว:" +accountMovedShort: "บัญชีนี้ถูกโอนย้ายไปแล้วค่ะ" +operationForbidden: "ห้ามดำเนินการ" +forceShowAds: "แสดงโฆษณาเสมอ" +addMemo: "เพิ่มมีโม" +editMemo: "แก้ไขมีโม" +reactionsList: "ปฏิกิริยา" +renotesList: "Renotes รีโน้ต" +notificationDisplay: "การแจ้งเตือน" +leftTop: "บนซ้าย" +rightTop: "บนขวา" +leftBottom: "ล่างซ้าย" +rightBottom: "ล่างขวา" +stackAxis: "ทิศทางการซ้อน" +vertical: "แนวตั้ง" +horizontal: "ด้านข้าง" +position: "ตำแหน่ง" +serverRules: "กฎของเซิฟเวอร์" +pleaseConfirmBelowBeforeSignup: "โปรดยืนยันด้านล่างก่อนกำลังลงชื่อสมัครนะค่ะ" +pleaseAgreeAllToContinue: "คุณต้องยอมรับทุกช่องตรงด้านบนเพื่อดำเนินการต่อค่ะ" +continue: "ดำเนินการต่อ" +preservedUsernames: "ชื่อผู้ใช้ที่สงวนไว้" +preservedUsernamesDescription: "ลิสต์ชื่อผู้ใช้ที่จะสำรองโดยคั่นด้วยการแบ่งบรรทัดนั้น เพราะสิ่งเหล่านี้จะไม่สามารถทำได้ในระหว่างการสร้างบัญชีตามปกติ บัญชีที่มีอยู่แล้วนั้นโดยใช้ชื่อผู้ใช้เหล่านี้จะไม่ได้รับผลกระทบอะไร" +createNoteFromTheFile: "เรียบเรียงโน้ตจากไฟล์นี้" +youFollowing: "ติดตามแล้ว" +options: "ตัวเลือกบทบาท" +_serverRules: + description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ" +_accountMigration: + moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง" + moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น" + moveFromLabel: "บัญชีที่จะย้ายจาก:" + moveFromDescription: "สร้างนามแฝงสำหรับบัญชีที่จะย้ายจากบัญชีนี้ ถ้าหากคุณต้องการโอนผู้ติดตาม สิ่งนี้ต้องทำก่อนโอนก่อนนะค่ะ! หลังจากนั้น ป้อนบัญชีที่จะย้ายไปในรูปแบบต่อไปนี้: @person@instance.com" + moveTo: "ย้ายข้อมูลบัญชีนี้ไปยังบัญชีอีกหนึ่ง" + moveToLabel: "บัญชีที่จะย้ายไปที่:" + moveCannotBeUndone: "ไม่สามารถยกเลิกการโอนย้ายบัญชีได้" + moveAccountDescription: "การกระทำนี้ไม่สามารถย้อนกลับได้นะ ขั้นตอนแรก ต้องสร้างนามแฝงสำหรับบัญชีนี้ในบัญชีที่คุณต้องการย้ายไป หลังจากนั้นแล้ว ป้อนบัญชีที่จะย้ายไปในรูปแบบดังต่อไปนี้: @person@instance.com" + moveAccountHowTo: "หากต้องการย้ายข้อมูลก่อนอื่นให้สร้างชื่อแทนสำหรับบัญชีนี้ ในบัญชีที่จะต้องการย้ายไป\nหลังจากที่คุณสร้างนามแฝงนั้นแล้ว ให้ป้อนบัญชีที่ต้องการจะย้ายไปในรูปแบบดังต่อไปนี้: @username@server.example.com" + startMigration: "โอนย้าย" + migrationConfirm: "ย้ายข้อมูลบัญชีนี้ไปที่ {account} จริงๆนะ เมื่อมีการเริ่มต้นแล้ว กระบวนการนี้จะไม่สามารถหยุดหรือนำกลับคืนมาได้ และคุณจะไม่สามารถใช้บัญชีนี้ในสถานะดั้งเดิมได้อีกต่อไป\n\nนอกจากนี้ เพื่อให้แน่ใจยืนยันว่าคุณได้สร้างนามแฝงในบัญชีที่จะย้ายข้อมูลนะค่ะ" + movedAndCannotBeUndone: "\nบัญชีนี้ถูกโอนย้ายไปแล้ว\nไม่สามารถย้อนกลับโอนย้ายข้อมูลได้" + postMigrationNote: "บัญชีนี้จะถูกเลิกติดตามบัญชีทั้งหมดที่กำลังติดตามภายใน 24 ชั่วโมงหลังจากการย้ายข้อมูลนั้นเสร็จสิ้น ทั้งจำนวนผู้ติดตามและผู้ติดตามนั้นจะกลายเป็นศูนย์ เพื่อหลีกเลี่ยงป้องกันไม่ให้ผู้ติดตามของคุณนั้นไม่สามารถเห็นโพสต์เฉพาะผู้ติดตามของบัญชีนี้ได้ แต่อย่างไรก็ตามแล้วพวกเขาจะยังคงติดตามบัญชีนี้ต่อไป" + movedTo: "บัญชีที่จะย้ายไปที่:" _achievements: earnedAt: "ได้รับเมื่อ" _types: @@ -1112,6 +1234,9 @@ _achievements: _client30min: title: "พักผ่อนสักหน่อย" description: "ใช้เวลา 30 นาทีบน CherryPick" + _client60min: + title: "ไม่มี \"Miss\" ใน CherryPick นะค่ะ !" + description: "เปิด CherryPick ค้างไว้แล้วอย่างน้อย 60 นาที" _noteDeletedWithin1min: title: "ไม่เป็นไร" description: "ลบโน้ตภายในหนึ่งนาทีหลังจากที่โพสต์" @@ -1200,6 +1325,10 @@ _role: iconUrl: "ไอคอน URL" asBadge: "แสดงเป็นตรา" descriptionOfAsBadge: "ไอคอนของบทบาทนี้จะปรากฏถัดจากชื่อผู้ใช้ของผู้ใช้งานด้วยบทบาทนี้ถ้าหากเปิดใช้งาน" + isExplorable: "บทบาทไทม์ไลน์เป็นแบบสาธารณะ" + descriptionOfIsExplorable: "ไทม์ไลน์ของบทบาทนี้จะสามารถเข้าถึงได้แบบสาธารณะถ้าหากเปิดใช้งาน เส้นเวลาของบทบาทนั้นจะไม่ถูกเปิดเผยต่อสาธารณะ ถึงแม้ว่าจะไม่เปิดเผยต่อสาธารณะแม้แต่ว่า...จะตั้งค่าไว้ยังไงก็ตาม" + displayOrder: "ตำแหน่ง" + descriptionOfDisplayOrder: "ยิ่งตัวเลขสูง ตำแหน่ง UI ก็ยิ่งสูงขึ้นนะ" canEditMembersByModerator: "อนุญาตให้ผู้ดูแลแก้ไขสมาชิก" descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ ผู้ดูแลนอกเหนือจากผู้ดูแลระบบแล้ว จะสามารถกำหนดและยกเลิกการมอบหมายบทบาทนี้ให้กับผู้ใช้ได้ เมื่อปิด เฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถกำหนดผู้ใช้ได้นะ" priority: "ลำดับความสำคัญ" @@ -1225,6 +1354,7 @@ _role: rateLimitFactor: "ขีดจำกัดอัตรา" descriptionOfRateLimitFactor: "ขีดจํากัดอัตราที่ต่ำกว่ามีข้อจํากัดน้อยกว่าข้อจํากัดที่สูงกว่า" canHideAds: "ซ่อนโฆษณา" + canSearchNotes: "การใช้การค้นหาโน้ต" _condition: isLocal: "ผู้ใช้ภายใน" isRemote: "ผู้ใช้ระยะไกล" @@ -1234,6 +1364,8 @@ _role: followersMoreThanOrEq: "จำนวนผู้ติดตามมากกว่าหรือเท่ากับ\n" followingLessThanOrEq: "จำนวนบัญชีต่อไปนี้คือ น้อยกว่าหรือเท่ากับ" followingMoreThanOrEq: "จำนวนบัญชีต่อไปนี้คือ มากกว่าหรือเท่ากับ" + notesLessThanOrEq: "จำนวนโพสต์น้อยกว่าเท่ากับ" + notesMoreThanOrEq: "จำนวนโพสต์มากกว่าเท่ากับ" and: "และ" or: "หรือ" not: "ไม่" @@ -1409,6 +1541,8 @@ _channel: following: "ติดตามแล้ว" usersCount: "{n} ผู้เข้าร่วม" notesCount: "{n} โน้ต" + nameAndDescription: "ชื่อและคำอธิบาย" + nameOnly: "ชื่อเท่านั้น" _menuDisplay: sideFull: "ด้านข้าง" sideIcon: "ด้านข้าง (ไอคอน)" @@ -1520,47 +1654,37 @@ _ago: weeksAgo: "{n} สัปดาห์ที่แล้ว" monthsAgo: "{n} เดือนที่แล้ว" yearsAgo: "{n} ปีที่ผ่านมา" + invalid: "ไม่พบผลลัพธ์" _time: second: "วินาที" minute: "นาที" hour: "ชั่วโมง" day: "วัน" -_tutorial: - title: "วิธีการใช้งาน CherryPick" - step1_1: "ยินดีต้อนรับค่ะ!" - step1_2: "หน้านี้เรียกว่า \"ไทม์ไลน์\" มันจะแสดง \"โน้ตย่อ\" ที่เรียงลำดับตามลำดับเวลาของคนที่คุณ \"ติดตาม\"" - step1_3: "ไทม์ไลน์ของคุณนั้นว่างเปล่า เนื่องจากคุณยังไม่ได้โพสต์โน้ตย่อหรือไม่ได้ติดตามใครเลย" - step2_1: "มาตั้งค่าโปรไฟล์ของคุณให้เสร็จก่อนเขียนโน้ตย่อหรือติดตามใครก็ได้" - step2_2: "การให้ข้อมูลบางอย่างเกี่ยวกับตัวคุณนั้น จะทำให้ผู้อื่นทราบว่าต้องการดูโน้ตย่อของคุณหรือติดตามคุณได้ง่ายขึ้น" - step3_1: "ตั้งค่าโปรไฟล์ของคุณเสร็จแล้ว?" - step3_2: "จากนั้นลองโพสต์โน้ตกันต่อไป คุณสามารถทำได้โดยกดปุ่มที่มีไอคอนดินสอบนหน้าจอนะ" - step3_3: "กรอกโมดอลแล้วกดปุ่มด้านบนขวาเพื่อโพสต์" - step3_4: "ไม่มีอะไรจะพูดงั้นหรอ ลอง \"เพียงแค่ตั้งค่าว่า CherryPick ของฉัน\"!" - step4_1: "เสร็จสิ้นการโพสต์โน้ตย่อแรกของคุณแล้วอย่างงั้นหรอ?" - step4_2: "ไชโย! ตอนนี้โน้ตย่อแรกของคุณได้ปรากฏบนไทม์ไลน์ของคุณแล้วนะ" - step5_1: "ตอนนี้ มาลองทำไทม์ไลน์เพิ่มเติมของคุณให้ดูมีชีวิตชีวามากขึ้นโดยการติดตามคนอื่น" - step5_2: "{featured} จะแสดงโน้ตยอดนิยมให้คุณเห็นในกรณีนี้ {explore} จะช่วยให้คุณค้นหาผู้ใช้ยอดนิยมได้ ลองหาคนที่คุณต้องการติดตามที่นั่นสิ!" - step5_3: "หากต้องการติดตามผู้ใช้รายอื่น ให้คลิกที่ไอคอนและกดปุ่ม \"ติดตาม\" บนโปรไฟล์ของพวกเขาได้เลยจ้า" - step5_4: "หากผู้ใช้รายอื่นมีไอคอนแม่กุญแจที่อยู่ข้างชื่อ อาจต้องใช้เวลาสักระยะกว่าที่ผู้ใช้รายนั้นจะอนุมัติคำขอติดตามของคุณ" - step6_1: "คุณสามารถเห็นโน้ตย่อของผู้ใช้รายอื่นบนไทม์ไลน์ของคุณได้แล้วตอนนี้" - step6_2: "คุณยังสามารถใส่ \"ปฏิกิริยา\" ลงในโน้ตของคนอื่นเพื่อตอบกลับได้อย่างรวดเร็ว" - step6_3: "หากต้องการแนบ \"ปฏิกิริยา\" ให้กดเครื่องหมาย \"+\" ในโน้ตของผู้ใช้รายอื่นแล้วเลือกอีโมจิที่คุณต้องการโต้ตอบด้วย" - step7_1: "ยินดีด้วยนะ! คุณได้เสร็จสิ้นการกวดวิชาพื้นฐานของ CherryPick แล้ว" - step7_2: "ถ้าหากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับ CherryPick ให้ลองใช้ส่วน {help}" - step7_3: "ตอนนี้ ถ้าอย่างนั้นก็ขอให้โชคดีและสนุกกับ CherryPick! 🚀" - step8_1: "สุดท้ายนี้นายต้องการเปิดใช้งานการแจ้งเตือนแบบพุชหรือป่าว?" - step8_2: "การเปิดใช้งานสิ่งเหล่านี้ จะช่วยให้คุณนั้นได้รับการแจ้งเตือนสำหรับการกล่าวถึง การแสดงรีแอคชั่น การติดตาม ฯลฯ เป็นต้น ถึงแม้ว่าจะไม่ได้เปิด CherryPick ก็ตาม" - step8_3: "คุณสามารถเปลี่ยนการตั้งค่านี้ในภายหลังได้ตลอดเวลานะ" _2fa: alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว" - registerDevice: "ลงทะเบียนอุปกรณ์ใหม่" - registerKey: "ลงทะเบียนรหัสความปลอดภัย" + registerTOTP: "ลงทะเบียนแอพตัวตรวจสอบสิทธิ์" + passwordToTOTP: "กรอกรหัสผ่าน" step1: "ขั้นตอนแรก ติดตั้งแอปยืนยันตัวตน (เช่น {a} หรือ {b}) บนอุปกรณ์ของคุณ" step2: "จากนั้นสแกนรหัส QR ที่แสดงบนหน้าจอนี้" + step2Click: "การคลิกที่รหัส QR นี้จะช่วยให้คุณนั้นสามารถลงทะเบียน 2FA กับคีย์ความปลอดภัยหรือแอปตรวจสอบความถูกต้องของโทรศัพท์ได้" step2Url: "คุณยังสามารถป้อนบน URL นี้หากคุณใช้โปรแกรมเดสก์ท็อป:" + step3Title: "ป้อนรหัสยืนยัน" step3: "ป้อนโทเค็นที่แอปของคุณให้มาเพื่อเสร็จสิ้นการตั้งค่า" step4: "นับจากนี้เป็นต้นไปการพยายามเข้าสู่ระบบในอนาคตนั้น อาจจะต้องขอโทเค็นในการเข้าสู่ระบบดังกล่าว" + securityKeyNotSupported: "เบราว์เซอร์ของคุณไม่รองรับคีย์ความปลอดภัยนะ" + registerTOTPBeforeKey: "กรุณาตั้งค่าแอปยืนยันตัวตนเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" securityKeyInfo: "นอกจากนี้การตรวจสอบความถูกต้องด้วยลายนิ้วมือหรือ PIN แล้ว คุณยังสามารถตั้งค่าการตรวจสอบสิทธิ์ผ่านคีย์ความปลอดภัยของฮาร์ดแวร์ที่รองรับ FIDO2 เพื่อเพิ่มความปลอดภัยให้กับบัญชีของคุณ" + chromePasskeyNotSupported: "ขณะนี้ยังไม่รองรับรหัสผ่านของ Chrome" + registerSecurityKey: "ลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" + securityKeyName: "ป้อนชื่อคีย์" + tapSecurityKey: "กรุณาทำตามเบราว์เซอร์ของคุณเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" + removeKey: "ลบคีย์ความปลอดภัยออก" + removeKeyConfirm: "ลบข้อมูลสำรอง {name} มั้ย?" + whyTOTPOnlyRenew: "ไม่สามารถลบแอปตัวรับรองความถูกต้องได้ตราบใดที่มีการลงทะเบียนคีย์ความปลอดภัยไว้แล้ว" + renewTOTP: "กำหนดค่าแอพตัวตรวจสอบสิทธิ์ใหม่" + renewTOTPConfirm: "วิธีการแบบนี้จะทําให้รหัสยืนยันจากแอพก่อนหน้าของคุณหยุดทํางานเลยนะ" + renewTOTPOk: "ตั้งค่าคอนฟิกใหม่" + renewTOTPCancel: "ไม่เป็นไร" _permissions: "read:account": "ดูข้อมูลบัญชีของคุณ" "write:account": "แก้ไขข้อมูลบัญชีของคุณ" @@ -1684,8 +1808,8 @@ _visibility: followersDescription: "ทำให้ผู้ติดตามนั้นมองเห็นแค่คุณเท่านั้น" specified: "ไดเร็ค" specifiedDescription: "ทำให้มองเห็นได้เฉพาะผู้ใช้ที่ระบุเท่านั้น" - localOnly: "เฉพาะท้องถิ่น" - localOnlyDescription: "ผู้ใช้ระยะไกลนั้นไม่สามารถมองเห็นได้" + disableFederation: "ไม่มีสหภาพ" + disableFederationDescription: "อย่าส่งไปยังอินสแตนซ์อื่น" _postForm: replyPlaceholder: "ตอบกลับโน้ตนี้..." quotePlaceholder: "อ้างโน้ตนี้..." @@ -1845,6 +1969,7 @@ _notification: receiveFollowRequest: "ได้รับคำขอติดตาม\n" followRequestAccepted: "ยอมรับคำขอติดตาม" groupInvited: "ได้รับคำเชิญเข้ากลุ่ม" + achievementEarned: "ปลดล็อกความสำเร็จแล้ว" app: "การแจ้งเตือนจากแอปที่มีลิงก์" _actions: followBack: "ติดตามกลับด้วย" @@ -1877,3 +2002,27 @@ _deck: channel: "แชนแนล" mentions: "พูดถึง" direct: "ไดเร็ค" + roleTimeline: "บทบาทไทม์ไลน์" +_dialog: + charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}" + charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}" +_disabledTimeline: + title: "ปิดใช้งานไทม์ไลน์" + description: "คุณไม่สามารถใช้ไทม์ไลน์นี้ภายใต้บทบาทปัจจุบันของคุณได้" +_drivecleaner: + orderBySizeDesc: "ขนาดไฟล์จากมากไปหาน้อย" + orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก" +_webhookSettings: + createWebhook: "สร้าง Webhook" + name: "ชื่อ" + secret: "ความลับ" + events: "อีเว้นท์ Webhook" + active: "เปิดใช้งาน" + _events: + follow: "เมื่อกำลังติดตามผู้ใช้" + followed: "เมื่อกำลังติดตามแล้ว" + note: "เมื่อกำลังโพสต์โน้ต" + reply: "เมื่อได้รับการตอบกลับ" + renote: "รีโน้ตแล้วเมื่อ" + reaction: "เมื่อได้รับรีแอคชั่น" + mention: "เมื่อกำลังถูกกล่าวถึง" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 8de529d0f2..ff22773b87 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -49,6 +49,7 @@ deleteAndEdit: "Видалити й редагувати" deleteAndEditConfirm: "Ви впевнені, що хочете видалити цю нотатку та відредагувати її? Ви втратите всі реакції, поширення та відповіді на неї." addToList: "Додати до списку" sendMessage: "Надіслати повідомлення" +copyRSS: "Скопіювати RSS" copyUsername: "Скопіювати ім’я користувача" searchUser: "Пошук користувачів" reply: "Відповісти" @@ -128,6 +129,7 @@ unblockConfirm: "Ви впевнені, що хочете розблокуват suspendConfirm: "Ви впевнені, що хочете призупинити цей акаунт?" unsuspendConfirm: "Ви впевнені, що хочете відновити цей акаунт?" selectList: "Виберіть список" +selectChannel: "Виберіть канал" selectAntenna: "Виберіть антену" selectWidget: "Виберіть віджет" editWidgets: "Редагувати віджети" @@ -255,12 +257,13 @@ noMoreHistory: "Подальшої історії немає" startMessaging: "Розпочати діалог" nUsersRead: "Прочитали {n}" agreeTo: "Я погоджуюсь з {0}" -tos: "Умови використання" +agreeBelow: "Я погоджуюся з наведеним нижче" start: "Розпочати" home: "Домівка" remoteUserCaution: "Інформація може бути неповною, оскільки це віддалений користувач." activity: "Активність" images: "Зображення" +image: "Зображення" birthday: "День народження" yearsOld: "{age} років" registeredDate: "Приєднання" @@ -387,13 +390,12 @@ about: "Інформація" aboutMisskey: "Про CherryPick" administrator: "Адмін" token: "Токен" -twoStepAuthentication: "Двохфакторна аутентифікація" +2fa: "Двофакторна аутентифікація" +totp: "Програма аутентифікації" moderator: "Модератор" moderation: "Модерація" nUsersMentioned: "Згадали: {n}" securityKey: "Ключ захисту" -securityKeyName: "Назва ключа" -registerSecurityKey: "Зареєструвати ключ захисту" lastUsed: "Востаннє використано" unregister: "Скасувати реєстрацію" passwordLessLogin: "Налаштувати вхід без пароля" @@ -450,7 +452,6 @@ passwordMatched: "Все вірно" passwordNotMatched: "Паролі не співпадають" signinWith: "Увійти за допомогою {x}" signinFailed: "Не вдалося увійти. Введені ім’я користувача або пароль неправильнi." -tapSecurityKey: "Торкніться ключа безпеки" or: "або" language: "Мова" uiLanguage: "Мова інтерфейсу" @@ -461,6 +462,8 @@ youHaveNoGroups: "Немає груп" joinOrCreateGroup: "Отримуйте запрошення до груп або створюйте свої власні групи." noHistory: "Історія порожня" signinHistory: "Історія входів" +enableAdvancedMfm: "Увімкнути розширений MFM" +enableAnimatedMfm: "Увімкнути анімований MFM" doing: "Виконується" category: "Категорія" tags: "Теги" @@ -585,7 +588,6 @@ tokenRequested: "Надати доступ до акаунту" pluginTokenRequestedDescription: "Цей плагін зможе використовувати дозволи які тут вказані." notificationType: "Тип сповіщення" edit: "Редагувати" -useStarForReactionFallback: "Використовувати ★ як запасний варіант, якщо емодзі реакції невідомий" emailServer: "Email сервер" enableEmail: "Увімкнути функцію доставки пошти" emailConfigInfo: "Використовується для підтвердження електронної пошти підчас реєстрації, а також для відновлення паролю." @@ -713,6 +715,7 @@ accentColor: "Акцент" textColor: "Текст" saveAs: "Зберегти як…" advanced: "Розширені" +advancedSettings: "Розширені налаштування" value: "Значення" createdAt: "Створено" updatedAt: "Останнє оновлення" @@ -777,6 +780,7 @@ popularPosts: "Популярні дописи" shareWithNote: "Поділитися нотаткою" ads: "Реклама" expiration: "Опитування закінчується" +startingperiod: "Початковий період" memo: "Примітка" priority: "Пріоритет" high: "Високий" @@ -840,6 +844,7 @@ searchByGoogle: "Пошук" instanceDefaultLightTheme: "Світла тема за промовчанням" instanceDefaultDarkTheme: "Темна тема за промовчанням" mutePeriod: "Тривалість приховування" +period: "Опитування закінчується" indefinitely: "Ніколи" tenMinutes: "10 хвилин" oneHour: "1 година" @@ -897,8 +902,19 @@ like: "Вподобати" unlike: "Не вподобати" numberOfLikes: "Вподобання" show: "Відображення" +roles: "Ролі" +role: "Роль" +normalUser: "Звичайний користувач" +undefined: "Не визначено" +assign: "Призначити" +unassign: "Скасувати призначення" color: "Колір" achievements: "Досягнення" +joinThisServer: "Зареєструватися на цьому сервері" +exploreOtherServers: "Знайти інший сервер" +letsLookAtTimeline: "Перегляд історії" +horizontal: "Збоку" +youFollowing: "Підписки" _achievements: earnedAt: "Відкрито" _types: @@ -1113,11 +1129,20 @@ _achievements: _loggedInOnNewYearsDay: title: "З Новим роком!" description: "Увійшли в перший день року" + _cookieClicked: + flavor: "Чекайте, це вірний сайт?" _brainDiver: title: "Brain Diver" description: "Відправити посилання на \"Brain Diver\"" flavor: "CherryPick-CherryPick La-Tu-Ma" _role: + new: "Нова роль" + edit: "Змінити роль" + name: "Назва ролі" + description: "Опис ролі" + permission: "Права ролі" + assignTarget: "Призначити" + manual: "Вручну" priority: "Пріоритет" _priority: low: "Низький" @@ -1374,45 +1399,20 @@ _ago: weeksAgo: "{n} тиж. тому" monthsAgo: "{n} міс. тому" yearsAgo: "{n} р. тому" + invalid: "Тут нічого немає" _time: second: "с" minute: "х" hour: "г" day: "д" -_tutorial: - title: "Як користуватись CherryPick" - step1_1: "Ласкаво просимо!" - step1_2: "Ця сторінка має назву \"стрічка подій\". На ній з'являються записи користувачів на яких ви підписані." - step1_3: "Наразі ваша стрічка порожня, оскільки ви ще не написали жодної нотатки і не підписані на інших." - step2_1: "Перш ніж зробити запис або підписатись на когось, заповніть свій профіль." - step2_2: "Надання деякої інформації про себе допоможе іншим користувачам вирішити підписатись на вас." - step3_1: "Ви успішно налаштували свій обліковий запис?" - step3_2: "Наступним кроком є написання нотатки. Це можна зробити, натиснувши зображення олівця на екрані." - step3_3: "Після написання вмісту ви можете опублікувати його, натиснувши кнопку у верхньому правому куті форми." - step3_4: "Не знаєте що написати? Спробуйте \"Привіт, CherryPick!\"" - step4_1: "Ви розмістили свій перший запис?" - step4_2: "Ура! Ваш перший запис відображається на вашій стрічці подій." - step5_1: "Настав час оживити вашу стрічку подій підписавшись на інших користувачів." - step5_2: "{explore} допоможе вам знайти цікавих людей та підписатися на них." - step5_3: "Щоб підписатись на інших користувачів, нажміть на їхнє зображення, а потім на кнопку \"підписатись\"." - step5_4: "Якщо користувач має замок при імені, то йому потрібно буде вручну підтвердити вашу заявку на підписку." - step6_1: "Тепер ви повинні бачити записи інших користувачів на вашій стрічці подій." - step6_2: "Також ви можете швидко відповісти, або \"відреагувати\" на записи інших користувачів." - step6_3: "Щоб \"відреагувати\", нажміть на знак плюс \"+\" на записі і виберіть емоджі яким ви хочете \"відреагувати\"." - step7_1: "Вітаю! Ви пройшли ознайомлення з CherryPick." - step7_2: "Якщо ви хочете більше дізнатись про CherryPick, зайдіть в розділ {help}." - step7_3: "Насолоджуйтесь CherryPick! 🚀" - step8_1: "Наостанку, чи бажаєте ви ввімкнути push-сповіщення?" - step8_3: "Ви завжди можете змінити цей параметр пізніше." _2fa: alreadyRegistered: "Двофакторна автентифікація вже налаштована." - registerDevice: "Зареєструвати новий пристрій" - registerKey: "Зареєструвати новий ключ безпеки" step1: "Спершу встановіть на свій пристрій програму автентифікації (наприклад {a} або {b})." step2: "Потім відскануйте QR-код, який відображається на цьому екрані." step2Url: "Ви також можете ввести цю URL-адресу, якщо використовуєте програму для ПК:" step3: "Щоб завершити налаштування, введіть токен, наданий вашою програмою." step4: "Відтепер будь-які майбутні спроби входу вимагатимуть такого токена." + renewTOTPCancel: "Не зараз" _permissions: "read:account": "Переглядати дані профілю" "write:account": "Змінити дані акаунту" @@ -1521,8 +1521,6 @@ _visibility: followersDescription: "Тільки для підписників" specified: "Особисто" specifiedDescription: "Лише для певних користувачів" - localOnly: "Локально" - localOnlyDescription: "Приховано для віддалених користувачів" _postForm: replyPlaceholder: "Відповідь на цю нотатку..." quotePlaceholder: "Прокоментуйте цю нотатку..." @@ -1694,3 +1692,6 @@ _deck: channel: "Канали" mentions: "Згадки" direct: "Особисте" +_webhookSettings: + name: "Ім'я" + active: "Увімкнено" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 11c3986855..be60d50049 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -2,6 +2,7 @@ _lang_: "Tiếng Việt" headlineMisskey: "Mạng xã hội liên hợp" introMisskey: "Xin chào! CherryPick là một nền tảng tiểu blog phi tập trung mã nguồn mở.\nViết \"tút\" để chia sẻ những suy nghĩ của bạn 📡\nBằng \"biểu cảm\", bạn có thể bày tỏ nhanh chóng cảm xúc của bạn với các tút 👍\nHãy khám phá một thế giới mới! 🚀" +poweredByMisskeyDescription: "{name} là một trong những chủ máy của CherryPick là nền tảng mã nguồn mở" monthAndDay: "{day} tháng {month}" search: "Tìm kiếm" notifications: "Thông báo" @@ -10,12 +11,13 @@ password: "Mật khẩu" forgotPassword: "Quên mật khẩu" fetchingAsApObject: "Đang nạp dữ liệu từ Fediverse..." ok: "Đồng ý" -gotIt: "Đã hiểu!" +gotIt: "Hiểu rồi!" cancel: "Hủy" +noThankYou: "Không, cảm ơn" enterUsername: "Nhập tên người dùng" renotedBy: "Chia sẻ bởi {user}" -noNotes: "Chưa có tút nào." -noNotifications: "Không có thông báo" +noNotes: "Chưa có bài viết nào." +noNotifications: "Chưa có thông báo" instance: "Máy chủ" settings: "Cài đặt" basicSettings: "Thiết lập chung" @@ -47,6 +49,7 @@ deleteAndEdit: "Sửa" deleteAndEditConfirm: "Bạn có chắc muốn sửa tút này? Những biểu cảm, lượt trả lời và đăng lại sẽ bị mất." addToList: "Thêm vào danh sách" sendMessage: "Gửi tin nhắn" +copyRSS: "Sao chép RSS" copyUsername: "Chép tên người dùng" searchUser: "Tìm kiếm người dùng" reply: "Trả lời" @@ -65,13 +68,13 @@ export: "Xuất dữ liệu" files: "Tập tin" download: "Tải xuống" driveFileDeleteConfirm: "Bạn có chắc muốn xóa tập tin \"{name}\"? Tút liên quan cũng sẽ bị xóa theo." -unfollowConfirm: "Bạn có chắc muốn ngưng theo dõi {name}?" +unfollowConfirm: "Bạn ngừng theo dõi {name}?" exportRequested: "Đang chuẩn bị xuất tập tin. Quá trình này có thể mất ít phút. Nó sẽ được tự động thêm vào Drive sau khi hoàn thành." importRequested: "Bạn vừa yêu cầu nhập dữ liệu. Quá trình này có thể mất ít phút." lists: "Danh sách" noLists: "Bạn chưa có danh sách nào" -note: "Tút" -notes: "Tút" +note: "Bài viết" +notes: "Bài Viết" following: "Đang theo dõi" followers: "Người theo dõi" followsYou: "Theo dõi bạn" @@ -88,7 +91,7 @@ enterListName: "Đặt tên cho danh sách" privacy: "Bảo mật" makeFollowManuallyApprove: "Yêu cầu theo dõi cần được duyệt" defaultNoteVisibility: "Kiểu tút mặc định" -follow: "Đang theo dõi" +follow: "Theo dõi" followRequest: "Gửi yêu cầu theo dõi" followRequests: "Yêu cầu theo dõi" unfollow: "Ngưng theo dõi" @@ -100,7 +103,9 @@ renoted: "Đã đăng lại." cantRenote: "Không thể đăng lại tút này." cantReRenote: "Không thể đăng lại một tút đăng lại." quote: "Trích dẫn" -pinnedNote: "Tút ghim" +inChannelRenote: "Chia sẻ trong kênh này" +inChannelQuote: "Trích dẫn trong kênh này" +pinnedNote: "Bài viết đã ghim" pinned: "Ghim" you: "Bạn" clickToShow: "Nhấn để xem" @@ -126,6 +131,7 @@ unblockConfirm: "Bạn có chắc muốn bỏ chặn người này?" suspendConfirm: "Bạn có chắc muốn vô hiệu hóa người này?" unsuspendConfirm: "Bạn có chắc muốn bỏ vô hiệu hóa người này?" selectList: "Chọn danh sách" +selectChannel: "Lựa chọn kênh" selectAntenna: "Chọn một antenna" selectWidget: "Chọn tiện ích" editWidgets: "Sửa tiện ích" @@ -141,8 +147,8 @@ cacheRemoteFiles: "Tập tin cache từ xa" cacheRemoteFilesDescription: "Khi tùy chọn này bị tắt, các tập tin từ xa sẽ được tải trực tiếp từ máy chủ khác. Điều này sẽ giúp giảm dung lượng lưu trữ nhưng lại tăng lưu lượng truy cập, vì hình thu nhỏ sẽ không được tạo." flagAsBot: "Đánh dấu đây là tài khoản bot" flagAsBotDescription: "Bật tùy chọn này nếu tài khoản này được kiểm soát bởi một chương trình. Nếu được bật, nó sẽ được đánh dấu để các nhà phát triển khác ngăn chặn chuỗi tương tác vô tận với các bot khác và điều chỉnh hệ thống nội bộ của CherryPick để coi tài khoản này như một bot." -flagAsCat: "Tài khoản này là mèo" -flagAsCatDescription: "Bật tùy chọn này để đánh dấu tài khoản là một con mèo." +flagAsCat: "Chế độ Mèeeeeeeeeeo!!" +flagAsCatDescription: "Nếu mà em là một con mèo thì cứ bật nó kiu mèo mèo mèeeeeeeo!! " flagShowTimelineReplies: "Hiện lượt trả lời trong bảng tin" flagShowTimelineRepliesDescription: "Hiện lượt trả lời của người bạn theo dõi trên tút của những người khác." autoAcceptFollowed: "Tự động phê duyệt theo dõi từ những người mà bạn đang theo dõi" @@ -155,7 +161,7 @@ setWallpaper: "Đặt ảnh bìa" removeWallpaper: "Xóa ảnh bìa" searchWith: "Tìm kiếm: {q}" youHaveNoLists: "Bạn chưa có danh sách nào" -followConfirm: "Bạn có chắc muốn theo dõi {name}?" +followConfirm: "Bạn theo dõi {name}?" proxyAccount: "Tài khoản proxy" proxyAccountDescription: "Tài khoản proxy là tài khoản hoạt động như một người theo dõi từ xa cho người dùng trong những điều kiện nhất định. Ví dụ: khi người dùng thêm người dùng từ xa vào danh sách, hoạt động của người dùng từ xa sẽ không được chuyển đến phiên bản nếu không có người dùng cục bộ nào theo dõi người dùng đó, vì vậy tài khoản proxy sẽ theo dõi." host: "Host" @@ -198,7 +204,7 @@ blockedUsers: "Người đã chặn" noUsers: "Chưa có ai" editProfile: "Sửa hồ sơ" noteDeleteConfirm: "Bạn có chắc muốn xóa tút này?" -pinLimitExceeded: "Bạn đã đạt giới hạn số lượng tút có thể ghim" +pinLimitExceeded: "Bạn không thể ghim bài viết nữa" intro: "Đã cài đặt CherryPick! Xin hãy tạo tài khoản admin." done: "Xong" processing: "Đang xử lý" @@ -253,12 +259,14 @@ noMoreHistory: "Không còn gì để đọc" startMessaging: "Bắt đầu trò chuyện" nUsersRead: "đọc bởi {n}" agreeTo: "Tôi đồng ý {0}" -tos: "Điều khoản dịch vụ" +agreeBelow: "Đồng ý với nội dung dưới đây" +basicNotesBeforeCreateAccount: "Những điều cơ bản cần chú ý " start: "Bắt đầu" home: "Trang chính" remoteUserCaution: "Vì người dùng này ở máy chủ khác, thông tin hiển thị có thể không đầy đủ." activity: "Hoạt động" images: "Hình ảnh" +image: "Hình ảnh" birthday: "Sinh nhật" yearsOld: "{age} tuổi" registeredDate: "Tham gia" @@ -339,7 +347,7 @@ pinnedUsersDescription: "Liệt kê mỗi hàng một tên người dùng xuốn pinnedPages: "Trang đã ghim" pinnedPagesDescription: "Liệt kê các trang thú vị để ghim trên máy chủ." pinnedClipId: "ID của clip muốn ghim" -pinnedNotes: "Tút ghim" +pinnedNotes: "Bài viết đã ghim" hcaptcha: "hCaptcha" enableHcaptcha: "Bật hCaptcha" hcaptchaSiteKey: "Khóa của trang" @@ -348,6 +356,8 @@ recaptcha: "reCAPTCHA" enableRecaptcha: "Bật reCAPTCHA" recaptchaSiteKey: "Khóa của trang" recaptchaSecretKey: "Khóa bí mật" +turnstile: "Turnstile" +enableTurnstile: "Áp dụng Turnstile" turnstileSiteKey: "Khóa của trang" turnstileSecretKey: "Khóa bí mật" avoidMultiCaptchaConfirm: "Dùng nhiều hệ thống Captcha có thể gây nhiễu giữa chúng. Bạn có muốn tắt các hệ thống Captcha khác hiện đang hoạt động không? Nếu bạn muốn chúng tiếp tục được bật, hãy nhấn hủy." @@ -383,16 +393,19 @@ about: "Giới thiệu" aboutMisskey: "Về CherryPick" administrator: "Quản trị viên" token: "Token" -twoStepAuthentication: "Xác minh 2 bước" +2fa: "Xác thực 2 yếu tố" +totp: "Ứng dụng xác thực" +totpDescription: "Nhắn mã OTP bằng ứng dụng xác thực" moderator: "Kiểm duyệt viên" moderation: "Kiểm duyệt" nUsersMentioned: "Dùng bởi {n} người" +securityKeyAndPasskey: "Mã bảo mật・Passkey" securityKey: "Khóa bảo mật" -securityKeyName: "Tên khoá" -registerSecurityKey: "Đăng ký khóa bảo mật" lastUsed: "Dùng lần cuối" +lastUsedAt: "Lần cuối sử dụng: {t}" unregister: "Hủy đăng ký" passwordLessLogin: "Đăng nhập không mật khẩu" +passwordLessLoginDescription: "Đăng nhập bằng chỉ mã bảo mật hoặc passkey, không sử dụng mật khẩu." resetPassword: "Đặt lại mật khẩu" newPasswordIs: "Mật khẩu mới là \"{password}\"" reduceUiAnimation: "Giảm chuyển động UI" @@ -435,7 +448,7 @@ invitations: "Mời" invitationCode: "Mã mời" checking: "Đang kiểm tra..." available: "Khả dụng" -unavailable: "Không khả dụng" +unavailable: "Không sử dụng được" usernameInvalidFormat: "Bạn có thể dùng viết hoa/viết thường, chữ số, và dấu gạch dưới." tooShort: "Quá ngắn" tooLong: "Quá dài" @@ -446,17 +459,20 @@ passwordMatched: "Trùng khớp" passwordNotMatched: "Không trùng khớp" signinWith: "Đăng nhập bằng {x}" signinFailed: "Không thể đăng nhập. Vui lòng kiểm tra tên người dùng và mật khẩu của bạn." -tapSecurityKey: "Nhấn mã bảo mật của bạn" or: "Hoặc" language: "Ngôn ngữ" uiLanguage: "Ngôn ngữ giao diện" groupInvited: "Bạn đã được mời tham gia nhóm" 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" youHaveNoGroups: "Không có nhóm nào" joinOrCreateGroup: "Tham gia hoặc tạo một nhóm mới." noHistory: "Không có dữ liệu" signinHistory: "Lịch sử đăng nhập" +enableAdvancedMfm: "Xem bài MFM chất lượng cao." +enableAnimatedMfm: "Xem bài MFM có chuyển động" doing: "Đang xử lý..." category: "Phân loại" tags: "Thẻ" @@ -581,7 +597,6 @@ tokenRequested: "Cấp quyền truy cập vào tài khoản" pluginTokenRequestedDescription: "Plugin này sẽ có thể sử dụng các quyền được đặt ở đây." notificationType: "Loại thông báo" edit: "Sửa" -useStarForReactionFallback: "Dùng ★ nếu emoji biểu cảm không có" emailServer: "Email máy chủ" enableEmail: "Bật phân phối email" emailConfigInfo: "Được dùng để xác minh email của bạn lúc đăng ký hoặc nếu bạn quên mật khẩu của mình" @@ -644,7 +659,7 @@ random: "Ngẫu nhiên" system: "Hệ thống" switchUi: "Chuyển đổi giao diện người dùng" desktop: "Desktop" -clip: "Ghim" +clip: "Lưu bài viết" createNew: "Tạo mới" optional: "Không bắt buộc" createNewClip: "Tạo một ghim mới" @@ -683,7 +698,7 @@ pageLikesCount: "Số lượng trang đã thích" pageLikedCount: "Số lượng thích trang đã nhận" contact: "Liên hệ" useSystemFont: "Dùng phông chữ mặc định của hệ thống" -clips: "Ghim" +clips: "Lưu bài viết" experimentalFeatures: "Tính năng thử nghiệm" developer: "Nhà phát triển" makeExplorable: "Không hiện tôi trong \"Khám phá\"" @@ -709,6 +724,7 @@ accentColor: "Màu phụ" textColor: "Màu chữ" saveAs: "Lưu thành" advanced: "Nâng cao" +advancedSettings: "Cài đặt nâng cao" value: "Giá trị" createdAt: "Ngày tạo" updatedAt: "Cập nhật lúc" @@ -774,6 +790,7 @@ popularPosts: "Tút được xem nhiều nhất" shareWithNote: "Chia sẻ kèm với tút" ads: "Quảng cáo" expiration: "Thời hạn" +startingperiod: "Thời gian bắt đầu\n" memo: "Lưu ý" priority: "Ưu tiên" high: "Cao" @@ -806,6 +823,7 @@ lastCommunication: "Lần giao tiếp cuối" resolved: "Đã xử lý" unresolved: "Chờ xử lý" breakFollow: "Xóa người theo dõi" +breakFollowConfirm: "Bạn bỏ theo dõi tài khoản này không?" itsOn: "Đã bật" itsOff: "Đã tắt" emailRequiredForSignup: "Yêu cầu địa chỉ email khi đăng ký" @@ -842,16 +860,19 @@ instanceDefaultLightTheme: "Theme máy chủ Sáng-Rộng" instanceDefaultDarkTheme: "Theme máy chủ Tối-Rộng" instanceDefaultThemeDescription: "Nhập mã theme trong định dạng đối tượng." mutePeriod: "Thời hạn ẩn" +period: "Thời hạn" indefinitely: "Vĩnh viễn" tenMinutes: "10 phút" oneHour: "1 giờ" oneDay: "1 ngày" oneWeek: "1 tuần" +oneMonth: "1 tháng" reflectMayTakeTime: "Có thể mất một thời gian để điều này được áp dụng." failedToFetchAccountInformation: "Không thể lấy thông tin tài khoản" rateLimitExceeded: "Giới hạn quá mức" cropImage: "Cắt hình ảnh" cropImageAsk: "Bạn có muốn cắt ảnh này?" +cropNo: "Để nguyên" file: "Tập tin" recentNHours: "{n}h trước" recentNDays: "{n} ngày trước" @@ -894,15 +915,233 @@ navbar: "Thanh điều hướng" shuffle: "Xáo trộn" account: "Tài khoản của bạn" move: "Di chuyển" +pushNotification: "Thông báo đẩy" +subscribePushNotification: "Bật thông báo đẩy" +unsubscribePushNotification: "Tắt thông báo đẩy" +pushNotificationAlreadySubscribed: "Đang bật thông báo đẩy" +sendPushNotificationReadMessage: "Xóa thông báo đẩy sau khi đọc thông báo hay tin nhắn" +sendPushNotificationReadMessageCaption: "Thông báo như {emptyPushNotificationMessage} sẽ hiển thị trong giây phút. Tiêu tốn pin của máy bạn có thể tăng lên hơn nữa." +windowMaximize: "Phóng to" +windowRestore: "Khôi phục" +caption: "Mô tả" +loggedInAsBot: "Đang đăng nhập bằng tài khoản Bot" +tools: "Công Cụ" +cannotLoad: "Không tải được" +numberOfProfileView: "Số lần mở hồ sơ" like: "Thích" +unlike: "Bỏ lượt thích" +numberOfLikes: "Lượt thích" show: "Hiển thị" +neverShow: "Không hiển thị nữa" +remindMeLater: "Để sau" +didYouLikeMisskey: "Bạn có ưa thích CherryPick không?" +pleaseDonate: "CherryPick là phần mềm miễn phí mà {host} đang sử dụng. Xin mong bạn quyên góp cho chúng tôi để chúng tôi có thể tiếp tục phát triển dịch vụ này. Xin cảm ơn!!" +roles: "Vai trò" +role: "Vai trò" +normalUser: "Người dùng bình thường" +undefined: "Chưa xác định" color: "Màu sắc" +manageCustomEmojis: "Quản lý CustomEmoji" +cannotPerformTemporary: "Tạm thời không sử dụng được" +cannotPerformTemporaryDescription: "Tạm thời không sử dụng được vì lần số điều kiện quá giới hạn. Thử lại sau mọt lát nữa." +achievements: "Thành tích" +gotInvalidResponseError: "Không nhận được trả lời chủ máy" +gotInvalidResponseErrorDescription: "Chủ máy có lẻ ngừng hoạt động hoặc bảo trí. Thử lại sau một lát nữa. " +thisPostMayBeAnnoying: "Bạn đăng bài này có thể làm phiền cho người ta." +thisPostMayBeAnnoyingHome: "Đăng trên trang chính" +thisPostMayBeAnnoyingCancel: "Từ chối" +thisPostMayBeAnnoyingIgnore: "Đăng bài để nguyên" +collapseRenotes: "Không hiển thị bài viết đã từng xem" +internalServerError: "Lỗi trong chủ máy" +internalServerErrorDescription: "Trong chủ máy lỗi bất ngờ xảy ra" +copyErrorInfo: "Sao chép thông tin lỗi" +joinThisServer: "Đăng ký trên chủ máy này" +exploreOtherServers: "Tìm chủ máy khác" +letsLookAtTimeline: "Thử xem Timeline" +horizontal: "Thanh bên" +youFollowing: "Đang theo dõi" +_achievements: + earnedAt: "Ngày thu nhận" + _types: + _notes1: + title: "just setting up my msky" + description: "Lần đầu tiên đăng bài" + flavor: "Chúc bạn trên Miskey vui vẻ nha!!" + _notes10: + title: "Một số bài viết" + description: "Đăng bài 10 lần" + _notes100: + title: "Rất nhiều bài biết" + description: "Đăng bài 100 lần" + _notes500: + title: "Như đầy bài viết" + description: "Đăng bài 500 lần" + _notes1000: + title: "Ngọn núi bài viết" + description: "Đăng bài 1000 lần" + _notes5000: + title: "Bài viết chảy như suối" + description: "Đăng bài 5000 lần" + _notes10000: + title: "Bài Viết siu nhìu" + description: "Đăng bài 10000 lần" + _notes20000: + title: "Need more note" + description: "Đã đăng bài 20,000 lần rồi" + _notes30000: + title: "ĐĂNG VỚI BÀI" + description: "Đã đăng bài 30,000 lần rồi" + _notes40000: + title: "Nhà xưởng dăng bài" + description: "Đã đăng bài 40,000 lần rồi" + _notes50000: + title: "Hàng tinh đăng bài" + description: "Đã đăng bài 50,000 lần rồi" + _notes100000: + flavor: "Liệu viết bài gì tầm này vậy? " + _login3: + title: "Sơ cấp I" + description: "Tổng số ngày đăng nhập đạt 3 ngày" + flavor: "Từ nay các bạn cứ xem như mình là một Misskist đó" + _login7: + title: "Sơ cấp II" + description: "Tổng số ngày đăng nhập đạt 7 ngày" + flavor: "Bạn dần quen chưa? " + _login15: + title: "Sơ cấp III" + description: "Tổng số ngày đăng nhập đạt 7 ngày" + _login30: + title: "Misskist cấp I" + description: "Tổng số ngày đăng nhập đạt 30 ngày" + _login60: + title: "Misskist cấp II" + description: "Tổng số ngày đăng nhập đạt 60 ngày" + _login100: + title: "Misskist cấp III" + description: "Tổng số ngày đăng nhập đạt 100 ngày" + flavor: "Người dùng này, chính vì đó là một Misskist" + _login200: + title: "Khách hàng thường xuyên cấp I" + description: "Tổng số ngày đăng nhập đạt 200 ngày" + _login300: + title: "Khách hàng thường xuyên cấp II" + description: "Tổng số ngày đăng nhập đạt 300 ngày" + _login400: + title: "Khách hàng thường xuyên cấp III" + description: "Tổng số ngày đăng nhập đạt 400 ngày" + _markedAsCat: + title: "Tôi là một con mèo" + description: "Bật chế độ mèo" + flavor: "Mà tên chưa có" + _following1: + title: "Theo dõi đầu tiên" + description: "Lần đầu tiên theo dõi " + _following10: + title: "Cứ theo dõi và theo dõi" + description: "Vừa theo dõi hơn 10 người" + _following50: + title: "Bạn bè nhiều quá" + description: "Vừa theo dõi hơn 50 người" + _following100: + title: "Trăm bạn bè" + description: "Vừa theo dõi vượt lên 100 người" + _following300: + title: "Quá nhiều bạn bè" + description: "Vừa theo dõi vượt lên 300 người" + _followers1: + title: "Ai đầu tiên theo dõi bạn" + description: "Lần đầu tiên được theo dõi" + _followers10: + title: "FOLLOW ME!!" + description: "Người theo dõi bạn vượt lên 10 người" + _followers500: + title: "Trạm phát sóng" + _followers1000: + title: "Người có tầm ảnh hưởng" + description: "Người theo dõi bạn vượt lên 1000 người" + _collectAchievements30: + title: "Người sưu tập thành tích" + description: "Vừa lấy thành tích hơn 30 cái" + _viewAchievements3min: + title: "Yêu Thành tích" + description: "Ngắm danh sách thành tích đến tận hơn 3 phút" + _iLoveMisskey: + title: "Tôi Yêu CherryPick" + description: "Đăng lời nói \"I ❤ #CherryPick\"" + flavor: "Xin chân thành cảm ơn bạn đã sử dụng CherryPick!! by Đội ngũ phát triển" + _foundTreasure: + title: "Tìm kiếm kho báu" + description: "Tìm thấy được những kho báu cất giấu" + _client30min: + title: "Giải lao xỉu" + _noteDeletedWithin1min: + title: "Xem như không có gì đâu nha" + _postedAtLateNight: + title: "Loài ăn đêm" + description: "Đăng bài trong đêm khuya " + _postedAt0min0sec: + title: "Tín hiệu báo giờ" + description: "Đăng bài vào 0 phút 0 giây" + flavor: "Piiiiiii ĐÂY LÀ TIẾNG NÓI VIỆT NAM" + _selfQuote: + title: "Nói đến bản thân" + description: "Trích dẫn bài viết của mình" + _htl20npm: + title: "Timeline trôi như con sông" + description: "Timeline trang chính tốc độ vượt lên 20npm" + _viewInstanceChart: + title: "Nhà phân tích" + description: "Xem biểu đồ của chủ máy" + _outputHelloWorldOnScratchpad: + title: "Chào thế giới!" + _open3windows: + title: "Nhiều cửa sổ" + description: "Mở cửa sổ hơn 3 cửa sổ" + _reactWithoutRead: + title: "Bài này bạn đọc kỹ chứ? " + description: "Phản hồi trong vọng 3 giây sau bài viết có hơn 100 ký tự mới được đăng lên" + _clickedClickHere: + title: "Bấm đây" + description: "Bấm chỗ này" + _justPlainLucky: + title: "Chỉ là một cuộc máy mắn" + description: "Mỗi 10 giây thu nhận được với tỷ lệ 0.005%" + _setNameToSyuilo: + title: "Ngưỡng mộ với vị thần" + description: "Đạt tên là syuilo" + _loggedInOnBirthday: + title: "Sinh nhật vủi vẻ" + description: "Đăng nhập vào ngày sinh" + _loggedInOnNewYearsDay: + title: "Chức mừng năm mới" + description: "Đăng nhập vào Tết Nguyên đàn dương lịch" + flavor: "Chúc bạn năm mới AN KHANG THỊNH VƯỢNG, VẠN SỰ NHƯ Ý!!" + _cookieClicked: + flavor: "Bạn nhầm phầm mềm chứ?" _role: priority: "Ưu tiên" _priority: low: "Thấp" middle: "Vừa" high: "Cao" + _options: + gtlAvailable: "Xem Timeline xã hội" + ltlAvailable: "Xem Timeline trong máy chủ này" + canPublicNote: "Cho phép đăng bài công khai" + canManageCustomEmojis: "Quản lý CustomEmoji" + driveCapacity: "Dữ liệu Drive" + pinMax: "Giới hạn ghim bài viết" + antennaMax: "Giới hạn tạo ăng ten" + canHideAds: "Tắt quảng cáo" + _condition: + createdMoreThan: "Trôi qua ~ sau khi lập tài khoản" + followersLessThanOrEq: "Người theo dõi ít hơn ~" + followersMoreThanOrEq: "Người theo dõi có ~ trở lên" + followingLessThanOrEq: "Theo dõi ít hơn ~" + followingMoreThanOrEq: "Theo dõi có ~ trở lên" + and: "~ mà ~" + or: "~ hay là ~" + not: "Không phải ~" _sensitiveMediaDetection: description: "Giảm nỗ lực kiểm duyệt máy chủ thông qua việc tự động nhận dạng media NSFW thông qua học máy. Điều này sẽ làm tăng một chút áp lực trên máy chủ." sensitivity: "Phát hiện nhạy cảm" @@ -1186,44 +1425,27 @@ _ago: weeksAgo: "{n} tuần trước" monthsAgo: "{n} tháng trước" yearsAgo: "{n} năm trước" + invalid: "Không có gì ở đây" _time: second: "s" minute: "phút" hour: "giờ" day: "ngày" -_tutorial: - title: "Cách dùng CherryPick" - step1_1: "Xin chào!" - step1_2: "Trang này gọi là \"bảng tin\". Nó hiện \"tút\" từ những người mà bạn \"theo dõi\" theo thứ tự thời gian." - step1_3: "Bảng tin của bạn đang trống, bởi vì bạn chưa đăng tút nào hoặc chưa theo dõi ai." - step2_1: "Hãy hoàn thành việc thiết lập hồ sơ của bạn trước khi viết tút hoặc theo dõi bất kỳ ai." - step2_2: "Cung cấp một số thông tin giới thiệu bạn là ai sẽ giúp người khác dễ dàng biết được họ muốn đọc tút hay theo dõi bạn." - step3_1: "Hoàn thành thiết lập hồ sơ của bạn?" - step3_2: "Sau đó, hãy thử đăng một tút tiếp theo. Bạn có thể làm như vậy bằng cách nhấn vào nút có biểu tượng bút chì trên màn hình." - step3_3: "Nhập nội dung vào khung soạn thảo và nhấn nút đăng ở góc trên." - step3_4: "Chưa biết nói gì? Thử \"Tôi mới tham gia CherryPick\"!" - step4_1: "Đăng xong tút đầu tiên của bạn?" - step4_2: "De! Tút đầu tiên của bạn đã hiện trên bảng tin." - step5_1: "Bây giờ, hãy thử làm cho bảng tin của bạn sinh động hơn bằng cách theo dõi những người khác." - step5_2: "{feature} sẽ hiển thị cho bạn các tút nổi bật trên máy chủ này. {explore} sẽ cho phép bạn tìm thấy những người dùng thú vị. Hãy thử tìm những người bạn muốn theo dõi ở đó!" - step5_3: "Để theo dõi những người dùng khác, hãy nhấn vào ảnh đại diện của họ và nhấn nút \"Theo dõi\" trên hồ sơ của họ." - step5_4: "Nếu người dùng khác có biểu tượng ổ khóa bên cạnh tên của họ, có thể mất một khoảng thời gian để người dùng đó phê duyệt yêu cầu theo dõi của bạn theo cách thủ công." - step6_1: "Bạn sẽ có thể xem tút của những người dùng khác trên bảng tin của mình ngay bây giờ." - step6_2: "Bạn cũng có thể đặt \"biểu cảm\" trên tút của người khác để phản hồi nhanh chúng." - step6_3: "Để đính kèm \"biểu cảm\", hãy nhấn vào dấu \"+\" trên tút của người dùng khác rồi chọn biểu tượng cảm xúc mà bạn muốn dùng." - step7_1: "Xin chúc mừng! Bây giờ bạn đã hoàn thành phần hướng dẫn cơ bản của CherryPick." - step7_2: "Nếu bạn muốn tìm hiểu thêm về CherryPick, hãy thử phần {help}." - step7_3: "Bây giờ, chúc may mắn và vui vẻ với CherryPick! 🚀" _2fa: alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước." - registerDevice: "Đăng ký một thiết bị" - registerKey: "Đăng ký một mã bảo vệ" + passwordToTOTP: "Nhắn mật mã" step1: "Trước tiên, hãy cài đặt một ứng dụng xác minh (chẳng hạn như {a} hoặc {b}) trên thiết bị của bạn." step2: "Sau đó, quét mã QR hiển thị trên màn hình này." step2Url: "Bạn cũng có thể nhập URL này nếu sử dụng một chương trình máy tính:" step3: "Nhập mã token do ứng dụng của bạn cung cấp để hoàn tất thiết lập." step4: "Kể từ bây giờ, những lần đăng nhập trong tương lai sẽ yêu cầu mã token đăng nhập đó." securityKeyInfo: "Bên cạnh xác minh bằng vân tay hoặc mã PIN, bạn cũng có thể thiết lập xác minh thông qua khóa bảo mật phần cứng hỗ trợ FIDO2 để bảo mật hơn nữa cho tài khoản của mình." + removeKey: "Xóa mã bảo mật" + removeKeyConfirm: "Xóa bản sao lưu {name}?" + renewTOTP: "Cài đặt lại ứng dụng xác thực" + renewTOTPConfirm: "Mã xác nhận cũ của ứng dụng xác thực không thể sử dụng được nữa" + renewTOTPOk: "Cài đặt lại" + renewTOTPCancel: "Không, cảm ơn" _permissions: "read:account": "Xem thông tin tài khoản của bạn" "write:account": "Sửa thông tin tài khoản của bạn" @@ -1258,12 +1480,15 @@ _permissions: "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" _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?" shareAccessAsk: "Bạn có chắc muốn cho phép ứng dụng này truy cập vào tài khoản của mình không?" + permission: "{name} đang yêu cầu quyền hạn dưới đây" permissionAsk: "Ứng dụng này yêu cầu các quyền sau" pleaseGoBack: "Vui lòng quay lại ứng dụng" callback: "Quay lại ứng dụng" denied: "Truy cập bị từ chối" + pleaseLogin: "Bạn phải đăng nhập để cho ứng dụng phép truy cập" _antennaSources: all: "Toàn bộ tút" homeTimeline: "Tút từ những người đã theo dõi" @@ -1302,9 +1527,12 @@ _widgets: jobQueue: "Công việc chờ xử lý" serverMetric: "Thống kê máy chủ" aiscript: "AiScript console" + aiscriptApp: "AiScript App" aichan: "Ai" + userList: "Danh sách người dùng" _userList: chooseList: "Chọn danh sách" + clicker: "clicker" _cw: hide: "Ẩn" show: "Tải thêm" @@ -1341,8 +1569,8 @@ _visibility: followersDescription: "Dành riêng cho người theo dõi" specified: "Nhắn riêng" specifiedDescription: "Chỉ người được nhắc đến mới thấy" - localOnly: "Chỉ trên máy chủ" - localOnlyDescription: "Không hiển thị với người ở máy chủ khác" + disableFederation: "Không liên hợp" + disableFederationDescription: "Không đưa tin cho chủ máy khác" _postForm: replyPlaceholder: "Trả lời tút này" quotePlaceholder: "Trích dẫn tút này" @@ -1352,7 +1580,7 @@ _postForm: b: "Hôm nay bạn có gì vui?" c: "Bạn đang nghĩ gì?" d: "Bạn muốn nói gì?" - e: "Bắt đầu viết..." + e: "Cứ viết trên đây" f: "Đang chờ bạn viết..." _profile: name: "Tên" @@ -1368,6 +1596,7 @@ _profile: changeBanner: "Đổi ảnh bìa" _exportOrImport: allNotes: "Toàn bộ tút" + favoritedNotes: "Bài viết đã thích" followingList: "Đang theo dõi" muteList: "Ẩn" blockingList: "Chặn" @@ -1406,7 +1635,16 @@ _timelines: social: "Xã hội" global: "Liên hợp" _play: + new: "Tạo Play mới" + edit: "Edit play" + created: "Bạn vừa tạo play rồi" + updated: "Bạn vừa cập nhật play rồi" + deleted: "Bạn vừa xóa play rồi" + pageSetting: "Cài đặt play" + editThisPage: "Edit play này" viewSource: "Xem mã nguồn" + my: "Play của mình" + liked: "Play đã thích" featured: "Nổi tiếng" title: "Tựa đề" script: "Kịch bản" @@ -1477,7 +1715,9 @@ _notification: yourFollowRequestAccepted: "Yêu cầu theo dõi của bạn đã được chấp nhận" youWereInvitedToGroup: "Bạn đã được mời tham gia nhóm" pollEnded: "Cuộc bình chọn đã kết thúc" + unreadAntennaNote: "Ăng ten" emptyPushNotificationMessage: "Đã cập nhật thông báo đẩy" + achievementEarned: "Hoàn thành Achievement" _types: all: "Toàn bộ" follow: "Đang theo dõi" @@ -1490,6 +1730,7 @@ _notification: receiveFollowRequest: "Yêu cầu theo dõi" followRequestAccepted: "Yêu cầu theo dõi được chấp nhận" groupInvited: "Mời vào nhóm" + achievementEarned: "Hoàn thành Achievement" app: "Từ app liên kết" _actions: followBack: "đã theo dõi lại bạn" @@ -1522,3 +1763,9 @@ _deck: channel: "Kênh" mentions: "Lượt nhắc" direct: "Nhắn riêng" +_dialog: + charactersExceeded: "Bạn nhắn quá giới hạn ký tự!! Hiện nay {current} / giới hạn {max}" + charactersBelow: "Bạn nhắn quá ít tối thiểu ký tự!! Hiện nay {current} / Tối thiểu {min}" +_webhookSettings: + name: "Tên" + active: "Đã bật" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index e7b3b94c9d..ea5004254f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -2,7 +2,7 @@ _lang_: "中文(简体)" headlineMisskey: "通过帖子连接在一起的网络" introMisskey: "欢迎!CherryPick是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n来探索新的世界吧!🚀" -poweredByMisskeyDescription: "{name} 由开源平台 CherryPick 驱动(也被称为 CherryPick 实例)" +poweredByMisskeyDescription: "{name} 是开源平台 CherryPick 的服务器之一。" monthAndDay: "{month}月 {day}日" search: "搜索" notifications: "通知" @@ -16,16 +16,17 @@ cancel: "取消" noThankYou: "不用,谢谢" enterUsername: "输入用户名" renotedBy: "由 {user} 转贴" -noNotes: "没有帖子" +noNotes: "没有帖文" noNotifications: "无通知" -instance: "实例" +instance: "服务器" settings: "设置" +notificationSettings: "通知设置" basicSettings: "基本设置" otherSettings: "其他设置" openInWindow: "在新窗口中打开" profile: "个人资料" timeline: "时间线" -noAccountDescription: "这个人很懒,没有写自我介绍" +noAccountDescription: "此用户尚无自我介绍" login: "登录" loggingIn: "正在登录..." logout: "登出" @@ -60,7 +61,7 @@ youGotNewFollower: "你有新的关注者" receiveFollowRequest: "您收到了关注请求" followRequestAccepted: "您的关注请求被通过了" mention: "提及" -mentions: "提及" +mentions: "提到我的" directNotes: "私信" importAndExport: "导入和导出" import: "导入" @@ -85,7 +86,7 @@ somethingHappened: "出现了一些问题!" retry: "重试" pageLoadError: "页面加载失败。" pageLoadErrorDescription: "这通常是由于网络或浏览器缓存的原因。请清除缓存或等待片刻后重试。" -serverIsDead: "服务器没有响应。 请稍等片刻,然后重试。" +serverIsDead: "没有服务器响应。 请稍后再试。" youShouldUpgradeClient: "请重新加载并使用新版本的客户端查看此页面。" enterListName: "输入列表名称" privacy: "隐私" @@ -95,7 +96,7 @@ follow: "关注" followRequest: "关注申请" followRequests: "关注申请" unfollow: "取消关注" -followRequestPending: "发送关注请求" +followRequestPending: "关注请求批准中" enterEmoji: "输入表情符号" renote: "转发" unrenote: "取消转发" @@ -103,6 +104,8 @@ renoted: "已转发。" cantRenote: "该帖无法转发。" cantReRenote: "转发无法被再次转发。" quote: "引用" +inChannelRenote: "在频道内转发" +inChannelQuote: "在频道内引用" pinnedNote: "已置顶的帖子" pinned: "置顶" you: "您" @@ -111,15 +114,17 @@ sensitive: "敏感内容" add: "添加" reaction: "回应" reactions: "回应" -reactionSetting: "在选择器中显示的回应" +reactionSetting: "在选择器中显示回应" reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。" rememberNoteVisibility: "保存上次设置的可见性" attachCancel: "删除附件" markAsSensitive: "标记为敏感内容" unmarkAsSensitive: "取消标记为敏感内容" -enterFileName: "请输入文件名" +enterFileName: "输入文件名" mute: "屏蔽" unmute: "解除屏蔽" +renoteMute: "屏蔽转帖" +renoteUnmute: "解除屏蔽转帖" block: "拉黑" unblock: "取消拉黑" suspend: "冻结" @@ -138,21 +143,22 @@ customEmojis: "自定义表情符号" emoji: "表情符号" emojis: "表情符号" emojiName: "表情符号名称" -emojiUrl: "表情符号地址" +emojiUrl: "emoji 地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" -cacheRemoteFiles: "远程文件缓存" -cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程实例载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" +cacheRemoteFiles: "缓存远程文件" +cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" flagAsBot: "这是一个机器人账号" -flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让CherryPick的内部系统将此帐户识别为机器人。" +flagAsBotDescription: "如果此账户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让CherryPick的内部系统将此账户识别为机器人。" flagAsCat: "将这个账户设定为一只猫" flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。\n开启后,会在您的头像上出现猫耳朵,并将你的帖子中的「na」替换为「nya」,日文同理。" flagShowTimelineReplies: "在时间线上显示帖子的回复" flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的帖子外,还会显示其他用户对帖子的回复。" -autoAcceptFollowed: "自动允许关注者的关注" +autoAcceptFollowed: "自动允许来自我关注的用户对我的关注请求" addAccount: "添加账户" +reloadAccountsList: "更新账户列表" loginFailed: "登录失败" -showOnRemote: "转到所在实例显示" +showOnRemote: "转到所在服务器显示" general: "常规设置" wallpaper: "壁纸" setWallpaper: "设置壁纸" @@ -161,13 +167,13 @@ searchWith: "搜索:{q}" youHaveNoLists: "列表为空" followConfirm: "你确定要关注{name}吗?" proxyAccount: "代理账户" -proxyAccountDescription: "代理账户是在某些情况下充当用户的远程关注者的账户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该实例,因此将代之以代理账户。" +proxyAccountDescription: "代理账户是在某些情况下充当用户的远程关注者的账户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该服务器,因此将代之以代理账户。" host: "主机名" selectUser: "选择用户" recipient: "收件人" annotation: "注解" federation: "联合" -instances: "实例" +instances: "服务器" registeredAt: "初次观测" latestRequestReceivedAt: "上次收到的请求" latestStatus: "最后状态" @@ -176,7 +182,7 @@ charts: "图表" perHour: "每小时" perDay: "每天" stopActivityDelivery: "停止发送活动" -blockThisInstance: "阻止此实例向本实例推流" +blockThisInstance: "阻止此服务器向本服务器推流" operations: "操作" software: "软件" version: "版本" @@ -187,18 +193,18 @@ jobQueue: "作业队列" cpuAndMemory: "CPU和内存" network: "网络" disk: "存储" -instanceInfo: "实例信息" +instanceInfo: "服务器信息" statistics: "统计" clearQueue: "清除队列" clearQueueConfirmTitle: "确定清除队列?" clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需要这样做。" clearCachedFiles: "清除缓存" clearCachedFilesConfirm: "确定要清除缓存文件?" -blockedInstances: "被阻拦的实例" -blockedInstancesDescription: "设定要阻拦的实例,以换行来进行分割。被阻拦的实例将无法与本实例进行交换通讯。" +blockedInstances: "被阻拦的服务器" +blockedInstancesDescription: "设定要阻拦的服务器,以换行来进行分割。被阻拦的服务器将无法与本服务器进行交换通讯。" muteAndBlock: "屏蔽/拉黑" mutedUsers: "已屏蔽用户" -blockedUsers: "被拉黑的用户" +blockedUsers: "已拉黑的用户" noUsers: "无用户" editProfile: "编辑资料" noteDeleteConfirm: "要删除该帖子吗?" @@ -218,9 +224,9 @@ all: "全部" subscribing: "已订阅" publishing: "投递中" notResponding: "没有响应" -instanceFollowing: "关注实例" -instanceFollowers: "关注实例" -instanceUsers: "实例用户" +instanceFollowing: "关注服务器" +instanceFollowers: "关注的服务器" +instanceUsers: "服务器用户" changePassword: "修改密码" security: "安全" retypedNotMatch: "两次输入不一致!" @@ -257,14 +263,16 @@ noMoreHistory: "没有更多的历史记录" startMessaging: "添加聊天" nUsersRead: "{n}人已读" agreeTo: "勾选则表示已阅读并同意{0}" -agreeBelow: "同意以下观点" +agree: "同意" +agreeBelow: "同意以下内容" basicNotesBeforeCreateAccount: "基本注意事项" -tos: "服务条款" +termsOfService: "服务条款" start: "开始" home: "首页" -remoteUserCaution: "由于此用户来自其它实例,显示的信息可能不完整。" +remoteUserCaution: "由于此用户来自其它服务器,显示的信息可能不完整。" activity: "活动" images: "图片" +image: "图片" birthday: "生日" yearsOld: "{age}岁" registeredDate: "注册于" @@ -312,8 +320,8 @@ unwatch: "取消关注" accept: "允许" reject: "拒绝" normal: "正常" -instanceName: "实例名称" -instanceDescription: "实例介绍" +instanceName: "服务器名称" +instanceDescription: "服务器简介" maintainerName: "管理员名称" maintainerEmail: "管理员电子邮箱" tosUrl: "服务条款URL" @@ -331,7 +339,7 @@ enableLocalTimeline: "启用本地时间线功能" enableGlobalTimeline: "启用全局时间线" disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和数据图表也可以继续使用。" registration: "注册" -enableRegistration: "允许新用户注册" +enableRegistration: "允许任何人注册" invite: "邀请" driveCapacityPerLocalAccount: "每个用户的网盘空间" driveCapacityPerRemoteAccount: "每个远程用户的网盘容量" @@ -343,17 +351,17 @@ basicInfo: "基本信息" pinnedUsers: "置顶用户" pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。" pinnedPages: "固定页面" -pinnedPagesDescription: "输入您要固定到实例首页的页面路径,以换行符分隔。" +pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,以换行符分隔。" pinnedClipId: "置顶的便签ID" pinnedNotes: "已置顶的帖子" hcaptcha: "hCaptcha" enableHcaptcha: "启用 hCaptcha" hcaptchaSiteKey: "网站密钥" -hcaptchaSecretKey: "hCaptcha 密钥(SecretKey)" +hcaptchaSecretKey: "密钥" recaptcha: "reCAPTCHA" enableRecaptcha: "启用 reCAPTCHA\n(请注意, 此功能在中国大陆不可用. 如果启用, 可能导致无法正常使用登录或注册等功能)" recaptchaSiteKey: "网站密钥" -recaptchaSecretKey: "reCAPTCHA 密钥" +recaptchaSecretKey: "reCAPTCHA 密钥(SecretKey)" turnstile: "Turnstile" enableTurnstile: "启用Turnstile" turnstileSiteKey: "网站密钥" @@ -391,16 +399,19 @@ about: "关于" aboutMisskey: "关于 CherryPick" administrator: "管理员" token: "Token (令牌)" -twoStepAuthentication: "两步验证" +2fa: "双因素认证" +totp: "身份验证应用" +totpDescription: "使用认证应用输入一次性密码。" moderator: "监察员" moderation: "管理" nUsersMentioned: "{n} 被提到" +securityKeyAndPasskey: "安全密钥或 Passkey" securityKey: "安全密钥" -securityKeyName: "密钥名称" -registerSecurityKey: "注册硬件安全密钥" lastUsed: "最后使用:" +lastUsedAt: "最后使用: {t}" unregister: "删除账户" passwordLessLogin: "无密码登录" +passwordLessLoginDescription: "不使用密码,仅使用安全密钥或Passkey登录" resetPassword: "重置密码" newPasswordIs: "新的密码是「{password}」" reduceUiAnimation: "减少UI动画" @@ -454,7 +465,6 @@ passwordMatched: "密码一致" passwordNotMatched: "密码不一致" signinWith: "以{x}登录" signinFailed: "无法登录,请检查您的用户名和密码是否正确。" -tapSecurityKey: "轻触硬件安全密钥" or: "或者" language: "语言" uiLanguage: "显示语言" @@ -465,6 +475,7 @@ native: "原生" disableDrawer: "不显示抽屉菜单" youHaveNoGroups: "没有群组" joinOrCreateGroup: "请加入一个现有的群组,或者创建新群组。" +showNoteActionsOnlyHover: "仅在悬停时显示帖子操作" noHistory: "没有历史记录" signinHistory: "登录历史" enableAdvancedMfm: "启用扩展MFM" @@ -477,6 +488,8 @@ createAccount: "注册账户" existingAccount: "现有的账户" regenerate: "重新生成" fontSize: "字体大小" +mediaListWithOneImageAppearance: "仅一张图片的媒体列表高度" +limitTo: "上限为{x}" noFollowRequests: "没有关注申请" openImageInNewTab: "在新标签页中打开图片" dashboard: "管理面板" @@ -496,7 +509,7 @@ showFeaturedNotesInTimeline: "在时间线上显示热门推荐" objectStorage: "对象存储" useObjectStorage: "使用对象存储" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "用于引用的URL。如果您正在使用CDN或反向代理,请指定其URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" +objectStorageBaseUrlDesc: "这里是用于参考的URL,如果您正在使用CDN或反向代理,请指定其URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" objectStorageBucket: "存储桶" objectStorageBucketDesc: "请指定使用的对象存储服务的存储桶名称。" objectStoragePrefix: "前缀" @@ -510,9 +523,11 @@ objectStorageUseSSLDesc: "如果不使用https进行API连接,请关闭。" objectStorageUseProxy: "使用代理" objectStorageUseProxyDesc: "如果您不使用代理进行API连接,请将其关闭。" objectStorageSetPublicRead: "上传时设置为public-read" +s3ForcePathStyleDesc: "启用 s3ForcePathStyle 会强制将存储桶名称指定为 URL 中路径的一部分,而不是主机名。使用自托管 Minio 等时可能需要启用。" serverLogs: "服务器日志" deleteAll: "全部删除" showFixedPostForm: "在时间线顶部显示发帖框" +showFixedPostFormInChannel: "在时间线顶部显示发帖对话框(频道)" newNoteRecived: "有新的帖子" sounds: "提示音" sound: "提示音" @@ -545,11 +560,15 @@ updateRemoteUser: "更新远程用户信息" deleteAllFiles: "删除所有文件" deleteAllFilesConfirm: "要删除所有文件吗?" removeAllFollowing: "取消所有关注" -removeAllFollowingDescription: "取消{host}的所有关注者。当实例不存在时执行。" +removeAllFollowingDescription: "取消{host}的所有关注者。当服务器不再存在时执行。" userSuspended: "该用户已被冻结。" userSilenced: "该用户已被禁言。" yourAccountSuspendedTitle: "账户已被冻结" yourAccountSuspendedDescription: "由于违反了服务器的服务条款或其他原因,该账户已被冻结。 您可以与管理员联系以了解更多信息。 请不要创建一个新的账户。" +tokenRevoked: "令牌无效" +tokenRevokedDescription: "登录令牌已经失效。请重新登录。" +accountDeleted: "帐户已删除" +accountDeletedDescription: "此帐户已经被删除。" menu: "菜单" divider: "分割线" addItem: "添加项目" @@ -593,7 +612,6 @@ tokenRequested: "允许访问账户" pluginTokenRequestedDescription: "此插件将能够拥有此处设置的权限" notificationType: "通知类型" edit: "编辑" -useStarForReactionFallback: "如果回应的是未知表情符号,则使用★作为代替" emailServer: "邮件服务器" enableEmail: "启用发送邮件功能" emailConfigInfo: "用于确认电子邮件和密码重置" @@ -611,7 +629,7 @@ testEmail: "邮件发送测试" wordMute: "文字屏蔽" regexpError: "正则表达式错误" regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:" -instanceMute: "实例的屏蔽" +instanceMute: "被屏蔽的服务器" userSaysSomething: "{name}说了什么,但是被屏蔽词过滤了" makeActive: "启用" display: "显示" @@ -642,15 +660,15 @@ abuseReported: "内容已发送。感谢您提交信息。" reporter: "举报者" reporteeOrigin: "举报来源" reporterOrigin: "举报者来源" -forwardReport: "将该举报信息转发给远程实例" -forwardReportIsAnonymous: "勾选则在远程实例上显示的举报者是匿名的系统账号,而不是您的账号。" +forwardReport: "将该举报信息转发给远程服务器" +forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。" send: "发送" abuseMarkAsResolved: "处理完毕" openInNewTab: "在新标签页中打开" openInSideView: "在侧边栏中打开" defaultNavigationBehaviour: "默认导航" editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号" -instanceTicker: "帖子的实例信息" +instanceTicker: "帖子的服务器来源" waitingFor: "等待{x}" random: "随机" system: "系统" @@ -683,7 +701,7 @@ driveFilesCount: "网盘的文件数" driveUsage: "网盘的空间用量" noCrawle: "要求搜索引擎不索引该用户" noCrawleDescription: "要求搜索引擎不要收录(索引)您的用户页面,帖子,页面等。" -lockedAccountInfo: "即使通过了关注请求,只要您不将帖子可见范围设置成“关注者”,任何人都可以看到您的帖子。" +lockedAccountInfo: "即使启用该功能,只要您不将帖子可见范围设置为“仅关注者”,任何人都还是可以看到您的帖子。" alwaysMarkSensitive: "默认将媒体文件标记为敏感内容" loadRawImages: "添加附件图像的缩略图时使用原始图像质量" disableShowingAnimatedImages: "不播放动画" @@ -697,6 +715,8 @@ contact: "联系人" useSystemFont: "使用系统默认字体" clips: "便签" experimentalFeatures: "实验性功能" +experimental: "实验性的" +thisIsExperimentalFeature: "这是一项实验性功能。规范可能会变更,或者可能无法正常工作。" developer: "开发者" makeExplorable: "使账号可见。" makeExplorableDescription: "关闭时,账号不会显示在\"发现\"中。" @@ -739,7 +759,7 @@ capacity: "容量" inUse: "已使用" editCode: "编辑代码" apply: "应用" -receiveAnnouncementFromInstance: "从实例接收通知" +receiveAnnouncementFromInstance: "从服务器接收通知" emailNotification: "邮件通知" publish: "发布" inChannelSearch: "频道内搜索" @@ -767,7 +787,7 @@ active: "活动" offline: "离线" notRecommended: "不推荐" botProtection: "Bot防御" -instanceBlocking: "被阻拦的实例" +instanceBlocking: "被阻拦的服务器" selectAccount: "选择账户" switchAccount: "切换账户" enabled: "已启用" @@ -787,6 +807,7 @@ popularPosts: "热门投稿" shareWithNote: "在帖子中分享" ads: "广告" expiration: "截止时间" +startingperiod: "开始时间" memo: "便笺" priority: "优先级" high: "高" @@ -813,12 +834,13 @@ translatedFrom: "从 {x} 翻译" accountDeletionInProgress: "正在删除账户" usernameInfo: "在服务器上唯一标识您的帐户的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。" aiChanMode: "小蓝模式" -keepCw: "保留CW" +keepCw: "回复时维持隐藏内容" pubSub: "Pub/Sub账户" lastCommunication: "最近通信" resolved: "已解决" unresolved: "未解决" breakFollow: "移除关注者" +breakFollowConfirm: "你想取消关注吗?" itsOn: "已开启" itsOff: "已关闭" emailRequiredForSignup: "注册账户需要电子邮件地址" @@ -851,21 +873,23 @@ themeColor: "主题颜色" size: "大小" numberOfColumn: "列数" searchByGoogle: "Google" -instanceDefaultLightTheme: "实例默认浅色主题" -instanceDefaultDarkTheme: "实例默认深色主题" +instanceDefaultLightTheme: "服务器默认浅色主题" +instanceDefaultDarkTheme: "服务器默认深色主题" instanceDefaultThemeDescription: "以对象格式键入主题代码" mutePeriod: "屏蔽期限" +period: "截止时间" indefinitely: "永久" tenMinutes: "10分钟" oneHour: "1小时" oneDay: "1天" oneWeek: "1周" +oneMonth: "1 个月" reflectMayTakeTime: "可能需要一些时间才能体现出效果。" failedToFetchAccountInformation: "获取账户信息失败" rateLimitExceeded: "已超過速率限制" cropImage: "剪裁图像" cropImageAsk: "是否要裁剪图像?" -cropYes: "已裁剪" +cropYes: "去裁剪" cropNo: "就这样吧!" file: "文件" recentNHours: "最近{n}小时" @@ -882,7 +906,7 @@ typeToConfirm: "输入 {x} 以确认操作。" deleteAccount: "删除账户" document: "文档" numberOfPageCache: "缓存页数" -numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。" +numberOfPageCacheDescription: "设置较高的值会更方便用户,但服务器负载和内存使用量会增加。" logoutConfirm: "是否确认登出?" lastActiveDate: "最后活跃时间" statusbar: "状态栏" @@ -901,9 +925,10 @@ remoteOnly: "仅远程" failedToUpload: "上传失败" cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法上传。" cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。" +cannotUploadBecauseExceedsFileSizeLimit: "无法上传文件,超过文件大小限制。" beta: "测试" enableAutoSensitive: "自动 NSFW 识别" -enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据实例自动设置。" +enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。" activeEmailValidationDescription: "开启用户的电子邮件地址验证,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。" navbar: "导航栏" shuffle: "随机" @@ -913,10 +938,11 @@ pushNotification: "推送通知" subscribePushNotification: "启用推送通知消息" unsubscribePushNotification: "停用推送通知消息" pushNotificationAlreadySubscribed: "推送通知消息已启用" -pushNotificationNotSupported: "浏览器或实例不支持推送通知消息" +pushNotificationNotSupported: "浏览器或服务器不支持推送通知消息" sendPushNotificationReadMessage: "删除已读推送通知消息" sendPushNotificationReadMessageCaption: "“{emptyPushNotificationMessage}”的通知消息将会显示。您终端设备的电池消耗可能会增加。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "还原" caption: "标题" loggedInAsBot: "以Bot账户登录" @@ -933,6 +959,7 @@ didYouLikeMisskey: "您喜欢CherryPick吗?" pleaseDonate: "CherryPick是{host}所使用的免费软件。为了今后也能够维持CherryPick的开发,请在有余力的情况下进行捐助!" roles: "角色" role: "角色" +noRole: "角色不存在" normalUser: "普通用户" undefined: "未定义" assign: "分配" @@ -942,6 +969,10 @@ manageCustomEmojis: "管理自定义表情符号" youCannotCreateAnymore: "抱歉,您无法再创建更多了。" cannotPerformTemporary: "暂时不可用" cannotPerformTemporaryDescription: "因操作过于频繁,暂时不可用,请稍后再试。" +invalidParamError: "参数错误" +invalidParamErrorDescription: "请求参数出现问题。通常是因为bug造成的,但也可能是输入文字数量过多之类的原因。" +permissionDeniedError: "操作被拒绝" +permissionDeniedErrorDescription: "本账户没有执行该操作的权限。" preset: "預設值" selectFromPresets: "從預設值中選擇" achievements: "成就" @@ -951,6 +982,89 @@ thisPostMayBeAnnoying: "这个帖子可能会让其他人感到困扰。" thisPostMayBeAnnoyingHome: "发到首页" thisPostMayBeAnnoyingCancel: "取消" thisPostMayBeAnnoyingIgnore: "就这样发布" +collapseRenotes: "省略显示已经看过的转发内容" +internalServerError: "内部服务器错误" +internalServerErrorDescription: "内部服务器发生了预期外的错误" +copyErrorInfo: "复制错误信息" +joinThisServer: "在本服务器上注册" +exploreOtherServers: "探索其他服务器" +letsLookAtTimeline: "时间线" +disableFederationConfirm: "确定要禁用联合?" +disableFederationConfirmWarn: "禁用联合不会将帖子设为私有。在大多数情况下,不需要禁用联合。" +disableFederationOk: "联合禁用" +invitationRequiredToRegister: "此服务器目前只允许拥有邀请码的人注册。" +emailNotSupported: "此服务器不支持发送邮件" +postToTheChannel: "发布到频道" +cannotBeChangedLater: "之后不能再更改。" +reactionAcceptance: "接受表情回应" +likeOnly: "仅点赞" +likeOnlyForRemote: "远程仅点赞" +rolesAssignedToMe: "指派给自己的角色" +resetPasswordConfirm: "确定重置密码?" +sensitiveWords: "敏感词" +sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。" +notesSearchNotAvailable: "帖子检索不可用" +license: "许可信息" +unfavoriteConfirm: "确定要取消收藏吗?" +myClips: "我的便签" +drivecleaner: "网盘整理" +retryAllQueuesNow: "立刻重试所有队列" +retryAllQueuesConfirmTitle: "要再尝试一次吗?" +retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" +enableChartsForRemoteUser: "生成远程用户的图表" +enableChartsForFederatedInstances: "生成远程服务器的图表" +showClipButtonInNoteFooter: "在贴文下方显示便签按钮" +largeNoteReactions: "使用大图标来显示回应" +noteIdOrUrl: "帖子ID或URL" +video: "视频" +videos: "视频" +dataSaver: "省流量模式" +accountMigration: "账户迁移" +accountMoved: "此用户已迁移账户" +accountMovedShort: "此帐户已迁移" +operationForbidden: "不允许此操作" +forceShowAds: "总是显示广告" +addMemo: "添加备注" +editMemo: "编辑备注" +reactionsList: "回应列表" +renotesList: "转发列表" +notificationDisplay: "显示通知" +leftTop: "屏幕左上方" +rightTop: "屏幕右上方" +leftBottom: "屏幕左下方" +rightBottom: "屏幕右下方" +stackAxis: "堆叠方向" +vertical: "纵向" +horizontal: "横向" +position: "位置" +serverRules: "服务器规则" +pleaseConfirmBelowBeforeSignup: "在这个服务器上注册账号前,请确认以下信息。" +pleaseAgreeAllToContinue: "必须全部勾选「同意」才能够继续。" +continue: "继续" +preservedUsernames: "保留的用户名" +createNoteFromTheFile: "从文件创建帖子" +youFollowing: "正在关注" +options: "选项" +_initialAccountSetting: + accountCreated: "账户创建完成了!" + letsStartAccountSetup: "来进行帐户的初始设置吧。" + letsFillYourProfile: "首先,来设定你的个人档案吧!" + profileSetting: "个人资料设置" + theseSettingsCanEditLater: "也可以在稍后修改这里的设置。" +_serverRules: + description: "在新用户注册前显示服务器的简单规则。推荐显示服务条款的主要内容。" +_accountMigration: + moveFrom: "从别的账号迁移到此账户" + moveFromLabel: "迁移前的账户" + moveFromDescription: "如果迁移时需要继承其他账户的关注者,请在此创造别名。此操作需要在实行迁移之前完成!请如已下输入需要迁移的账户:@person@instance.com" + moveTo: "把这个账户迁移到新的账户" + moveToLabel: "迁移后的账户" + moveCannotBeUndone: "一旦迁移账户,就无法撤销。" + moveAccountDescription: "此操作无法取消。请先确认您已在迁移后的账户上,为此账户创造了别名。创造别名后,请如以下输入您的迁移后的账户:@person@instance.com" + startMigration: "迁移" + migrationConfirm: "确定要把此账户迁移到{account}吗?一旦确定后,此操作无法取消,此账户也无法以原来的状态使用。\n同时,请确认迁移后的账户,已创造别名。" + movedAndCannotBeUndone: "该账户已被迁移。\n迁移操作无法撤销。" + movedTo: "迁移后的账户" _achievements: earnedAt: "达成时间" _types: @@ -1122,8 +1236,11 @@ _achievements: _client30min: title: "休息一下!" description: "启动客户端超过30分钟" + _client60min: + title: "CherryPick重度依赖" + description: "启动客户端超过60分钟" _noteDeletedWithin1min: - title: "无话可说" + title: "欲言又止" description: "发帖后一分钟内就将其删除" _postedAtLateNight: title: "夜猫子" @@ -1141,7 +1258,7 @@ _achievements: description: "在首页时间线的流速超过20npm" _viewInstanceChart: title: "分析师" - description: "查看了实例信息中的图表" + description: "查看了服务器信息中的图表" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "在AiScript控制台中输出 hello world" @@ -1178,7 +1295,7 @@ _achievements: _loggedInOnNewYearsDay: title: "恭贺新禧" description: "在元旦登入" - flavor: "今年也请对本实例多多指教!" + flavor: "今年也请对本服务器多多指教!" _cookieClicked: title: "点击饼干小游戏" description: "点击了可疑的饼干" @@ -1193,7 +1310,7 @@ _role: name: "角色名称" description: "角色描述" permission: "角色权限" - descriptionOfPermission: "监察员可以执行基本的审核操作。\n管理员可以更改实例的所有设置。" + descriptionOfPermission: "监察员可以执行基本地审核操作。\n管理员可以更改服务器的所有设置。" assignTarget: "授权对象" descriptionOfAssignTarget: "手动指手动选择谁被包括在这个角色中。\n符合条件指设置条件以自动包括符合条件的用户。" manual: "手动" @@ -1210,6 +1327,10 @@ _role: iconUrl: "图标URL" asBadge: "作为徽章显示" descriptionOfAsBadge: "开启后,用户名旁边将会出现角色图标。" + isExplorable: "公开角色时间线" + descriptionOfIsExplorable: "打开后将公开角色时间线。如果角色不是公开的,就无法公开时间线。" + displayOrder: "显示顺序" + descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" canEditMembersByModerator: "允许监察者编辑成员" descriptionOfCanEditMembersByModerator: "如果选中,监察者和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" @@ -1221,7 +1342,7 @@ _role: gtlAvailable: "查看全局时间线" ltlAvailable: "查看本地时间线" canPublicNote: "允许公开发帖" - canInvite: "发放实例邀请码" + canInvite: "发放服务器邀请码" canManageCustomEmojis: "管理自定义表情符号" driveCapacity: "网盘容量" pinMax: "帖子置顶数量限制" @@ -1235,6 +1356,7 @@ _role: rateLimitFactor: "速率限制" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" canHideAds: "可以隐藏广告" + canSearchNotes: "是否可以搜索帖子" _condition: isLocal: "是本地用户" isRemote: "是远程用户" @@ -1244,6 +1366,8 @@ _role: followersMoreThanOrEq: "关注者不少于" followingLessThanOrEq: "关注中不多于" followingMoreThanOrEq: "关注中不少于" + notesLessThanOrEq: "帖子数在~以下" + notesMoreThanOrEq: "帖子数在~以上" and: "符合以下全部条件" or: "符合以下任一条件" not: "不符合以下任何条件" @@ -1283,7 +1407,7 @@ _ad: _forgotPassword: enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。" ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。" - contactAdmin: "该实例不支持发送电子邮件。如果您想重设密码,请联系管理员。" + contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。" _gallery: my: "我的图库" liked: "喜欢的图片" @@ -1419,6 +1543,8 @@ _channel: following: "正在关注" usersCount: "有{n}人参与" notesCount: "有{n}个帖子" + nameAndDescription: "名称与描述" + nameOnly: "仅名称" _menuDisplay: sideFull: "横向" sideIcon: "横向(图标)" @@ -1434,10 +1560,10 @@ _wordMute: hard: "硬屏蔽" mutedNotes: "被屏蔽的帖子" _instanceMute: - instanceMuteDescription: "屏蔽配置实例中的所有帖子和转帖,包括实例的用户回复。" + instanceMuteDescription: "屏蔽配置服务器中的所有帖子和转帖,包括这些服务器上的用户回复。" instanceMuteDescription2: "设置时用换行符来分隔" - title: "隐藏实例已设置的帖子。" - heading: "屏蔽实例" + title: "隐藏服务器已设置的帖子。" + heading: "屏蔽服务器" _theme: explore: "寻找主题" install: "安装主题" @@ -1496,9 +1622,9 @@ _theme: infoFg: "信息文本" infoWarnBg: "警告背景" infoWarnFg: "警告文本" - cwBg: "CW 按钮背景" - cwFg: "CW 按钮文本" - cwHoverBg: "CW 按钮背景(悬停)" + cwBg: "隐藏内容按钮背景" + cwFg: "隐藏内容按钮文本" + cwHoverBg: "隐藏内容按钮背景(悬停)" toastBg: "Toast通知背景" toastFg: "Toast通知文本" buttonBg: "按钮背景" @@ -1530,47 +1656,40 @@ _ago: weeksAgo: "{n}周前" monthsAgo: "{n}月前" yearsAgo: "{n}年前" + invalid: "没有" _time: second: "秒" minute: "分" hour: "小时" day: "日" -_tutorial: - title: "CherryPick的使用方法" - step1_1: "欢迎!" - step1_2: "这个页面叫做「时间线」,它会按照时间顺序显示所有你「关注」的人所发的「帖子」。" - step1_3: "如果你并没有发布任何帖子,也没有关注其他的人,你的时间线页面应当什么都没有显示。" - step2_1: "在您想要发帖或关注其他人之前,请先设置一下个人资料吧。" - step2_2: "如果别人能够更加的了解你,关注你的概率也会得到提升。" - step3_1: "已经设置完个人资料了吗?" - step3_2: "那么接下来,试着写一些什么东西来发布吧。你可以通过点击屏幕上的铅笔图标来打开投稿页面。" - step3_3: "写完内容后,点击窗口右上方的按钮就可以投稿。" - step3_4: "不知道说些什么好吗?那就写下「CherryPick我来啦!」这样的话吧。" - step4_1: "将你的话语发布出去了吗?" - step4_2: "太棒了!现在你可以在你的时间线中看到你刚刚发布的帖子了。" - step5_1: "接下来,关注其他人来使时间线更生动吧。" - step5_2: "{featured}将向您展示热门趋势的帖子。 {explore}将让您找到热门用户。 尝试关注您喜欢的人!" - step5_3: "要关注其他用户,请单击他的头像,然后在他的个人资料上按下“关注”按钮。" - step5_4: "如果用户的名称旁边有锁定图标,则该用户需要手动批准您的关注请求。" - step6_1: "现在,您将可以在时间线上看到其他用户的帖子。" - step6_2: "您还可以在其他人的帖子上进行「回应」,以快速做出简单回复。" - step6_3: "在他人的贴子上按下「+」图标,即可选择想要的表情来进行「回应」。" - step7_1: "对CherryPick基本操作的简单介绍,就到此结束了。 辛苦了!" - step7_2: "如果你想了解更多有关CherryPick的信息,请参见{help}。" - step7_3: "接下来,享受CherryPick带来的乐趣吧🚀" - step8_1: "最后,您想要启用推送通知消息吗?" - step8_2: "通过接收推送通知消息,即使没开 CherryPick,您也可以收到回应、关注、提及等的消息。" - step8_3: "您也可以稍后再更改通知设置。" +_timelineTutorial: + step3_1: "将想说的话发出去了吗?" + step3_2: "太棒了!现在你可以在你的时间线中看到刚刚发布的帖子了。" _2fa: alreadyRegistered: "此设备已被注册" - registerDevice: "注册设备" - registerKey: "注册密钥" + registerTOTP: "开始设置认证应用" + passwordToTOTP: "请输入您的密码" step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。" step2: "然后,扫描屏幕上显示的二维码。" + step2Click: "通过点击QR码,您可以使用设备上安装的身份验证器应用程序或密钥环进行注册" step2Url: "在桌面应用程序中输入以下URL:" + step3Title: "输入验证码" step3: "输入您的应用提供的动态口令以完成设置。" step4: "从现在开始,任何登录操作都将要求您提供动态口令。" - securityKeyInfo: "您可以设置使用支持FIDO2的硬件安全密钥、设备上的指纹或PIN来保护您的登录过程。" + securityKeyNotSupported: "您的浏览器不支持安全密钥。" + registerTOTPBeforeKey: "要注册安全密钥或Passkey,请先设置验证器应用程序。" + securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 码以及 Passkey 等。" + chromePasskeyNotSupported: "目前不支持 Chrome 的Passkey。" + registerSecurityKey: "注册安全密钥或Passkey" + securityKeyName: "输入密钥名称" + tapSecurityKey: "请按照浏览器说明操作来注册安全密钥或Passkey。" + removeKey: "删除安全密钥" + removeKeyConfirm: "您确定要删除 {name} 吗?" + whyTOTPOnlyRenew: "如果注册了安全密钥,则无法取消验证器应用程序上的设置。" + renewTOTP: "重置验证器应用程序" + renewTOTPConfirm: "当前验证器应用程序的验证码将不再有效" + renewTOTPOk: "重新配置" + renewTOTPCancel: "不用,谢谢" _permissions: "read:account": "查看账户信息" "write:account": "更改帐户信息" @@ -1630,7 +1749,7 @@ _weekday: saturday: "星期六" _widgets: profile: "个人资料" - instanceInfo: "实例信息" + instanceInfo: "服务器信息" memo: "便签" notifications: "通知" timeline: "时间线" @@ -1643,8 +1762,8 @@ _widgets: photos: "照片" digitalClock: "数字时钟" unixClock: "UNIX时钟" - federation: "联邦宇宙" - instanceCloud: "实例云" + federation: "联合" + instanceCloud: "服务器云" postForm: "投稿窗口" slideshow: "幻灯片展示" button: "按钮" @@ -1694,8 +1813,8 @@ _visibility: followersDescription: "仅发送至关注者" specified: "指定用户" specifiedDescription: "仅发送至指定用户" - localOnly: "仅限本地" - localOnlyDescription: "对远程用户不可见" + disableFederation: "不参与联合" + disableFederationDescription: "不发送到其他服务器" _postForm: replyPlaceholder: "回复这个帖子..." quotePlaceholder: "引用这个帖子..." @@ -1711,7 +1830,7 @@ _profile: name: "昵称" username: "用户名" description: "个人简介" - youCanIncludeHashtags: "你可以在个人简介中包含一个#标签。" + youCanIncludeHashtags: "你可以在个人简介中包含一些#标签。" metadata: "附加信息" metadataEdit: "附加信息编辑" metadataDescription: "最多可以在个人资料中以表格形式显示四条其他信息。" @@ -1855,6 +1974,7 @@ _notification: receiveFollowRequest: "收到关注请求" followRequestAccepted: "关注请求已通过" groupInvited: "加入群组邀请" + achievementEarned: "取得的成就" app: "关联应用的通知" _actions: followBack: "回关" @@ -1887,3 +2007,27 @@ _deck: channel: "频道" mentions: "提及" direct: "指定用户" + roleTimeline: "角色时间线" +_dialog: + charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" + charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" +_disabledTimeline: + title: "时间线已禁用" + description: "您不能在当前角色使用时间线。" +_drivecleaner: + orderBySizeDesc: "按大小降序排列" + orderByCreatedAtAsc: "按添加日期降序排列" +_webhookSettings: + createWebhook: "创建 Webhook" + name: "名称" + secret: "密钥" + events: "何时运行Webhook" + active: "已启用" + _events: + follow: "关注时" + followed: "被关注时" + note: "发布贴文时" + reply: "收到回复时" + renote: "被转发时" + reaction: "被回应时" + mention: "被提及时" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 2502eab68b..9faab75185 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -2,7 +2,7 @@ _lang_: "繁體中文" headlineMisskey: "貼文連繫網路" introMisskey: "歡迎! CherryPick是一個開放原始碼且去中心化的社群網路。\n透過「貼文」分享周邊新鮮事,並告訴其他人您的想法!📡\n透過「反應」功能,對大家的貼文表達情感!👍\n一起來探索這個新的世界吧!🚀" -poweredByMisskeyDescription: "{name}是使用開放原始碼平台CherryPick的服務之一(稱為 CherryPick 實例)。\n" +poweredByMisskeyDescription: "{name}是使用開放原始碼平台CherryPick的服務之一(稱為 CherryPick 伺服器)。\n" monthAndDay: "{month}月 {day}日" search: "搜尋" notifications: "通知" @@ -15,11 +15,12 @@ gotIt: "知道了" cancel: "取消" noThankYou: "現在不要" enterUsername: "輸入使用者名稱" -renotedBy: "{user} 轉傳了" +renotedBy: "{user} 轉發了" noNotes: "無貼文。" noNotifications: "沒有通知" -instance: "實例" +instance: "伺服器" settings: "設定" +notificationSettings: "通知選項" basicSettings: "基本設定" otherSettings: "其他設定" openInWindow: "在新視窗開啟" @@ -46,7 +47,7 @@ copyContent: "複製內容" copyLink: "複製連結" delete: "刪除" deleteAndEdit: "刪除並編輯" -deleteAndEditConfirm: "要刪除並再次編輯嗎?此貼文的所有情感、轉發和回覆也將會消失。" +deleteAndEditConfirm: "要刪除並再次編輯嗎?此貼文的所有反應、轉發和回覆也將會消失。" addToList: "加入至清單" sendMessage: "發送訊息" copyRSS: "複製RSS" @@ -85,7 +86,7 @@ somethingHappened: "發生錯誤" retry: "重試" pageLoadError: "載入頁面失敗" pageLoadErrorDescription: "這通常是因為網路錯誤或是瀏覽器快取殘留的原因。請先清除瀏覽器快取,稍後再重試" -serverIsDead: "伺服器沒有回應。請稍等片刻,然後重試。" +serverIsDead: "伺服器沒有回應。請稍等片刻再試。" youShouldUpgradeClient: "請重新載入以使用新版本的客戶端顯示此頁面" enterListName: "輸入清單名稱" privacy: "隱私" @@ -95,22 +96,24 @@ follow: "追隨" followRequest: "追隨請求" followRequests: "追隨請求" unfollow: "取消追隨" -followRequestPending: "追隨許可批准中" +followRequestPending: "追隨許可待批准" enterEmoji: "輸入表情符號" renote: "轉發" unrenote: "取消轉發" -renoted: "轉傳成功" +renoted: "轉發成功" cantRenote: "無法轉發此貼文。" -cantReRenote: "無法轉傳之前已經轉傳過的內容。" +cantReRenote: "無法轉發之前已經轉發過的內容。" quote: "引用" +inChannelRenote: "在頻道內轉發" +inChannelQuote: "在頻道內引用" pinnedNote: "已置頂的貼文" pinned: "置頂" you: "您" clickToShow: "按一下以顯示" sensitive: "敏感內容" add: "新增" -reaction: "情感" -reactions: "情感" +reaction: "反應" +reactions: "反應" reactionSetting: "在選擇器中顯示反應" reactionSettingDescription2: "拖動以重新列序,點擊以刪除,按下 + 添加。" rememberNoteVisibility: "記住貼文可見性" @@ -120,14 +123,16 @@ unmarkAsSensitive: "取消標記為敏感內容" enterFileName: "請輸入檔案名稱" mute: "靜音" unmute: "解除靜音" +renoteMute: "將轉發貼文靜音" +renoteUnmute: "解除轉發貼文的靜音" block: "封鎖" unblock: "解除封鎖" suspend: "凍結" unsuspend: "解除凍結" blockConfirm: "確定要封鎖此用戶?" unblockConfirm: "確定解除封鎖此用戶?" -suspendConfirm: "確定凍結此帳號?" -unsuspendConfirm: "確定解凍此帳號?" +suspendConfirm: "確定凍結此帳戶?" +unsuspendConfirm: "確定解凍此帳戶?" selectList: "選擇清單" selectChannel: "選擇頻道" selectAntenna: "選擇天線" @@ -145,12 +150,13 @@ cacheRemoteFiles: "快取遠端檔案" cacheRemoteFilesDescription: "禁用此設定會停止遠端檔案的緩存,從而節省儲存空間,但資料會因直接連線從而產生額外連接數據。" flagAsBot: "此使用者是機器人" flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整CherryPick內部系統將本帳戶識別為機器人" -flagAsCat: "此使用者是貓" +flagAsCat: "喵~~~~~~~~~~~~~~!!!!!!!!!!!!" flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示" flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" flagShowTimelineRepliesDescription: "啟用時,時間線除了顯示用戶的貼文以外,還會顯示用戶對其他貼文的回覆。" -autoAcceptFollowed: "自動追隨中使用者的追隨請求" +autoAcceptFollowed: "自動允許來自追隨中使用者的追隨請求" addAccount: "添加帳戶" +reloadAccountsList: "更新帳戶清單的資訊" loginFailed: "登入失敗" showOnRemote: "轉到所在實例顯示" general: "一般" @@ -161,13 +167,13 @@ searchWith: "搜尋: {q}" youHaveNoLists: "你沒有任何清單" followConfirm: "你真的要追隨{name}嗎?" proxyAccount: "代理帳戶" -proxyAccountDescription: "代理帳戶是在某些情況下充當其他伺服器用戶的帳戶。例如,當使用者將一個來自其他伺服器的帳戶放在列表中時,由於沒有其他使用者追蹤該帳戶,該指令不會傳送到該伺服器上,因此會由代理帳戶追蹤。" +proxyAccountDescription: "代理帳戶是在某些情況下充當其他伺服器用戶的帳戶。例如,當使用者將一個來自其他伺服器的帳戶放在列表中時,由於沒有其他使用者追隨該帳戶,該指令不會傳送到該伺服器上,因此會由代理帳戶追隨。" host: "主機" selectUser: "選取使用者" recipient: "收件人" annotation: "註解" federation: "站台聯邦" -instances: "實例" +instances: "伺服器" registeredAt: "初次觀測" latestRequestReceivedAt: "上次收到的請求" latestStatus: "最後狀態" @@ -176,7 +182,7 @@ charts: "圖表" perHour: "每小時" perDay: "每日" stopActivityDelivery: "停止發送活動" -blockThisInstance: "封鎖此實例" +blockThisInstance: "封鎖此伺服器" operations: "操作" software: "軟體" version: "版本" @@ -187,15 +193,15 @@ jobQueue: "佇列" cpuAndMemory: "CPU及記憶體用量" network: "網路" disk: "硬碟" -instanceInfo: "實例資訊" +instanceInfo: "伺服器資訊" statistics: "統計" clearQueue: "清除佇列" clearQueueConfirmTitle: "確定要清除佇列嗎?" clearQueueConfirmText: "未發佈的貼文將不會發佈。您通常不需要確認。" clearCachedFiles: "清除快取資料" clearCachedFilesConfirm: "確定要清除所有遠端暫存資料嗎?" -blockedInstances: "已封鎖的實例" -blockedInstancesDescription: "請逐行輸入需要封鎖的實例。已封鎖的實例將無法與本實例進行通訊。" +blockedInstances: "已封鎖的伺服器" +blockedInstancesDescription: "請逐行輸入需要封鎖的伺服器。已封鎖的伺服器將無法與本伺服器進行通訊。" muteAndBlock: "靜音和封鎖" mutedUsers: "已靜音用戶" blockedUsers: "已封鎖用戶" @@ -211,15 +217,15 @@ default: "預設" defaultValueIs: "預設值:{value}" noCustomEmojis: "沒有自訂的表情符號" noJobs: "沒有任務" -federating: "整合搜索中" +federating: "聯邦運作中" blocked: "已封鎖" suspended: "已凍結" all: "全部" subscribing: "訂閱中" publishing: "直播中" notResponding: "沒有回應" -instanceFollowing: "追蹤實例" -instanceFollowers: "追蹤實例" +instanceFollowing: "追隨的伺服器" +instanceFollowers: "伺服器的追隨者" instanceUsers: "用戶" changePassword: "修改密碼" security: "安全性" @@ -257,14 +263,16 @@ noMoreHistory: "沒有更多歷史紀錄" startMessaging: "開始聊天" nUsersRead: "{n}人已讀" agreeTo: "我同意{0}" +agree: "同意" agreeBelow: "同意以下內容" basicNotesBeforeCreateAccount: "基本注意事項" -tos: "使用條款" +termsOfService: "服務條款" start: "開始" home: "首頁" remoteUserCaution: "由於該使用者來自遠端實例,因此資訊可能非即時的。" activity: "動態" images: "圖片" +image: "圖片" birthday: "生日" yearsOld: "{age}歲" registeredDate: "註冊日期" @@ -312,8 +320,8 @@ unwatch: "取消追隨" accept: "接受" reject: "拒絕" normal: "正常" -instanceName: "實例名稱" -instanceDescription: "實例說明" +instanceName: "伺服器名稱" +instanceDescription: "伺服器介紹" maintainerName: "管理員名稱" maintainerEmail: "管理員郵箱" tosUrl: "服務條款URL" @@ -384,23 +392,26 @@ recentlyUpdatedUsers: "最近發文的使用者" recentlyRegisteredUsers: "新加入使用者" recentlyDiscoveredUsers: "最近發現的使用者" exploreUsersCount: "有{count}個使用者" -exploreFediverse: "探索聯邦世界" +exploreFediverse: "探索聯邦宇宙" popularTags: "熱門標籤" userList: "使用者清單" about: "關於" aboutMisskey: "關於 CherryPick" administrator: "管理員" token: "權杖" -twoStepAuthentication: "兩階段驗證" +2fa: "雙因素驗證" +totp: "驗證應用程式" +totpDescription: "以驗證應用程式輸入一次性密碼" moderator: "審查員" moderation: "審查" nUsersMentioned: "提到了{n}" +securityKeyAndPasskey: "安全金鑰・Passkey" securityKey: "安全金鑰" -securityKeyName: "金鑰名稱" -registerSecurityKey: "註冊安全金鑰" lastUsed: "上次使用" -unregister: "註銷帳號" +lastUsedAt: "最後使用:{t}" +unregister: "註銷帳戶" passwordLessLogin: "設置無密碼登入" +passwordLessLoginDescription: "不使用密碼,以安全金鑰或 Passkey 登入" resetPassword: "重置密碼" newPasswordIs: "新密碼為「{password}」" reduceUiAnimation: "減少介面的動態視覺" @@ -430,7 +441,7 @@ title: "標題" text: "文字" enable: "啟用" next: "下一步" -retype: "重新輸入" +retype: "再次輸入" noteOf: "{user}的貼文" inviteToGroup: "邀請至群組" quoteAttached: "引用" @@ -454,7 +465,6 @@ passwordMatched: "密碼一致" passwordNotMatched: "密碼不一致" signinWith: "以{x}登錄" signinFailed: "登入失敗。 請檢查使用者名稱和密碼。" -tapSecurityKey: "點擊安全密鑰" or: "或者" language: "語言" uiLanguage: "介面語言" @@ -465,6 +475,7 @@ native: "原生" disableDrawer: "不顯示下拉式選單" youHaveNoGroups: "找不到群組" joinOrCreateGroup: "請加入現有群組,或創建新群組。" +showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項" noHistory: "沒有歷史紀錄" signinHistory: "登入歷史" enableAdvancedMfm: "啟用高級MFM" @@ -477,7 +488,9 @@ createAccount: "建立帳戶" existingAccount: "現有帳戶" regenerate: "再生" fontSize: "字體大小" -noFollowRequests: "沒有跟隨您的請求" +mediaListWithOneImageAppearance: "僅1枚圖片的媒體列表高度" +limitTo: "上限為{x}" +noFollowRequests: "沒有追隨您的請求" openImageInNewTab: "於新分頁中開啟圖片" dashboard: "儀表板" local: "本地" @@ -486,7 +499,7 @@ total: "合計" weekOverWeekChanges: "與上週相比" dayOverDayChanges: "與前一日相比" appearance: "外觀" -clientSettings: "用戶端設定" +clientSettings: "客戶端設定" accountSettings: "帳戶設定" promotion: "推廣" promote: "推廣" @@ -510,9 +523,11 @@ objectStorageUseSSLDesc: "如果不使用https進行API連接,請關閉" objectStorageUseProxy: "使用網路代理" objectStorageUseProxyDesc: "如果不使用代理進行API連接,請關閉" objectStorageSetPublicRead: "上傳時設定為\"public-read\"" +s3ForcePathStyleDesc: "啟用 s3ForcePathStyle 會強制將儲存槽名稱指定為 URL 中路徑的一部分,而不是主機名。 使用自託管 Minio 之類的可能需要啟用。" serverLogs: "伺服器日誌" deleteAll: "刪除所有記錄" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" +showFixedPostFormInChannel: "於時間軸頁頂顯示「發送貼文」方框(頻道)" newNoteRecived: "發現新的貼文" sounds: "音效" sound: "音效" @@ -534,8 +549,8 @@ installedDate: "安裝時間" lastUsedDate: "最後上線日期" state: "狀態" sort: "排序" -ascendingOrder: "遞增" -descendingOrder: "遞減" +ascendingOrder: "昇冪" +descendingOrder: "降冪" scratchpad: "暫存記憶體" scratchpadDescription: "AiScript控制台為AiScript提供了實驗環境。您可以在此編寫、執行和確認代碼與CherryPick互動的结果。" output: "輸出" @@ -544,22 +559,27 @@ disablePagesScript: "停用頁面的AiScript腳本" updateRemoteUser: "更新遠端使用者資訊" deleteAllFiles: "刪除所有檔案" deleteAllFilesConfirm: "要删除所有檔案嗎?" -removeAllFollowing: "解除所有追蹤" -removeAllFollowingDescription: "解除{host}所有的追蹤。在實例不再存在時執行。" +removeAllFollowing: "解除所有追隨" +removeAllFollowingDescription: "解除{host}所有的追隨。在伺服器不再存在時執行。" userSuspended: "該使用者已被停用" userSilenced: "該用戶已被禁言。" yourAccountSuspendedTitle: "帳戶已被凍結" yourAccountSuspendedDescription: "由於違反了伺服器的服務條款或其他原因,該帳戶已被凍結。 您可以與管理員連繫以了解更多訊息。 請不要創建一個新的帳戶。" +tokenRevoked: "權杖無效" +tokenRevokedDescription: "登入權杖失效,請重新登入。" +accountDeleted: "帳戶已被刪除" +accountDeletedDescription: "這個帳戶已被刪除。" menu: "選單" divider: "分割線" addItem: "新增項目" +rearrange: "排序方式" relays: "中繼" addRelay: "新增中繼" inboxUrl: "收件夾URL" addedRelays: "已加入的中繼" serviceworkerInfo: "您需要啟用推送通知" deletedNote: "已删除的貼文" -invisibleNote: "隱藏的貼文" +invisibleNote: "私密的貼文" enableInfiniteScroll: "啟用自動滾動頁面模式" visibility: "可見性" poll: "投票" @@ -593,7 +613,6 @@ tokenRequested: "允許存取帳戶" pluginTokenRequestedDescription: "此外掛將擁有在此設定的權限。" notificationType: "通知形式" edit: "編輯" -useStarForReactionFallback: "以★代替未知的表情符號" emailServer: "電郵伺服器" enableEmail: "啟用發送電郵功能" emailConfigInfo: "用於確認電郵地址及密碼重置" @@ -671,14 +690,14 @@ repliesCount: "回覆數量" renotesCount: "轉發數量" repliedCount: "回覆數量" renotedCount: "轉發次數" -followingCount: "正在跟隨的用戶數量" -followersCount: "跟隨者數量" -sentReactionsCount: "情感發送次數" -receivedReactionsCount: "情感收到次數" +followingCount: "正在追隨的用戶數量" +followersCount: "追隨者數量" +sentReactionsCount: "反應發送次數" +receivedReactionsCount: "收到反應次數" pollVotesCount: "已統計的投票數" pollVotedCount: "已投票數" -yes: "確定" -no: "取消" +yes: "是" +no: "否" driveFilesCount: "雲端硬碟檔案數量" driveUsage: "雲端硬碟使用量" noCrawle: "拒絕搜尋引擎索引" @@ -697,6 +716,8 @@ contact: "聯絡人" useSystemFont: "使用系統預設的字型" clips: "摘錄" experimentalFeatures: "實驗中的功能" +experimental: "實驗性" +thisIsExperimentalFeature: "這是實驗性的功能。可能會有變更規格和不能正常動作的可能性。" developer: "開發者" makeExplorable: "使自己的帳戶能夠在「探索」頁面中顯示" makeExplorableDescription: "如果關閉,帳戶將不會被顯示在「探索」頁面中。" @@ -732,8 +753,8 @@ registry: "登錄表" closeAccount: "停用帳戶" currentVersion: "目前版本" latestVersion: "最新版本" -youAreRunningUpToDateClient: "您所使用的用戶端已經是最新的。" -newVersionOfClientAvailable: "新版本的用戶端可用。" +youAreRunningUpToDateClient: "您所使用的客戶端已經是最新的。" +newVersionOfClientAvailable: "新版本的客戶端可用。" usageAmount: "使用量" capacity: "容量" inUse: "已使用" @@ -787,6 +808,7 @@ popularPosts: "熱門的貼文" shareWithNote: "在貼文中分享" ads: "廣告" expiration: "期限" +startingperiod: "開始期間" memo: "備忘錄" priority: "優先級" high: "高" @@ -818,7 +840,8 @@ pubSub: "Pub/Sub 帳戶" lastCommunication: "最近的通信" resolved: "已解決" unresolved: "未解決" -breakFollow: "移除追蹤者" +breakFollow: "解除追隨者" +breakFollowConfirm: "確定要取消被追隨嗎?" itsOn: "已開啟" itsOff: "已關閉" emailRequiredForSignup: "註冊帳戶需要電子郵件地址" @@ -855,11 +878,13 @@ instanceDefaultLightTheme: "實例預設的淺色主題" instanceDefaultDarkTheme: "實例預設的深色主題" instanceDefaultThemeDescription: "輸入物件形式的主题代碼" mutePeriod: "靜音的期限" +period: "期限" indefinitely: "無期限" tenMinutes: "10分鐘" oneHour: "1小時" oneDay: "1天" oneWeek: "1週" +oneMonth: "1個月" reflectMayTakeTime: "可能需要一些時間才會出現效果。" failedToFetchAccountInformation: "取得帳戶資訊失敗" rateLimitExceeded: "已超過速率限制" @@ -876,10 +901,10 @@ recommended: "推薦" check: "檢查" driveCapOverrideLabel: "更改這個使用者的雲端硬碟容量上限" driveCapOverrideCaption: "如果指定0以下的值,就會被取消。" -requireAdminForView: "必須以管理員帳號登入才可以檢視。" -isSystemAccount: "由系統自動建立與管理的帳號。" +requireAdminForView: "必須以管理員帳戶登入才可以檢視。" +isSystemAccount: "由系統自動建立與管理的帳戶。" typeToConfirm: "要執行這項操作,請輸入 {x} " -deleteAccount: "刪除帳號" +deleteAccount: "刪除帳戶" document: "文件" numberOfPageCache: "快取頁面數" numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" @@ -901,6 +926,7 @@ remoteOnly: "僅限遠端" failedToUpload: "上傳失敗" cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上傳。" cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上傳。" +cannotUploadBecauseExceedsFileSizeLimit: "由於超過了檔案大小的限制,無法上傳。" beta: "Beta" enableAutoSensitive: "自動NSFW判定" enableAutoSensitiveDescription: "如果可用,請利用機器學習在媒體上自動設置 NSFW 旗標。 即使關閉此功能,依實例而定也可能會自動設置。" @@ -917,9 +943,10 @@ pushNotificationNotSupported: "瀏覽器或實例不支援推播通知" sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "復原" caption: "標題" -loggedInAsBot: "以機器人帳號登入中" +loggedInAsBot: "以機器人帳戶登入中" tools: "工具" cannotLoad: "無法載入" numberOfProfileView: "個人檔案檢視次數" @@ -933,6 +960,7 @@ didYouLikeMisskey: "您是否喜愛CherryPick呢?" pleaseDonate: "CherryPick是由{host}使用的免費軟體。請贊助我們,讓開發能夠持續!" roles: "角色" role: "角色" +noRole: "沒有角色" normalUser: "一般使用者" undefined: "未定義" assign: "指派" @@ -942,6 +970,10 @@ manageCustomEmojis: "管理自訂表情符號" youCannotCreateAnymore: "您無法再建立更多了。" cannotPerformTemporary: "暫時無法進行" cannotPerformTemporaryDescription: "由於超過操作次數限制,暫時無法進行。請過一段時間之後再嘗試。" +invalidParamError: "參數錯誤" +invalidParamErrorDescription: "請求參數有問題。通常是bug造成的,但也有輸入的字元數過多之類的可能性。" +permissionDeniedError: "操作被拒絕" +permissionDeniedErrorDescription: "本帳號沒有執行這個操作的權限。" preset: "預設值" selectFromPresets: "從預設值中選擇" achievements: "成就" @@ -952,6 +984,108 @@ thisPostMayBeAnnoyingHome: "發布到首頁" thisPostMayBeAnnoyingCancel: "退出" thisPostMayBeAnnoyingIgnore: "直接發布貼文" collapseRenotes: "省略顯示已看過的轉發貼文" +internalServerError: "內部伺服器錯誤" +internalServerErrorDescription: "內部伺服器發生了非預期的錯誤。" +copyErrorInfo: "複製錯誤資訊" +joinThisServer: "在此伺服器上註冊" +exploreOtherServers: "探索其他伺服器" +letsLookAtTimeline: "看看時間軸" +disableFederationConfirm: "要停止聯邦功能嗎?" +disableFederationConfirmWarn: "即使停止了聯邦功能,貼文也不會變成私密的。在大部分的情況下,沒有必要停止聯邦功能。" +disableFederationOk: "停止聯邦功能" +invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" +emailNotSupported: "這個伺服器不支援寄送郵件" +postToTheChannel: "發布到頻道" +cannotBeChangedLater: "之後不能變更。" +reactionAcceptance: "接受表情反應" +likeOnly: "僅限讚" +likeOnlyForRemote: "遠端僅限讚" +rolesAssignedToMe: "指派給自己的角色" +resetPasswordConfirm: "重設密碼?" +sensitiveWords: "敏感詞" +sensitiveWordsDescription: "將含有設定詞彙的貼文可見性設為發送至首頁。可以用換行來進行複數的設定。" +sensitiveWordsDescription2: "用空格分隔關鍵詞構成AND格式,用斜線包圍關鍵字構成正規表達式。" +notesSearchNotAvailable: "無法使用搜尋貼文功能。" +license: "授權" +unfavoriteConfirm: "要取消收錄我的最愛嗎?" +myClips: "我的摘錄" +drivecleaner: "雲端硬碟清掃器" +retryAllQueuesNow: "立刻重試所有佇列" +retryAllQueuesConfirmTitle: "要現在重試嗎?" +retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" +enableChartsForRemoteUser: "生成遠端用戶的圖表" +enableChartsForFederatedInstances: "生成遠端伺服器的圖表" +showClipButtonInNoteFooter: "將摘錄添加至貼文" +largeNoteReactions: "將貼文的反應放大顯示" +noteIdOrUrl: "貼文ID或URL" +video: "影片" +videos: "影片" +dataSaver: "數據節省模式" +accountMigration: "遷移帳戶" +accountMoved: "這個使用者已遷移至新的帳戶:" +accountMovedShort: "此帳戶已遷移" +operationForbidden: "不允許此操作" +forceShowAds: "總是顯示廣告" +addMemo: "新增備註" +editMemo: "編輯備註" +reactionsList: "反應列表" +renotesList: "轉發貼文列表" +notificationDisplay: "通知的顯示" +leftTop: "左上" +rightTop: "右上" +leftBottom: "左下" +rightBottom: "右下" +stackAxis: "堆疊方向" +vertical: "縱向" +horizontal: "側向" +position: "位置" +serverRules: "伺服器規則" +pleaseConfirmBelowBeforeSignup: "在本伺服器註冊之前,請確認下列事項。" +pleaseAgreeAllToContinue: "必須全部勾選「同意」才能繼續。" +continue: "繼續" +preservedUsernames: "保留的使用者名稱" +preservedUsernamesDescription: "換行列舉要保留的使用者名稱。此處指定的使用者名稱,在建立帳戶時無法使用,但由管理者所建立的帳戶不受此限。此外,既有的帳戶也不受影響。" +createNoteFromTheFile: "由此檔案建立貼文" +archive: "封存" +channelArchiveConfirmTitle: "要封存{name}嗎?" +channelArchiveConfirmDescription: "封存以後,在頻道列表與搜索結果中不會顯示,也無法發布新的貼文。" +thisChannelArchived: "這個頻道已被封存。" +displayOfNote: "顯示貼文" +initialAccountSetting: "初始設定" +youFollowing: "追隨中" +preventAiLearning: "拒絕接受產生式AI的學習" +preventAiLearningDescription: "要求外部的文章產生AI或圖像產生AI不以發布的貼文和圖像等內容為學習對象。這是透過在HTML響應中包含noai旗標來實現的,但不能完全防止AI的學習,因為這要看該AI是否遵守這個要求。" +options: "選項" +_initialAccountSetting: + accountCreated: "帳戶已建立完成!" + letsStartAccountSetup: "來進行帳戶的初始設定吧。" + letsFillYourProfile: "首先,來設定您的個人檔案吧。" + profileSetting: "個人檔案設定" + theseSettingsCanEditLater: "這裡的設定可以在之後變更。" + youCanEditMoreSettingsInSettingsPageLater: "除此之外,還可以在「設定」頁面進行各種設定。之後請確認看看。" + followUsers: "為了構築時間軸,試著追蹤您感興趣的使用者吧。" + pushNotificationDescription: "啟用推送通知,就可以在設備上接收{name}的通知。" + initialAccountSettingCompleted: "初始設定完成了!" + haveFun: "盡情享受{name}吧!" + ifYouNeedLearnMore: "關於如何使用{name}(Misskey)的詳細資訊,請見{link}。" + skipAreYouSure: "要略過初始設定嗎?" +_serverRules: + description: "設定伺服器的簡要規則,在新的註冊之前顯示。建議的內容是使用條款的摘要。" +_accountMigration: + moveFrom: "從其他帳戶遷移到這個帳戶" + moveFromSub: "為另一個帳戶建立別名" + moveFromLabel: "要遷移過來的帳戶:" + moveFromDescription: "如果你想把追隨者從別的帳戶遷移過來,必須先在這裡建立別名。請務必在執行遷移之前建立別名!請像這樣輸入要遷移的帳戶:@person@instance.com" + moveTo: "將這個帳戶遷移至新的帳戶" + moveToLabel: "要遷移到的帳戶:" + moveCannotBeUndone: "一旦遷移帳戶,就無法取消。" + moveAccountDescription: "這個操作不可撤銷。首先,請確認已在要遷移到的帳戶中為這個帳戶建立了一個別名。建立別名之後,像這樣輸入你要遷移到的帳戶:@person@instance.com" + moveAccountHowTo: "要遷移帳戶,首先要在目標帳戶中為此帳戶建立一個別名。\n 建立別名後,像這樣輸入目標帳戶:@username@server.example.com" + startMigration: "遷移" + migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" + movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。" + postMigrationNote: "在遷移操作後的24小時之後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。" + movedTo: "要遷移到的帳戶:" _achievements: earnedAt: "獲得日期" _types: @@ -1068,7 +1202,7 @@ _achievements: title: "有備而來" description: "設定了個人檔案" _markedAsCat: - title: "我是貓" + title: "吾輩乃貓是也" description: "已將帳戶設定為貓" flavor: "還沒有名字。" _following1: @@ -1076,31 +1210,31 @@ _achievements: description: "首次追隨了" _following10: title: "跟著跟著" - description: "跟隨超過10人了" + description: "追隨超過10人了" _following50: title: "朋友很多" - description: "跟隨超過50人了" + description: "追隨超過50人了" _following100: title: "100位朋友" - description: "跟隨超過100人了" + description: "追隨超過100人了" _following300: title: "朋友過多" - description: "跟隨超過300人了" + description: "追隨超過300人了" _followers1: title: "第一個追隨者" description: "第一次被追隨" _followers10: title: "Follow me!" - description: "跟隨者超過10人了" + description: "追隨者超過10人了" _followers50: title: "成群結隊" - description: "跟隨者超過50人了" + description: "追隨者超過50人了" _followers100: - title: "紅人" - description: "跟隨者超過100人了" + title: "熱門人物" + description: "追隨者超過100人了" _followers300: title: "請排成一排" - description: "跟隨者超過300人了" + description: "追隨者超過300人了" _followers500: title: "基地台" description: "超過500名追隨者了" @@ -1122,7 +1256,10 @@ _achievements: description: "發現了隱藏的寶藏" _client30min: title: "休息一下" - description: "用戶端啟動已超過30分鐘" + description: "客戶端啟動已超過30分鐘" + _client60min: + title: "CherryPick看太多" + description: "客戶端啟動已超過60分鐘" _noteDeletedWithin1min: title: "現在沒有了" description: "發文後1分鐘內刪文" @@ -1154,7 +1291,7 @@ _achievements: description: "試圖遞迴套入雲端硬碟資料夾" _reactWithoutRead: title: "有好好讀過嗎?" - description: "對包含100字以上內容的貼文做出情感反應" + description: "對包含100字以上內容的貼文在3秒以內做出反應" _clickedClickHere: title: "點擊這裡" description: "已點擊這裡了" @@ -1211,6 +1348,10 @@ _role: iconUrl: "圖示的URL" asBadge: "顯示為徽章" descriptionOfAsBadge: "開啟的話,角色圖示會顯示在用戶名旁邊。" + isExplorable: "公開角色時間軸" + descriptionOfIsExplorable: "若開啟則公開角色時間軸。若角色不是公開的,則無法公開時間軸。" + displayOrder: "顯示順序" + descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" canEditMembersByModerator: "允許編輯審查員的成員" descriptionOfCanEditMembersByModerator: "如果開啟,管理員與審查員都可以為使用者指派/解除指派該角色。如果關閉,則只有管理員可以執行。" priority: "優先級" @@ -1225,6 +1366,7 @@ _role: canInvite: "發行實例邀請碼" canManageCustomEmojis: "管理自訂表情符號" driveCapacity: "雲端硬碟容量" + alwaysMarkNsfw: "總是將檔案標記為NSFW" pinMax: "置頂貼文的最大數量" antennaMax: "可建立的天線數量" wordMuteMax: "靜音文字的最大字數" @@ -1236,6 +1378,7 @@ _role: rateLimitFactor: "速率限制" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" canHideAds: "不顯示廣告" + canSearchNotes: "可否搜尋貼文" _condition: isLocal: "本地使用者" isRemote: "遠端使用者" @@ -1245,6 +1388,8 @@ _role: followersMoreThanOrEq: "追隨者人數在~以上" followingLessThanOrEq: "追隨人數在~以下" followingMoreThanOrEq: "追隨人數在~以上" + notesLessThanOrEq: "發布數在~以下" + notesMoreThanOrEq: "發布數在~以上" and: "~和~" or: "~或~" not: "~否" @@ -1417,9 +1562,11 @@ _channel: removeBanner: "移除橫幅圖像" featured: "熱門貼文" owned: "管理中" - following: "關注中" + following: "追隨中" usersCount: "有{n}人參與" notesCount: "有{n}個貼文" + nameAndDescription: "名稱與說明" + nameOnly: "僅名稱" _menuDisplay: sideFull: "側向" sideIcon: "側向(圖示)" @@ -1531,47 +1678,47 @@ _ago: weeksAgo: "{n}周前" monthsAgo: "{n}個月前" yearsAgo: "{n}年前" + invalid: "未發現" _time: second: "秒" minute: "分鐘" hour: "小時" day: "日" -_tutorial: - title: "CherryPick使用方法" - step1_1: "歡迎!" - step1_2: "此為「時間軸」頁面,它會按照時間順序顯示你「追隨」的人發出的「貼文」" - step1_3: "由於你沒有發佈任何貼文,也沒有追隨任何人,所以你的時間軸目前是空的。" - step2_1: "在發文或追隨其他人之前先讓我們設定一下個人資料吧。" - step2_2: "提供一些關於自己的資訊來讓其他人更有追隨你的意願。" - step3_1: "個人資料都設定好了嗎?" - step3_2: "接下來,讓我們來試試看發個文,按一下畫面上的鉛筆圖示來開始" - step3_3: "輸入完內容後,按視窗右上角的按鈕來發文" - step3_4: "不知道該寫什麼內容嗎?試試看「開始使用CherryPick了」如何。" - step4_1: "貼文發出去了嗎?" - step4_2: "如果你的貼文出現在時間軸上,就代表發文成功。" - step5_1: "現在試試看追隨其他人來讓你的時間軸變得更生動吧。" - step5_2: "你會在{featured}上看到受歡迎的貼文,你也可以從列表中追隨你喜歡的人,或者在{explore}上找到熱門使用者。" - step5_3: "想要追隨其他人,只要點擊他們的大頭貼並按「追隨」即可。" - step5_4: "如果使用者的名字旁有鎖頭的圖示,代表他們需要手動核准你的追隨請求。" - step6_1: "現在你可以在時間軸上看到其他用戶的貼文。" - step6_2: "你也可以對別人的貼文作出「情感」,作出簡單的回覆。" - step6_3: "在他人的貼文按下\"+\"圖標,即可選擇喜好的表情符號進行回應。" - step7_1: "以上為CherryPick的基本操作說明,教學在此告一段落。辛苦了。" - step7_2: "歡迎到{help}來瞭解更多CherryPick相關介紹。" - step7_3: "那麼,祝您在CherryPick玩的開心~ 🚀" - step8_1: "最後,要不要試試看啟用推播通知呢?" - step8_2: "透過接收推播通知,即使沒有打開CherryPick,您也會知道反應、追隨與提及的情況。" - step8_3: "通知的設定可以在之後變更。" +_timelineTutorial: + title: "Misskey的使用方法" + step1_1: "這個畫面是「時間軸」。發布到{name}的「貼文」按照時間順序顯示。" + step1_2: "時間軸有多種類型,例如在「首頁時間軸」中流動的是您追蹤的人的貼文;而在「本地時間軸」流動的是{name}全體的貼文。" + step2_1: "試試看,發布個貼文吧!按畫面上鉛筆圖示的按鈕開啟表格。" + step2_2: "初次貼文的內容,建議包括自我介紹以及「開始使用{name}」。" + step3_1: "貼文發出去了嗎?" + step3_2: "如果你的貼文出現在時間軸上,就代表發文成功。" + step4_1: "可以對貼文標記「反應」。" + step4_2: "點擊貼文的「+」圖示,即可選擇喜好的表情符號來標記反應。" _2fa: alreadyRegistered: "此設備已經被註冊過了" - registerDevice: "註冊裝置" - registerKey: "註冊鍵" + registerTOTP: "開始設定驗證應用程式" + passwordToTOTP: "請輸入密碼" step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。" step2: "然後,掃描螢幕上的QR code。" + step2Click: "點擊QR code,可以使用設備上安裝的驗證應用程式或金鑰環進行註冊。" step2Url: "在桌面版應用中,請輸入以下的URL:" + step3Title: "輸入驗證碼" step3: "輸入您的App提供的權杖以完成設定。" step4: "從現在開始,任何登入操作都將要求您提供權杖。" + securityKeyNotSupported: "您的瀏覽器不支援安全金鑰。" + registerTOTPBeforeKey: "要註冊安全金鑰・Passkey,請先設定驗證應用程式。" securityKeyInfo: "您可以設定使用支援FIDO2的硬體安全鎖、終端設備的指纹認證或者PIN碼來登入。" + chromePasskeyNotSupported: "目前不支援Chrome的Passkey。" + registerSecurityKey: "註冊安全金鑰・Passkey" + securityKeyName: "輸入金鑰名稱" + tapSecurityKey: "按照瀏覽器的說明操作,註冊安全金鑰和Passkey。" + removeKey: "刪除安全金鑰" + removeKeyConfirm: "要刪除{name}嗎?" + whyTOTPOnlyRenew: "如果註冊了安全金鑰,則無法解除驗證應用程式的設定。" + renewTOTP: "重設驗證應用程式" + renewTOTPConfirm: "目前驗證應用程式的驗證碼將無法使用。" + renewTOTPOk: "重設" + renewTOTPCancel: "現在不要" _permissions: "read:account": "查看我的帳戶資訊" "write:account": "更改我的帳戶資訊" @@ -1590,8 +1737,8 @@ _permissions: "write:notes": "撰寫或刪除貼文" "read:notifications": "查看通知" "write:notifications": "編輯通知" - "read:reactions": "查看情感" - "write:reactions": "編輯情感" + "read:reactions": "查看反應" + "write:reactions": "編輯反應" "write:votes": "投票" "read:pages": "顯示頁面" "write:pages": "編輯頁面" @@ -1644,7 +1791,7 @@ _widgets: photos: "照片" digitalClock: "電子時鐘" unixClock: "UNIX時間" - federation: "聯邦宇宙" + federation: "站台聯邦" instanceCloud: "實例雲" postForm: "發佈窗口" slideshow: "幻燈片" @@ -1695,8 +1842,8 @@ _visibility: followersDescription: "僅發送至關注者" specified: "指定使用者" specifiedDescription: "僅發送至指定使用者" - localOnly: "僅限本地" - localOnlyDescription: "對遠端使用者隱藏" + disableFederation: "停用聯邦" + disableFederationDescription: "不要傳遞給其他實例" _postForm: replyPlaceholder: "回覆此貼文..." quotePlaceholder: "引用此貼文..." @@ -1758,7 +1905,7 @@ _instanceCharts: _timelines: home: "首頁" local: "本地" - social: "社群" + social: "社交" global: "公開" _play: new: "新增Play" @@ -1856,6 +2003,7 @@ _notification: receiveFollowRequest: "已收到追隨請求" followRequestAccepted: "追隨請求已接受" groupInvited: "加入社群邀請" + achievementEarned: "獲得成就" app: "應用程式通知" _actions: followBack: "回關" @@ -1888,3 +2036,27 @@ _deck: channel: "頻道" mentions: "提及" direct: "指定使用者" + roleTimeline: "角色時間軸" +_dialog: + charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" + charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" +_disabledTimeline: + title: "停用的時間軸" + description: "目前的角色無法使用這個時間軸。" +_drivecleaner: + orderBySizeDesc: "檔案由大到小" + orderByCreatedAtAsc: "依照加入的日期順序" +_webhookSettings: + createWebhook: "建立 Webhook" + name: "名稱" + secret: "秘密" + events: "什麼時候運行Webhook" + active: "已啟用" + _events: + follow: "當你追隨時" + followed: "當被追隨時" + note: "當發布貼文時" + reply: "當收到回覆時" + renote: "當被轉發時" + reaction: "當獲得反應時" + mention: "當被提到時" diff --git a/package.json b/package.json index 93b96fedad..a7c1744941 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "cherrypick", - "version": "13.5.6-cp-3.1.0", + "version": "13.12.1", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/kokonect-link/cherrypick.git" }, - "packageManager": "pnpm@7.27.0", + "packageManager": "pnpm@8.3.1", "workspaces": [ "packages/frontend", "packages/backend", @@ -16,10 +16,12 @@ "scripts": { "build-pre": "node ./scripts/build-pre.js", "build": "pnpm build-pre && pnpm -r build && pnpm gulp", - "start": "cd packages/backend && node ./built/boot/index.js", + "build-storybook": "pnpm --filter frontend build-storybook", + "start": "pnpm check:connect && cd packages/backend && node ./built/boot/index.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/index.js", "init": "pnpm migrate", "migrate": "cd packages/backend && pnpm migrate", + "check:connect": "cd packages/backend && pnpm check:connect", "migrateandstart": "pnpm migrate && pnpm start", "gulp": "pnpm exec gulp build", "watch": "pnpm dev", @@ -30,8 +32,8 @@ "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "jest": "cd packages/backend && pnpm jest", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", - "test": "pnpm jest", - "test-and-coverage": "pnpm jest-and-coverage", + "test": "pnpm -r test", + "test-and-coverage": "pnpm -r test-and-coverage", "format": "pnpm exec gulp format", "clean": "node ./scripts/clean.js", "clean-all": "node ./scripts/clean-all.js", @@ -50,20 +52,20 @@ "gulp-replace": "1.1.4", "gulp-terser": "2.1.0", "js-yaml": "4.1.0", - "mitt": "3.0.0", - "typescript": "4.9.5" + "typescript": "5.0.4", + "mitt": "3.0.0" }, "devDependencies": { "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", - "@typescript-eslint/eslint-plugin": "5.51.0", - "@typescript-eslint/parser": "5.51.0", + "@typescript-eslint/eslint-plugin": "5.59.2", + "@typescript-eslint/parser": "5.59.2", "cross-env": "7.0.3", - "cypress": "12.5.1", - "eslint": "8.33.0", - "start-server-and-test": "1.15.3" + "cypress": "12.11.0", + "eslint": "8.39.0", + "start-server-and-test": "2.0.0" }, "optionalDependencies": { - "@tensorflow/tfjs-core": "4.2.0" + "@tensorflow/tfjs-core": "4.4.0" } } diff --git a/packages/backend/.eslintrc.cjs b/packages/backend/.eslintrc.cjs index 5a06889dcd..f9fe4814e6 100644 --- a/packages/backend/.eslintrc.cjs +++ b/packages/backend/.eslintrc.cjs @@ -1,7 +1,7 @@ module.exports = { parserOptions: { tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], + project: ['./tsconfig.json', './test/tsconfig.json'], }, extends: [ '../shared/.eslintrc.js', diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index 55a88456ef..08d4222d01 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -1,25 +1,23 @@ { - "$schema": "https://json.schemastore.org/swcrc", - "jsc": { - "parser": { - "syntax": "typescript", - "dynamicImport": true, - "decorators": true - }, - "transform": { - "legacyDecorator": true, - "decoratorMetadata": true - }, + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { + "syntax": "typescript", + "dynamicImport": true, + "decorators": true + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true + }, "experimental": { "keepImportAssertions": true }, - "baseUrl": ".", + "baseUrl": "src", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["*"] }, "target": "es2021" - }, - "minify": false + }, + "minify": false } diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html index 6e74db0573..e41d47f046 100644 --- a/packages/backend/assets/redoc.html +++ b/packages/backend/assets/redoc.html @@ -19,6 +19,6 @@ - + diff --git a/packages/backend/assets/tabler-badges/medal.png b/packages/backend/assets/tabler-badges/medal.png new file mode 100644 index 0000000000..44dc7b8c1e Binary files /dev/null and b/packages/backend/assets/tabler-badges/medal.png differ diff --git a/packages/backend/check_connect.js b/packages/backend/check_connect.js new file mode 100644 index 0000000000..ef0a350fbf --- /dev/null +++ b/packages/backend/check_connect.js @@ -0,0 +1,17 @@ +import Redis from 'ioredis'; +import { loadConfig } from './built/config.js'; + +const config = loadConfig(); +const redis = new Redis({ + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:`, + db: config.redis.db ?? 0, +}); + +redis.on('connect', () => redis.disconnect()); +redis.on('error', (e) => { + throw e; +}); diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs index 2f11f6a3e9..6b1afec734 100644 --- a/packages/backend/jest.config.cjs +++ b/packages/backend/jest.config.cjs @@ -20,7 +20,7 @@ module.exports = { // collectCoverage: false, // An array of glob patterns indicating a set of files for which coverage information should be collected - collectCoverageFrom: ['src/**/*.ts'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'], // The directory where Jest should output its coverage files coverageDirectory: "coverage", @@ -91,7 +91,7 @@ module.exports = { // See https://github.com/swc-project/jest/issues/64#issuecomment-1029753225 // TODO: Use `--allowImportingTsExtensions` on TypeScript 5.0 so that we can // directly import `.ts` files without this hack. - '^(\\.{1,2}/.*)\\.js$': '$1', + '^((?:\\.{1,2}|[A-Z:])*/.*)\\.js$': '$1', }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader @@ -159,7 +159,8 @@ module.exports = { // The glob patterns Jest uses to detect test files testMatch: [ "/test/unit/**/*.ts", - //"/test/e2e/**/*.ts" + "/src/**/*.test.ts", + "/test/e2e/**/*.ts", ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped @@ -206,4 +207,13 @@ module.exports = { // watchman: true, extensionsToTreatAsEsm: ['.ts'], + + testTimeout: 60000, + + // Let Jest kill the test worker whenever it grows too much + // (It seems there's a known memory leak issue in Node.js' vm.Script used by Jest) + // https://github.com/facebook/jest/issues/11956 + maxWorkers: 1, // Make it use worker (that can be killed and restarted) + logHeapUsage: true, // To debug when out-of-memory happens on CI + workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB) }; diff --git a/packages/backend/migration/1654589815249-increase-smtp-char-limit.js b/packages/backend/migration/1654589815249-increase-smtp-char-limit.js new file mode 100644 index 0000000000..0140dd7988 --- /dev/null +++ b/packages/backend/migration/1654589815249-increase-smtp-char-limit.js @@ -0,0 +1,17 @@ +export class increaseSmtpCharLimit1654589815249 { + name = 'increaseSmtpCharLimit1654589815249' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpHost" TYPE character varying(256)`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(256)`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(256)`); + } + + async down(queryRunner) { + // It's safe to keep extended character limit, rather than shrinking with risk of breaking settings + + // await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpHost" TYPE character varying(128)`); + // await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(128)`); + // await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(128)`); + } +} diff --git a/packages/backend/migration/1660183643857-multipleTranslationServices.js b/packages/backend/migration/1660183643857-multipleTranslationServices.js index fc8e9d3d0f..9f2b6ce725 100644 --- a/packages/backend/migration/1660183643857-multipleTranslationServices.js +++ b/packages/backend/migration/1660183643857-multipleTranslationServices.js @@ -1,17 +1,17 @@ export class multipleTranslationServices1660183643857 { - name = 'multipleTranslationServices1660183643857' + name = 'multipleTranslationServices1660183643857' - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "meta" ADD "translatorType" character varying(32)`); - await queryRunner.query('SELECT "deeplAuthKey" FROM "meta" where "deeplAuthKey" is not null') - .then(deeplAuthKey => { - if (deeplAuthKey.length > 0) { - return queryRunner.query('UPDATE "meta" SET "translatorType" = "DeepL"'); - } - }) - } + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "translatorType" character varying(32)`); + await queryRunner.query('SELECT "deeplAuthKey" FROM "meta" where "deeplAuthKey" is not null') + .then(deeplAuthKey => { + if (deeplAuthKey.length > 0) { + return queryRunner.query('UPDATE "meta" SET "translatorType" = "DeepL"'); + } + }) + } - async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "translatorType"`); - } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "translatorType"`); + } } diff --git a/packages/backend/migration/1665091090561-add-renote-muting.js b/packages/backend/migration/1665091090561-add-renote-muting.js new file mode 100644 index 0000000000..d2ed2bd2e9 --- /dev/null +++ b/packages/backend/migration/1665091090561-add-renote-muting.js @@ -0,0 +1,16 @@ + +export class addRenoteMuting1665091090561 { + constructor() { + this.name = 'addRenoteMuting1665091090561'; + } + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/migration/1675053125067-fixforeignkeyreports.js b/packages/backend/migration/1675053125067-fixforeignkeyreports.js new file mode 100644 index 0000000000..ca5c10b11f --- /dev/null +++ b/packages/backend/migration/1675053125067-fixforeignkeyreports.js @@ -0,0 +1,15 @@ +export class fixforeignkeyreports1675053125067 { + name = 'fixforeignkeyreports1675053125067' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId")`); + await queryRunner.query(`DELETE FROM "abuse_user_report" WHERE "targetUserId" NOT IN (SELECT "id" FROM "user")`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT IF EXISTS "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_a9021cc2e1feb5f72d3db6e9f5"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + } +} diff --git a/packages/backend/migration/1676438468213-ad3.js b/packages/backend/migration/1676438468213-ad3.js index 082bb31325..18f56e8d36 100644 --- a/packages/backend/migration/1676438468213-ad3.js +++ b/packages/backend/migration/1676438468213-ad3.js @@ -1,9 +1,9 @@ export class ad1676438468213 { name = 'ad1676438468213'; async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "ad" ADD "startAt" TIMESTAMP WITH TIME ZONE NOT NULL`); + await queryRunner.query(`ALTER TABLE "ad" ADD "startsAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); } async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "startAt"`); + await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "startsAt"`); } } diff --git a/packages/backend/migration/1677570181236-role-assignment-expires-at.js b/packages/backend/migration/1677570181236-role-assignment-expires-at.js new file mode 100644 index 0000000000..3ac2edab0a --- /dev/null +++ b/packages/backend/migration/1677570181236-role-assignment-expires-at.js @@ -0,0 +1,13 @@ +export class roleAssignmentExpiresAt1677570181236 { + name = 'roleAssignmentExpiresAt1677570181236' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role_assignment" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`CREATE INDEX "IDX_539b6c08c05067599743bb6389" ON "role_assignment" ("expiresAt") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_539b6c08c05067599743bb6389"`); + await queryRunner.query(`ALTER TABLE "role_assignment" DROP COLUMN "expiresAt"`); + } +} diff --git a/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js new file mode 100644 index 0000000000..f1765dd146 --- /dev/null +++ b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js @@ -0,0 +1,11 @@ +export class perNoteReactionAcceptance1678164627293 { + name = 'perNoteReactionAcceptance1678164627293' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "reactionAcceptance" character varying(64)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "reactionAcceptance"`); + } +} diff --git a/packages/backend/migration/1678426061773-tweak-varchar-length.js b/packages/backend/migration/1678426061773-tweak-varchar-length.js new file mode 100644 index 0000000000..984c41dba6 --- /dev/null +++ b/packages/backend/migration/1678426061773-tweak-varchar-length.js @@ -0,0 +1,68 @@ +export class tweakVarcharLength1678426061773 { + name = 'tweakVarcharLength1678426061773' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "name" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "maintainerName" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "maintainerEmail" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "langs" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "pinnedUsers" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hiddenTags" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "blockedHosts" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "themeColor" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "mascotImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "bannerUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "backgroundImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "logoImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "errorImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "iconUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hcaptchaSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hcaptchaSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "recaptchaSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "recaptchaSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "turnstileSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "turnstileSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "summalyProxy" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "email" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpHost" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPublicKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPrivateKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "deeplAuthKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" RENAME COLUMN "ToSUrl" TO "termsOfServiceUrl"`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "termsOfServiceUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "feedbackUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageBucket" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStoragePrefix" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageBaseUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageEndpoint" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageRegion" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageAccessKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(65536)`, undefined); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___readWrite" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___read" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___write" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___readWrite" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___read" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___write" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideYear" TYPE integer`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" RENAME COLUMN "termsOfServiceUrl" TO "ToSUrl"`); + } +} diff --git a/packages/backend/migration/1678427401214-remove-unused.js b/packages/backend/migration/1678427401214-remove-unused.js new file mode 100644 index 0000000000..ee643e7776 --- /dev/null +++ b/packages/backend/migration/1678427401214-remove-unused.js @@ -0,0 +1,13 @@ +export class removeUnused1678427401214 { + name = 'removeUnused1678427401214' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedPages"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedClipId"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedClipId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{/featured,/channels,/explore,/pages,/about-misskey}'`); + } +} diff --git a/packages/backend/migration/1678602320354-role-display-order.js b/packages/backend/migration/1678602320354-role-display-order.js new file mode 100644 index 0000000000..de8f6f1033 --- /dev/null +++ b/packages/backend/migration/1678602320354-role-display-order.js @@ -0,0 +1,11 @@ +export class roleDisplayOrder1678602320354 { + name = 'roleDisplayOrder1678602320354' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "displayOrder" integer NOT NULL DEFAULT '0'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "displayOrder"`); + } +} diff --git a/packages/backend/migration/1678694614599-sensitive-words.js b/packages/backend/migration/1678694614599-sensitive-words.js new file mode 100644 index 0000000000..6d4c5730c7 --- /dev/null +++ b/packages/backend/migration/1678694614599-sensitive-words.js @@ -0,0 +1,11 @@ +export class sensitiveWords1678694614599 { + name = 'sensitiveWords1678694614599' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveWords" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveWords"`); + } +} diff --git a/packages/backend/migration/1678869617549-retention-date-key.js b/packages/backend/migration/1678869617549-retention-date-key.js new file mode 100644 index 0000000000..1a31b9a750 --- /dev/null +++ b/packages/backend/migration/1678869617549-retention-date-key.js @@ -0,0 +1,14 @@ +export class retentionDateKey1678869617549 { + name = 'retentionDateKey1678869617549' + + async up(queryRunner) { + await queryRunner.query(`TRUNCATE TABLE "retention_aggregation"`, undefined); + await queryRunner.query(`ALTER TABLE "retention_aggregation" ADD "dateKey" character varying(512) NOT NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_f7c3576b37bd2eec966ae24477" ON "retention_aggregation" ("dateKey") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_f7c3576b37bd2eec966ae24477"`); + await queryRunner.query(`ALTER TABLE "retention_aggregation" DROP COLUMN "dateKey"`); + } +} diff --git a/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js b/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js new file mode 100644 index 0000000000..656a921770 --- /dev/null +++ b/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js @@ -0,0 +1,11 @@ +export class addPropsForCustomEmoji1678945242650 { + name = 'addPropsForCustomEmoji1678945242650' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" ADD "license" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "license"`); + } +} diff --git a/packages/backend/migration/1678953978856-clip-favorite.js b/packages/backend/migration/1678953978856-clip-favorite.js new file mode 100644 index 0000000000..aa5dc93a6e --- /dev/null +++ b/packages/backend/migration/1678953978856-clip-favorite.js @@ -0,0 +1,23 @@ +export class clipFavorite1678953978856 { + name = 'clipFavorite1678953978856' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "clip_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "clipId" character varying(32) NOT NULL, CONSTRAINT "PK_1b539f43906f05ebcabe752a977" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_25a31662b0b0cc9af6549a9d71" ON "clip_favorite" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b1754a39d0b281e07ed7c078ec" ON "clip_favorite" ("userId", "clipId") `); + await queryRunner.query(`ALTER TABLE "clip" ADD "lastClippedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`CREATE INDEX "IDX_a3eac04ae2aa9e221e7596114a" ON "clip" ("lastClippedAt") `); + await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_fce61c7986cee54393e79f1d849" FOREIGN KEY ("clipId") REFERENCES "clip"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_fce61c7986cee54393e79f1d849"`); + await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711"`); + await queryRunner.query(`DROP INDEX "public"."IDX_a3eac04ae2aa9e221e7596114a"`); + await queryRunner.query(`ALTER TABLE "clip" DROP COLUMN "lastClippedAt"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1754a39d0b281e07ed7c078ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_25a31662b0b0cc9af6549a9d71"`); + await queryRunner.query(`DROP TABLE "clip_favorite"`); + } +} diff --git a/packages/backend/migration/1679309757174-antenna-active.js b/packages/backend/migration/1679309757174-antenna-active.js new file mode 100644 index 0000000000..69e845c142 --- /dev/null +++ b/packages/backend/migration/1679309757174-antenna-active.js @@ -0,0 +1,17 @@ +export class antennaActive1679309757174 { + name = 'antennaActive1679309757174' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT 'now'`); + await queryRunner.query(`ALTER TABLE "antenna" ADD "isActive" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`CREATE INDEX "IDX_084c2abb8948ef59a37dce6ac1" ON "antenna" ("lastUsedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_36ef5192a1ce55ed0e40aa4db5" ON "antenna" ("isActive") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_36ef5192a1ce55ed0e40aa4db5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_084c2abb8948ef59a37dce6ac1"`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "isActive"`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "lastUsedAt"`); + } +} diff --git a/packages/backend/migration/1679639483253-enableChartsForRemoteUser.js b/packages/backend/migration/1679639483253-enableChartsForRemoteUser.js new file mode 100644 index 0000000000..42faab7466 --- /dev/null +++ b/packages/backend/migration/1679639483253-enableChartsForRemoteUser.js @@ -0,0 +1,11 @@ +export class enableChartsForRemoteUser1679639483253 { + name = 'enableChartsForRemoteUser1679639483253' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForRemoteUser" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForRemoteUser"`); + } +} diff --git a/packages/backend/migration/1679651580149-cleanup.js b/packages/backend/migration/1679651580149-cleanup.js new file mode 100644 index 0000000000..1f00f3cc1f --- /dev/null +++ b/packages/backend/migration/1679651580149-cleanup.js @@ -0,0 +1,11 @@ +export class cleanup1679651580149 { + name = 'cleanup1679651580149' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "useStarForReactionFallback"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "useStarForReactionFallback" boolean NOT NULL DEFAULT false`); + } +} diff --git a/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js b/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js new file mode 100644 index 0000000000..0733339841 --- /dev/null +++ b/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js @@ -0,0 +1,11 @@ +export class enableChartsForFederatedInstances1679652081809 { + name = 'enableChartsForFederatedInstances1679652081809' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForFederatedInstances" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForFederatedInstances"`); + } +} diff --git a/packages/backend/migration/1680228513388-channelFavorite.js b/packages/backend/migration/1680228513388-channelFavorite.js new file mode 100644 index 0000000000..afc676959a --- /dev/null +++ b/packages/backend/migration/1680228513388-channelFavorite.js @@ -0,0 +1,21 @@ +export class channelFavorite1680228513388 { + name = 'channelFavorite1680228513388' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "channel_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_59bddfd54d48689a298d41af00c" PRIMARY KEY ("id")); COMMENT ON COLUMN "channel_favorite"."createdAt" IS 'The created date of the ChannelFavorite.'`); + await queryRunner.query(`CREATE INDEX "IDX_735a5544f9249d412255f47f95" ON "channel_favorite" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_d3ca0db011b75ac2a940a2337d" ON "channel_favorite" ("channelId") `); + await queryRunner.query(`CREATE INDEX "IDX_8302bd27226605ece14842fb25" ON "channel_favorite" ("userId") `); + await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_8302bd27226605ece14842fb25a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_8302bd27226605ece14842fb25a"`); + await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8302bd27226605ece14842fb25"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d3ca0db011b75ac2a940a2337d"`); + await queryRunner.query(`DROP INDEX "public"."IDX_735a5544f9249d412255f47f95"`); + await queryRunner.query(`DROP TABLE "channel_favorite"`); + } +} diff --git a/packages/backend/migration/1680238118084-channelNotePining.js b/packages/backend/migration/1680238118084-channelNotePining.js new file mode 100644 index 0000000000..126eae87ea --- /dev/null +++ b/packages/backend/migration/1680238118084-channelNotePining.js @@ -0,0 +1,11 @@ +export class channelNotePining1680238118084 { + name = 'channelNotePining1680238118084' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "pinnedNoteIds" character varying(128) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "pinnedNoteIds"`); + } +} diff --git a/packages/backend/migration/1680491187535-cleanup.js b/packages/backend/migration/1680491187535-cleanup.js new file mode 100644 index 0000000000..1e609ca060 --- /dev/null +++ b/packages/backend/migration/1680491187535-cleanup.js @@ -0,0 +1,10 @@ +export class cleanup1680491187535 { + name = 'cleanup1680491187535' + + async up(queryRunner) { + await queryRunner.query(`DROP TABLE "antenna_note" `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/migration/1680582195041-cleanup.js b/packages/backend/migration/1680582195041-cleanup.js new file mode 100644 index 0000000000..c587e456a5 --- /dev/null +++ b/packages/backend/migration/1680582195041-cleanup.js @@ -0,0 +1,11 @@ +export class cleanup1680582195041 { + name = 'cleanup1680582195041' + + async up(queryRunner) { + await queryRunner.query(`DROP TABLE "notification" `); + } + + async down(queryRunner) { + + } +} diff --git a/packages/backend/migration/1680702787050-UserMemo.js b/packages/backend/migration/1680702787050-UserMemo.js new file mode 100644 index 0000000000..7446bf8da5 --- /dev/null +++ b/packages/backend/migration/1680702787050-UserMemo.js @@ -0,0 +1,18 @@ +export class UserMemo1680702787050 { + name = 'UserMemo1680702787050' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "user_memo" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "targetUserId" character varying(32) NOT NULL, "memo" character varying(2048) NOT NULL, CONSTRAINT "PK_e9aaa58f7d3699a84d79078f4d9" PRIMARY KEY ("id")); COMMENT ON COLUMN "user_memo"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "user_memo"."targetUserId" IS 'The ID of target user.'; COMMENT ON COLUMN "user_memo"."memo" IS 'Memo.'`); + await queryRunner.query(`CREATE INDEX "IDX_650b49c5639b5840ee6a2b8f83" ON "user_memo" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_66ac4a82894297fd09ba61f3d3" ON "user_memo" ("targetUserId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_faef300913c738265638ba3ebc" ON "user_memo" ("userId", "targetUserId") `); + await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35"`); + await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e"`); + await queryRunner.query(`DROP TABLE "user_memo"`); + } +} diff --git a/packages/backend/migration/1680775031481-avatar-url-and-banner-url.js b/packages/backend/migration/1680775031481-avatar-url-and-banner-url.js new file mode 100644 index 0000000000..7c5fe7ac5e --- /dev/null +++ b/packages/backend/migration/1680775031481-avatar-url-and-banner-url.js @@ -0,0 +1,17 @@ +export class AvatarUrlAndBannerUrl1680775031481 { + name = 'AvatarUrlAndBannerUrl1680775031481' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "avatarUrl" character varying(512)`); + await queryRunner.query(`ALTER TABLE "user" ADD "bannerUrl" character varying(512)`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`); + await queryRunner.query(`ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerUrl"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarUrl"`); + } +} diff --git a/packages/backend/migration/1680931179228-account-move.js b/packages/backend/migration/1680931179228-account-move.js new file mode 100644 index 0000000000..821318d1bc --- /dev/null +++ b/packages/backend/migration/1680931179228-account-move.js @@ -0,0 +1,17 @@ +export class AccountMove1680931179228 { + name = 'AccountMove1680931179228' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedToUri" character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); + await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" text`); + await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`); + } +} diff --git a/packages/backend/migration/1681400427971-serverRules.js b/packages/backend/migration/1681400427971-serverRules.js new file mode 100644 index 0000000000..2364e8e1d2 --- /dev/null +++ b/packages/backend/migration/1681400427971-serverRules.js @@ -0,0 +1,11 @@ +export class ServerRules1681400427971 { + name = 'ServerRules1681400427971' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "serverRules" character varying(280) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "serverRules"`); + } +} diff --git a/packages/backend/migration/1681870960239-RoleTLSetting.js b/packages/backend/migration/1681870960239-RoleTLSetting.js new file mode 100644 index 0000000000..2280f44eaa --- /dev/null +++ b/packages/backend/migration/1681870960239-RoleTLSetting.js @@ -0,0 +1,12 @@ +export class RoleTLSetting1681870960239 { + name = 'RoleTLSetting1681870960239' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "isExplorable" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isExplorable"`); + } + +} diff --git a/packages/backend/migration/1682190963894-movedAt.js b/packages/backend/migration/1682190963894-movedAt.js new file mode 100644 index 0000000000..1f8f030a5c --- /dev/null +++ b/packages/backend/migration/1682190963894-movedAt.js @@ -0,0 +1,13 @@ +export class MovedAt1682190963894 { + name = 'MovedAt1682190963894' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedAt"`); + } +} diff --git a/packages/backend/migration/1682754135458-preservedUsernames.js b/packages/backend/migration/1682754135458-preservedUsernames.js new file mode 100644 index 0000000000..46a0826f43 --- /dev/null +++ b/packages/backend/migration/1682754135458-preservedUsernames.js @@ -0,0 +1,11 @@ +export class PreservedUsernames1682754135458 { + name = 'PreservedUsernames1682754135458' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "preservedUsernames" character varying(1024) array NOT NULL DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "preservedUsernames"`); + } +} diff --git a/packages/backend/migration/1682985520254-channelColor.js b/packages/backend/migration/1682985520254-channelColor.js new file mode 100644 index 0000000000..294b7372b2 --- /dev/null +++ b/packages/backend/migration/1682985520254-channelColor.js @@ -0,0 +1,11 @@ +export class ChannelColor1682985520254 { + name = 'ChannelColor1682985520254' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "color" character varying(16) NOT NULL DEFAULT '#86b300'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "color"`); + } +} diff --git a/packages/backend/migration/1683328299359-channelArchive.js b/packages/backend/migration/1683328299359-channelArchive.js new file mode 100644 index 0000000000..83695ff537 --- /dev/null +++ b/packages/backend/migration/1683328299359-channelArchive.js @@ -0,0 +1,13 @@ +export class ChannelArchive1683328299359 { + name = 'ChannelArchive1683328299359' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "isArchived" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_cc7c72974f1b2f385a8921f094" ON "channel" ("isArchived") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_cc7c72974f1b2f385a8921f094"`); + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "isArchived"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 90acaac754..6d86c19d80 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -7,46 +7,76 @@ "start": "node ./built/index.js", "start:test": "NODE_ENV=test node ./built/index.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js", - "build:swc": "swc src -d built -D", + "check:connect": "node ./check_connect.js", + "build": "swc src -d built -D", "watch:swc": "swc src -d built -D -w", - "build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json", + "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "node watch.mjs", - "lint": "tsc --noEmit && eslint --quiet \"src/**/*.ts\"", - "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand", - "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand", + "typecheck": "tsc --noEmit", + "eslint": "eslint --quiet \"src/**/*.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", + "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit", "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-and-coverage": "pnpm jest-and-coverage" }, "optionalDependencies": { - "@tensorflow/tfjs": "4.2.0", - "@tensorflow/tfjs-node": "4.2.0" + "@swc/core-android-arm64": "1.3.11", + "@swc/core-darwin-arm64": "1.3.56", + "@swc/core-darwin-x64": "1.3.56", + "@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", + "slacc-android-arm-eabi": "0.0.7", + "slacc-android-arm64": "0.0.7", + "slacc-darwin-arm64": "0.0.7", + "slacc-darwin-universal": "0.0.7", + "slacc-darwin-x64": "0.0.7", + "slacc-linux-arm-gnueabihf": "0.0.7", + "slacc-linux-arm64-gnu": "0.0.7", + "slacc-linux-arm64-musl": "0.0.7", + "slacc-linux-x64-gnu": "0.0.7", + "slacc-win32-arm64-msvc": "0.0.7", + "slacc-win32-x64-msvc": "0.0.7" }, "dependencies": { - "@bull-board/api": "4.11.1", - "@bull-board/fastify": "4.11.1", - "@bull-board/ui": "4.11.1", - "@discordapp/twemoji": "14.0.2", + "@aws-sdk/client-s3": "3.321.1", + "@aws-sdk/lib-storage": "3.321.1", + "@aws-sdk/node-http-handler": "3.321.1", + "@bull-board/api": "5.1.2", + "@bull-board/fastify": "5.1.2", + "@bull-board/ui": "5.1.2", + "@discordapp/twemoji": "14.1.2", "@fastify/accepts": "4.1.0", "@fastify/cookie": "8.3.0", - "@fastify/cors": "8.2.0", - "@fastify/http-proxy": "8.4.0", - "@fastify/multipart": "7.4.0", - "@fastify/static": "6.8.0", + "@fastify/cors": "8.2.1", + "@fastify/http-proxy": "9.0.0", + "@fastify/multipart": "7.6.0", + "@fastify/static": "6.10.1", "@fastify/view": "7.4.1", - "@nestjs/common": "9.3.7", - "@nestjs/core": "9.3.7", - "@nestjs/testing": "9.3.7", + "@nestjs/common": "9.4.0", + "@nestjs/core": "9.4.0", + "@nestjs/testing": "9.4.0", "@peertube/http-signature": "1.7.0", "@sinonjs/fake-timers": "10.0.2", + "@swc/cli": "0.1.62", + "@swc/core": "1.3.56", + "@vitalets/google-translate-api": "8.0.0", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", "autwh": "0.1.0", - "aws-sdk": "2.1295.0", "bcryptjs": "2.4.3", - "blurhash": "2.0.4", - "bull": "4.10.3", + "blurhash": "2.0.5", + "bull": "4.10.4", "cacheable-lookup": "6.1.0", "cbor": "8.1.0", "chalk": "5.2.0", @@ -55,43 +85,46 @@ "cli-highlight": "2.1.11", "color-convert": "2.0.1", "content-disposition": "0.5.4", - "date-fns": "2.29.3", + "date-fns": "2.30.0", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", - "fastify": "4.12.0", + "fastify": "4.17.0", "feed": "4.2.2", - "file-type": "18.2.0", + "file-type": "18.3.0", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", - "got": "12.5.3", + "got": "12.6.0", + "happy-dom": "9.10.2", "hpagent": "1.2.0", - "ioredis": "4.28.5", + "ioredis": "5.3.2", "ip-cidr": "3.1.0", "is-svg": "4.3.2", "js-yaml": "4.1.0", - "jsdom": "21.1.0", + "jsdom": "21.1.1", "json5": "2.2.3", - "jsonld": "8.1.0", - "jsrsasign": "10.6.1", + "jsonld": "8.1.1", + "meilisearch": "0.32.3", + "jsrsasign": "10.8.6", "mfm-js": "0.23.3", "mime-types": "2.1.35", - "misskey-js": "0.0.15", + "misskey-js": "workspace:*", "ms": "3.0.0-canary.1", "nested-property": "4.0.0", - "node-fetch": "3.3.0", + "node-fetch": "3.3.1", "nodemailer": "6.9.1", "nsfwjs": "2.4.2", "oauth": "0.10.0", "os-utils": "0.0.14", + "otpauth": "9.1.1", "parse5": "7.1.2", - "pg": "8.9.0", + "pg": "8.10.0", "private-ip": "3.0.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.2", "punycode": "2.3.0", "pureimage": "0.3.17", - "qrcode": "1.5.1", + "qrcode": "1.5.3", "random-seed": "0.3.0", "ratelimiter": "3.4.1", "re2": "1.18.0", @@ -99,57 +132,54 @@ "reflect-metadata": "0.1.13", "rename": "1.0.4", "rndstr": "1.0.0", - "rss-parser": "3.12.0", - "rxjs": "7.8.0", + "rss-parser": "3.13.0", + "rxjs": "7.8.1", "s-age": "1.1.2", - "sanitize-html": "2.9.0", + "sanitize-html": "2.10.0", "seedrandom": "3.0.5", - "semver": "7.3.8", - "sharp": "0.31.3", - "speakeasy": "2.0.0", + "semver": "7.5.0", + "sharp": "0.32.1", + "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", + "slacc": "0.0.7", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.17.8", + "systeminformation": "5.17.12", "tinycolor2": "1.6.0", "tmp": "0.2.1", - "tsc-alias": "1.8.2", - "tsconfig-paths": "4.1.2", + "tsc-alias": "1.8.6", + "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typeorm": "0.3.11", - "typescript": "4.9.5", + "typeorm": "0.3.15", + "typescript": "5.0.4", "ulid": "2.3.0", "unzipper": "0.10.11", "uuid": "9.0.0", "vary": "1.1.2", - "web-push": "3.5.0", + "web-push": "3.6.1", "websocket": "1.0.34", - "ws": "8.12.0", + "ws": "8.13.0", "xev": "3.0.2" }, "devDependencies": { - "@jest/globals": "29.4.2", - "@redocly/openapi-core": "1.0.0-beta.123", - "@swc/cli": "0.1.61", - "@swc/core": "1.3.34", - "@swc/jest": "0.2.24", + "@jest/globals": "29.5.0", + "@swc/jest": "0.2.26", "@types/accepts": "1.3.5", - "@types/archiver": "5.3.1", + "@types/archiver": "5.3.2", "@types/bcryptjs": "2.4.2", "@types/bull": "4.10.0", "@types/cbor": "6.0.0", "@types/color-convert": "2.0.0", "@types/content-disposition": "0.5.5", "@types/escape-regexp": "0.0.1", - "@types/fluent-ffmpeg": "2.1.20", - "@types/ioredis": "4.28.10", - "@types/jest": "29.4.0", + "@types/fluent-ffmpeg": "2.1.21", + "@types/jest": "29.5.1", "@types/js-yaml": "4.0.5", - "@types/jsdom": "21.1.0", + "@types/jsdom": "21.1.1", "@types/jsonld": "1.5.8", - "@types/jsrsasign": "10.5.5", + "@types/jsrsasign": "10.5.8", "@types/mime-types": "2.1.1", - "@types/node": "18.13.0", + "@types/node": "18.16.3", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.7", "@types/oauth": "0.9.1", @@ -161,26 +191,26 @@ "@types/ratelimiter": "3.4.4", "@types/redis": "4.0.11", "@types/rename": "1.0.4", - "@types/sanitize-html": "2.8.0", + "@types/sanitize-html": "2.9.0", "@types/semver": "7.3.13", - "@types/sharp": "0.31.1", + "@types/sharp": "0.32.0", "@types/sinonjs__fake-timers": "8.1.2", - "@types/speakeasy": "2.0.7", "@types/tinycolor2": "1.4.3", "@types/tmp": "0.2.3", "@types/unzipper": "0.10.5", - "@types/uuid": "9.0.0", + "@types/uuid": "9.0.1", "@types/vary": "1.1.0", "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.51.0", - "@typescript-eslint/parser": "5.51.0", + "@typescript-eslint/eslint-plugin": "5.59.2", + "@typescript-eslint/parser": "5.59.2", + "aws-sdk-client-mock": "^2.1.1", "cross-env": "7.0.3", - "eslint": "8.33.0", + "eslint": "8.39.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", - "jest": "29.4.2", - "jest-mock": "29.4.2" + "jest": "29.5.0", + "jest-mock": "29.5.0" } } diff --git a/packages/backend/src/@types/redis-lock.d.ts b/packages/backend/src/@types/redis-lock.d.ts new file mode 100644 index 0000000000..9242656a98 --- /dev/null +++ b/packages/backend/src/@types/redis-lock.d.ts @@ -0,0 +1,8 @@ +declare module 'redis-lock' { + import type Redis from 'ioredis'; + + type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise) => void; + function redisLock(client: Redis.Redis, retryDelay: number): Lock; + + export = redisLock; +} diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 35416209a0..5fb4e8ef3c 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -1,18 +1,17 @@ +import { setTimeout } from 'node:timers/promises'; import { Global, Inject, Module } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; -import { createRedisConnection } from '@/redis.js'; +import { MeiliSearch } from 'meilisearch'; import { DI } from './di-symbols.js'; import { loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; import type { Provider, OnApplicationShutdown } from '@nestjs/common'; -const config = loadConfig(); - const $config: Provider = { provide: DI.config, - useValue: config, + useValue: loadConfig(), }; const $db: Provider = { @@ -24,21 +23,65 @@ const $db: Provider = { inject: [DI.config], }; -const $redis: Provider = { - provide: DI.redis, +const $meilisearch: Provider = { + provide: DI.meilisearch, useFactory: (config) => { - const redisClient = createRedisConnection(config); - return redisClient; + if (config.meilisearch) { + return new MeiliSearch({ + host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`, + apiKey: config.meilisearch.apiKey, + }); + } else { + return null; + } }, inject: [DI.config], }; -const $redisSubscriber: Provider = { - provide: DI.redisSubscriber, +const $redis: Provider = { + provide: DI.redis, useFactory: (config) => { - const redisSubscriber = createRedisConnection(config); - redisSubscriber.subscribe(config.host); - return redisSubscriber; + return new Redis.Redis({ + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:`, + db: config.redis.db ?? 0, + }); + }, + inject: [DI.config], +}; + +const $redisForPub: Provider = { + provide: DI.redisForPub, + useFactory: (config) => { + const redis = new Redis.Redis({ + port: config.redisForPubsub.port, + host: config.redisForPubsub.host, + family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, + password: config.redisForPubsub.pass, + keyPrefix: `${config.redisForPubsub.prefix}:`, + db: config.redisForPubsub.db ?? 0, + }); + return redis; + }, + inject: [DI.config], +}; + +const $redisForSub: Provider = { + provide: DI.redisForSub, + useFactory: (config) => { + const redis = new Redis.Redis({ + port: config.redisForPubsub.port, + host: config.redisForPubsub.host, + family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, + password: config.redisForPubsub.pass, + keyPrefix: `${config.redisForPubsub.prefix}:`, + db: config.redisForPubsub.db ?? 0, + }); + redis.subscribe(config.host); + return redis; }, inject: [DI.config], }; @@ -46,21 +89,31 @@ const $redisSubscriber: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $redis, $redisSubscriber], - exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule], + providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub], + exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @Inject(DI.db) private db: DataSource, @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPub) private redisForPub: Redis.Redis, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, ) {} async onApplicationShutdown(signal: string): Promise { + if (process.env.NODE_ENV === 'test') { + // XXX: + // Shutting down the existing connections causes errors on Jest as + // Misskey has asynchronous postgres/redis connections that are not + // awaited. + // Let's wait for some random time for them to finish. + await setTimeout(5000); + } await Promise.all([ this.db.destroy(), this.redisClient.disconnect(), - this.redisSubscriber.disconnect(), + this.redisForPub.disconnect(), + this.redisForSub.disconnect(), ]); } } diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts index 04aa26e652..45ded5495c 100644 --- a/packages/backend/src/boot/common.ts +++ b/packages/backend/src/boot/common.ts @@ -16,12 +16,14 @@ export async function server() { app.enableShutdownHooks(); const serverService = app.get(ServerService); - serverService.launch(); + await serverService.launch(); app.get(ChartManagementService).start(); app.get(JanitorService).start(); app.get(QueueStatsService).start(); app.get(ServerStatsService).start(); + + return app; } export async function jobQueue() { @@ -32,4 +34,6 @@ export async function jobQueue() { jobQueue.get(QueueProcessorService).start(); jobQueue.get(ChartManagementService).start(); + + return jobQueue; } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index a0c3d27c48..a117d4d05f 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; /** @@ -25,6 +25,14 @@ export type Source = { disableCache?: boolean; extra?: { [x: string]: string }; }; + dbReplications?: boolean; + dbSlaves?: { + host: string; + port: number; + db: string; + user: string; + pass: string; + }[]; redis: { host: string; port: number; @@ -33,13 +41,27 @@ export type Source = { db?: number; prefix?: string; }; - elasticsearch: { + redisForPubsub?: { host: string; port: number; + family?: number; + pass: string; + db?: number; + prefix?: string; + }; + redisForJobQueue?: { + host: string; + port: number; + family?: number; + pass: string; + db?: number; + prefix?: string; + }; + meilisearch?: { + host: string; + port: string; + apiKey: string; ssl?: boolean; - user?: string; - pass?: string; - index?: string; }; proxy?: string; @@ -60,8 +82,10 @@ export type Source = { deliverJobConcurrency?: number; inboxJobConcurrency?: number; + relashionshipJobConcurrency?: number; deliverJobPerSec?: number; inboxJobPerSec?: number; + relashionshipJobPerSec?: number; deliverJobMaxAttempts?: number; inboxJobMaxAttempts?: number; @@ -91,6 +115,8 @@ export type Mixin = { mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; + redisForPubsub: NonNullable; + redisForJobQueue: NonNullable; }; export type Config = Source & Mixin; @@ -106,9 +132,11 @@ const dir = `${_dirname}/../../../.config`; /** * Path of configuration file */ -const path = process.env.NODE_ENV === 'test' - ? `${dir}/test.yml` - : `${dir}/default.yml`; +const path = process.env.MISSKEY_CONFIG_YML + ? resolve(dir, process.env.MISSKEY_CONFIG_YML) + : process.env.NODE_ENV === 'test' + ? resolve(dir, 'test.yml') + : resolve(dir, 'default.yml'); export function loadConfig() { const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); @@ -151,6 +179,8 @@ export function loadConfig() { : null; if (!config.redis.prefix) config.redis.prefix = mixin.host; + if (config.redisForPubsub == null) config.redisForPubsub = config.redis; + if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis; return Object.assign(config, mixin); } diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index 6c7f214214..ee1a9a3093 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -56,6 +56,11 @@ export const FILE_TYPE_BROWSERSAFE = [ 'audio/webm', 'audio/aac', + + // see https://github.com/misskey-dev/misskey/pull/10686 + 'audio/flac', + 'audio/wav', + // backward compatibility 'audio/x-flac', 'audio/vnd.wave', ]; diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts new file mode 100644 index 0000000000..ab11785e28 --- /dev/null +++ b/packages/backend/src/core/AccountMoveService.ts @@ -0,0 +1,347 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, In, MoreThan, Not } from 'typeorm'; + +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js'; +import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; +import type { User } from '@/models/entities/User.js'; + +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { RelayService } from '@/core/RelayService.js'; +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 { CacheService } from '@/core/CacheService.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'; + +@Injectable() +export class AccountMoveService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private apPersonService: ApPersonService, + 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 cacheService: CacheService, + private queueService: QueueService, + ) { + } + + /** + * Move a local account to a new account. + * + * After delivering Move activity, its local followers unfollow the old account and then follow the new one. + */ + @bindThis + public async moveFromLocal(src: LocalUser, dst: LocalUser | RemoteUser): Promise { + const srcUri = this.userEntityService.getUserUri(src); + const dstUri = this.userEntityService.getUserUri(dst); + + // add movedToUri to indicate that the user has moved + const update = {} as Partial; + update.alsoKnownAs = src.alsoKnownAs?.includes(dstUri) ? src.alsoKnownAs : src.alsoKnownAs?.concat([dstUri]) ?? [dstUri]; + update.movedToUri = dstUri; + update.movedAt = new Date(); + await this.usersRepository.update(src.id, update); + Object.assign(src, update); + + // Update cache + this.cacheService.uriPersonCache.set(srcUri, src); + + const srcPerson = await this.apRendererService.renderPerson(src); + const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); + await this.apDeliverManagerService.deliverToFollowers(src, updateAct); + this.relayService.deliverToRelays(src, updateAct); + + // Deliver Move activity to the followers of the old account + const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst)); + await this.apDeliverManagerService.deliverToFollowers(src, moveAct); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack(src.id, src, { detail: true, includeSecrets: true }); + this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); + + // Unfollow after 24 hours + const followings = await this.followingsRepository.findBy({ + followerId: src.id, + }); + this.queueService.createDelayedUnfollowJob(followings.map(following => ({ + from: { id: src.id }, + to: { id: following.followeeId }, + })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24); + + await this.postMoveProcess(src, dst); + + return iObj; + } + + @bindThis + public async postMoveProcess(src: User, dst: User): Promise { + // Copy blockings and mutings, and update lists + try { + await Promise.all([ + this.copyBlocking(src, dst), + this.copyMutings(src, dst), + this.updateLists(src, dst), + ]); + } catch { + /* skip if any error happens */ + } + + // follow the new account + const proxy = await this.proxyAccountService.fetch(); + const followings = await this.followingsRepository.findBy({ + followeeId: src.id, + followerHost: IsNull(), // follower is local + followerId: proxy ? Not(proxy.id) : undefined, + }); + const followJobs = followings.map(following => ({ + from: { id: following.followerId }, + to: { id: dst.id }, + })) as RelationshipJobData[]; + + // Decrease following count instead of unfollowing. + try { + await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src); + } catch { + /* skip if any error happens */ + } + + // Should be queued because this can cause a number of follow per one move. + this.queueService.createFollowJob(followJobs); + } + + @bindThis + public async copyBlocking(src: ThinUser, dst: ThinUser): Promise { + // Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving. + // So block the destination account here. + const srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id }); + const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id }); + const blockerIds = dstBlockings.map(blocking => blocking.blockerId); + // reblock the destination account + const blockJobs: RelationshipJobData[] = []; + for (const blocking of srcBlockings) { + if (blockerIds.includes(blocking.blockerId)) continue; // skip if already blocked + blockJobs.push({ from: { id: blocking.blockerId }, to: { id: dst.id } }); + } + // no need to unblock the old account because it may be still functional + this.queueService.createBlockJob(blockJobs); + } + + @bindThis + public async copyMutings(src: ThinUser, dst: ThinUser): Promise { + // Insert new mutings with the same values except mutee + const oldMutings = await this.mutingsRepository.findBy([ + { muteeId: src.id, expiresAt: IsNull() }, + { muteeId: src.id, expiresAt: MoreThan(new Date()) }, + ]); + if (oldMutings.length === 0) return; + + // Check if the destination account is already indefinitely muted by the muter + const existingMutingsMuterUserIds = await this.mutingsRepository.findBy( + { muteeId: dst.id, expiresAt: IsNull() }, + ).then(mutings => mutings.map(muting => muting.muterId)); + + const newMutings: Map = new Map(); + + // 重複しないようにIDを生成 + const genId = (): string => { + let id: string; + do { + id = this.idService.genId(); + } while (newMutings.has(id)); + return id; + }; + for (const muting of oldMutings) { + if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely + newMutings.set(genId(), { + ...muting, + createdAt: new Date(), + muteeId: dst.id, + }); + } + + const arrayToInsert = Array.from(newMutings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.mutingsRepository.insert(arrayToInsert); + } + + /** + * Update lists while moving accounts. + * - No removal of the old account from the lists + * - Users number limit is not checked + * + * @param src ThinUser (old account) + * @param dst User (new account) + * @returns Promise + */ + @bindThis + public async updateLists(src: ThinUser, dst: User): Promise { + // Return if there is no list to be updated. + const oldJoinings = await this.userListJoiningsRepository.find({ + where: { + userId: src.id, + }, + }); + if (oldJoinings.length === 0) return; + + const existingUserListIds = await this.userListJoiningsRepository.find({ + where: { + userId: dst.id, + }, + }).then(joinings => joinings.map(joining => joining.userListId)); + + const newJoinings: Map = new Map(); + + // 重複しないようにIDを生成 + const genId = (): string => { + let id: string; + do { + id = this.idService.genId(); + } while (newJoinings.has(id)); + return id; + }; + for (const joining of oldJoinings) { + if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list + newJoinings.set(genId(), { + createdAt: new Date(), + userId: dst.id, + userListId: joining.userListId, + }); + } + + const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.userListJoiningsRepository.insert(arrayToInsert); + + // 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 } }]); + } + } + } + + @bindThis + private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise { + if (localFollowerIds.length === 0) return; + + // Set the old account's following and followers counts to 0. + await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 }); + + // Decrease following counts of local followers by 1. + await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1); + + // Decrease follower counts of local followees by 1. + const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id }); + if (oldFollowings.length > 0) { + await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1); + } + + // 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); + } + }); + } + + // FIXME: expensive? + for (const followerId of localFollowerIds) { + this.perUserFollowingChart.update({ id: followerId, host: null }, oldAccount, false); + } + } + + /** + * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる + * + * @param dst movedToUrlを指定するユーザー + * @param check + * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか + * @returns Promise + */ + @bindThis + public async validateAlsoKnownAs( + dst: LocalUser | RemoteUser, + check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise = () => true, + instant = false, + ): Promise { + let resultUser: LocalUser | RemoteUser | null = null; + + if (this.userEntityService.isRemoteUser(dst)) { + if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + await this.apPersonService.updatePerson(dst.uri); + } + dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst; + } + + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null; + + const dstUri = this.userEntityService.getUserUri(dst); + + for (const srcUri of dst.alsoKnownAs) { + try { + let src = await this.apPersonService.fetchPerson(srcUri); + if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー + + if (this.userEntityService.isRemoteUser(dst)) { + if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + await this.apPersonService.updatePerson(srcUri); + } + + src = await this.apPersonService.fetchPerson(srcUri) ?? src; + } + + if (src.movedToUri === dstUri) { + if (await check(resultUser, src)) { + resultUser = src; + } + if (instant && resultUser) return resultUser; + } + } catch { + /* skip if any error happens */ + } + } + + return resultUser; + } +} diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index d8ba7b169d..b146fc66be 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -29,7 +29,7 @@ export class AccountUpdateService { public async publishToFollowers(userId: User['id']) { const user = await this.usersRepository.findOneBy({ id: userId }); if (user == null) throw new Error('user not found'); - + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 1278102aa6..1a048c98bc 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -3,7 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const ACHIEVEMENT_TYPES = [ 'notes1', @@ -64,6 +64,7 @@ export const ACHIEVEMENT_TYPES = [ 'iLoveCherryPick', 'foundTreasure', 'client30min', + 'client60min', 'noteDeletedWithin1min', 'postedAtLateNight', 'postedAt0min0sec', @@ -90,7 +91,7 @@ export class AchievementService { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, ) { } @@ -114,7 +115,7 @@ export class AchievementService { }], }); - this.createNotificationService.createNotification(userId, 'achievementEarned', { + this.notificationService.createNotification(userId, 'achievementEarned', { achievement: type, }); } diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index a71327e947..00cc0f2eb6 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { Antenna } from '@/models/entities/Antenna.js'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; @@ -10,9 +10,9 @@ import { isUserRelated } from '@/misc/is-user-related.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { PushNotificationService } from '@/core/PushNotificationService.js'; import * as Acct from '@/misc/acct.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; -import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js'; +import type { MutingsRepository, NotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @@ -24,8 +24,11 @@ export class AntennaService implements OnApplicationShutdown { private antennas: Antenna[]; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @@ -33,9 +36,6 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, @@ -55,12 +55,12 @@ export class AntennaService implements OnApplicationShutdown { this.antennasFetched = false; this.antennas = []; - this.redisSubscriber.on('message', this.onRedisMessage); + this.redisForSub.on('message', this.onRedisMessage); } @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onRedisMessage); + this.redisForSub.off('message', this.onRedisMessage); } @bindThis @@ -74,12 +74,14 @@ export class AntennaService implements OnApplicationShutdown { this.antennas.push({ ...body, createdAt: new Date(body.createdAt), + lastUsedAt: new Date(body.lastUsedAt), }); break; case 'antennaUpdated': this.antennas[this.antennas.findIndex(a => a.id === body.id)] = { ...body, createdAt: new Date(body.createdAt), + lastUsedAt: new Date(body.lastUsedAt), }; break; case 'antennaDeleted': @@ -92,55 +94,24 @@ export class AntennaService implements OnApplicationShutdown { } @bindThis - public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise { - // 通知しない設定になっているか、自分自身の投稿なら既読にする - const read = !antenna.notify || (antenna.userId === noteUser.id); - - this.antennaNotesRepository.insert({ - id: this.idService.genId(), - antennaId: antenna.id, - noteId: note.id, - read: read, - }); - - this.globalEventService.publishAntennaStream(antenna.id, 'note', note); - - if (!read) { - const mutings = await this.mutingsRepository.find({ - where: { - muterId: antenna.userId, - }, - select: ['muteeId'], - }); - - // Copy - const _note: Note = { - ...note, - }; - - if (note.replyId != null) { - _note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId }); - } - if (note.renoteId != null) { - _note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); - } - - if (isUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { - return; - } - - // 2秒経っても既読にならなかったら通知 - setTimeout(async () => { - const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); - if (unread) { - this.globalEventService.publishMainStream(antenna.userId, 'unreadAntenna', antenna); - this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', { - antenna: { id: antenna.id, name: antenna.name }, - note: await this.noteEntityService.pack(note), - }); - } - }, 2000); + public async addNoteToAntennas(note: Note, noteUser: { id: User['id']; username: string; host: string | null; }): Promise { + const antennas = await this.getAntennas(); + const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); + const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); + + const redisPipeline = this.redisClient.pipeline(); + + for (const antenna of matchedAntennas) { + redisPipeline.xadd( + `antennaTimeline:${antenna.id}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + this.globalEventService.publishAntennaStream(antenna.id, 'note', note); } + + redisPipeline.exec(); } // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている @@ -162,11 +133,11 @@ export class AntennaService implements OnApplicationShutdown { if (!listUsers.includes(note.userId)) return false; } else if (antenna.src === 'group') { const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! }); - + const groupUsers = (await this.userGroupJoiningsRepository.findBy({ userGroupId: joining.userGroupId, })).map(x => x.userId); - + if (!groupUsers.includes(note.userId)) return false; } else if (antenna.src === 'users') { const accts = antenna.users.map(x => { @@ -182,13 +153,15 @@ export class AntennaService implements OnApplicationShutdown { .filter(xs => xs.length > 0); if (keywords.length > 0) { - if (note.text == null) return false; + if (note.text == null && note.cw == null) return false; + + const _text = (note.text ?? '') + '\n' + (note.cw ?? ''); const matched = keywords.some(and => and.every(keyword => antenna.caseSensitive - ? note.text!.includes(keyword) - : note.text!.toLowerCase().includes(keyword.toLowerCase()), + ? _text.includes(keyword) + : _text.toLowerCase().includes(keyword.toLowerCase()), )); if (!matched) return false; @@ -200,13 +173,15 @@ export class AntennaService implements OnApplicationShutdown { .filter(xs => xs.length > 0); if (excludeKeywords.length > 0) { - if (note.text == null) return false; - + if (note.text == null && note.cw == null) return false; + + const _text = (note.text ?? '') + '\n' + (note.cw ?? ''); + const matched = excludeKeywords.some(and => and.every(keyword => antenna.caseSensitive - ? note.text!.includes(keyword) - : note.text!.toLowerCase().includes(keyword.toLowerCase()), + ? _text.includes(keyword) + : _text.toLowerCase().includes(keyword.toLowerCase()), )); if (matched) return false; @@ -224,7 +199,9 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async getAntennas() { if (!this.antennasFetched) { - this.antennas = await this.antennasRepository.find(); + this.antennas = await this.antennasRepository.findBy({ + isActive: true, + }); this.antennasFetched = true; } diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts index 5f3072a415..8dd805552b 100644 --- a/packages/backend/src/core/AppLockService.ts +++ b/packages/backend/src/core/AppLockService.ts @@ -1,7 +1,7 @@ import { promisify } from 'node:util'; import { Inject, Injectable } from '@nestjs/common'; import redisLock from 'redis-lock'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -12,7 +12,7 @@ const retryDelay = 100; @Injectable() export class AppLockService { - private lock: (key: string, timeout?: number) => Promise<() => void>; + private lock: (key: string, timeout?: number, _?: (() => Promise) | undefined) => Promise<() => void>; constructor( @Inject(DI.redis) diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts new file mode 100644 index 0000000000..cf1e81ffc8 --- /dev/null +++ b/packages/backend/src/core/CacheService.ts @@ -0,0 +1,172 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; +import type { LocalUser, User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { StreamMessages } from '@/server/api/stream/types.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class CacheService implements OnApplicationShutdown { + public userByIdCache: MemoryKVCache; + public localUserByNativeTokenCache: MemoryKVCache; + public localUserByIdCache: MemoryKVCache; + public uriPersonCache: MemoryKVCache; + public userProfileCache: RedisKVCache; + public userMutingsCache: RedisKVCache>; + public userBlockingCache: RedisKVCache>; + public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ + public renoteMutingsCache: RedisKVCache>; + public userFollowingsCache: RedisKVCache>; + public userFollowingChannelsCache: RedisKVCache>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + private userEntityService: UserEntityService, + ) { + //this.onMessage = this.onMessage.bind(this); + + this.userByIdCache = new MemoryKVCache(Infinity); + this.localUserByNativeTokenCache = new MemoryKVCache(Infinity); + this.localUserByIdCache = new MemoryKVCache(Infinity); + this.uriPersonCache = new MemoryKVCache(Infinity); + + this.userProfileCache = new RedisKVCache(this.redisClient, 'userProfile', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮 + }); + + this.userMutingsCache = new RedisKVCache>(this.redisClient, 'userMutings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userBlockingCache = new RedisKVCache>(this.redisClient, 'userBlocking', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userBlockedCache = new RedisKVCache>(this.redisClient, 'userBlocked', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.renoteMutingsCache = new RedisKVCache>(this.redisClient, 'renoteMutings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userFollowingsCache = new RedisKVCache>(this.redisClient, 'userFollowings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.channelFollowingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as StreamMessages['internal']['payload']; + switch (type) { + case 'userChangeSuspendedState': + case 'remoteUserUpdated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }); + this.userByIdCache.set(user.id, user); + for (const [k, v] of this.uriPersonCache.cache.entries()) { + if (v.value?.id === user.id) { + this.uriPersonCache.set(k, user); + } + } + if (this.userEntityService.isLocalUser(user)) { + this.localUserByNativeTokenCache.set(user.token!, user); + this.localUserByIdCache.set(user.id, user); + } + break; + } + case 'userTokenRegenerated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as LocalUser; + this.localUserByNativeTokenCache.delete(body.oldToken); + this.localUserByNativeTokenCache.set(body.newToken, user); + break; + } + case 'follow': { + const follower = this.userByIdCache.get(body.followerId); + if (follower) follower.followingCount++; + const followee = this.userByIdCache.get(body.followeeId); + if (followee) followee.followersCount++; + break; + } + default: + break; + } + } + } + + @bindThis + public findUserById(userId: User['id']) { + return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined) { + this.redisForSub.off('message', this.onMessage); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 6a6d1b864a..5f25850ee5 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -1,12 +1,11 @@ import { Module } from '@nestjs/common'; -import { DI } from '../di-symbols.js'; +import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AntennaService } from './AntennaService.js'; import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; import { CaptchaService } from './CaptchaService.js'; -import { CreateNotificationService } from './CreateNotificationService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; @@ -41,9 +40,9 @@ import { S3Service } from './S3Service.js'; import { SignupService } from './SignupService.js'; import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; import { UserBlockingService } from './UserBlockingService.js'; -import { UserCacheService } from './UserCacheService.js'; +import { CacheService } from './CacheService.js'; import { UserFollowingService } from './UserFollowingService.js'; -import { UserKeypairStoreService } from './UserKeypairStoreService.js'; +import { UserKeypairService } from './UserKeypairService.js'; import { UserListService } from './UserListService.js'; import { UserMutingService } from './UserMutingService.js'; import { UserSuspendService } from './UserSuspendService.js'; @@ -52,6 +51,7 @@ import { WebhookService } from './WebhookService.js'; import { ProxyAccountService } from './ProxyAccountService.js'; import { UtilityService } from './UtilityService.js'; import { FileInfoService } from './FileInfoService.js'; +import { SearchService } from './SearchService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; import FederationChart from './chart/charts/federation.js'; import NotesChart from './chart/charts/notes.js'; @@ -85,6 +85,7 @@ import { InstanceEntityService } from './entities/InstanceEntityService.js'; import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js'; import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; import { MutingEntityService } from './entities/MutingEntityService.js'; +import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; @@ -124,13 +125,13 @@ import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; -const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService }; const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; @@ -166,9 +167,9 @@ const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; -const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService }; +const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; -const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService }; +const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; @@ -176,6 +177,8 @@ const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', u const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; +const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; + const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart }; @@ -210,6 +213,7 @@ const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', use const $MessagingMessageEntityService: Provider = { provide: 'MessagingMessageEntityService', useExisting: MessagingMessageEntityService }; const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; +const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; @@ -251,13 +255,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ], providers: [ LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, AppLockService, AchievementService, CaptchaService, - CreateNotificationService, CreateSystemUserService, CustomEmojiService, DeleteAccountService, @@ -293,9 +297,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SignupService, TwoFactorAuthenticationService, UserBlockingService, - UserCacheService, + CacheService, UserFollowingService, - UserKeypairStoreService, + UserKeypairService, UserListService, UserMutingService, UserSuspendService, @@ -303,6 +307,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebhookService, UtilityService, FileInfoService, + SearchService, ChartLoggerService, FederationChart, NotesChart, @@ -336,6 +341,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MessagingMessageEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -372,13 +378,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, $AppLockService, $AchievementService, $CaptchaService, - $CreateNotificationService, $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, @@ -414,9 +420,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SignupService, $TwoFactorAuthenticationService, $UserBlockingService, - $UserCacheService, + $CacheService, $UserFollowingService, - $UserKeypairStoreService, + $UserKeypairService, $UserListService, $UserMutingService, $UserSuspendService, @@ -424,6 +430,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebhookService, $UtilityService, $FileInfoService, + $SearchService, $ChartLoggerService, $FederationChart, $NotesChart, @@ -457,6 +464,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MessagingMessageEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, @@ -494,13 +502,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting exports: [ QueueModule, LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, AppLockService, AchievementService, CaptchaService, - CreateNotificationService, CreateSystemUserService, CustomEmojiService, DeleteAccountService, @@ -536,9 +544,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SignupService, TwoFactorAuthenticationService, UserBlockingService, - UserCacheService, + CacheService, UserFollowingService, - UserKeypairStoreService, + UserKeypairService, UserListService, UserMutingService, UserSuspendService, @@ -546,6 +554,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebhookService, UtilityService, FileInfoService, + SearchService, FederationChart, NotesChart, UsersChart, @@ -578,6 +587,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MessagingMessageEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -614,13 +624,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, $AppLockService, $AchievementService, $CaptchaService, - $CreateNotificationService, $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, @@ -656,9 +666,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SignupService, $TwoFactorAuthenticationService, $UserBlockingService, - $UserCacheService, + $CacheService, $UserFollowingService, - $UserKeypairStoreService, + $UserKeypairService, $UserListService, $UserMutingService, $UserSuspendService, @@ -666,6 +676,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebhookService, $UtilityService, $FileInfoService, + $SearchService, $FederationChart, $NotesChart, $UsersChart, @@ -698,6 +709,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MessagingMessageEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, @@ -733,4 +745,4 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#endregion ], }) -export class CoreModule {} +export class CoreModule { } diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts deleted file mode 100644 index cd47844a75..0000000000 --- a/packages/backend/src/core/CreateNotificationService.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; -import type { User } from '@/models/entities/User.js'; -import type { Notification } from '@/models/entities/Notification.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; -import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; -import { PushNotificationService } from '@/core/PushNotificationService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class CreateNotificationService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - private notificationEntityService: NotificationEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - private pushNotificationService: PushNotificationService, - ) { - } - - @bindThis - public async createNotification( - notifieeId: User['id'], - type: Notification['type'], - data: Partial, - ): Promise { - if (data.notifierId && (notifieeId === data.notifierId)) { - return null; - } - - const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); - - const isMuted = profile?.mutingNotificationTypes.includes(type); - - // Create notification - const notification = await this.notificationsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - notifieeId: notifieeId, - type: type, - // 相手がこの通知をミュートしているようなら、既読を予めつけておく - isRead: isMuted, - ...data, - } as Partial) - .then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); - - const packed = await this.notificationEntityService.pack(notification, {}); - - // Publish notification event - this.globalEventService.publishMainStream(notifieeId, 'notification', packed); - - // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する - setTimeout(async () => { - const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); - if (fresh == null) return; // 既に削除されているかもしれない - if (fresh.isRead) return; - - //#region ただしミュートしているユーザーからの通知なら無視 - const mutings = await this.mutingsRepository.findBy({ - muterId: notifieeId, - }); - if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { - return; - } - //#endregion - - this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); - this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); - - if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); - if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); - }, 2000); - - return notification; - } - - // TODO - //const locales = await import('../../../../locales/index.js'); - - // TODO: locale ファイルをクライアント用とサーバー用で分けたい - - @bindThis - private async emailNotificationFollow(userId: User['id'], follower: User) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; - const locale = locales[userProfile.lang ?? 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); - */ - } - - @bindThis - private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; - const locale = locales[userProfile.lang ?? 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); - */ - } -} diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 63f0319442..d4407d88ae 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -1,24 +1,29 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource, In, IsNull } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Emoji } from '@/models/entities/Emoji.js'; -import type { EmojisRepository, Note } from '@/models/index.js'; +import type { EmojisRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import type { Config } from '@/config.js'; -import { ReactionService } from '@/core/ReactionService.js'; import { query } from '@/misc/prelude/url.js'; +import type { Serialized } from '@/server/api/stream/types.js'; @Injectable() export class CustomEmojiService { - private cache: Cache; + private cache: MemoryKVCache; + public localEmojisCache: RedisSingleCache>; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.config) private config: Config, @@ -32,9 +37,22 @@ export class CustomEmojiService { private idService: IdService, private emojiEntityService: EmojiEntityService, private globalEventService: GlobalEventService, - private reactionService: ReactionService, ) { - this.cache = new Cache(1000 * 60 * 60 * 12); + this.cache = new MemoryKVCache(1000 * 60 * 60 * 12); + + this.localEmojisCache = new RedisSingleCache>(this.redisClient, 'localEmojis', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60 * 3, // 3m + fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), + toRedisConverter: (value) => JSON.stringify(Array.from(value.values())), + fromRedisConverter: (value) => { + if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す) + return new Map(JSON.parse(value).map((x: Serialized) => [x.name, { + ...x, + updatedAt: x.updatedAt ? new Date(x.updatedAt) : null, + }])); + }, + }); } @bindThis @@ -44,6 +62,7 @@ export class CustomEmojiService { category: string | null; aliases: string[]; host: string | null; + license: string | null; }): Promise { const emoji = await this.emojisRepository.insert({ id: this.idService.genId(), @@ -55,19 +74,179 @@ export class CustomEmojiService { originalUrl: data.driveFile.url, publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, type: data.driveFile.webpublicType ?? data.driveFile.type, + license: data.license, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); if (data.host == null) { - await this.db.queryResultCache!.remove(['meta_emojis']); + this.localEmojisCache.refresh(); + + //this.prefetchEmojis([{name: data.name, host: null}]); + this.cache.set(`${data.name} ${data.host}`, emoji); this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: await this.emojiEntityService.pack(emoji.id), + emoji: await this.emojiEntityService.packDetailed(emoji.id), }); } return emoji; } + @bindThis + public async update(id: Emoji['id'], data: { + name?: string; + category?: string | null; + aliases?: string[]; + license?: string | null; + }): Promise { + const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); + const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); + if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); + + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + name: data.name, + category: data.category, + aliases: data.aliases, + license: data.license, + }); + + this.localEmojisCache.refresh(); + + const updated = await this.emojiEntityService.packDetailed(emoji.id); + + if (emoji.name === data.name) { + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: [updated], + }); + } else { + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: [await this.emojiEntityService.packDetailed(emoji)], + }); + + this.globalEventService.publishBroadcastStream('emojiAdded', { + emoji: updated, + }); + } + } + + @bindThis + public async addAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + aliases: [...new Set(emoji.aliases.concat(aliases))], + }); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async setAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + aliases: aliases, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async removeAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + aliases: emoji.aliases.filter(x => !aliases.includes(x)), + }); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async setCategoryBulk(ids: Emoji['id'][], category: string | null) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + category: category, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async setLicenseBulk(ids: Emoji['id'][], license: string | null) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + license: license, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async delete(id: Emoji['id']) { + const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); + + await this.emojisRepository.delete(emoji.id); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: [await this.emojiEntityService.packDetailed(emoji)], + }); + } + + @bindThis + public async deleteBulk(ids: Emoji['id'][]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.delete(emoji.id); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: await this.emojiEntityService.packDetailedMany(emojis), + }); + } + @bindThis private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { // クエリに使うホスト @@ -82,7 +261,7 @@ export class CustomEmojiService { } @bindThis - private parseEmojiStr(emojiName: string, noteUserHost: string | null) { + public parseEmojiStr(emojiName: string, noteUserHost: string | null) { const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); if (!match) return { name: null, host: null }; @@ -114,16 +293,7 @@ export class CustomEmojiService { const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); if (emoji == null) return null; - - const isLocal = emoji.host == null; - const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - const url = isLocal - ? emojiUrl - : this.config.proxyRemoteFiles - ? `${this.config.mediaProxy}/emoji.webp?${query({ url: emojiUrl })}` - : emojiUrl; - - return url; + return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) } /** @@ -141,30 +311,6 @@ export class CustomEmojiService { return res; } - @bindThis - public aggregateNoteEmojis(notes: Note[]) { - let emojis: { name: string | null; host: string | null; }[] = []; - for (const note of notes) { - emojis = emojis.concat(note.emojis - .map(e => this.parseEmojiStr(e, note.userHost))); - if (note.renote) { - emojis = emojis.concat(note.renote.emojis - .map(e => this.parseEmojiStr(e, note.renote!.userHost))); - if (note.renote.user) { - emojis = emojis.concat(note.renote.user.emojis - .map(e => this.parseEmojiStr(e, note.renote!.userHost))); - } - } - const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; - emojis = emojis.concat(customReactions); - if (note.user) { - emojis = emojis.concat(note.user.emojis - .map(e => this.parseEmojiStr(e, note.userHost))); - } - } - return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; - } - /** * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します */ diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 2acb5f2303..327283106f 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -36,8 +36,5 @@ export class DeleteAccountService { await this.usersRepository.update(user.id, { isDeleted: true, }); - - // Terminate streaming - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); } } diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 852c1f32e3..bd535c6032 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr'; import PrivateIp from 'private-ip'; import chalk from 'chalk'; import got, * as Got from 'got'; +import { parse } from 'content-disposition'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -32,13 +33,18 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string): Promise { + public async downloadUrl(url: string, path: string): Promise<{ + filename: string; + }> { this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); const timeout = 30 * 1000; const operationTimeout = 60 * 1000; const maxSize = this.config.maxFileSize ?? 262144000; + const urlObj = new URL(url); + let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; + const req = got.stream(url, { headers: { 'User-Agent': this.config.userAgent, @@ -77,6 +83,18 @@ export class DownloadService { req.destroy(); } } + + const contentDisposition = res.headers['content-disposition']; + if (contentDisposition != null) { + try { + const parsed = parse(contentDisposition); + if (parsed.parameters.filename) { + filename = parsed.parameters.filename; + } + } catch (e) { + this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e }); + } + } }).on('downloadProgress', (progress: Got.Progress) => { if (progress.transferred > maxSize) { this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); @@ -95,6 +113,10 @@ export class DownloadService { } this.logger.succ(`Download finished: ${chalk.cyan(url)}`); + + return { + filename, + }; } @bindThis diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index b15c967c85..1483b55469 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -2,7 +2,9 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; import sharp from 'sharp'; +import { sharpBmp } from 'sharp-read-bmp'; import { IsNull } from 'typeorm'; +import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -33,7 +35,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { FileInfoService } from '@/core/FileInfoService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; -import type S3 from 'aws-sdk/clients/s3.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; type AddFileArgs = { /** User who wish to add file */ @@ -56,6 +59,8 @@ type AddFileArgs = { uri?: string | null; /** Mark file as sensitive */ sensitive?: boolean | null; + /** Extension to force */ + ext?: string | null; requestIp?: string | null; requestHeaders?: Record | null; @@ -78,6 +83,7 @@ type UploadFromUrlArgs = { export class DriveService { private registerLogger: Logger; private downloaderLogger: Logger; + private deleteLogger: Logger; constructor( @Inject(DI.config) @@ -115,12 +121,13 @@ export class DriveService { const logger = new Logger('drive', 'blue'); this.registerLogger = logger.createSubLogger('register', 'yellow'); this.downloaderLogger = logger.createSubLogger('downloader'); + this.deleteLogger = logger.createSubLogger('delete'); } /*** * Save file * @param path Path for original - * @param name Name for original + * @param name Name for original (should be extention corrected) * @param type Content-Type for original * @param hash Hash for original * @param size Size for original @@ -146,7 +153,7 @@ export class DriveService { } // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 - // 許可されているファイル形式でしか拡張子をつけない + // 許可されているファイル形式でしかURLに拡張子をつけない if (!FILE_TYPE_BROWSERSAFE.includes(type)) { ext = ''; } @@ -168,7 +175,7 @@ export class DriveService { //#region Uploads this.registerLogger.info(`uploading original: ${key}`); const uploads = [ - this.upload(key, fs.createReadStream(path), type, name), + this.upload(key, fs.createReadStream(path), type, null, name), ]; if (alts.webpublic) { @@ -176,7 +183,7 @@ export class DriveService { webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); - uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name)); } if (alts.thumbnail) { @@ -184,7 +191,7 @@ export class DriveService { thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`)); } await Promise.all(uploads); @@ -273,8 +280,8 @@ export class DriveService { } } - if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/svg+xml'].includes(type)) { - this.registerLogger.debug('web image and thumbnail not created (not an required file)'); + if (!isMimeImage(type, 'sharp-convertible-image-with-bmp')) { + this.registerLogger.debug('web image and thumbnail not created (cannot convert by sharp)'); return { webpublic: null, thumbnail: null, @@ -283,22 +290,16 @@ export class DriveService { let img: sharp.Sharp | null = null; let satisfyWebpublic: boolean; + let isAnimated: boolean; try { - img = sharp(path); + img = await sharpBmp(path, type); const metadata = await img.metadata(); - const isAnimated = metadata.pages && metadata.pages > 1; - - // skip animated - if (isAnimated) { - return { - webpublic: null, - thumbnail: null, - }; - } + isAnimated = !!(metadata.pages && metadata.pages > 1); satisfyWebpublic = !!( - type !== 'image/svg+xml' && type !== 'image/webp' && type !== 'image/avif' && + type !== 'image/svg+xml' && // security reason + type !== 'image/avif' && // not supported by Mastodon and MS Edge !(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && metadata.width && metadata.width <= 2048 && metadata.height && metadata.height <= 2048 @@ -314,15 +315,13 @@ export class DriveService { // #region webpublic let webpublic: IImage | null = null; - if (generateWeb && !satisfyWebpublic) { + if (generateWeb && !satisfyWebpublic && !isAnimated) { this.registerLogger.info('creating web image'); try { if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048); - } else if (['image/png'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); - } else if (['image/svg+xml'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048); + } else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) { webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); } else { this.registerLogger.debug('web image not created (not an required image)'); @@ -332,6 +331,7 @@ export class DriveService { } } else { if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)'); + else if (isAnimated) this.registerLogger.info('web image not created (animated image)'); else this.registerLogger.info('web image not created (from remote)'); } // #endregion webpublic @@ -340,10 +340,10 @@ export class DriveService { let thumbnail: IImage | null = null; try { - if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(type)) { - thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280); + if (isAnimated) { + thumbnail = await this.imageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 }); } else { - this.registerLogger.debug('thumbnail not created (not an required file)'); + thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422); } } catch (err) { this.registerLogger.warn('thumbnail not created (an error occured)', err as Error); @@ -360,7 +360,7 @@ export class DriveService { * Upload to ObjectStorage */ @bindThis - private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) { if (type === 'image/apng') type = 'image/png'; if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; @@ -372,34 +372,35 @@ export class DriveService { Body: stream, ContentType: type, CacheControl: 'max-age=31536000, immutable', - } as S3.PutObjectRequest; + } as PutObjectCommandInput; - if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (filename) params.ContentDisposition = contentDisposition( + 'inline', + // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 + // 許可されているファイル形式でしか拡張子をつけない + ext ? correctFilename(filename, ext) : filename, + ); if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - const s3 = this.s3Service.getS3(meta); - - const upload = s3.upload(params, { - partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, - }); - - await upload.promise() + await this.s3Service.upload(meta, params) .then( result => { - if (result) { + if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); - } else { - this.registerLogger.error(`Upload Result Empty: key = ${key}, filename = ${filename}`); + } else { // AbortMultipartUploadCommandOutput + this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); } - }, + }) + .catch( err => { this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); }, ); } + // Expire oldest file (without avatar or banner) of remote user @bindThis - private async deleteOldFile(user: RemoteUser) { + private async expireOldFile(user: RemoteUser, driveCapacity: number) { const q = this.driveFilesRepository.createQueryBuilder('file') .where('file.userId = :userId', { userId: user.id }) .andWhere('file.isLink = FALSE'); @@ -412,12 +413,17 @@ export class DriveService { q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); } + //This selete is hard coded, be careful if change database schema + q.addSelect('SUM("file"."size") OVER (ORDER BY "file"."id" DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', 'acc_usage'); q.orderBy('file.id', 'ASC'); - const oldFile = await q.getOne(); + const fileList = await q.getRawMany(); + const exceedFileIds = fileList.filter((x: any) => x.acc_usage > driveCapacity).map((x: any) => x.file_id); - if (oldFile) { - this.deleteFile(oldFile, true); + for (const fileId of exceedFileIds) { + const file = await this.driveFilesRepository.findOneBy({ id: fileId }); + if (file == null) continue; + this.deleteFile(file, true); } } @@ -439,10 +445,16 @@ export class DriveService { sensitive = null, requestIp = null, requestHeaders = null, + ext = null, }: AddFileArgs): Promise { let skipNsfwCheck = false; const instance = await this.metaService.fetch(); - if (user == null) skipNsfwCheck = true; + 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; @@ -466,7 +478,12 @@ export class DriveService { //} // detect name - const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + const detectedName = correctFilename( + // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 + // extを付加してデータベースの文字数制限に当たることはまずない + (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', + ext ?? info.type.ext, + ); if (user && !force) { // Check if there is a file with the same hash @@ -486,22 +503,19 @@ export class DriveService { //#region Check drive usage if (user && !isLink) { const usage = await this.driveFileEntityService.calcDriveUsageOf(user); + const isLocalUser = this.userEntityService.isLocalUser(user); const policies = await this.roleService.getUserPolicies(user.id); const driveCapacity = 1024 * 1024 * policies.driveCapacityMb; this.registerLogger.debug('drive capacity override applied'); this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); - this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); - // If usage limit exceeded - if (usage + info.size > driveCapacity) { - if (this.userEntityService.isLocalUser(user)) { + if (driveCapacity < usage + info.size) { + if (isLocalUser) { throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); - } else { - // (アバターまたはバナーを含まず)最も古いファイルを削除する - this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser); } + await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser, driveCapacity - info.size); } } //#endregion @@ -522,10 +536,10 @@ export class DriveService { }; const properties: { - width?: number; - height?: number; - orientation?: number; - } = {}; + width?: number; + height?: number; + orientation?: number; + } = {}; if (info.width) { properties['width'] = info.width; @@ -562,6 +576,7 @@ export class DriveService { if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; + if (userRoleNSFW) file.isSensitive = true; if (url !== null) { file.src = url; @@ -610,17 +625,20 @@ export class DriveService { if (user) { this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { - // Publish driveFileCreated event + // Publish driveFileCreated event this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile); this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile); }); } - // 統計を更新 this.driveChart.update(file, true); - this.perUserDriveChart.update(file, true); - if (file.userHost !== null) { - this.instanceChart.updateDrive(file, true); + if (file.userHost == null) { + // ローカルユーザーのみ + this.perUserDriveChart.update(file, true); + } else { + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateDrive(file, true); + } } return file; @@ -686,7 +704,7 @@ export class DriveService { @bindThis private async deletePostProcess(file: DriveFile, isExpired = false) { - // リモートファイル期限切れ削除後は直リンクにする + // リモートファイル期限切れ削除後は直リンクにする if (isExpired && file.userHost !== null && file.uri != null) { this.driveFilesRepository.update(file.id, { isLink: true, @@ -703,24 +721,37 @@ export class DriveService { this.driveFilesRepository.delete(file.id); } - // 統計を更新 this.driveChart.update(file, false); - this.perUserDriveChart.update(file, false); - if (file.userHost !== null) { - this.instanceChart.updateDrive(file, false); + if (file.userHost == null) { + // ローカルユーザーのみ + this.perUserDriveChart.update(file, false); + } else { + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateDrive(file, false); + } } } @bindThis public async deleteObjectStorageFile(key: string) { const meta = await this.metaService.fetch(); + try { + const param = { + Bucket: meta.objectStorageBucket, + Key: key, + } as DeleteObjectCommandInput; - const s3 = this.s3Service.getS3(meta); - - await s3.deleteObject({ - Bucket: meta.objectStorageBucket!, - Key: key, - }).promise(); + await this.s3Service.delete(meta, param); + } catch (err: any) { + if (err.name === 'NoSuchKey') { + this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error); + return; + } else { + throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { + cause: err, + }); + } + } } @bindThis @@ -736,24 +767,19 @@ export class DriveService { requestIp = null, requestHeaders = null, }: UploadFromUrlArgs): Promise { - let name = new URL(url).pathname.split('/').pop() ?? null; - if (name == null || !this.driveFileEntityService.validateFileName(name)) { - name = null; - } - - // If the comment is same as the name, skip comment - // (image.name is passed in when receiving attachment) - if (comment !== null && name === comment) { - comment = null; - } - // Create temp file const [path, cleanup] = await createTemp(); - + try { // write content at URL to temp file - await this.downloadService.downloadUrl(url, path); - + const { filename: name } = await this.downloadService.downloadUrl(url, path); + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name === comment) { + comment = null; + } + const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); this.downloaderLogger.succ(`Got: ${driveFile.id}`); return driveFile!; diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index e83b037dd7..8b9a87a380 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -1,7 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; import type { InstancesRepository } from '@/models/index.js'; import type { Instance } from '@/models/entities/Instance.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -9,23 +10,41 @@ import { bindThis } from '@/decorators.js'; @Injectable() export class FederatedInstanceService { - private cache: Cache; + public federatedInstanceCache: RedisKVCache; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, private utilityService: UtilityService, private idService: IdService, ) { - this.cache = new Cache(1000 * 60 * 60); + this.federatedInstanceCache = new RedisKVCache(this.redisClient, 'federatedInstance', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60 * 3, // 3m + fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => { + const parsed = JSON.parse(value); + if (parsed == null) return null; + return { + ...parsed, + firstRetrievedAt: new Date(parsed.firstRetrievedAt), + latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, + infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, + }; + }, + }); } @bindThis public async fetch(host: string): Promise { host = this.utilityService.toPuny(host); - const cached = this.cache.get(host); + const cached = await this.federatedInstanceCache.get(host); if (cached) return cached; const index = await this.instancesRepository.findOneBy({ host }); @@ -37,24 +56,25 @@ export class FederatedInstanceService { firstRetrievedAt: new Date(), }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); - this.cache.set(host, i); + this.federatedInstanceCache.set(host, i); return i; } else { - this.cache.set(host, index); + this.federatedInstanceCache.set(host, index); return index; } } @bindThis - public async updateCachePartial(host: string, data: Partial): Promise { - host = this.utilityService.toPuny(host); + public async update(id: Instance['id'], data: Partial): Promise { + const result = await this.instancesRepository.createQueryBuilder().update() + .set(data) + .where('id = :id', { id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); - const cached = this.cache.get(host); - if (cached == null) return; - - this.cache.set(host, { - ...cached, - ...data, - }); + this.federatedInstanceCache.set(result.host, result); } } diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 35f30deeb4..8103d5afe9 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -2,7 +2,6 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import { JSDOM } from 'jsdom'; import tinycolor from 'tinycolor2'; -import fetch from 'node-fetch'; import type { Instance } from '@/models/entities/Instance.js'; import type { InstancesRepository } from '@/models/index.js'; import { AppLockService } from '@/core/AppLockService.js'; @@ -11,6 +10,7 @@ import { DI } from '@/di-symbols.js'; import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { DOMWindow } from 'jsdom'; type NodeInfo = { @@ -43,6 +43,7 @@ export class FetchInstanceMetadataService { private appLockService: AppLockService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, + private federatedInstanceService: FederatedInstanceService, ) { this.logger = this.loggerService.getLogger('metadata', 'cyan'); } @@ -97,7 +98,7 @@ export class FetchInstanceMetadataService { if (favicon) updates.faviconUrl = favicon; if (themeColor) updates.themeColor = themeColor; - await this.instancesRepository.update(instance.id, updates); + await this.federatedInstanceService.update(instance.id, updates); this.logger.succ(`Successfuly updated metadata of ${instance.host}`); } catch (e) { diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 67337b5056..b6cae5ea75 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -3,9 +3,9 @@ import * as crypto from 'node:crypto'; import { join } from 'node:path'; import * as stream from 'node:stream'; import * as util from 'node:util'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { FSWatcher } from 'chokidar'; -import { fileTypeFromFile } from 'file-type'; +import * as fileType from 'file-type'; import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; @@ -301,21 +301,34 @@ export class FileInfoService { return fs.promises.access(path).then(() => true, () => false); } + @bindThis + public fixMime(mime: string | fileType.MimeType): string { + // see https://github.com/misskey-dev/misskey/pull/10686 + if (mime === "audio/x-flac") { + return "audio/flac"; + } + if (mime === "audio/vnd.wave") { + return "audio/wav"; + } + + return mime; + } + /** * Detect MIME Type and extension */ @bindThis public async detectType(path: string): Promise<{ - mime: string; - ext: string | null; -}> { + mime: string; + ext: string | null; + }> { // Check 0 byte const fileSize = await this.getFileSize(path); if (fileSize === 0) { return TYPE_OCTET_STREAM; } - const type = await fileTypeFromFile(path); + const type = await fileType.fileTypeFromFile(path); if (type) { // XMLはSVGかもしれない @@ -324,7 +337,7 @@ export class FileInfoService { } return { - mime: type.mime, + mime: this.fixMime(type.mime), ext: type.ext, }; } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 784612149d..b44a5c9fa3 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -1,17 +1,15 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; import type { UserList } from '@/models/entities/UserList.js'; import type { UserGroup } from '@/models/entities/UserGroup.js'; import type { Antenna } from '@/models/entities/Antenna.js'; -import type { Channel } from '@/models/entities/Channel.js'; import type { StreamChannels, AdminStreamTypes, AntennaStreamTypes, BroadcastTypes, - ChannelStreamTypes, DriveStreamTypes, GroupMessagingStreamTypes, InternalStreamTypes, @@ -20,12 +18,13 @@ import type { MessagingStreamTypes, NoteStreamTypes, UserListStreamTypes, - UserStreamTypes, + RoleTimelineStreamTypes, } from '@/server/api/stream/types.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; +import { Role } from '@/models'; @Injectable() export class GlobalEventService { @@ -33,8 +32,8 @@ export class GlobalEventService { @Inject(DI.config) private config: Config, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForPub) + private redisForPub: Redis.Redis, ) { } @@ -44,7 +43,7 @@ export class GlobalEventService { { type: type, body: null } : { type: type, body: value }; - this.redisClient.publish(this.config.host, JSON.stringify({ + this.redisForPub.publish(this.config.host, JSON.stringify({ channel: channel, message: message, })); @@ -55,11 +54,6 @@ export class GlobalEventService { this.publish('internal', type, typeof value === 'undefined' ? null : value); } - @bindThis - public publishUserEvent(userId: User['id'], type: K, value?: UserStreamTypes[K]): void { - this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); - } - @bindThis public publishBroadcastStream(type: K, value?: BroadcastTypes[K]): void { this.publish('broadcast', type, typeof value === 'undefined' ? null : value); @@ -83,11 +77,6 @@ export class GlobalEventService { }); } - @bindThis - public publishChannelStream(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void { - this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value); - } - @bindThis public publishUserListStream(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void { this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); @@ -113,6 +102,11 @@ export class GlobalEventService { this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishRoleTimelineStream(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { + this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishNotesStream(note: Packed<'Note'>): void { this.publish('notesStream', null, note); diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 31c0819e50..8aa6ccfc4e 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -2,11 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { genAid } from '@/misc/id/aid.js'; -import { genMeid } from '@/misc/id/meid.js'; -import { genMeidg } from '@/misc/id/meidg.js'; +import { genAid, parseAid } from '@/misc/id/aid.js'; +import { genMeid, parseMeid } from '@/misc/id/meid.js'; +import { genMeidg, parseMeidg } from '@/misc/id/meidg.js'; import { genObjectId } from '@/misc/id/object-id.js'; import { bindThis } from '@/decorators.js'; +import { parseUlid } from '@/misc/id/ulid.js'; @Injectable() export class IdService { @@ -32,4 +33,16 @@ export class IdService { default: throw new Error('unrecognized id generation method'); } } + + @bindThis + public parse(id: string): { date: Date; } { + switch (this.method) { + case 'aid': return parseAid(id); + case 'objectid': + case 'meid': return parseMeid(id); + case 'meidg': return parseMeidg(id); + case 'ulid': return parseUlid(id); + default: throw new Error('unrecognized id generation method'); + } + } } diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts index fbc02f504b..3246475d12 100644 --- a/packages/backend/src/core/ImageProcessingService.ts +++ b/packages/backend/src/core/ImageProcessingService.ts @@ -15,15 +15,28 @@ export type IImageStream = { type: string; }; -export type IImageStreamable = IImage | IImageStream; +export type IImageSharp = { + data: sharp.Sharp; + ext: string | null; + type: string; +}; + +export type IImageStreamable = IImage | IImageStream | IImageSharp; export const webpDefault: sharp.WebpOptions = { - quality: 85, + quality: 77, alphaQuality: 95, lossless: false, nearLossless: false, smartSubsample: true, mixed: true, + effort: 2, +}; + +export const avifDefault: sharp.AvifOptions = { + quality: 60, + lossless: false, + effort: 2, }; import { bindThis } from '@/decorators.js'; @@ -37,36 +50,6 @@ export class ImageProcessingService { ) { } - /** - * Convert to JPEG - * with resize, remove metadata, resolve orientation, stop animation - */ - @bindThis - public async convertToJpeg(path: string, width: number, height: number): Promise { - return this.convertSharpToJpeg(await sharp(path), width, height); - } - - @bindThis - public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .jpeg({ - quality: 85, - progressive: true, - }) - .toBuffer(); - - return { - data, - ext: 'jpg', - type: 'image/jpeg', - }; - } - /** * Convert to WebP * with resize, remove metadata, resolve orientation, stop animation @@ -78,36 +61,29 @@ export class ImageProcessingService { @bindThis public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .webp(options) - .toBuffer(); + const result = this.convertSharpToWebpStream(sharp, width, height, options); return { - data, - ext: 'webp', - type: 'image/webp', + data: await result.data.toBuffer(), + ext: result.ext, + type: result.type, }; } @bindThis - public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { + public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { return this.convertSharpToWebpStream(sharp(path), width, height, options); } @bindThis - public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { + public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { const data = sharp .resize(width, height, { fit: 'inside', withoutEnlargement: true, }) .rotate() - .webp(options) + .webp(options); return { data, @@ -115,13 +91,56 @@ export class ImageProcessingService { type: 'image/webp', }; } + + /** + * Convert to Avif + * with resize, remove metadata, resolve orientation, stop animation + */ + @bindThis + public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise { + return this.convertSharpToAvif(sharp(path), width, height, options); + } + + @bindThis + public async convertSharpToAvif(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise { + const result = this.convertSharpToAvifStream(sharp, width, height, options); + + return { + data: await result.data.toBuffer(), + ext: result.ext, + type: result.type, + }; + } + + @bindThis + public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { + return this.convertSharpToAvifStream(sharp(path), width, height, options); + } + + @bindThis + public convertSharpToAvifStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { + const data = sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .avif(options); + + return { + data, + ext: 'avif', + type: 'image/avif', + }; + } + /** * Convert to PNG * with resize, remove metadata, resolve orientation, stop animation */ @bindThis public async convertToPng(path: string, width: number, height: number): Promise { - return this.convertSharpToPng(await sharp(path), width, height); + return this.convertSharpToPng(sharp(path), width, height); } @bindThis diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts index ee9ae0733f..4fb3fc5b4f 100644 --- a/packages/backend/src/core/InstanceActorService.ts +++ b/packages/backend/src/core/InstanceActorService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import type { LocalUser } from '@/models/entities/User.js'; import type { UsersRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import { DI } from '@/di-symbols.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; import { bindThis } from '@/decorators.js'; @@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const; @Injectable() export class InstanceActorService { - private cache: Cache; + private cache: MemorySingleCache; constructor( @Inject(DI.usersRepository) @@ -19,12 +19,12 @@ export class InstanceActorService { private createSystemUserService: CreateSystemUserService, ) { - this.cache = new Cache(Infinity); + this.cache = new MemorySingleCache(Infinity); } @bindThis public async getInstanceActor(): Promise { - const cached = this.cache.get(null); + const cached = this.cache.get(); if (cached) return cached; const user = await this.usersRepository.findOneBy({ @@ -33,11 +33,11 @@ export class InstanceActorService { }) as LocalUser | undefined; if (user) { - this.cache.set(null, user); + this.cache.set(user); return user; } else { const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as LocalUser; - this.cache.set(null, created); + this.cache.set(created); return created; } } diff --git a/packages/backend/src/core/MessagingService.ts b/packages/backend/src/core/MessagingService.ts index 3a8a25c602..ca4082b23f 100644 --- a/packages/backend/src/core/MessagingService.ts +++ b/packages/backend/src/core/MessagingService.ts @@ -137,7 +137,7 @@ export class MessagingService { const activity = this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note)); - this.queueService.deliver(user, activity, recipientUser.inbox); + this.queueService.deliver(user, activity, recipientUser.inbox, false); } return messageObj; } @@ -159,7 +159,7 @@ export class MessagingService { if (this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipient)) { const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), user)); - this.queueService.deliver(user, activity, recipient.inbox); + this.queueService.deliver(user, activity, recipient.inbox, false); } } else if (message.groupId) { this.globalEventService.publishGroupMessagingStream(message.groupId, 'deleted', message.id); @@ -297,10 +297,10 @@ export class MessagingService { if (contents.length > 1) { const collection = this.apRendererService.renderOrderedCollection(null, contents.length, undefined, undefined, contents); - this.queueService.deliver(user, this.apRendererService.addContext(collection), recipient.inbox); + this.queueService.deliver(user, this.apRendererService.addContext(collection), recipient.inbox, false); } else { for (const content of contents) { - this.queueService.deliver(user, this.apRendererService.addContext(content), recipient.inbox); + this.queueService.deliver(user, this.apRendererService.addContext(content), recipient.inbox, false); } } } diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 4b792c083d..0b861be8d0 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { Meta } from '@/models/entities/Meta.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown { private intervalId: NodeJS.Timer; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.db) private db: DataSource, @@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown { }, 1000 * 60 * 5); } - this.redisSubscriber.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { clearInterval(this.intervalId); - this.redisSubscriber.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 6c40ba25a1..9b2d5dc0ff 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -1,9 +1,8 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as parse5 from 'parse5'; -import { JSDOM } from 'jsdom'; +import { Window } from 'happy-dom'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; import type { IMentionedRemoteUsers } from '@/models/entities/Note.js'; @@ -236,7 +235,7 @@ export class MfmService { return null; } - const { window } = new JSDOM(''); + const { window } = new Window(); const doc = window.document; @@ -301,7 +300,7 @@ export class MfmService { hashtag: (node) => { const a = doc.createElement('a'); - a.href = `${this.config.url}/tags/${node.props.hashtag}`; + a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`); a.textContent = `#${node.props.hashtag}`; a.setAttribute('rel', 'tag'); return a; @@ -327,7 +326,7 @@ export class MfmService { link: (node) => { const a = doc.createElement('a'); - a.href = node.props.url; + a.setAttribute('href', node.props.url); appendChildren(node.children, a); return a; }, @@ -336,7 +335,7 @@ export class MfmService { const a = doc.createElement('a'); const { username, host, acct } = node.props; const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); - a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`; + a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`); a.className = 'u-url mention'; a.textContent = acct; return a; @@ -361,14 +360,14 @@ export class MfmService { url: (node) => { const a = doc.createElement('a'); - a.href = node.props.url; + a.setAttribute('href', node.props.url); a.textContent = node.props.url; return a; }, search: (node) => { const a = doc.createElement('a'); - a.href = `https://www.google.com/search?q=${node.props.query}`; + a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`); a.textContent = node.props.content; return a; }, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 6d2995c6e1..9cbf2c19bb 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,6 +1,8 @@ +import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; -import { Not, In, DataSource } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; +import { In, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; @@ -18,7 +20,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js import { checkWordMute } from '@/misc/check-word-mute.js'; import type { Channel } from '@/models/entities/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { Cache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { UserProfile } from '@/models/entities/UserProfile.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -29,7 +31,7 @@ import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { WebhookService } from '@/core/WebhookService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { AntennaService } from '@/core/AntennaService.js'; @@ -43,8 +45,10 @@ 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'; -const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); +const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -58,7 +62,7 @@ class NotificationManager { constructor( private mutingsRepository: MutingsRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, notifier: { id: User['id']; }, note: Note, ) { @@ -99,7 +103,7 @@ class NotificationManager { // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する if (!mentioneesMutedUserIds.includes(this.notifier.id)) { - this.createNotificationService.createNotification(x.target, x.reason, { + this.notificationService.createNotification(x.target, x.reason, { notifierId: this.notifier.id, noteId: this.note.id, }); @@ -124,6 +128,7 @@ type Option = { files?: DriveFile[] | null; poll?: IPoll | null; localOnly?: boolean | null; + reactionAcceptance?: Note['reactionAcceptance']; disableRightClick?: boolean | null; cw?: string | null; visibility?: string; @@ -138,7 +143,9 @@ type Option = { }; @Injectable() -export class NoteCreateService { +export class NoteCreateService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( @Inject(DI.config) private config: Config, @@ -146,6 +153,9 @@ export class NoteCreateService { @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -179,7 +189,7 @@ export class NoteCreateService { private globalEventService: GlobalEventService, private queueService: QueueService, private noteReadService: NoteReadService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, private hashtagService: HashtagService, @@ -189,11 +199,13 @@ export class NoteCreateService { private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private roleService: RoleService, + private metaService: MetaService, + private searchService: SearchService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private activeUsersChart: ActiveUsersChart, private instanceChart: InstanceChart, - ) {} + ) { } @bindThis public async create(user: { @@ -228,7 +240,9 @@ export class NoteCreateService { if (data.channel != null) data.localOnly = true; if (data.visibility === 'public' && data.channel == null) { - if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { + if ((data.text != null) && (await this.metaService.fetch()).sensitiveWords.some(w => data.text!.includes(w))) { + data.visibility = 'home'; + } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { data.visibility = 'home'; } } @@ -315,7 +329,18 @@ export class NoteCreateService { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); - setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!)); + if (data.channel) { + this.redisClient.xadd( + `channelTimeline:${data.channel.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + } + + setImmediate('post created', { signal: this.#shutdownController.signal }).then( + () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); return note; } @@ -342,6 +367,7 @@ export class NoteCreateService { emojis, userId: user.id, localOnly: data.localOnly!, + reactionAcceptance: data.reactionAcceptance, disableRightClick: data.disableRightClick!, visibility: data.visibility as any, visibleUserIds: data.visibility === 'specified' @@ -382,7 +408,7 @@ export class NoteCreateService { // 投稿を作成 try { if (insert.hasPoll) { - // Start transaction + // Start transaction await this.db.transaction(async transactionalEntityManager => { await transactionalEntityManager.insert(Note, insert); @@ -405,7 +431,7 @@ export class NoteCreateService { return insert; } catch (e) { - // duplicate key error + // duplicate key error if (isDuplicateKeyValueError(e)) { const err = new Error('Duplicated note'); err.name = 'duplicated'; @@ -426,15 +452,20 @@ export class NoteCreateService { createdAt: User['createdAt']; isBot: User['isBot']; }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - // 統計を更新 + const meta = await this.metaService.fetch(); + this.notesChart.update(note, true); - this.perUserNotesChart.update(user, note, true); + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserNotesChart.update(user, note, true); + } // Register host if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(i => { + this.federatedInstanceService.fetch(user.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - this.instanceChart.updateNote(i.host, note, true); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } }); } @@ -447,7 +478,7 @@ export class NoteCreateService { this.incNotesCountOfUser(user); // Word mute - mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({ + mutedWordsCache.fetch(() => this.userProfilesRepository.find({ where: { enableWordMute: true, }, @@ -467,26 +498,7 @@ export class NoteCreateService { } }); - // Antenna - for (const antenna of (await this.antennaService.getAntennas())) { - this.antennaService.checkHitAntenna(antenna, note, user).then(hit => { - if (hit) { - this.antennaService.addNoteToAntenna(antenna, note, user); - } - }); - } - - // Channel - if (note.channelId) { - this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => { - for (const following of followings) { - this.noteReadService.insertNoteUnread(following.followerId, note, { - isSpecified: false, - isMentioned: false, - }); - } - }); - } + this.antennaService.addNoteToAntennas(note, user); if (data.reply) { this.saveReply(data.reply, note); @@ -540,6 +552,8 @@ export class NoteCreateService { this.globalEventService.publishNotesStream(noteObj); + 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) { @@ -549,7 +563,7 @@ export class NoteCreateService { } }); - const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note); + const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); await this.createMentionedEvents(mentionedUsers, note, nm); @@ -719,17 +733,9 @@ export class NoteCreateService { @bindThis private index(note: Note) { - if (note.text == null || this.config.elasticsearch == null) return; - /* - es!.index({ - index: this.config.elasticsearch.index ?? 'misskey_note', - id: note.id.toString(), - body: { - text: normalizeForSearch(note.text), - userId: note.userId, - userHost: note.userHost, - }, - });*/ + if (note.text == null && note.cw == null) return; + + this.searchService.indexNote(note); } @bindThis @@ -759,4 +765,8 @@ export class NoteCreateService { return mentionedUsers; } + + onApplicationShutdown(signal?: string | undefined) { + this.#shutdownController.abort(); + } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 571b625523..dd878f7bba 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -16,6 +16,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class NoteDeleteService { @@ -39,6 +40,7 @@ export class NoteDeleteService { private federatedInstanceService: FederatedInstanceService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, + private metaService: MetaService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private instanceChart: InstanceChart, @@ -95,14 +97,19 @@ export class NoteDeleteService { } //#endregion - // 統計を更新 + const meta = await this.metaService.fetch(); + this.notesChart.update(note, false); - this.perUserNotesChart.update(user, note, false); + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserNotesChart.update(user, note, false); + } if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(i => { + this.federatedInstanceService.fetch(user.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - this.instanceChart.updateNote(i.host, note, false); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, false); + } }); } } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 84983d600e..1129bd159c 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -1,25 +1,20 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { In, IsNull, Not } from 'typeorm'; +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 { User } from '@/models/entities/User.js'; -import type { Channel } from '@/models/entities/Channel.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Note } from '@/models/entities/Note.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository, AntennaNotesRepository } from '@/models/index.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; -import { NotificationService } from './NotificationService.js'; -import { AntennaService } from './AntennaService.js'; -import { PushNotificationService } from './PushNotificationService.js'; @Injectable() -export class NoteReadService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, +export class NoteReadService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @@ -29,21 +24,8 @@ export class NoteReadService { @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - - private userEntityService: UserEntityService, private idService: IdService, private globalEventService: GlobalEventService, - private notificationService: NotificationService, - private antennaService: AntennaService, - private pushNotificationService: PushNotificationService, ) { } @@ -54,99 +36,68 @@ export class NoteReadService { isMentioned: boolean; }): Promise { //#region ミュートしているなら無視 - // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする const mute = await this.mutingsRepository.findBy({ muterId: userId, }); if (mute.map(m => m.muteeId).includes(note.userId)) return; //#endregion - + // スレッドミュート const threadMute = await this.noteThreadMutingsRepository.findOneBy({ userId: userId, threadId: note.threadId ?? note.id, }); if (threadMute) return; - + const unread = { id: this.idService.genId(), noteId: note.id, userId: userId, isSpecified: params.isSpecified, isMentioned: params.isMentioned, - noteChannelId: note.channelId, noteUserId: note.userId, }; - + await this.noteUnreadsRepository.insert(unread); - + // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(async () => { + setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id }); - + if (exist == null) return; - + if (params.isMentioned) { this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); } if (params.isSpecified) { this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id); } - if (note.channelId) { - this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id); - } - }, 2000); - } + }, () => { /* aborted, ignore it */ }); + } @bindThis public async read( userId: User['id'], notes: (Note | Packed<'Note'>)[], - info?: { - following: Set; - followingChannels: Set; - }, ): Promise { - const followingChannels = info?.followingChannels ? info.followingChannels : new Set((await this.channelFollowingsRepository.find({ - where: { - followerId: userId, - }, - select: ['followeeId'], - })).map(x => x.followeeId)); - - const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const readMentions: (Note | Packed<'Note'>)[] = []; const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; - const readChannelNotes: (Note | Packed<'Note'>)[] = []; - const readAntennaNotes: (Note | Packed<'Note'>)[] = []; - + for (const note of notes) { if (note.mentions && note.mentions.includes(userId)) { readMentions.push(note); } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { readSpecifiedNotes.push(note); } - - if (note.channelId && followingChannels.has(note.channelId)) { - readChannelNotes.push(note); - } - - if (note.user != null) { // たぶんnullになることは無いはずだけど一応 - for (const antenna of myAntennas) { - if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) { - readAntennaNotes.push(note); - } - } - } } - - if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { + + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0)) { // Remove the record await this.noteUnreadsRepository.delete({ userId: userId, - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), }); - + // TODO: ↓まとめてクエリしたい this.noteUnreadsRepository.countBy({ @@ -168,49 +119,10 @@ export class NoteReadService { this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); } }); - - this.noteUnreadsRepository.countBy({ - userId: userId, - noteChannelId: Not(IsNull()), - }).then(channelNoteCount => { - if (channelNoteCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllChannels'); - } - }); - - this.notificationService.readNotificationByQuery(userId, { - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), - }); - } - - if (readAntennaNotes.length > 0) { - await this.antennaNotesRepository.update({ - antennaId: In(myAntennas.map(a => a.id)), - noteId: In(readAntennaNotes.map(n => n.id)), - }, { - read: true, - }); - - // TODO: まとめてクエリしたい - for (const antenna of myAntennas) { - const count = await this.antennaNotesRepository.countBy({ - antennaId: antenna.id, - read: false, - }); - - if (count === 0) { - this.globalEventService.publishMainStream(userId, 'readAntenna', antenna); - this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); - } - } - - this.userEntityService.getHasUnreadAntenna(userId).then(unread => { - if (!unread) { - this.globalEventService.publishMainStream(userId, 'readAllAntennas'); - this.pushNotificationService.pushNotification(userId, 'readAllAntennas', undefined); - } - }); } } + + onApplicationShutdown(signal?: string | undefined): void { + this.#shutdownController.abort(); + } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 9fef36dd2c..a245908c98 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -1,72 +1,158 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import * as Redis from 'ioredis'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { NotificationsRepository } from '@/models/index.js'; -import type { UsersRepository } from '@/models/index.js'; +import type { MutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import type { Notification } from '@/models/entities/Notification.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { GlobalEventService } from './GlobalEventService.js'; -import { PushNotificationService } from './PushNotificationService.js'; import { bindThis } from '@/decorators.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() -export class NotificationService { - constructor( - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, +export class NotificationService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private notificationEntityService: NotificationEntityService, private userEntityService: UserEntityService, + private idService: IdService, private globalEventService: GlobalEventService, private pushNotificationService: PushNotificationService, + private cacheService: CacheService, ) { } @bindThis - public async readNotification( + public async readAllNotification( userId: User['id'], - notificationIds: Notification['id'][], + force = false, ) { - if (notificationIds.length === 0) return; + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); + + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '-', + 'COUNT', 1); + const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - // Update documents - const result = await this.notificationsRepository.update({ - notifieeId: userId, - id: In(notificationIds), - isRead: false, - }, { - isRead: true, - }); + if (latestNotificationId == null) return; - if (result.affected === 0) return; + this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId); - if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId); - else return this.postReadNotifications(userId, notificationIds); - } - - @bindThis - public async readNotificationByQuery( - userId: User['id'], - query: Record, - ) { - const notificationIds = await this.notificationsRepository.findBy({ - ...query, - notifieeId: userId, - isRead: false, - }).then(notifications => notifications.map(notification => notification.id)); - - return this.readNotification(userId, notificationIds); + if (force || latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) { + return this.postReadAllNotifications(userId); + } } @bindThis private postReadAllNotifications(userId: User['id']) { this.globalEventService.publishMainStream(userId, 'readAllNotifications'); - return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); + this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); } @bindThis - private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { - this.globalEventService.publishMainStream(userId, 'readNotifications', notificationIds); - return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds }); + public async createNotification( + notifieeId: User['id'], + type: Notification['type'], + data: Partial, + ): Promise { + const profile = await this.cacheService.userProfileCache.fetch(notifieeId); + const isMuted = profile.mutingNotificationTypes.includes(type); + if (isMuted) return null; + + if (data.notifierId) { + if (notifieeId === data.notifierId) { + return null; + } + + const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); + if (mutings.has(data.notifierId)) { + return null; + } + } + + const notification = { + id: this.idService.genId(), + createdAt: new Date(), + type: type, + ...data, + } as Notification; + + const redisIdPromise = this.redisClient.xadd( + `notificationTimeline:${notifieeId}`, + 'MAXLEN', '~', '300', + '*', + 'data', JSON.stringify(notification)); + + const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); + + // Publish notification event + this.globalEventService.publishMainStream(notifieeId, 'notification', packed); + + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); + if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return; + + this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); + this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); + + if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + }, () => { /* aborted, ignore it */ }); + + return notification; + } + + // TODO + //const locales = await import('../../../../locales/index.js'); + + // TODO: locale ファイルをクライアント用とサーバー用で分けたい + + @bindThis + private async emailNotificationFollow(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ + } + + @bindThis + private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ + } + + onApplicationShutdown(signal?: string | undefined): void { + this.#shutdownController.abort(); } } diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts index 94adbf2756..368753d9a7 100644 --- a/packages/backend/src/core/PollService.ts +++ b/packages/backend/src/core/PollService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, UsersRepository, PollsRepository, PollVotesRepository, User } from '@/models/index.js'; import type { Note } from '@/models/entities/Note.js'; diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts index 7ed322ae65..780e56ef10 100644 --- a/packages/backend/src/core/ProxyAccountService.ts +++ b/packages/backend/src/core/ProxyAccountService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository } from '@/models/index.js'; -import type { LocalUser, User } from '@/models/entities/User.js'; +import type { LocalUser } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index b18b7bb2cd..71871beb9f 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -1,32 +1,31 @@ import { Inject, Injectable } from '@nestjs/common'; import push from 'web-push'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema'; +import type { Packed } from '@/misc/json-schema'; import { getNoteSummary } from '@/misc/get-note-summary.js'; -import type { SwSubscriptionsRepository } from '@/models/index.js'; +import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js'; import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; +import { RedisKVCache } from '@/misc/cache.js'; // Defined also packages/sw/types.ts#L13 -type pushNotificationsTypes = { +type PushNotificationsTypes = { 'notification': Packed<'Notification'>; 'unreadMessagingMessage': Packed<'MessagingMessage'>; 'unreadAntennaNote': { antenna: { id: string, name: string }; note: Packed<'Note'>; }; - 'readNotifications': { notificationIds: string[] }; 'readAllNotifications': undefined; 'readAllMessagingMessages': undefined; 'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string }; - 'readAntenna': { antennaId: string }; - 'readAllAntennas': undefined; }; // Reduce length because push message servers have character limits -function truncateBody(type: T, body: pushNotificationsTypes[T]): pushNotificationsTypes[T] { - if (body === undefined) return body; +function truncateBody(type: T, body: PushNotificationsTypes[T]): PushNotificationsTypes[T] { + if (typeof body !== 'object') return body; return { ...body, @@ -43,25 +42,35 @@ function truncateBody(type: T, body: pus } } : {}), }; - - return body; } @Injectable() export class PushNotificationService { + private subscriptionsCache: RedisKVCache; + constructor( @Inject(DI.config) private config: Config, + @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 + memoryCacheLifetime: 1000 * 60 * 3, // 3m + fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), + }); } @bindThis - public async pushNotification(userId: string, type: T, body: pushNotificationsTypes[T]) { + 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; @@ -71,20 +80,13 @@ export class PushNotificationService { meta.swPublicKey, meta.swPrivateKey); - // Fetch - const subscriptions = await this.swSubscriptionsRepository.findBy({ - userId: userId, - }); + const subscriptions = await this.subscriptionsCache.fetch(userId); for (const subscription of subscriptions) { - // Continue if sendReadMessage is false if ([ - 'readNotifications', 'readAllNotifications', 'readAllMessagingMessages', 'readAllMessagingMessagesOfARoom', - 'readAntenna', - 'readAllAntennas', ].includes(type) && !subscription.sendReadMessage) continue; const pushSubscription = { diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index c334d749e6..0cee2076bf 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository } from '@/models/index.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -29,6 +29,9 @@ export class QueryService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, ) { } @@ -269,5 +272,24 @@ export class QueryService { q.setParameters({ meId: me.id }); } } -} + @bindThis + public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') + .select('renote_muting.muteeId') + .where('renote_muting.muterId = :muterId', { muterId: me.id }); + + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb.where('note.renoteId IS NOT NULL'); + qb.andWhere('note.text IS NULL'); + qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); + })) + .orWhere('note.renoteId IS NULL') + .orWhere('note.text IS NOT NULL'); + })); + + q.setParameters(mutingQuery.getParameters()); + } +} diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index edd843977b..d4905a5f88 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -3,18 +3,18 @@ import Bull from 'bull'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../queue/types.js'; +import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, DbJobMap } from '../queue/types.js'; function q(config: Config, name: string, limitPerSec = -1) { return new Bull(name, { redis: { - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - db: config.redis.db ?? 0, + port: config.redisForJobQueue.port, + host: config.redisForJobQueue.host, + family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family, + password: config.redisForJobQueue.pass, + db: config.redisForJobQueue.db ?? 0, }, - prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', + prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue', limiter: limitPerSec > 0 ? { max: limitPerSec, duration: 1000, @@ -41,7 +41,8 @@ export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; export type DeliverQueue = Bull.Queue; export type InboxQueue = Bull.Queue; -export type DbQueue = Bull.Queue; +export type DbQueue = Bull.Queue>; +export type RelationshipQueue = Bull.Queue; export type ObjectStorageQueue = Bull.Queue; export type WebhookDeliverQueue = Bull.Queue; @@ -75,6 +76,12 @@ const $db: Provider = { inject: [DI.config], }; +const $relationship: Provider = { + provide: 'queue:relationship', + useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64), + inject: [DI.config], +}; + const $objectStorage: Provider = { provide: 'queue:objectStorage', useFactory: (config: Config) => q(config, 'objectStorage'), @@ -96,6 +103,7 @@ const $webhookDeliver: Provider = { $deliver, $inbox, $db, + $relationship, $objectStorage, $webhookDeliver, ], @@ -105,6 +113,7 @@ const $webhookDeliver: Provider = { $deliver, $inbox, $db, + $relationship, $objectStorage, $webhookDeliver, ], diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 4bf41e0ac1..b4ffffecc0 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -1,13 +1,15 @@ import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; +import Bull from 'bull'; import type { IActivity } from '@/core/activitypub/type.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; -import type { ThinUser } from '../queue/types.js'; +import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; +import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; @Injectable() @@ -21,12 +23,13 @@ export class QueueService { @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:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, ) {} @bindThis - public deliver(user: ThinUser, content: IActivity | null, to: string | null) { + public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { if (content == null) return null; if (to == null) return null; @@ -36,6 +39,7 @@ export class QueueService { }, content, to, + isSharedInbox, }; return this.deliverQueue.add(data, { @@ -55,7 +59,7 @@ export class QueueService { activity: activity, signature, }; - + return this.inboxQueue.add(data, { attempts: this.config.inboxJobMaxAttempts ?? 8, timeout: 5 * 60 * 1000, // 5min @@ -70,7 +74,7 @@ export class QueueService { @bindThis public createDeleteDriveFilesJob(user: ThinUser) { return this.dbQueue.add('deleteDriveFiles', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -80,7 +84,7 @@ export class QueueService { @bindThis public createExportCustomEmojisJob(user: ThinUser) { return this.dbQueue.add('exportCustomEmojis', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -90,7 +94,7 @@ export class QueueService { @bindThis public createExportNotesJob(user: ThinUser) { return this.dbQueue.add('exportNotes', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -100,7 +104,7 @@ export class QueueService { @bindThis public createExportFavoritesJob(user: ThinUser) { return this.dbQueue.add('exportFavorites', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -110,7 +114,7 @@ export class QueueService { @bindThis public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { return this.dbQueue.add('exportFollowing', { - user: user, + user: { id: user.id }, excludeMuting, excludeInactive, }, { @@ -122,7 +126,7 @@ export class QueueService { @bindThis public createExportMuteJob(user: ThinUser) { return this.dbQueue.add('exportMuting', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -132,7 +136,7 @@ export class QueueService { @bindThis public createExportBlockingJob(user: ThinUser) { return this.dbQueue.add('exportBlocking', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -142,7 +146,17 @@ export class QueueService { @bindThis public createExportUserListsJob(user: ThinUser) { return this.dbQueue.add('exportUserLists', { - user: user, + user: { id: user.id }, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + @bindThis + public createExportAntennasJob(user: ThinUser) { + return this.dbQueue.add('exportAntennas', { + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -152,7 +166,7 @@ export class QueueService { @bindThis public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importFollowing', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -160,10 +174,16 @@ export class QueueService { }); } + @bindThis + public createImportFollowingToDbJob(user: ThinUser, targets: string[]) { + const jobs = targets.map(rel => this.generateToDbJobData('importFollowingToDb', { user, target: rel })); + return this.dbQueue.addBulk(jobs); + } + @bindThis public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importMuting', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -174,7 +194,7 @@ export class QueueService { @bindThis public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importBlocking', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -182,10 +202,32 @@ export class QueueService { }); } + @bindThis + public createImportBlockingToDbJob(user: ThinUser, targets: string[]) { + const jobs = targets.map(rel => this.generateToDbJobData('importBlockingToDb', { user, target: rel })); + return this.dbQueue.addBulk(jobs); + } + + @bindThis + private generateToDbJobData>(name: T, data: D): { + name: string, + data: D, + opts: Bull.JobOptions, + } { + return { + name, + data, + opts: { + removeOnComplete: true, + removeOnFail: true, + }, + }; + } + @bindThis public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importUserLists', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -196,7 +238,7 @@ export class QueueService { @bindThis public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importCustomEmojis', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -204,10 +246,21 @@ export class QueueService { }); } + @bindThis + public createImportAntennasJob(user: ThinUser, antenna: Antenna) { + return this.dbQueue.add('importAntennas', { + user: { id: user.id }, + antenna, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { return this.dbQueue.add('deleteAccount', { - user: user, + user: { id: user.id }, soft: opts.soft, }, { removeOnComplete: true, @@ -215,6 +268,58 @@ export class QueueService { }); } + @bindThis + public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean }[]) { + const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[]) { + const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) { + const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay })); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { + const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createUnblockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { + const jobs = blockings.map(rel => this.generateRelationshipJobData('unblock', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): { + name: string, + data: RelationshipJobData, + opts: Bull.JobOptions, + } { + return { + name, + data: { + from: { id: data.from.id }, + to: { id: data.to.id }, + silent: data.silent, + requestId: data.requestId, + }, + opts: { + removeOnComplete: true, + removeOnFail: true, + ...opts, + }, + }; + } + @bindThis public createDeleteObjectStorageFileJob(key: string) { return this.objectStorageQueue.add('deleteFile', { @@ -245,7 +350,7 @@ export class QueueService { createdAt: Date.now(), eventId: uuid(), }; - + return this.webhookDeliverQueue.add(data, { attempts: 4, timeout: 1 * 60 * 1000, // 1min @@ -263,7 +368,7 @@ export class QueueService { //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); this.deliverQueue.clean(0, 'delayed'); - + this.inboxQueue.once('cleaned', (jobs, status) => { //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 9fccc14ee4..a274b19e4b 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; +import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { RemoteUser, User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; @@ -9,7 +8,7 @@ import { IdService } from '@/core/IdService.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; import { emojiRegex } from '@/misc/emoji-regex.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; @@ -20,6 +19,9 @@ import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; + +const FALLBACK = '❤'; const legacies: Record = { 'like': '👍', @@ -58,9 +60,6 @@ export class ReactionService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -72,6 +71,7 @@ export class ReactionService { private utilityService: UtilityService, private metaService: MetaService, + private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private userBlockingService: UserBlockingService, @@ -79,7 +79,7 @@ export class ReactionService { private globalEventService: GlobalEventService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private perUserReactionsChart: PerUserReactionsChart, ) { } @@ -93,15 +93,18 @@ export class ReactionService { throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); } } - + // check visibility if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } - - // TODO: cache - reaction = await this.toDbReaction(reaction, user.host); - + + if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { + reaction = '❤️'; + } else { + reaction = await this.toDbReaction(reaction, user.host); + } + const record: NoteReaction = { id: this.idService.genId(), createdAt: new Date(), @@ -109,7 +112,7 @@ export class ReactionService { userId: user.id, reaction, }; - + // Create reaction try { await this.noteReactionsRepository.insert(record); @@ -119,7 +122,7 @@ export class ReactionService { noteId: note.id, userId: user.id, }); - + if (exists.reaction !== reaction) { // 別のリアクションがすでにされていたら置き換える await this.delete(user, note); @@ -132,7 +135,7 @@ export class ReactionService { throw e; } } - + // Increment reactions count const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; await this.notesRepository.createQueryBuilder().update() @@ -142,39 +145,45 @@ export class ReactionService { }) .where('id = :id', { id: note.id }) .execute(); - - this.perUserReactionsChart.update(user, note); - + + const meta = await this.metaService.fetch(); + + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserReactionsChart.update(user, note); + } + // カスタム絵文字リアクションだったら絵文字情報も送る const decodedReaction = this.decodeReaction(reaction); - - const emoji = await this.emojisRepository.findOne({ - where: { - name: decodedReaction.name, - host: decodedReaction.host ?? IsNull(), - }, - select: ['name', 'host', 'originalUrl', 'publicUrl'], - }); - + + const customEmoji = decodedReaction.name == null ? null : decodedReaction.host == null + ? (await this.customEmojiService.localEmojisCache.fetch()).get(decodedReaction.name) + : await this.emojisRepository.findOne( + { + where: { + name: decodedReaction.name, + host: decodedReaction.host, + }, + }); + this.globalEventService.publishNoteStream(note.id, 'reacted', { reaction: decodedReaction.reaction, - emoji: emoji != null ? { - name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, + emoji: customEmoji != null ? { + name: customEmoji.host ? `${customEmoji.name}@${customEmoji.host}` : `${customEmoji.name}@.`, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: emoji.publicUrl || emoji.originalUrl, + url: customEmoji.publicUrl || customEmoji.originalUrl, } : null, userId: user.id, }); - + // リアクションされたユーザーがローカルユーザーなら通知を作成 if (note.userHost === null) { - this.createNotificationService.createNotification(note.userId, 'reaction', { + this.notificationService.createNotification(note.userId, 'reaction', { notifierId: user.id, noteId: note.id, reaction: reaction, }); } - + //#region 配信 if (this.userEntityService.isLocalUser(user) && !note.localOnly) { const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note)); @@ -183,7 +192,7 @@ export class ReactionService { const reactee = await this.usersRepository.findOneBy({ id: note.userId }); dm.addDirectRecipe(reactee as RemoteUser); } - + if (['public', 'home', 'followers'].includes(note.visibility)) { dm.addFollowersRecipe(); } else if (note.visibility === 'specified') { @@ -192,7 +201,7 @@ export class ReactionService { dm.addDirectRecipe(u as RemoteUser); } } - + dm.execute(); } //#endregion @@ -205,18 +214,18 @@ export class ReactionService { noteId: note.id, userId: user.id, }); - + if (exist == null) { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } - + // Delete reaction const result = await this.noteReactionsRepository.delete(exist.id); - + if (result.affected !== 1) { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } - + // Decrement reactions count const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; await this.notesRepository.createQueryBuilder().update() @@ -225,14 +234,14 @@ export class ReactionService { }) .where('id = :id', { id: note.id }) .execute(); - + if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1); - + this.globalEventService.publishNoteStream(note.id, 'unreacted', { reaction: this.decodeReaction(exist.reaction).reaction, userId: user.id, }); - + //#region 配信 if (this.userEntityService.isLocalUser(user) && !note.localOnly) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user)); @@ -246,12 +255,6 @@ export class ReactionService { } //#endregion } - - @bindThis - public async getFallbackReaction(): Promise { - const meta = await this.metaService.fetch(); - return meta.useStarForReactionFallback ? '⭐' : '👍'; - } @bindThis public convertLegacyReactions(reactions: Record) { @@ -286,7 +289,7 @@ export class ReactionService { @bindThis public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise { - if (reaction == null) return await this.getFallbackReaction(); + if (reaction == null) return FALLBACK; reacterHost = this.utilityService.toPunyNullable(reacterHost); @@ -296,7 +299,7 @@ export class ReactionService { // Unicode絵文字 const match = emojiRegex.exec(reaction); if (match) { - // 合字を含む1つの絵文字 + // 合字を含む1つの絵文字 const unicode = match[0]; // 異体字セレクタ除去 @@ -306,15 +309,17 @@ export class ReactionService { const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); if (custom) { const name = custom[1]; - const emoji = await this.emojisRepository.findOneBy({ - host: reacterHost ?? IsNull(), - name, - }); + const emoji = reacterHost == null + ? (await this.customEmojiService.localEmojisCache.fetch()).get(name) + : await this.emojisRepository.findOneBy({ + host: reacterHost, + name, + }); if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; } - return await this.getFallbackReaction(); + return FALLBACK; } @bindThis diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 2e07825e9b..9d34d82be2 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -3,7 +3,7 @@ import { IsNull } from 'typeorm'; import type { LocalUser, User } from '@/models/entities/User.js'; import type { RelaysRepository, UsersRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { Cache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { Relay } from '@/models/entities/Relay.js'; import { QueueService } from '@/core/QueueService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; @@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const; @Injectable() export class RelayService { - private relaysCache: Cache; + private relaysCache: MemorySingleCache; constructor( @Inject(DI.usersRepository) @@ -30,7 +30,7 @@ export class RelayService { private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, ) { - this.relaysCache = new Cache(1000 * 60 * 10); + this.relaysCache = new MemorySingleCache(1000 * 60 * 10); } @bindThis @@ -57,7 +57,7 @@ export class RelayService { const relayActor = await this.getRelayActor(); const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); const activity = this.apRendererService.addContext(follow); - this.queueService.deliver(relayActor, activity, relay.inbox); + this.queueService.deliver(relayActor, activity, relay.inbox, false); return relay; } @@ -76,7 +76,7 @@ export class RelayService { const follow = this.apRendererService.renderFollowRelay(relay, relayActor); const undo = this.apRendererService.renderUndo(follow, relayActor); const activity = this.apRendererService.addContext(undo); - this.queueService.deliver(relayActor, activity, relay.inbox); + this.queueService.deliver(relayActor, activity, relay.inbox, false); await this.relaysRepository.delete(relay.id); } @@ -109,7 +109,7 @@ export class RelayService { public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise { if (activity == null) return; - const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({ + const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ status: 'accepted', })); if (relays.length === 0) return; @@ -120,7 +120,7 @@ export class RelayService { const signed = await this.apRendererService.attachLdSignature(copy, user); for (const relay of relays) { - this.queueService.deliver(user, signed, relay.inbox); + this.queueService.deliver(user, signed, relay.inbox, false); } } } diff --git a/packages/backend/src/core/RemoteLoggerService.ts b/packages/backend/src/core/RemoteLoggerService.ts index 0ea5d7b42f..3d45605836 100644 --- a/packages/backend/src/core/RemoteLoggerService.ts +++ b/packages/backend/src/core/RemoteLoggerService.ts @@ -1,7 +1,6 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class RemoteLoggerService { diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index b72dce5180..ff68c24219 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -4,7 +4,7 @@ import chalk from 'chalk'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; -import type { RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -33,7 +33,7 @@ export class RemoteUserResolveService { } @bindThis - public async resolveUser(username: string, host: string | null): Promise { + public async resolveUser(username: string, host: string | null): Promise { const usernameLower = username.toLowerCase(); if (host == null) { @@ -44,7 +44,7 @@ export class RemoteUserResolveService { } else { return u; } - }); + }) as LocalUser; } host = this.utilityService.toPuny(host); @@ -57,7 +57,7 @@ export class RemoteUserResolveService { } else { return u; } - }); + }) as LocalUser; } const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null; @@ -109,7 +109,7 @@ export class RemoteUserResolveService { if (u == null) { throw new Error('user not found'); } else { - return u; + return u as LocalUser | RemoteUser; } }); } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index b84d5e7585..68087ccc3b 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -1,16 +1,19 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { In } from 'typeorm'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/entities/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { Packed } from '@/misc/json-schema'; import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { @@ -19,8 +22,10 @@ export type RolePolicies = { canPublicNote: boolean; canInvite: boolean; canManageCustomEmojis: boolean; + canSearchNotes: boolean; canHideAds: boolean; driveCapacityMb: number; + alwaysMarkNsfw: boolean; pinLimit: number; antennaLimit: number; wordMuteLimit: number; @@ -38,8 +43,10 @@ export const DEFAULT_POLICIES: RolePolicies = { canPublicNote: true, canInvite: false, canManageCustomEmojis: false, + canSearchNotes: false, canHideAds: false, driveCapacityMb: 100, + alwaysMarkNsfw: false, pinLimit: 5, antennaLimit: 5, wordMuteLimit: 200, @@ -53,12 +60,18 @@ export const DEFAULT_POLICIES: RolePolicies = { @Injectable() export class RoleService implements OnApplicationShutdown { - private rolesCache: Cache; - private roleAssignmentByUserIdCache: Cache; + private rolesCache: MemorySingleCache; + private roleAssignmentByUserIdCache: MemoryKVCache; + + public static AlreadyAssignedError = class extends Error {}; + public static NotAssignedError = class extends Error {}; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -70,15 +83,17 @@ export class RoleService implements OnApplicationShutdown { private roleAssignmentsRepository: RoleAssignmentsRepository, private metaService: MetaService, - private userCacheService: UserCacheService, + private cacheService: CacheService, private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private idService: IdService, ) { //this.onMessage = this.onMessage.bind(this); - this.rolesCache = new Cache(Infinity); - this.roleAssignmentByUserIdCache = new Cache(Infinity); + this.rolesCache = new MemorySingleCache(1000 * 60 * 60 * 1); + this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 1); - this.redisSubscriber.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -89,7 +104,7 @@ export class RoleService implements OnApplicationShutdown { const { type, body } = obj.message as StreamMessages['internal']['payload']; switch (type) { case 'roleCreated': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { cached.push({ ...body, @@ -101,7 +116,7 @@ export class RoleService implements OnApplicationShutdown { break; } case 'roleUpdated': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { const i = cached.findIndex(x => x.id === body.id); if (i > -1) { @@ -116,9 +131,9 @@ export class RoleService implements OnApplicationShutdown { break; } case 'roleDeleted': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { - this.rolesCache.set(null, cached.filter(x => x.id !== body.id)); + this.rolesCache.set(cached.filter(x => x.id !== body.id)); } break; } @@ -128,6 +143,7 @@ export class RoleService implements OnApplicationShutdown { cached.push({ ...body, createdAt: new Date(body.createdAt), + expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, }); } break; @@ -182,6 +198,12 @@ export class RoleService implements OnApplicationShutdown { case 'followingMoreThanOrEq': { return user.followingCount >= value.value; } + case 'notesLessThanOrEq': { + return user.notesCount <= value.value; + } + case 'notesMoreThanOrEq': { + return user.notesCount >= value.value; + } default: return false; } @@ -193,11 +215,14 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getUserRoles(userId: User['id']) { - const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + const now = Date.now(); + let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + // 期限切れのロールを除外 + assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); - const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; + const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); return [...assignedRoles, ...matchedCondRoles]; } @@ -207,13 +232,16 @@ export class RoleService implements OnApplicationShutdown { */ @bindThis public async getUserBadgeRoles(userId: User['id']) { - const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + const now = Date.now(); + let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + // 期限切れのロールを除外 + assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); if (badgeCondRoles.length > 0) { - const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; + const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula)); return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; } else { @@ -250,8 +278,10 @@ export class RoleService implements OnApplicationShutdown { canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), + canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), + alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), @@ -278,7 +308,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getModeratorIds(includeAdmins = true): Promise { - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)), @@ -298,7 +328,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getAdministratorIds(): Promise { - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const administratorRoles = roles.filter(r => r.isAdministrator); const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(administratorRoles.map(r => r.id)), @@ -316,8 +346,86 @@ export class RoleService implements OnApplicationShutdown { return users; } + @bindThis + public async assign(userId: User['id'], roleId: Role['id'], expiresAt: Date | null = null): Promise { + const now = new Date(); + + const existing = await this.roleAssignmentsRepository.findOneBy({ + roleId: roleId, + userId: userId, + }); + + if (existing) { + if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) { + await this.roleAssignmentsRepository.delete({ + roleId: roleId, + userId: userId, + }); + } else { + throw new RoleService.AlreadyAssignedError(); + } + } + + const created = await this.roleAssignmentsRepository.insert({ + id: this.idService.genId(), + createdAt: now, + expiresAt: expiresAt, + roleId: roleId, + userId: userId, + }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); + + this.rolesRepository.update(roleId, { + lastUsedAt: new Date(), + }); + + this.globalEventService.publishInternalEvent('userRoleAssigned', created); + } + + @bindThis + public async unassign(userId: User['id'], roleId: Role['id']): Promise { + const now = new Date(); + + const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId }); + if (existing == null) { + throw new RoleService.NotAssignedError(); + } else if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) { + await this.roleAssignmentsRepository.delete({ + roleId: roleId, + userId: userId, + }); + throw new RoleService.NotAssignedError(); + } + + await this.roleAssignmentsRepository.delete(existing.id); + + this.rolesRepository.update(roleId, { + lastUsedAt: now, + }); + + this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); + } + + @bindThis + public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise { + const roles = await this.getUserRoles(note.userId); + + const redisPipeline = this.redisClient.pipeline(); + + for (const role of roles) { + redisPipeline.xadd( + `roleTimeline:${role.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); + } + + redisPipeline.exec(); + } + @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index 0ce69aaa74..01ce12ffdd 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -1,11 +1,16 @@ import { URL } from 'node:url'; +import * as http from 'node:http'; +import * as https from 'node:https'; import { Inject, Injectable } from '@nestjs/common'; -import S3 from 'aws-sdk/clients/s3.js'; +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; +import { NodeHttpHandler, NodeHttpHandlerOptions } from '@aws-sdk/node-http-handler'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Meta } from '@/models/entities/Meta.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3'; @Injectable() export class S3Service { @@ -18,23 +23,47 @@ export class S3Service { } @bindThis - public getS3(meta: Meta) { - const u = meta.objectStorageEndpoint != null - ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` - : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; - - return new S3({ - endpoint: meta.objectStorageEndpoint ?? undefined, - accessKeyId: meta.objectStorageAccessKey!, - secretAccessKey: meta.objectStorageSecretKey!, - region: meta.objectStorageRegion ?? undefined, - sslEnabled: meta.objectStorageUseSSL, - s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted - ? false - : meta.objectStorageS3ForcePathStyle, - httpOptions: { - agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy), - }, + public getS3Client(meta: Meta): S3Client { + const u = meta.objectStorageEndpoint + ? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}` + : `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent + + const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy); + const handlerOption: NodeHttpHandlerOptions = {}; + if (meta.objectStorageUseSSL) { + handlerOption.httpsAgent = agent as https.Agent; + } else { + handlerOption.httpAgent = agent as http.Agent; + } + + return new S3Client({ + endpoint: meta.objectStorageEndpoint ? u : undefined, + credentials: (meta.objectStorageAccessKey !== null && meta.objectStorageSecretKey !== null) ? { + accessKeyId: meta.objectStorageAccessKey, + secretAccessKey: meta.objectStorageSecretKey, + } : undefined, + region: meta.objectStorageRegion ? meta.objectStorageRegion : undefined, // 空文字列もundefinedにするため ?? は使わない + tls: meta.objectStorageUseSSL, + forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted + requestHandler: new NodeHttpHandler(handlerOption), }); } + + @bindThis + public async upload(meta: Meta, input: PutObjectCommandInput) { + const client = this.getS3Client(meta); + return new Upload({ + client, + params: input, + partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com') + ? 500 * 1024 * 1024 + : 8 * 1024 * 1024, + }).done(); + } + + @bindThis + public delete(meta: Meta, input: DeleteObjectCommandInput) { + const client = this.getS3Client(meta); + return client.send(new DeleteObjectCommand(input)); + } } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts new file mode 100644 index 0000000000..e68fde088d --- /dev/null +++ b/packages/backend/src/core/SearchService.ts @@ -0,0 +1,178 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { bindThis } from '@/decorators.js'; +import { Note } from '@/models/entities/Note.js'; +import { User } from '@/models/index.js'; +import type { NotesRepository } from '@/models/index.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { QueryService } from '@/core/QueryService.js'; +import { IdService } from '@/core/IdService.js'; +import type { Index, MeiliSearch } from 'meilisearch'; + +type K = string; +type V = string | number | boolean; +type Q = + { op: '=', k: K, v: V } | + { op: '!=', k: K, v: V } | + { op: '>', k: K, v: number } | + { op: '<', k: K, v: number } | + { op: '>=', k: K, v: number } | + { op: '<=', k: K, v: number } | + { op: 'and', qs: Q[] } | + { op: 'or', qs: Q[] } | + { op: 'not', q: Q }; + +function compileValue(value: V): string { + if (typeof value === 'string') { + return `'${value}'`; // TODO: escape + } else if (typeof value === 'number') { + return value.toString(); + } else if (typeof value === 'boolean') { + return value.toString(); + } + throw new Error('unrecognized value'); +} + +function compileQuery(q: Q): string { + switch (q.op) { + case '=': return `(${q.k} = ${compileValue(q.v)})`; + case '!=': return `(${q.k} != ${compileValue(q.v)})`; + case '>': return `(${q.k} > ${compileValue(q.v)})`; + case '<': return `(${q.k} < ${compileValue(q.v)})`; + case '>=': return `(${q.k} >= ${compileValue(q.v)})`; + case '<=': return `(${q.k} <= ${compileValue(q.v)})`; + case 'and': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' AND ') })`; + case 'or': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' OR ') })`; + case 'not': return `(NOT ${compileQuery(q.q)})`; + default: throw new Error('unrecognized query operator'); + } +} + +@Injectable() +export class SearchService { + private meilisearchNoteIndex: Index | null = null; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.meilisearch) + private meilisearch: MeiliSearch | null, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private queryService: QueryService, + private idService: IdService, + ) { + if (meilisearch) { + this.meilisearchNoteIndex = meilisearch.index('notes'); + this.meilisearchNoteIndex.updateSettings({ + searchableAttributes: [ + 'text', + 'cw', + ], + sortableAttributes: [ + 'createdAt', + ], + filterableAttributes: [ + 'createdAt', + 'userId', + 'userHost', + 'channelId', + ], + typoTolerance: { + enabled: false, + }, + pagination: { + maxTotalHits: 10000, + }, + }); + } + } + + @bindThis + public async indexNote(note: Note): Promise { + if (note.text == null && note.cw == null) return; + if (!['home', 'public'].includes(note.visibility)) return; + + if (this.meilisearch) { + this.meilisearchNoteIndex!.addDocuments([{ + id: note.id, + createdAt: note.createdAt.getTime(), + userId: note.userId, + userHost: note.userHost, + channelId: note.channelId, + cw: note.cw, + text: note.text, + }], { + primaryKey: 'id', + }); + } + } + + @bindThis + public async searchNote(q: string, me: User | null, opts: { + userId?: Note['userId'] | null; + channelId?: Note['channelId'] | null; + host?: string | null; + }, pagination: { + untilId?: Note['id']; + sinceId?: Note['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 === '.') { + // TODO: Meilisearchが2023/05/07現在値がNULLかどうかのクエリが書けない + } 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 notes = await this.notesRepository.findBy({ + id: In(res.hits.map(x => x.id)), + }); + 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); + + 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'); + + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + + return await query.take(pagination.limit).getMany(); + } + } +} diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 90a7186909..29eb65fda4 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -13,8 +13,9 @@ import { UsedUsername } from '@/models/entities/UsedUsername.js'; import generateUserToken from '@/misc/generate-native-user-token.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import UsersChart from './chart/charts/users.js'; -import { UtilityService } from './UtilityService.js'; +import UsersChart from '@/core/chart/charts/users.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class SignupService { @@ -34,6 +35,7 @@ export class SignupService { private utilityService: UtilityService, private userEntityService: UserEntityService, private idService: IdService, + private metaService: MetaService, private usersChart: UsersChart, ) { } @@ -44,6 +46,7 @@ export class SignupService { password?: string | null; passwordHash?: UserProfile['password'] | null; host?: string | null; + ignorePreservedUsernames?: boolean; }) { const { username, password, passwordHash, host } = opts; let hash = passwordHash; @@ -76,7 +79,17 @@ export class SignupService { if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { throw new Error('USED_USERNAME'); } - + + const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0; + + if (!opts.ignorePreservedUsernames && !isTheFirstUser) { + const instance = await this.metaService.fetch(true); + const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + if (isPreserved) { + throw new Error('USED_USERNAME'); + } + } + const keyPair = await new Promise((res, rej) => generateKeyPair('rsa', { modulusLength: 4096, @@ -90,7 +103,7 @@ export class SignupService { cipher: undefined, passphrase: undefined, }, - } as any, (err, publicKey, privateKey) => + }, (err, publicKey, privateKey) => err ? rej(err) : res([publicKey, privateKey]), )); @@ -112,9 +125,7 @@ export class SignupService { usernameLower: username.toLowerCase(), host: this.utilityService.toPunyNullable(host), token: secret, - isRoot: (await this.usersRepository.countBy({ - host: IsNull(), - })) === 0, + isRoot: isTheFirstUser, })); await transactionalEntityManager.save(new UserKeypair({ diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index be37bad52e..3ca22f8bbc 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -1,39 +1,29 @@ -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import Redis from 'ioredis'; +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { IdService } from '@/core/IdService.js'; import type { User } from '@/models/entities/User.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; +import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; import Logger from '@/logger.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { WebhookService } from '@/core/WebhookService.js'; import { bindThis } from '@/decorators.js'; -import { Cache } from '@/misc/cache.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; @Injectable() -export class UserBlockingService implements OnApplicationShutdown { +export class UserBlockingService implements OnModuleInit { private logger: Logger; - - // キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ - private blockingsByUserIdCache: Cache; + private userFollowingService: UserFollowingService; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, + private moduleRef: ModuleRef, @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, @@ -47,56 +37,29 @@ export class UserBlockingService implements OnApplicationShutdown { @Inject(DI.userListJoiningsRepository) private userListJoiningsRepository: UserListJoiningsRepository, + private cacheService: CacheService, private userEntityService: UserEntityService, private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, private webhookService: WebhookService, private apRendererService: ApRendererService, - private perUserFollowingChart: PerUserFollowingChart, private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('user-block'); + } - this.blockingsByUserIdCache = new Cache(Infinity); - - this.redisSubscriber.on('message', this.onMessage); + onModuleInit() { + this.userFollowingService = this.moduleRef.get('UserFollowingService'); } @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; - switch (type) { - case 'blockingCreated': { - const cached = this.blockingsByUserIdCache.get(body.blockerId); - if (cached) { - this.blockingsByUserIdCache.set(body.blockerId, [...cached, ...[body.blockeeId]]); - } - break; - } - case 'blockingDeleted': { - const cached = this.blockingsByUserIdCache.get(body.blockerId); - if (cached) { - this.blockingsByUserIdCache.set(body.blockerId, cached.filter(x => x !== body.blockeeId)); - } - break; - } - default: - break; - } - } - } - - @bindThis - public async block(blocker: User, blockee: User) { + public async block(blocker: User, blockee: User, silent = false) { await Promise.all([ - this.cancelRequest(blocker, blockee), - this.cancelRequest(blockee, blocker), - this.unFollow(blocker, blockee), - this.unFollow(blockee, blocker), + this.cancelRequest(blocker, blockee, silent), + this.cancelRequest(blockee, blocker, silent), + this.userFollowingService.unfollow(blocker, blockee, silent), + this.userFollowingService.unfollow(blockee, blocker, silent), this.removeFromList(blockee, blocker), ]); @@ -111,6 +74,9 @@ export class UserBlockingService implements OnApplicationShutdown { await this.blockingsRepository.insert(blocking); + this.cacheService.userBlockingCache.refresh(blocker.id); + this.cacheService.userBlockedCache.refresh(blockee.id); + this.globalEventService.publishInternalEvent('blockingCreated', { blockerId: blocker.id, blockeeId: blockee.id, @@ -118,12 +84,12 @@ export class UserBlockingService implements OnApplicationShutdown { if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { const content = this.apRendererService.addContext(this.apRendererService.renderBlock(blocking)); - this.queueService.deliver(blocker, content, blockee.inbox); + this.queueService.deliver(blocker, content, blockee.inbox, false); } } @bindThis - private async cancelRequest(follower: User, followee: User) { + private async cancelRequest(follower: User, followee: User, silent = false) { const request = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, @@ -144,11 +110,10 @@ export class UserBlockingService implements OnApplicationShutdown { }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); } - if (this.userEntityService.isLocalUser(follower)) { + if (this.userEntityService.isLocalUser(follower) && !silent) { this.userEntityService.pack(followee, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); @@ -163,61 +128,13 @@ export class UserBlockingService implements OnApplicationShutdown { // リモートにフォローリクエストをしていたらUndoFollow送信 if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } // リモートからフォローリクエストを受けていたらReject送信 if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); - this.queueService.deliver(followee, content, follower.inbox); - } - } - - @bindThis - private async unFollow(follower: User, followee: User) { - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (following == null) { - return; - } - - await Promise.all([ - this.followingsRepository.delete(following.id), - this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), - this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), - this.perUserFollowingChart.update(follower, followee, false), - ]); - - // Publish unfollow event - if (this.userEntityService.isLocalUser(follower)) { - this.userEntityService.pack(followee, follower, { - detail: true, - }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); - this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } - }); - } - - // リモートにフォローをしていたらUndoFollow送信 - if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox); - } - - // リモートからフォローをされていたらRejectFollow送信 - if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } } @@ -254,6 +171,9 @@ export class UserBlockingService implements OnApplicationShutdown { await this.blockingsRepository.delete(blocking.id); + this.cacheService.userBlockingCache.refresh(blocker.id); + this.cacheService.userBlockedCache.refresh(blockee.id); + this.globalEventService.publishInternalEvent('blockingDeleted', { blockerId: blocker.id, blockeeId: blockee.id, @@ -262,23 +182,12 @@ export class UserBlockingService implements OnApplicationShutdown { // deliver if remote bloking if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker)); - this.queueService.deliver(blocker, content, blockee.inbox); + this.queueService.deliver(blocker, content, blockee.inbox, false); } } @bindThis public async checkBlocked(blockerId: User['id'], blockeeId: User['id']): Promise { - const blockedUserIds = await this.blockingsByUserIdCache.fetch(blockerId, () => this.blockingsRepository.find({ - where: { - blockerId, - }, - select: ['blockeeId'], - }).then(records => records.map(record => record.blockeeId))); - return blockedUserIds.includes(blockeeId); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + return (await this.cacheService.userBlockingCache.fetch(blockerId)).has(blockeeId); } } diff --git a/packages/backend/src/core/UserCacheService.ts b/packages/backend/src/core/UserCacheService.ts deleted file mode 100644 index fc383d1c08..0000000000 --- a/packages/backend/src/core/UserCacheService.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; -import type { UsersRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; -import type { LocalUser, User } from '@/models/entities/User.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -@Injectable() -export class UserCacheService implements OnApplicationShutdown { - public userByIdCache: Cache; - public localUserByNativeTokenCache: Cache; - public localUserByIdCache: Cache; - public uriPersonCache: Cache; - - constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private userEntityService: UserEntityService, - ) { - //this.onMessage = this.onMessage.bind(this); - - this.userByIdCache = new Cache(Infinity); - this.localUserByNativeTokenCache = new Cache(Infinity); - this.localUserByIdCache = new Cache(Infinity); - this.uriPersonCache = new Cache(Infinity); - - this.redisSubscriber.on('message', this.onMessage); - } - - @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; - switch (type) { - case 'userChangeSuspendedState': - case 'remoteUserUpdated': { - const user = await this.usersRepository.findOneByOrFail({ id: body.id }); - this.userByIdCache.set(user.id, user); - for (const [k, v] of this.uriPersonCache.cache.entries()) { - if (v.value?.id === user.id) { - this.uriPersonCache.set(k, user); - } - } - if (this.userEntityService.isLocalUser(user)) { - this.localUserByNativeTokenCache.set(user.token, user); - this.localUserByIdCache.set(user.id, user); - } - break; - } - case 'userTokenRegenerated': { - const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as LocalUser; - this.localUserByNativeTokenCache.delete(body.oldToken); - this.localUserByNativeTokenCache.set(body.newToken, user); - break; - } - case 'follow': { - const follower = this.userByIdCache.get(body.followerId); - if (follower) follower.followingCount++; - const followee = this.userByIdCache.get(body.followeeId); - if (followee) followee.followersCount++; - break; - } - default: - break; - } - } - } - - @bindThis - public findById(userId: User['id']) { - return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); - } -} diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index d8426512bf..7d90bc2c08 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -1,23 +1,29 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { WebhookService } from '@/core/WebhookService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { bindThis } from '@/decorators.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { CacheService } from '@/core/CacheService.js'; +import type { Config } from '@/config.js'; import Logger from '../logger.js'; +import { IsNull } from 'typeorm'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; const logger = new Logger('following/create'); @@ -35,8 +41,15 @@ type Remote = RemoteUser | { type Both = Local | Remote; @Injectable() -export class UserFollowingService { +export class UserFollowingService implements OnModuleInit { + private userBlockingService: UserBlockingService; + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -52,26 +65,32 @@ export class UserFollowingService { @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, + private cacheService: CacheService, private userEntityService: UserEntityService, - private userBlockingService: UserBlockingService, private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, - private createNotificationService: CreateNotificationService, + private metaService: MetaService, + private notificationService: NotificationService, private federatedInstanceService: FederatedInstanceService, private webhookService: WebhookService, private apRendererService: ApRendererService, + private accountMoveService: AccountMoveService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, ) { } + onModuleInit() { + this.userBlockingService = this.moduleRef.get('UserBlockingService'); + } + @bindThis - public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise { + public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string, silent = false): Promise { const [follower, followee] = await Promise.all([ this.usersRepository.findOneByOrFail({ id: _follower.id }), this.usersRepository.findOneByOrFail({ id: _followee.id }), - ]); + ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser]; // check blocking const [blocking, blocked] = await Promise.all([ @@ -82,7 +101,7 @@ export class UserFollowingService { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) { // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); return; } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) { // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 @@ -121,17 +140,31 @@ export class UserFollowingService { if (followed) autoAccept = true; } + // Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account. + if (followee.isLocked && !autoAccept) { + autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs( + follower, + (oldSrc, newSrc) => this.followingsRepository.exist({ + where: { + followeeId: followee.id, + followerId: newSrc.id, + }, + }), + true, + )); + } + if (!autoAccept) { await this.createFollowRequest(follower, followee, requestId); return; } } - await this.insertFollowingDoc(followee, follower); + await this.insertFollowingDoc(followee, follower, silent); if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } } @@ -143,17 +176,18 @@ export class UserFollowingService { follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }, + silent = false, ): Promise { if (follower.id === followee.id) return; - + let alreadyFollowed = false as boolean; - + await this.followingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), followerId: follower.id, followeeId: followee.id, - + // 非正規化 followerHost: follower.host, followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null, @@ -169,59 +203,72 @@ export class UserFollowingService { throw err; } }); - + + this.cacheService.userFollowingsCache.refresh(follower.id); + const req = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, }); - + if (req) { await this.followRequestsRepository.delete({ followeeId: followee.id, followerId: follower.id, }); - + // 通知を作成 - this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', { + this.notificationService.createNotification(follower.id, 'followRequestAccepted', { notifierId: followee.id, }); } - + if (alreadyFollowed) return; this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); - - //#region Increment counts - await Promise.all([ - this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), - this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + + const [followeeUser, followerUser] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: followee.id }), + this.usersRepository.findOneByOrFail({ id: follower.id }), ]); - //#endregion - - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(i => { - this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - this.instanceChart.updateFollowing(i.host, true); - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(i => { - this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - this.instanceChart.updateFollowers(i.host, true); - }); + + // Neither followee nor follower has moved. + if (!followeeUser.movedToUri && !followerUser.movedToUri) { + //#region Increment counts + await Promise.all([ + this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), + this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetch(follower.host).then(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); + } + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, true); } - //#endregion - - this.perUserFollowingChart.update(follower, followee, true); - + // Publish follow event - if (this.userEntityService.isLocalUser(follower)) { + if (this.userEntityService.isLocalUser(follower) && !silent) { this.userEntityService.pack(followee.id, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); this.globalEventService.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); - + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'follow', { @@ -230,12 +277,12 @@ export class UserFollowingService { } }); } - + // Publish followed event if (this.userEntityService.isLocalUser(followee)) { this.userEntityService.pack(follower.id, followee).then(async packed => { this.globalEventService.publishMainStream(followee.id, 'followed', packed); - + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'followed', { @@ -243,9 +290,9 @@ export class UserFollowingService { }); } }); - + // 通知を作成 - this.createNotificationService.createNotification(followee.id, 'follow', { + this.notificationService.createNotification(followee.id, 'follow', { notifierId: follower.id, }); } @@ -261,28 +308,35 @@ export class UserFollowingService { }, silent = false, ): Promise { - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, + const following = await this.followingsRepository.findOne({ + relations: { + follower: true, + followee: true, + }, + where: { + followerId: follower.id, + followeeId: followee.id, + } }); - - if (following == null) { + + if (following === null || !following.follower || !following.followee) { logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } - + await this.followingsRepository.delete(following.id); - - this.decrementFollowing(follower, followee); - + + this.cacheService.userFollowingsCache.refresh(follower.id); + + this.decrementFollowing(following.follower, following.followee); + // Publish unfollow event if (!silent && this.userEntityService.isLocalUser(follower)) { this.userEntityService.pack(followee.id, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'unfollow', { @@ -291,48 +345,89 @@ export class UserFollowingService { } }); } - + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser), follower)); + this.queueService.deliver(follower, content, followee.inbox, false); } - + if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { // local user has null host - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); - this.queueService.deliver(followee, content, follower.inbox); + const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower as PartialRemoteUser, followee as PartialLocalUser), followee)); + this.queueService.deliver(followee, content, follower.inbox, false); } } - + @bindThis private async decrementFollowing( - follower: {id: User['id']; host: User['host']; }, - followee: { id: User['id']; host: User['host']; }, + follower: User, + followee: User, ): Promise { this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id }); - - //#region Decrement following / followers counts - await Promise.all([ - this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), - this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), - ]); - //#endregion - - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(i => { - this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - this.instanceChart.updateFollowing(i.host, false); - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - this.instanceChart.updateFollowers(i.host, false); - }); + + // Neither followee nor follower has moved. + if (!follower.movedToUri && !followee.movedToUri) { + //#region Decrement following / followers counts + await Promise.all([ + this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), + this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetch(follower.host).then(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); + } + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, false); + } else { + // Adjust following/followers counts + for (const user of [follower, followee]) { + if (user.movedToUri) continue; // No need to update if the user has already moved. + + const nonMovedFollowees = await this.followingsRepository.count({ + relations: { + followee: true, + }, + where: { + followerId: user.id, + followee: { + movedToUri: IsNull(), + } + } + }); + const nonMovedFollowers = await this.followingsRepository.count({ + relations: { + follower: true, + }, + where: { + followeeId: user.id, + follower: { + movedToUri: IsNull(), + } + } + }); + await this.usersRepository.update( + { id: user.id }, + { followingCount: nonMovedFollowees, followersCount: nonMovedFollowers }, + ); + } + + // TODO: adjust charts } - //#endregion - - this.perUserFollowingChart.update(follower, followee, false); } @bindThis @@ -346,23 +441,23 @@ export class UserFollowingService { requestId?: string, ): Promise { if (follower.id === followee.id) return; - + // check blocking const [blocking, blocked] = await Promise.all([ this.userBlockingService.checkBlocked(follower.id, followee.id), this.userBlockingService.checkBlocked(followee.id, follower.id), ]); - + if (blocking) throw new Error('blocking'); if (blocked) throw new Error('blocked'); - + const followRequest = await this.followRequestsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), followerId: follower.id, followeeId: followee.id, requestId, - + // 非正規化 followerHost: follower.host, followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined, @@ -371,25 +466,25 @@ export class UserFollowingService { followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined, followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined, }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0])); - + // Publish receiveRequest event if (this.userEntityService.isLocalUser(followee)) { this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventService.publishMainStream(followee.id, 'receiveFollowRequest', packed)); - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); - + // 通知を作成 - this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', { + this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { notifierId: follower.id, followRequestId: followRequest.id, }); } - + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)); - this.queueService.deliver(follower, content, followee.inbox); + const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); + this.queueService.deliver(follower, content, followee.inbox, false); } } @@ -403,27 +498,27 @@ export class UserFollowingService { }, ): Promise { if (this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser | PartialRemoteUser, followee as PartialRemoteUser), follower)); + if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } } - + const request = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, }); - + if (request == null) { throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); } - + await this.followRequestsRepository.delete({ followeeId: followee.id, followerId: follower.id, }); - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); @@ -440,18 +535,18 @@ export class UserFollowingService { followeeId: followee.id, followerId: follower.id, }); - + if (request == null) { throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); } - + await this.insertFollowingDoc(followee, follower); - + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); - this.queueService.deliver(followee, content, follower.inbox); + const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as PartialLocalUser, request.requestId!), followee)); + this.queueService.deliver(followee, content, follower.inbox, false); } - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); @@ -466,13 +561,13 @@ export class UserFollowingService { const requests = await this.followRequestsRepository.findBy({ followeeId: user.id, }); - + for (const request of requests) { const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId }); this.acceptFollowRequest(user, follower); } } - + /** * API following/request/reject */ @@ -535,15 +630,22 @@ export class UserFollowingService { */ @bindThis private async removeFollow(followee: Both, follower: Both): Promise { - const following = await this.followingsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, + const following = await this.followingsRepository.findOne({ + relations: { + followee: true, + follower: true, + }, + where: { + followeeId: followee.id, + followerId: follower.id, + } }); - if (!following) return; + if (!following || !following.followee || !following.follower) return; await this.followingsRepository.delete(following.id); - this.decrementFollowing(follower, followee); + + this.decrementFollowing(following.follower, following.followee); } /** @@ -557,7 +659,7 @@ export class UserFollowingService { }); const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } /** @@ -569,7 +671,6 @@ export class UserFollowingService { detail: true, }); - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packedFollowee); this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts new file mode 100644 index 0000000000..72c35c529c --- /dev/null +++ b/packages/backend/src/core/UserKeypairService.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { User } from '@/models/entities/User.js'; +import type { UserKeypairsRepository } from '@/models/index.js'; +import { RedisKVCache } from '@/misc/cache.js'; +import type { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class UserKeypairService { + private cache: RedisKVCache; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.userKeypairsRepository) + private userKeypairsRepository: UserKeypairsRepository, + ) { + this.cache = new RedisKVCache(this.redisClient, 'userKeypair', { + lifetime: 1000 * 60 * 60 * 24, // 24h + memoryCacheLifetime: Infinity, + fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), + }); + } + + @bindThis + public async getUserKeypair(userId: User['id']): Promise { + return await this.cache.fetch(userId); + } +} diff --git a/packages/backend/src/core/UserKeypairStoreService.ts b/packages/backend/src/core/UserKeypairStoreService.ts deleted file mode 100644 index 1d3cc87c8d..0000000000 --- a/packages/backend/src/core/UserKeypairStoreService.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { User } from '@/models/entities/User.js'; -import type { UserKeypairsRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; -import type { UserKeypair } from '@/models/entities/UserKeypair.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class UserKeypairStoreService { - private cache: Cache; - - constructor( - @Inject(DI.userKeypairsRepository) - private userKeypairsRepository: UserKeypairsRepository, - ) { - this.cache = new Cache(Infinity); - } - - @bindThis - public async getUserKeypair(userId: User['id']): Promise { - return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId })); - } -} diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index bc726a1feb..08cc907ebf 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class UserListService { @@ -29,6 +30,7 @@ export class UserListService { private roleService: RoleService, private globalEventService: GlobalEventService, private proxyAccountService: ProxyAccountService, + private queueService: QueueService, ) { } @@ -47,14 +49,14 @@ export class UserListService { userId: target.id, userListId: list.id, } as UserListJoining); - + this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); - + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする if (this.userEntityService.isRemoteUser(target)) { const proxy = await this.proxyAccountService.fetch(); if (proxy) { - this.userFollowingService.follow(proxy, target); + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]); } } } diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts index e98f11709f..d201ec6c04 100644 --- a/packages/backend/src/core/UserMutingService.ts +++ b/packages/backend/src/core/UserMutingService.ts @@ -1,34 +1,47 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, MutingsRepository } from '@/models/index.js'; +import { In } from 'typeorm'; +import type { MutingsRepository, Muting } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class UserMutingService { constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, private idService: IdService, - private queueService: QueueService, - private globalEventService: GlobalEventService, + private cacheService: CacheService, ) { } @bindThis - public async mute(user: User, target: User): Promise { + public async mute(user: User, target: User, expiresAt: Date | null = null): Promise { await this.mutingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), + expiresAt: expiresAt ?? null, muterId: user.id, muteeId: target.id, }); + + this.cacheService.userMutingsCache.refresh(user.id); + } + + @bindThis + public async unmute(mutings: Muting[]): Promise { + if (mutings.length === 0) return; + + await this.mutingsRepository.delete({ + id: In(mutings.map(m => m.id)), + }); + + const muterIds = [...new Set(mutings.map(m => m.muterId))]; + for (const muterId of muterIds) { + this.cacheService.userMutingsCache.refresh(muterId); + } } } diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 02903a0590..b197d335d8 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -35,7 +35,7 @@ export class UserSuspendService { if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにDelete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user)); + const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const queue: string[] = []; @@ -54,7 +54,7 @@ export class UserSuspendService { } for (const inbox of queue) { - this.queueService.deliver(user, content, inbox); + this.queueService.deliver(user, content, inbox, true); } } } @@ -65,7 +65,7 @@ export class UserSuspendService { if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにUndo Delete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); const queue: string[] = []; @@ -84,7 +84,7 @@ export class UserSuspendService { } for (const inbox of queue) { - this.queueService.deliver(user as any, content, inbox); + this.queueService.deliver(user as any, content, inbox, true); } } } diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index dd6c51c217..5869905db0 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -37,7 +37,7 @@ export class VideoProcessingService { }); }); - return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 280); + return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 422); } finally { cleanup(); } @@ -53,7 +53,7 @@ export class VideoProcessingService { thumbnail: '1', url, }) - ) + ); } } diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index 69df2d0c1b..3ee7990643 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -43,7 +43,8 @@ export class WebfingerService { const m = query.match(/^([^@]+)@(.*)/); if (m) { const hostname = m[2]; - return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); + const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; + return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`; } throw new Error(`Invalid query (${query})`); diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 30caa9682c..57baade777 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { WebhooksRepository } from '@/models/index.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import { DI } from '@/di-symbols.js'; @@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown { private webhooks: Webhook[] = []; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.webhooksRepository) private webhooksRepository: WebhooksRepository, ) { //this.onMessage = this.onMessage.bind(this); - this.redisSubscriber.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -47,6 +47,7 @@ export class WebhookService implements OnApplicationShutdown { this.webhooks.push({ ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }); } break; @@ -57,11 +58,13 @@ export class WebhookService implements OnApplicationShutdown { this.webhooks[i] = { ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }; } else { this.webhooks.push({ ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }); } } else { @@ -79,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/activitypub/ApAudienceService.ts b/packages/backend/src/core/activitypub/ApAudienceService.ts index 4c22297b37..8282a6324c 100644 --- a/packages/backend/src/core/activitypub/ApAudienceService.ts +++ b/packages/backend/src/core/activitypub/ApAudienceService.ts @@ -1,11 +1,9 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; +import { Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; -import { DI } from '@/di-symbols.js'; import type { RemoteUser, User } from '@/models/entities/User.js'; -import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; +import { concat, unique } from '@/misc/prelude/array.js'; import { bindThis } from '@/decorators.js'; -import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApIds } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; import type { ApObject } from './type.js'; import type { Resolver } from './ApResolverService.js'; diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index 9a894826c8..952ea4b91e 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -3,13 +3,13 @@ import escapeRegexp from 'escape-regexp'; import { DI } from '@/di-symbols.js'; import type { MessagingMessagesRepository, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { UserPublickey } from '@/models/entities/UserPublickey.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { Note } from '@/models/entities/Note.js'; import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; import { bindThis } from '@/decorators.js'; -import { RemoteUser, User } from '@/models/entities/User.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { getApId } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; import type { IObject } from './type.js'; @@ -32,8 +32,8 @@ export type UriParseResult = { @Injectable() export class ApDbResolverService { - private publicKeyCache: Cache; - private publicKeyByUserIdCache: Cache; + private publicKeyCache: MemoryKVCache; + private publicKeyByUserIdCache: MemoryKVCache; constructor( @Inject(DI.config) @@ -51,11 +51,11 @@ export class ApDbResolverService { @Inject(DI.userPublickeysRepository) private userPublickeysRepository: UserPublickeysRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, private apPersonService: ApPersonService, ) { - this.publicKeyCache = new Cache(Infinity); - this.publicKeyByUserIdCache = new Cache(Infinity); + this.publicKeyCache = new MemoryKVCache(Infinity); + this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); } @bindThis @@ -122,19 +122,19 @@ export class ApDbResolverService { * AP Person => Misskey User in DB */ @bindThis - public async getUserFromApId(value: string | IObject): Promise { + public async getUserFromApId(value: string | IObject): Promise { const parsed = this.parseUri(value); if (parsed.local) { if (parsed.type !== 'users') return null; - return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ + return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ id: parsed.id, - }).then(x => x ?? undefined)) ?? null; + }).then(x => x ?? undefined)) as LocalUser | undefined ?? null; } else { - return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ + return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ uri: parsed.uri, - })); + })) as RemoteUser | null; } } @@ -159,7 +159,7 @@ export class ApDbResolverService { if (key == null) return null; return { - user: await this.userCacheService.findById(key.userId) as RemoteUser, + user: await this.cacheService.findUserById(key.userId) as RemoteUser, key, }; } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 5e6ea69846..62a2a33a19 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -157,7 +157,8 @@ class DeliverManager { public async execute() { if (!this.userEntityService.isLocalUser(this.actor)) return; - const inboxes = new Set(); + // The value flags whether it is shared or not. + const inboxes = new Map(); /* build inbox list @@ -185,7 +186,7 @@ class DeliverManager { for (const following of followers) { const inbox = following.followerSharedInbox ?? following.followerInbox; - inboxes.add(inbox); + inboxes.set(inbox, following.followerSharedInbox != null); } } @@ -197,11 +198,12 @@ class DeliverManager { // check that they actually have an inbox && recipe.to.inbox != null, ) - .forEach(recipe => inboxes.add(recipe.to.inbox!)); + .forEach(recipe => inboxes.set(recipe.to.inbox!, false)); // deliver for (const inbox of inboxes) { - this.queueService.deliver(this.actor, this.activity, inbox); + // inbox[0]: inbox, inbox[1]: whether it is sharedInbox + this.queueService.deliver(this.actor, this.activity, inbox[0], inbox[1]); } } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 62f3827343..dab618a6cc 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -13,17 +13,19 @@ 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 { AccountMoveService } from '@/core/AccountMoveService.js'; import { IdService } from '@/core/IdService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { CacheService } from '@/core/CacheService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; import { MessagingService } from '@/core/MessagingService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, MessagingMessagesRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, MessagingMessagesRepository, AbuseUserReportsRepository, FollowRequestsRepository, } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; -import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -32,7 +34,7 @@ import { ApAudienceService } from './ApAudienceService.js'; import { ApPersonService } from './models/ApPersonService.js'; import { ApQuestionService } from './models/ApQuestionService.js'; import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IRead, IReject, IRemove, IUndo, IUpdate } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IRead, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; @Injectable() export class ApInboxService { @@ -80,12 +82,14 @@ export class ApInboxService { private apNoteService: ApNoteService, private apPersonService: ApPersonService, private apQuestionService: ApQuestionService, + private accountMoveService: AccountMoveService, + private cacheService: CacheService, private queueService: QueueService, private messagingService: MessagingService, ) { this.logger = this.apLoggerService.logger; } - + @bindThis public async performActivity(actor: RemoteUser, activity: IObject) { if (isCollectionOrOrderedCollection(activity)) { @@ -146,23 +150,26 @@ export class ApInboxService { await this.block(actor, activity); } else if (isFlag(activity)) { await this.flag(actor, activity); + } else if (isMove(activity)) { + await this.move(actor, activity); } else { - this.logger.warn(`unrecognized activity type: ${(activity as any).type}`); + this.logger.warn(`unrecognized activity type: ${activity.type}`); } } @bindThis private async follow(actor: RemoteUser, activity: IFollow): Promise { const followee = await this.apDbResolverService.getUserFromApId(activity.object); - + if (followee == null) { return 'skip: followee not found'; } - + if (followee.host != null) { return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; } - + + // don't queue because the sender may attempt again when timeout await this.userFollowingService.follow(actor, followee, activity.id); return 'ok'; } @@ -213,16 +220,16 @@ export class ApInboxService { const uri = activity.id ?? activity; this.logger.info(`Accept: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); throw err; }); - + if (isFollow(object)) return await this.acceptFollow(actor, object); - + return `skip: Unknown Accept type: ${getApType(object)}`; } @@ -255,18 +262,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.addPinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -435,10 +442,10 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + // 削除対象objectのtype let formerType: string | undefined; - + if (typeof activity.object === 'string') { // typeが不明だけど、どうせ消えてるのでremote resolveしない formerType = undefined; @@ -450,19 +457,19 @@ export class ApInboxService { formerType = toSingle(object.type); } } - + const uri = getApId(activity.object); - + // type不明でもactorとobjectが同じならばそれはPersonに違いない if (!formerType && actor.uri === uri) { formerType = 'Person'; } - + // それでもなかったらおそらくNote if (!formerType) { formerType = 'Note'; } - + if (validPost.includes(formerType)) { return await this.deleteNote(actor, uri); } else if (validActor.includes(formerType)) { @@ -475,51 +482,53 @@ export class ApInboxService { @bindThis private async deleteActor(actor: RemoteUser, uri: string): Promise { this.logger.info(`Deleting the Actor: ${uri}`); - + if (actor.uri !== uri) { return `skip: delete actor ${actor.uri} !== ${uri}`; } - - const user = await this.usersRepository.findOneByOrFail({ id: actor.id }); - if (user.isDeleted) { - this.logger.info('skip: already deleted'); + + const user = await this.usersRepository.findOneBy({ id: actor.id }); + if (user == null) { + return 'skip: actor not found'; + } else if (user.isDeleted) { + return 'skip: already deleted'; } - + const job = await this.queueService.createDeleteAccountJob(actor); - + await this.usersRepository.update(actor.id, { isDeleted: true, }); - + return `ok: queued ${job.name} ${job.id}`; } @bindThis private async deleteNote(actor: RemoteUser, uri: string): Promise { this.logger.info(`Deleting the Note: ${uri}`); - + const unlock = await this.appLockService.getApLock(uri); - + try { const note = await this.apDbResolverService.getNoteFromApId(uri); - + if (note == null) { const message = await this.apDbResolverService.getMessageFromApId(uri); if (message == null) return 'message not found'; - + if (message.userId !== actor.id) { return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; } - + await this.messagingService.deleteMessage(message); - + return 'ok: message deleted'; } - + if (note.userId !== actor.id) { return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; } - + await this.noteDeleteService.delete(actor, note); return 'ok: note deleted'; } finally { @@ -573,23 +582,23 @@ export class ApInboxService { @bindThis private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - + if (follower == null) { return 'skip: follower not found'; } - + if (!this.userEntityService.isLocalUser(follower)) { return 'skip: follower is not a local user'; } - + // relay const match = activity.id?.match(/follow-relay\/(\w+)/); if (match) { return await this.relayService.relayRejected(match[1]); } - + await this.userFollowingService.remoteReject(actor, follower); return 'ok'; } @@ -599,18 +608,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.removePinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -619,24 +628,25 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + const uri = activity.id ?? activity; - + this.logger.info(`Undo: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + + // don't queue because the sender may attempt again when timeout if (isFollow(object)) return await this.undoFollow(actor, object); if (isBlock(object)) return await this.undoBlock(actor, object); if (isLike(object)) return await this.undoLike(actor, object); if (isAnnounce(object)) return await this.undoAnnounce(actor, object); if (isAccept(object)) return await this.undoAccept(actor, object); - + return `skip: unknown object type ${getApType(object)}`; } @@ -646,17 +656,17 @@ export class ApInboxService { if (follower == null) { return 'skip: follower not found'; } - + const following = await this.followingsRepository.findOneBy({ followerId: follower.id, followeeId: actor.id, }); - + if (following) { await this.userFollowingService.unfollow(follower, actor); return 'ok: unfollowed'; } - + return 'skip: フォローされていない'; } @@ -745,16 +755,16 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { return 'skip: invalid actor'; } - + this.logger.debug('Update'); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + if (isActor(object)) { await this.apPersonService.updatePerson(actor.uri!, resolver, object); return 'ok: Person updated'; @@ -765,4 +775,13 @@ export class ApInboxService { return `skip: Unknown type: ${getApType(object)}`; } } + + @bindThis + private async move(actor: RemoteUser, activity: IMove): Promise { + // fetch the new and old accounts + const targetUri = getApHrefNullable(activity.target); + if (!targetUri) return 'skip: invalid activity target'; + + return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do'; + } } diff --git a/packages/backend/src/core/activitypub/ApLoggerService.ts b/packages/backend/src/core/activitypub/ApLoggerService.ts index b9bf1e4054..eeffab1b6d 100644 --- a/packages/backend/src/core/activitypub/ApLoggerService.ts +++ b/packages/backend/src/core/activitypub/ApLoggerService.ts @@ -1,7 +1,6 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type Logger from '@/logger.js'; import { RemoteLoggerService } from '@/core/RemoteLoggerService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ApLoggerService { diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 92bc1869e5..44738a9a77 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid'; import * as mfm from 'mfm-js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { PartialLocalUser, LocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import type { Relay } from '@/models/entities/Relay.js'; @@ -15,16 +15,18 @@ import type { Emoji } from '@/models/entities/Emoji.js'; import type { Poll } from '@/models/entities/Poll.js'; import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; import type { PollVote } from '@/models/entities/PollVote.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import { MfmService } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { UserKeypair } from '@/models/entities/UserKeypair.js'; import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import { LdSignatureService } from './LdSignatureService.js'; import { ApMfmService } from './ApMfmService.js'; -import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IRead, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; +import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IRead, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; import type { IIdentifier } from './models/identifier.js'; @Injectable() @@ -51,10 +53,11 @@ export class ApRendererService { @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, + private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, private ldSignatureService: LdSignatureService, - private userKeypairStoreService: UserKeypairStoreService, + private userKeypairService: UserKeypairService, private apMfmService: ApMfmService, private mfmService: MfmService, ) { @@ -64,7 +67,7 @@ export class ApRendererService { public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept { return { type: 'Accept', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, }; } @@ -73,7 +76,7 @@ export class ApRendererService { public renderAdd(user: LocalUser, target: any, object: any): IAdd { return { type: 'Add', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), target, object, }; @@ -81,7 +84,7 @@ export class ApRendererService { @bindThis public renderAnnounce(object: any, note: Note): IAnnounce { - const attributedTo = `${this.config.url}/users/${note.userId}`; + const attributedTo = this.userEntityService.genLocalUserUri(note.userId); let to: string[] = []; let cc: string[] = []; @@ -92,13 +95,16 @@ export class ApRendererService { } else if (note.visibility === 'home') { to = [`${attributedTo}/followers`]; cc = ['https://www.w3.org/ns/activitystreams#Public']; + } else if (note.visibility === 'followers') { + to = [`${attributedTo}/followers`]; + cc = []; } else { throw new Error('renderAnnounce: cannot render non-public note'); } return { id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, + actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', published: note.createdAt.toISOString(), to, @@ -117,11 +123,11 @@ export class ApRendererService { if (block.blockee?.uri == null) { throw new Error('renderBlock: missing blockee uri'); } - + return { type: 'Block', id: `${this.config.url}/blocks/${block.id}`, - actor: `${this.config.url}/users/${block.blockerId}`, + actor: this.userEntityService.genLocalUserUri(block.blockerId), object: block.blockee.uri, }; } @@ -130,15 +136,15 @@ export class ApRendererService { public renderCreate(object: IObject, note: Note): ICreate { const activity = { id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, + actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Create', published: note.createdAt.toISOString(), object, } as ICreate; - + if (object.to) activity.to = object.to; if (object.cc) activity.cc = object.cc; - + return activity; } @@ -146,7 +152,7 @@ export class ApRendererService { public renderDelete(object: IObject | string, user: { id: User['id']; host: null }): IDelete { return { type: 'Delete', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), }; @@ -156,7 +162,7 @@ export class ApRendererService { public renderDocument(file: DriveFile): IApDocument { return { type: 'Document', - mediaType: file.type, + mediaType: file.webpublicType ?? file.type, url: this.driveFileEntityService.getPublicUrl(file), name: file.comment, }; @@ -179,12 +185,11 @@ export class ApRendererService { } // to anonymise reporters, the reporting actor must be a system user - // object has to be a uri or array of uris @bindThis - public renderFlag(user: LocalUser, object: IObject | string | string[], content: string): IFlag { + public renderFlag(user: LocalUser, object: IObject | string, content: string): IFlag { return { type: 'Flag', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), content, object, }; @@ -195,7 +200,7 @@ export class ApRendererService { return { id: `${this.config.url}/activities/follow-relay/${relay.id}`, type: 'Follow', - actor: `${this.config.url}/users/${relayActor.id}`, + actor: this.userEntityService.genLocalUserUri(relayActor.id), object: 'https://www.w3.org/ns/activitystreams#Public', }; } @@ -206,21 +211,21 @@ export class ApRendererService { */ @bindThis public async renderFollowUser(id: User['id']) { - const user = await this.usersRepository.findOneByOrFail({ id: id }); - return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri; + const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser; + return this.userEntityService.getUserUri(user); } @bindThis public renderFollow( - follower: { id: User['id']; host: User['host']; uri: User['host'] }, - followee: { id: User['id']; host: User['host']; uri: User['host'] }, + follower: PartialLocalUser | PartialRemoteUser, + followee: PartialLocalUser | PartialRemoteUser, requestId?: string, ): IFollow { return { id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, type: 'Follow', - actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri!, - object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri!, + actor: this.userEntityService.getUserUri(follower)!, + object: this.userEntityService.getUserUri(followee)!, }; } @@ -248,7 +253,7 @@ export class ApRendererService { return { id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, type: 'Key', - owner: `${this.config.url}/users/${user.id}`, + owner: this.userEntityService.genLocalUserUri(user.id), publicKeyPem: createPublicKey(key.publicKey).export({ type: 'spki', format: 'pem', @@ -271,11 +276,7 @@ export class ApRendererService { if (reaction.startsWith(':')) { const name = reaction.replaceAll(':', ''); - // TODO: cache - const emoji = await this.emojisRepository.findOneBy({ - name, - host: IsNull(), - }); + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); if (emoji) object.tag = [this.renderEmoji(emoji)]; } @@ -284,14 +285,30 @@ export class ApRendererService { } @bindThis - public renderMention(mention: User): IApMention { + public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention { return { type: 'Mention', - href: this.userEntityService.isRemoteUser(mention) ? mention.uri! : `${this.config.url}/users/${(mention as LocalUser).id}`, + href: this.userEntityService.getUserUri(mention)!, name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`, }; } + @bindThis + public renderMove( + src: PartialLocalUser | PartialRemoteUser, + dst: PartialLocalUser | PartialRemoteUser, + ): IMove { + const actor = this.userEntityService.getUserUri(src)!; + const target = this.userEntityService.getUserUri(dst)!; + return { + id: `${this.config.url}/moves/${src.id}/${dst.id}`, + actor, + type: 'Move', + object: actor, + target, + }; + } + @bindThis public async renderNote(note: Note, dive = true, isTalk = false): Promise { const getPromisedFiles = async (ids: string[]) => { @@ -299,16 +316,16 @@ export class ApRendererService { const items = await this.driveFilesRepository.findBy({ id: In(ids) }); return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; }; - + let inReplyTo; let inReplyToNote: Note | null; - + if (note.replyId) { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); - + if (inReplyToNote != null) { const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - + if (inReplyToUser != null) { if (inReplyToNote.uri) { inReplyTo = inReplyToNote.uri; @@ -324,24 +341,24 @@ export class ApRendererService { } else { inReplyTo = null; } - + let quote; - + if (note.renoteId) { const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); - + if (renote) { quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; } } - - const attributedTo = `${this.config.url}/users/${note.userId}`; - + + const attributedTo = this.userEntityService.genLocalUserUri(note.userId); + const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); - + let to: string[] = []; let cc: string[] = []; - + if (note.visibility === 'public') { to = ['https://www.w3.org/ns/activitystreams#Public']; cc = [`${attributedTo}/followers`].concat(mentions); @@ -354,44 +371,44 @@ export class ApRendererService { } else { to = mentions; } - + const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({ id: In(note.mentions), }) : []; - + const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => this.renderMention(u)); - + const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser)); + const files = await getPromisedFiles(note.fileIds); - + const text = note.text ?? ''; let poll: Poll | null = null; - + if (note.hasPoll) { poll = await this.pollsRepository.findOneBy({ noteId: note.id }); } - + let apText = text; - + if (quote) { apText += `\n\nRE: ${quote}`; } - + const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - + const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { text: apText, })); - + const emojis = await this.getEmojis(note.emojis); const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); - + const tag = [ ...hashtagTags, ...mentionTags, ...apemojis, ]; - + const asPoll = poll ? { type: 'Question', content: this.apMfmService.getNoteHtml(Object.assign({}, note, { @@ -407,11 +424,11 @@ export class ApRendererService { }, })), } as const : {}; - + const asTalk = isTalk ? { _misskey_talk: true, } as const : {}; - + return { id: `${this.config.url}/notes/${note.id}`, type: 'Note', @@ -439,7 +456,7 @@ export class ApRendererService { @bindThis public async renderPerson(user: LocalUser) { - const id = `${this.config.url}/users/${user.id}`; + const id = this.userEntityService.genLocalUserUri(user.id); const isSystem = !!user.username.match(/\./); const [avatar, banner, profile] = await Promise.all([ @@ -477,7 +494,7 @@ export class ApRendererService { ...hashtagTags, ]; - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const person = { type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', @@ -503,6 +520,14 @@ export class ApRendererService { attachment: attachment.length ? attachment : undefined, } as any; + if (user.movedToUri) { + person.movedTo = user.movedToUri; + } + + if (user.alsoKnownAs) { + person.alsoKnownAs = user.alsoKnownAs; + } + if (profile.birthday) { person['vcard:bday'] = profile.birthday; } @@ -519,7 +544,7 @@ export class ApRendererService { return { type: 'Question', id: `${this.config.url}/questions/${note.id}`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), content: note.text ?? '', [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ name: text, @@ -545,7 +570,7 @@ export class ApRendererService { public renderReject(object: any, user: { id: User['id'] }): IReject { return { type: 'Reject', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, }; } @@ -554,7 +579,7 @@ export class ApRendererService { public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove { return { type: 'Remove', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), target, object, }; @@ -575,7 +600,7 @@ export class ApRendererService { return { type: 'Undo', ...(id ? { id } : {}), - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), }; @@ -585,7 +610,7 @@ export class ApRendererService { public renderUpdate(object: any, user: { id: User['id'] }): IUpdate { return { id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), type: 'Update', to: ['https://www.w3.org/ns/activitystreams#Public'], object, @@ -597,14 +622,14 @@ export class ApRendererService { public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: RemoteUser): ICreate { return { id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), type: 'Create', to: [pollOwner.uri], published: new Date().toISOString(), object: { id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, type: 'Note', - attributedTo: `${this.config.url}/users/${user.id}`, + attributedTo: this.userEntityService.genLocalUserUri(user.id), to: [pollOwner.uri], inReplyTo: note.uri, name: poll.choices[vote.choice], @@ -617,7 +642,7 @@ export class ApRendererService { if (typeof x === 'object' && x.id == null) { x.id = `${this.config.url}/${uuid()}`; } - + return Object.assign({ '@context': [ 'https://www.w3.org/ns/activitystreams', @@ -651,18 +676,18 @@ export class ApRendererService { ], }, x as T & { id: string; }); } - + @bindThis public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise { - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); - + const keypair = await this.userKeypairService.getUserKeypair(user.id); + const ldSignature = this.ldSignatureService.use(); ldSignature.debug = false; activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); - + return activity; } - + /** * Render OrderedCollectionPage * @param id URL of self @@ -703,11 +728,11 @@ export class ApRendererService { type: 'OrderedCollection', totalItems, }; - + if (first) page.first = first; if (last) page.last = last; if (orderedItems) page.orderedItems = orderedItems; - + return page; } @@ -715,13 +740,9 @@ export class ApRendererService { private async getEmojis(names: string[]): Promise { if (names == null || names.length === 0) return []; - const emojis = await Promise.all( - names.map(name => this.emojisRepository.findOneBy({ - name, - host: IsNull(), - })), - ); + const allEmojis = await this.customEmojiService.localEmojisCache.fetch(); + const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull); - return emojis.filter(emoji => emoji != null) as Emoji[]; + return emojis; } } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index bfd53dfabf..5005612ab8 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -4,7 +4,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { User } from '@/models/entities/User.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; @@ -28,31 +28,15 @@ type PrivateKey = { keyId: string; }; -@Injectable() -export class ApRequestService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - private userKeypairStoreService: UserKeypairStoreService, - private httpRequestService: HttpRequestService, - private loggerService: LoggerService, - ) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる - } - - @bindThis - private createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record }): Signed { +export class ApRequestCreator { + static createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record }): Signed { const u = new URL(args.url); const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`; const request: Request = { url: u.href, method: 'POST', - headers: this.objectAssignWithLcKey({ + headers: this.#objectAssignWithLcKey({ 'Date': new Date().toUTCString(), 'Host': u.host, 'Content-Type': 'application/activity+json', @@ -60,7 +44,7 @@ export class ApRequestService { }, args.additionalHeaders), }; - const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); + const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); return { request, @@ -70,21 +54,20 @@ export class ApRequestService { }; } - @bindThis - private createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }): Signed { + static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }): Signed { const u = new URL(args.url); const request: Request = { url: u.href, method: 'GET', - headers: this.objectAssignWithLcKey({ + headers: this.#objectAssignWithLcKey({ 'Accept': 'application/activity+json, application/ld+json', 'Date': new Date().toUTCString(), 'Host': new URL(args.url).host, }, args.additionalHeaders), }; - const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); + const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); return { request, @@ -94,13 +77,12 @@ export class ApRequestService { }; } - @bindThis - private signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { - const signingString = this.genSigningString(request, includeHeaders); + static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { + const signingString = this.#genSigningString(request, includeHeaders); const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; - request.headers = this.objectAssignWithLcKey(request.headers, { + request.headers = this.#objectAssignWithLcKey(request.headers, { Signature: signatureHeader, }); // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! @@ -114,9 +96,8 @@ export class ApRequestService { }; } - @bindThis - private genSigningString(request: Request, includeHeaders: string[]): string { - request.headers = this.lcObjectKey(request.headers); + static #genSigningString(request: Request, includeHeaders: string[]): string { + request.headers = this.#lcObjectKey(request.headers); const results: string[] = []; @@ -131,25 +112,40 @@ export class ApRequestService { return results.join('\n'); } - @bindThis - private lcObjectKey(src: Record): Record { + static #lcObjectKey(src: Record): Record { const dst: Record = {}; for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; return dst; } - @bindThis - private objectAssignWithLcKey(a: Record, b: Record): Record { - return Object.assign(this.lcObjectKey(a), this.lcObjectKey(b)); + static #objectAssignWithLcKey(a: Record, b: Record): Record { + return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b)); + } +} + +@Injectable() +export class ApRequestService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private userKeypairService: UserKeypairService, + private httpRequestService: HttpRequestService, + private loggerService: LoggerService, + ) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる } @bindThis public async signedPost(user: { id: User['id'] }, url: string, object: any) { const body = JSON.stringify(object); - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); - const req = this.createSignedPost({ + const req = ApRequestCreator.createSignedPost({ key: { privateKeyPem: keypair.privateKey, keyId: `${this.config.url}/users/${user.id}#main-key`, @@ -174,9 +170,9 @@ export class ApRequestService { */ @bindThis public async signedGet(url: string, user: { id: User['id'] }) { - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); - const req = this.createSignedGet({ + const req = ApRequestCreator.createSignedGet({ key: { privateKeyPem: keypair.privateKey, keyId: `${this.config.url}/users/${user.id}#main-key`, diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index df7bb46405..d3e0345c9c 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { LocalUser } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -151,7 +151,7 @@ export class Resolver { return Promise.all( [parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })), ) - .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, url))); + .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower as LocalUser | RemoteUser, followee as LocalUser | RemoteUser, url))); default: throw new Error(`resolveLocal: type ${parsed.type} unhandled`); } diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/LdSignatureService.ts index 618ae48b1b..2dc1a410ac 100644 --- a/packages/backend/src/core/activitypub/LdSignatureService.ts +++ b/packages/backend/src/core/activitypub/LdSignatureService.ts @@ -1,5 +1,5 @@ import * as crypto from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import { CONTEXTS } from './misc/contexts.js'; diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 3b671af127..0043907c21 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -12,6 +12,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; +import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApImageService { @@ -48,8 +49,8 @@ export class ApImageService { throw new Error('invalid image: url not privided'); } - if (!image.url.startsWith('https://')) { - throw new Error('invalid image: unexpected shcema of url: ' + image.url); + if (!checkHttps(image.url)) { + throw new Error('invalid image: unexpected schema of url: ' + image.url); } this.logger.info(`Creating the Image: ${image.url}`); diff --git a/packages/backend/src/core/activitypub/models/ApMentionService.ts b/packages/backend/src/core/activitypub/models/ApMentionService.ts index 1ed9fb89dd..c581840ca9 100644 --- a/packages/backend/src/core/activitypub/models/ApMentionService.ts +++ b/packages/backend/src/core/activitypub/models/ApMentionService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; import { DI } from '@/di-symbols.js'; -import type { User, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/index.js'; import type { Config } from '@/config.js'; import { toArray, unique } from '@/misc/prelude/array.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 560b19e57d..75cec69538 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -1,7 +1,8 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { MessagingMessagesRepository, PollsRepository, EmojisRepository, UsersRepository } from '@/models/index.js'; +import type { MessagingMessagesRepository, PollsRepository, EmojisRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type { RemoteUser } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; @@ -32,6 +33,7 @@ import { ApQuestionService } from './ApQuestionService.js'; import { ApImageService } from './ApImageService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject, IPost } from '../type.js'; +import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApNoteService { @@ -75,7 +77,7 @@ export class ApNoteService { } @bindThis - public validateNote(object: any, uri: string) { + public validateNote(object: IObject, uri: string) { const expectHost = this.utilityService.extractDbHost(uri); if (object == null) { @@ -89,9 +91,10 @@ export class ApNoteService { if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); } - - if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`); + + const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); + if (object.attributedTo && actualHost !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } return null; @@ -129,17 +132,17 @@ export class ApNoteService { throw new Error('invalid note'); } - const note: IPost = object as any; + const note = object as IPost; this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - if (note.id && !note.id.startsWith('https://')) { + if (note.id && !checkHttps(note.id)) { throw new Error('unexpected shcema of note.id: ' + note.id); } const url = getOneApHrefNullable(note.url); - if (url && !url.startsWith('https://')) { + if (url && !checkHttps(url)) { throw new Error('unexpected shcema of note url: ' + url); } @@ -152,7 +155,7 @@ export class ApNoteService { if (actor.isSuspended) { throw new Error('actor has been suspended'); } - + const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); let visibility = noteAudience.visibility; const visibleUsers = noteAudience.visibleUsers; @@ -166,7 +169,7 @@ export class ApNoteService { } let isMessaging = note._misskey_talk && visibility === 'specified'; - + const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); const apHashtags = await extractApHashtags(note.tag); @@ -187,7 +190,7 @@ export class ApNoteService { const reply: Note | null = note.inReplyTo ? await this.resolveNote(note.inReplyTo, resolver).then(x => { if (x == null) { - this.logger.warn('Specified inReplyTo, but nout found'); + this.logger.warn('Specified inReplyTo, but not found'); throw new Error('inReplyTo not found'); } else { return x; @@ -203,7 +206,7 @@ export class ApNoteService { return null; } } - + this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); throw err; }) @@ -292,14 +295,14 @@ export class ApNoteService { const apEmojis = emojis.map(emoji => emoji.name); const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); - + if (isMessaging) { for (const recipient of visibleUsers) { await this.messagingService.createMessage(actor, recipient, undefined, text ?? undefined, (files && files.length > 0) ? files[0] : null, object.id); return null; } } - + return await this.noteCreateService.create(actor, { createdAt: note.published ? new Date(note.published) : null, files, @@ -367,15 +370,17 @@ export class ApNoteService { if (!tags) return []; const eomjiTags = toArray(tags).filter(isEmoji); - + + const existingEmojis = await this.emojisRepository.findBy({ + host, + name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))), + }); + return await Promise.all(eomjiTags.map(async tag => { - const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); + const name = tag.name!.replaceAll(':', ''); tag.icon = toSingle(tag.icon); - const exists = await this.emojisRepository.findOneBy({ - host, - name, - }); + const exists = existingEmojis.find(x => x.name === name); if (exists) { if ((tag.updated != null && exists.updatedAt == null) diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 35d6985f7a..eea1d1b848 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -1,14 +1,14 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; 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/index.js'; +import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import type { RemoteUser } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; import { truncate } from '@/misc/truncate.js'; -import type { UserCacheService } from '@/core/UserCacheService.js'; +import type { CacheService } from '@/core/CacheService.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import type Logger from '@/logger.js'; @@ -30,6 +30,8 @@ import { StatusError } from '@/misc/status-error.js'; import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -39,7 +41,9 @@ 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, IObject, IApPropertyValue } from '../type.js'; +import type { IActor, IObject } from '../type.js'; +import type { AccountMoveService } from '@/core/AccountMoveService.js'; +import { checkHttps } from '@/misc/check-https.js'; const nameLength = 128; const summaryLength = 2048; @@ -48,11 +52,13 @@ const summaryLength = 2048; export class ApPersonService implements OnModuleInit { private utilityService: UtilityService; private userEntityService: UserEntityService; + private driveFileEntityService: DriveFileEntityService; private idService: IdService; private globalEventService: GlobalEventService; + private metaService: MetaService; private federatedInstanceService: FederatedInstanceService; private fetchInstanceMetadataService: FetchInstanceMetadataService; - private userCacheService: UserCacheService; + private cacheService: CacheService; private apResolverService: ApResolverService; private apNoteService: ApNoteService; private apImageService: ApImageService; @@ -62,6 +68,7 @@ export class ApPersonService implements OnModuleInit { private usersChart: UsersChart; private instanceChart: InstanceChart; private apLoggerService: ApLoggerService; + private accountMoveService: AccountMoveService; private logger: Logger; constructor( @@ -92,9 +99,10 @@ export class ApPersonService implements OnModuleInit { //private userEntityService: UserEntityService, //private idService: IdService, //private globalEventService: GlobalEventService, + //private metaService: MetaService, //private federatedInstanceService: FederatedInstanceService, //private fetchInstanceMetadataService: FetchInstanceMetadataService, - //private userCacheService: UserCacheService, + //private cacheService: CacheService, //private apResolverService: ApResolverService, //private apNoteService: ApNoteService, //private apImageService: ApImageService, @@ -110,11 +118,13 @@ export class ApPersonService implements OnModuleInit { onModuleInit() { this.utilityService = this.moduleRef.get('UtilityService'); this.userEntityService = this.moduleRef.get('UserEntityService'); + this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.idService = this.moduleRef.get('IdService'); this.globalEventService = this.moduleRef.get('GlobalEventService'); + this.metaService = this.moduleRef.get('MetaService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); - this.userCacheService = this.moduleRef.get('UserCacheService'); + this.cacheService = this.moduleRef.get('CacheService'); this.apResolverService = this.moduleRef.get('ApResolverService'); this.apNoteService = this.moduleRef.get('ApNoteService'); this.apImageService = this.moduleRef.get('ApImageService'); @@ -124,9 +134,16 @@ export class ApPersonService implements OnModuleInit { this.usersChart = this.moduleRef.get('UsersChart'); this.instanceChart = this.moduleRef.get('InstanceChart'); this.apLoggerService = this.moduleRef.get('ApLoggerService'); + this.accountMoveService = this.moduleRef.get('AccountMoveService'); 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 @@ -134,7 +151,7 @@ export class ApPersonService implements OnModuleInit { */ @bindThis private validateActor(x: IObject, uri: string): IActor { - const expectHost = this.utilityService.toPuny(new URL(uri).hostname); + const expectHost = this.punyHost(uri); if (x == null) { throw new Error('invalid Actor: object is null'); @@ -164,6 +181,9 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: wrong name'); } x.name = truncate(x.name, nameLength); + } else if (x.name === '') { + // Mastodon emits empty string when the name is not set. + x.name = undefined; } if (x.summary) { if (!(typeof x.summary === 'string' && x.summary.length > 0)) { @@ -172,7 +192,7 @@ export class ApPersonService implements OnModuleInit { x.summary = truncate(x.summary, summaryLength); } - const idHost = this.utilityService.toPuny(new URL(x.id!).hostname); + const idHost = this.punyHost(x.id); if (idHost !== expectHost) { throw new Error('invalid Actor: id has different host'); } @@ -182,7 +202,7 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: publicKey.id is not a string'); } - const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname); + const publicKeyIdHost = this.punyHost(x.publicKey.id); if (publicKeyIdHost !== expectHost) { throw new Error('invalid Actor: publicKey.id has different host'); } @@ -192,30 +212,30 @@ export class ApPersonService implements OnModuleInit { } /** - * Personをフェッチします。 + * uriからUser(Person)をフェッチします。 * - * Misskeyに対象のPersonが登録されていればそれを返します。 + * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。 */ @bindThis - public async fetchPerson(uri: string, resolver?: Resolver): Promise { + public async fetchPerson(uri: string): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - const cached = this.userCacheService.uriPersonCache.get(uri); + const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null; if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ - if (uri.startsWith(this.config.url + '/')) { + if (uri.startsWith(`${this.config.url}/`)) { const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }); - if (u) this.userCacheService.uriPersonCache.set(uri, u); + const u = await this.usersRepository.findOneBy({ id }) as LocalUser; + if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }); + const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser; if (exist) { - this.userCacheService.uriPersonCache.set(uri, exist); + this.cacheService.uriPersonCache.set(uri, exist); return exist; } //#endregion @@ -227,7 +247,7 @@ export class ApPersonService implements OnModuleInit { * Personを作成します。 */ @bindThis - public async createPerson(uri: string, resolver?: Resolver): Promise { + 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)) { @@ -242,7 +262,7 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Creating the Person: ${person.id}`); - const host = this.utilityService.toPuny(new URL(object.id).hostname); + const host = this.punyHost(object.id); const { fields } = this.analyzeAttachments(person.attachment ?? []); @@ -254,8 +274,8 @@ export class ApPersonService implements OnModuleInit { const url = getOneApHrefNullable(person.url); - if (url && !url.startsWith('https://')) { - throw new Error('unexpected shcema of person url: ' + url); + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } // Create user @@ -271,6 +291,9 @@ export class ApPersonService implements OnModuleInit { lastFetchedAt: new Date(), name: truncate(person.name, nameLength), isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo, + movedAt: person.movedTo ? new Date() : null, + alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, username: person.preferredUsername, usernameLower: person.preferredUsername!.toLowerCase(), @@ -324,10 +347,12 @@ export class ApPersonService implements OnModuleInit { } // Register host - this.federatedInstanceService.fetch(host).then(i => { + this.federatedInstanceService.fetch(host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); - this.instanceChart.newUser(i.host); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.newUser(i.host); + } }); this.usersChart.update(user!, true); @@ -347,54 +372,69 @@ export class ApPersonService implements OnModuleInit { const avatarId = avatar ? avatar.id : null; const bannerId = banner ? banner.id : null; + const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null; + const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null; + const avatarBlurhash = avatar ? avatar.blurhash : null; + const bannerBlurhash = banner ? banner.blurhash : null; await this.usersRepository.update(user!.id, { avatarId, bannerId, + avatarUrl, + bannerUrl, + avatarBlurhash, + bannerBlurhash, }); - user!.avatarId = avatarId; - user!.bannerId = bannerId; - //#endregion + user!.avatarId = avatarId; + user!.bannerId = bannerId; + user!.avatarUrl = avatarUrl; + user!.bannerUrl = bannerUrl; + user!.avatarBlurhash = avatarBlurhash; + user!.bannerBlurhash = bannerBlurhash; + //#endregion - //#region カスタム絵文字取得 - const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { - this.logger.info(`extractEmojis: ${err}`); - return [] as Emoji[]; - }); + //#region カスタム絵文字取得 + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { + this.logger.info(`extractEmojis: ${err}`); + return [] as Emoji[]; + }); - const emojiNames = emojis.map(emoji => emoji.name); + const emojiNames = emojis.map(emoji => emoji.name); - await this.usersRepository.update(user!.id, { - emojis: emojiNames, - }); - //#endregion + await this.usersRepository.update(user!.id, { + emojis: emojiNames, + }); + //#endregion - await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); + await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); - return user!; + return user!; } /** * Personの情報を更新します。 * Misskeyに対象のPersonが登録されていなければ無視します。 + * もしアカウントの移行が確認された場合、アカウント移行処理を行います。 + * * @param uri URI of Person * @param resolver Resolver * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) + * @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない(無限ループ防止) */ @bindThis - public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { + public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) { + if (uri.startsWith(`${this.config.url}/`)) { return; } //#region このサーバーに既に登録されているか - const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser; + const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null; - if (exist == null) { + if (exist === null) { return; } //#endregion @@ -433,8 +473,8 @@ export class ApPersonService implements OnModuleInit { const url = getOneApHrefNullable(person.url); - if (url && !url.startsWith('https://')) { - throw new Error('unexpected shcema of person url: ' + url); + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } const updates = { @@ -449,15 +489,30 @@ export class ApPersonService implements OnModuleInit { isBot: getApType(object) === 'Service', isCat: (person as any).isCat === true, isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo ?? null, + alsoKnownAs: person.alsoKnownAs ?? null, isExplorable: !!person.discoverable, - } as Partial; + } as Partial & Pick; + + const moving = + // 移行先がない→ある + (!exist.movedToUri && updates.movedToUri) || + // 移行先がある→別のもの + (exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri); + // 移行先がある→ない、ない→ないは無視 + + if (moving) updates.movedAt = new Date(); if (avatar) { updates.avatarId = avatar.id; + updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + updates.avatarBlurhash = avatar.blurhash; } if (banner) { updates.bannerId = banner.id; + updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + updates.bannerBlurhash = banner.blurhash; } // Update user @@ -491,6 +546,31 @@ export class ApPersonService implements OnModuleInit { }); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); + + const updated = { ...exist, ...updates }; + + this.cacheService.uriPersonCache.set(uri, updated); + + // 移行処理を行う + if (updated.movedAt && ( + // 初めて移行する場合はmovedAtがnullなので移行処理を許可 + exist.movedAt == null || + // 以前のmovingから14日以上経過した場合のみ移行処理を許可 + // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) + exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() + )) { + this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); + return this.processRemoteMove(updated, movePreventUris) + .then(result => { + this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`); + return result; + }) + .catch(e => { + this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e }); + }); + } + + return 'skip'; } /** @@ -500,7 +580,7 @@ export class ApPersonService implements OnModuleInit { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolvePerson(uri: string, resolver?: Resolver): Promise { + public async resolvePerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); //#region このサーバーに既に登録されていたらそれを返す @@ -575,4 +655,53 @@ export class ApPersonService implements OnModuleInit { } }); } + + /** + * リモート由来のアカウント移行処理を行います + * @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提) + * @param movePreventUris ここに列挙されたURIにsrc.movedToUriが含まれる場合、移行処理はしない(無限ループ防止) + */ + @bindThis + private async processRemoteMove(src: RemoteUser, movePreventUris: string[] = []): Promise { + if (!src.movedToUri) return 'skip: no movedToUri'; + if (src.uri === src.movedToUri) return 'skip: movedTo itself (src)'; // ??? + if (movePreventUris.length > 10) return 'skip: too many moves'; + + // まずサーバー内で検索して様子見 + let dst = await this.fetchPerson(src.movedToUri); + + if (dst && this.userEntityService.isLocalUser(dst)) { + // targetがローカルユーザーだった場合データベースから引っ張ってくる + dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as LocalUser; + } else if (dst) { + if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move'; + + // targetを見つけたことがあるならtargetをupdatePersonする + 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}/`)) { + // ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている + return 'failed: movedTo is local but not found'; + } + + // targetが知らない人だったらresolvePerson + // (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする) + dst = await this.resolvePerson(src.movedToUri); + } + + if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ??? + if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ??? + if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri'; + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) { + return 'skip: dst.alsoKnownAs is empty'; + } + if (!dst.alsoKnownAs?.includes(src.uri)) { + return 'skip: alsoKnownAs does not include from.uri'; + } + + await this.accountMoveService.postMoveProcess(src, dst); + + return 'ok'; + } } diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 9dc7ed4e31..7661f3ce70 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -1,8 +1,8 @@ -export type obj = { [x: string]: any }; +export type Obj = { [x: string]: any }; export type ApObject = IObject | string | (IObject | string)[]; export interface IObject { - '@context'?: string | string[] | obj | obj[]; + '@context'?: string | string[] | Obj | Obj[]; type: string | string[]; id?: string; name?: string | null; @@ -158,6 +158,8 @@ export interface IActor extends IObject { name?: string; preferredUsername?: string; manuallyApprovesFollowers?: boolean; + movedTo?: string; + alsoKnownAs?: string[]; discoverable?: boolean; inbox: string; sharedInbox?: string; // 後方互換性のため @@ -196,7 +198,8 @@ export const isPropertyValue = (object: IObject): object is IApPropertyValue => object && getApType(object) === 'PropertyValue' && typeof object.name === 'string' && - typeof (object as any).value === 'string'; + 'value' in object && + typeof object.value === 'string'; export interface IApMention extends IObject { type: 'Mention'; @@ -300,6 +303,11 @@ export interface IFlag extends IActivity { type: 'Flag'; } +export interface IMove extends IActivity { + type: 'Move'; + target: IObject | string; +} + export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; @@ -314,3 +322,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; +export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; diff --git a/packages/backend/src/core/chart/ChartLoggerService.ts b/packages/backend/src/core/chart/ChartLoggerService.ts index d392c6d595..afd3bab5a2 100644 --- a/packages/backend/src/core/chart/ChartLoggerService.ts +++ b/packages/backend/src/core/chart/ChartLoggerService.ts @@ -1,7 +1,6 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ChartLoggerService { diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts index 779a32ac5e..03e3612658 100644 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import FederationChart from './charts/federation.js'; @@ -62,8 +62,10 @@ export class ChartManagementService implements OnApplicationShutdown { async onApplicationShutdown(signal: string): Promise { clearInterval(this.saveIntervalId); - await Promise.all( - this.charts.map(chart => chart.save()), - ); + if (process.env.NODE_ENV !== 'test') { + await Promise.all( + this.charts.map(chart => chart.save()), + ); + } } } diff --git a/packages/backend/src/core/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts index da36b944f5..b63db591fb 100644 --- a/packages/backend/src/core/chart/charts/drive.ts +++ b/packages/backend/src/core/chart/charts/drive.ts @@ -1,5 +1,5 @@ import { Injectable, Inject } from '@nestjs/common'; -import { Not, IsNull, DataSource } from 'typeorm'; +import { DataSource } from 'typeorm'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/core/chart/charts/entities/active-users.ts b/packages/backend/src/core/chart/charts/entities/active-users.ts index 5767b76f8e..e291e37c1b 100644 --- a/packages/backend/src/core/chart/charts/entities/active-users.ts +++ b/packages/backend/src/core/chart/charts/entities/active-users.ts @@ -3,15 +3,15 @@ import Chart from '../../core.js'; export const name = 'activeUsers'; export const schema = { - 'readWrite': { intersection: ['read', 'write'], range: 'small' }, - 'read': { uniqueIncrement: true, range: 'small' }, - 'write': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinWeek': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinMonth': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinYear': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideWeek': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideMonth': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideYear': { uniqueIncrement: true, range: 'small' }, + 'readWrite': { intersection: ['read', 'write'] }, + 'read': { uniqueIncrement: true }, + 'write': { uniqueIncrement: true }, + 'registeredWithinWeek': { uniqueIncrement: true }, + 'registeredWithinMonth': { uniqueIncrement: true }, + 'registeredWithinYear': { uniqueIncrement: true }, + 'registeredOutsideWeek': { uniqueIncrement: true }, + 'registeredOutsideMonth': { uniqueIncrement: true }, + 'registeredOutsideYear': { uniqueIncrement: true }, } as const; export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts index 1e2a579dfa..d8966f34c1 100644 --- a/packages/backend/src/core/chart/charts/per-user-notes.ts +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -45,8 +45,8 @@ export default class PerUserNotesChart extends Chart { } @bindThis - public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean): Promise { - await this.commit({ + public update(user: { id: User['id'] }, note: Note, isAdditional: boolean): void { + this.commit({ 'total': isAdditional ? 1 : -1, 'inc': isAdditional ? 1 : 0, 'dec': isAdditional ? 0 : 1, diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index bc79ce26aa..9aee0b9f40 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -1,8 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { AntennasRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Antenna } from '@/models/entities/Antenna.js'; import { bindThis } from '@/decorators.js'; @@ -12,9 +11,6 @@ export class AntennaEntityService { @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.userGroupJoiningsRepository) private userGroupJoiningsRepository: UserGroupJoiningsRepository, ) { @@ -26,7 +22,6 @@ export class AntennaEntityService { ): Promise> { const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); - const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null; const userGroupJoining = antenna.userGroupJoiningId ? await this.userGroupJoiningsRepository.findOneBy({ id: antenna.userGroupJoiningId }) : null; return { @@ -43,7 +38,8 @@ export class AntennaEntityService { notify: antenna.notify, withReplies: antenna.withReplies, withFile: antenna.withFile, - hasUnreadNote, + isActive: antenna.isActive, + hasUnreadNote: false, // TODO }; } } diff --git a/packages/backend/src/core/entities/AppEntityService.ts b/packages/backend/src/core/entities/AppEntityService.ts index 781cbdcc6b..0b4c3935c7 100644 --- a/packages/backend/src/core/entities/AppEntityService.ts +++ b/packages/backend/src/core/entities/AppEntityService.ts @@ -1,11 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, AppsRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { App } from '@/models/entities/App.js'; import type { User } from '@/models/entities/User.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; @Injectable() diff --git a/packages/backend/src/core/entities/AuthSessionEntityService.ts b/packages/backend/src/core/entities/AuthSessionEntityService.ts index 4a74f9c2f6..b7edc8494e 100644 --- a/packages/backend/src/core/entities/AuthSessionEntityService.ts +++ b/packages/backend/src/core/entities/AuthSessionEntityService.ts @@ -2,10 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { AuthSessionsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { AuthSession } from '@/models/entities/AuthSession.js'; import type { User } from '@/models/entities/User.js'; -import { UserEntityService } from './UserEntityService.js'; import { AppEntityService } from './AppEntityService.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts index c9e15207b9..e169c7e90a 100644 --- a/packages/backend/src/core/entities/BlockingEntityService.ts +++ b/packages/backend/src/core/entities/BlockingEntityService.ts @@ -2,11 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { BlockingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class BlockingEntityService { diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 6ce590aa96..15ffd44861 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -1,14 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/index.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Channel } from '@/models/entities/Channel.js'; -import { UserEntityService } from './UserEntityService.js'; -import { DriveFileEntityService } from './DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { NoteEntityService } from './NoteEntityService.js'; +import { In } from 'typeorm'; @Injectable() export class ChannelEntityService { @@ -19,13 +19,19 @@ export class ChannelEntityService { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, ) { } @@ -34,6 +40,7 @@ export class ChannelEntityService { public async pack( src: Channel['id'] | Channel, me?: { id: User['id'] } | null | undefined, + detailed?: boolean, ): Promise> { const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); const meId = me ? me.id : null; @@ -47,6 +54,17 @@ export class ChannelEntityService { followeeId: channel.id, }) : null; + const favorite = meId ? await this.channelFavoritesRepository.findOneBy({ + userId: meId, + channelId: channel.id, + }) : null; + + const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ + where: { + id: In(channel.pinnedNoteIds), + }, + }) : []; + return { id: channel.id, createdAt: channel.createdAt.toISOString(), @@ -55,13 +73,21 @@ export class ChannelEntityService { description: channel.description, userId: channel.userId, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + pinnedNoteIds: channel.pinnedNoteIds, + color: channel.color, + isArchived: channel.isArchived, usersCount: channel.usersCount, notesCount: channel.notesCount, ...(me ? { isFollowing: following != null, + isFavorited: favorite != null, hasUnreadNote, } : {}), + + ...(detailed ? { + pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)), + } : {}), }; } } diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index 1e794391e9..33d3c53806 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -1,13 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ClipsRepository } from '@/models/index.js'; +import type { ClipFavoritesRepository, ClipsRepository, User } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { Clip } from '@/models/entities/Clip.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class ClipEntityService { @@ -15,6 +14,9 @@ export class ClipEntityService { @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + private userEntityService: UserEntityService, ) { } @@ -22,25 +24,31 @@ export class ClipEntityService { @bindThis public async pack( src: Clip['id'] | Clip, + me?: { id: User['id'] } | null | undefined, ): Promise> { + const meId = me ? me.id : null; const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); return await awaitAll({ id: clip.id, createdAt: clip.createdAt.toISOString(), + lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, userId: clip.userId, user: this.userEntityService.pack(clip.user ?? clip.userId), name: clip.name, description: clip.description, isPublic: clip.isPublic, + favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), + isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined, }); } @bindThis public packMany( clips: Clip[], + me?: { id: User['id'] } | null | undefined, ) { - return Promise.all(clips.map(x => this.pack(x))); + return Promise.all(clips.map(x => this.pack(x, me))); } } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 38af51a196..d82f36d971 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -1,10 +1,9 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { DataSource, In } from 'typeorm'; -import * as mfm from 'mfm-js'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { User } from '@/models/entities/User.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; @@ -22,6 +21,7 @@ type PackOptions = { }; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; +import { isNotNull } from '@/misc/is-not-null.js'; @Injectable() export class DriveFileEntityService { @@ -89,9 +89,7 @@ export class DriveFileEntityService { if (file.type.startsWith('video')) { if (file.thumbnailUrl) return file.thumbnailUrl; - if (this.config.videoThumbnailGenerator == null) { - return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); - } + return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); } else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { // 動画ではなくリモートかつメディアプロキシ return this.getProxiedUrl(file.uri, 'static'); @@ -106,7 +104,7 @@ export class DriveFileEntityService { const url = file.webpublicUrl ?? file.url; - return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? this.getProxiedUrl(url, 'static') : null); + return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? url : null); } @bindThis @@ -256,10 +254,35 @@ export class DriveFileEntityService { @bindThis public async packMany( - files: (DriveFile['id'] | DriveFile)[], + files: DriveFile[], options?: PackOptions, ): Promise[]> { const items = await Promise.all(files.map(f => this.packNullable(f, options))); return items.filter((x): x is Packed<'DriveFile'> => x != null); } + + @bindThis + public async packManyByIdsMap( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise['id'], Packed<'DriveFile'> | null>> { + if (fileIds.length === 0) return new Map(); + const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); + const packedFiles = await this.packMany(files, options); + const map = new Map['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); + for (const id of fileIds) { + if (!map.has(id)) map.set(id, null); + } + return map; + } + + @bindThis + public async packManyByIds( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise[]> { + if (fileIds.length === 0) return []; + const filesMap = await this.packManyByIdsMap(fileIds, options); + return fileIds.map(id => filesMap.get(id)).filter(isNotNull); + } } diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts index 0bb0f1754e..13929b145f 100644 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -2,11 +2,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { DriveFolder } from '@/models/entities/DriveFolder.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; @Injectable() diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 7d248e6342..3bad048bc0 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -1,50 +1,64 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { EmojisRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { Emoji } from '@/models/entities/Emoji.js'; import { bindThis } from '@/decorators.js'; -import { UserEntityService } from './UserEntityService.js'; @Injectable() export class EmojiEntityService { constructor( @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - - private userEntityService: UserEntityService, ) { } @bindThis - public async pack( + public async packSimple( src: Emoji['id'] | Emoji, - opts: { omitHost?: boolean; omitId?: boolean; withUrl?: boolean; } = { omitHost: true, omitId: true, withUrl: true }, - ): Promise> { - opts = { omitHost: true, omitId: true, withUrl: true, ...opts } - + ): Promise> { const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); return { - id: opts.omitId ? undefined : emoji.id, aliases: emoji.aliases, name: emoji.name, category: emoji.category, - host: opts.omitHost ? undefined : emoji.host, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: opts.withUrl ? (emoji.publicUrl || emoji.originalUrl) : undefined, + url: emoji.publicUrl || emoji.originalUrl, }; } @bindThis - public packMany( + public packSimpleMany( emojis: any[], - opts: { omitHost?: boolean; omitId?: boolean; withUrl?: boolean; } = {}, ) { - return Promise.all(emojis.map(x => this.pack(x, opts))); + return Promise.all(emojis.map(x => this.packSimple(x))); + } + + @bindThis + public async packDetailed( + src: Emoji['id'] | Emoji, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); + + return { + id: emoji.id, + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + host: emoji.host, + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + url: emoji.publicUrl || emoji.originalUrl, + license: emoji.license, + }; + } + + @bindThis + public packDetailedMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.packDetailed(x))); } } diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index 61bd18c04f..e52a591884 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Flash } from '@/models/entities/Flash.js'; diff --git a/packages/backend/src/core/entities/FlashLikeEntityService.ts b/packages/backend/src/core/entities/FlashLikeEntityService.ts index dcf12d53ea..0351ec3014 100644 --- a/packages/backend/src/core/entities/FlashLikeEntityService.ts +++ b/packages/backend/src/core/entities/FlashLikeEntityService.ts @@ -1,13 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { FlashLikesRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { FlashLike } from '@/models/entities/FlashLike.js'; import { bindThis } from '@/decorators.js'; -import { UserEntityService } from './UserEntityService.js'; import { FlashEntityService } from './FlashEntityService.js'; @Injectable() diff --git a/packages/backend/src/core/entities/FollowRequestEntityService.ts b/packages/backend/src/core/entities/FollowRequestEntityService.ts index 88c91d0f21..c2edc6a13a 100644 --- a/packages/backend/src/core/entities/FollowRequestEntityService.ts +++ b/packages/backend/src/core/entities/FollowRequestEntityService.ts @@ -1,8 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { FollowRequestsRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { FollowRequest } from '@/models/entities/FollowRequest.js'; diff --git a/packages/backend/src/core/entities/FollowingEntityService.ts b/packages/backend/src/core/entities/FollowingEntityService.ts index a833ae719b..55ba4e67ad 100644 --- a/packages/backend/src/core/entities/FollowingEntityService.ts +++ b/packages/backend/src/core/entities/FollowingEntityService.ts @@ -2,10 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Following } from '@/models/entities/Following.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; type LocalFollowerFollowing = Following & { @@ -31,7 +32,6 @@ type RemoteFolloweeFollowing = Following & { followeeInbox: string; followeeSharedInbox: string; }; -import { bindThis } from '@/decorators.js'; @Injectable() export class FollowingEntityService { diff --git a/packages/backend/src/core/entities/GalleryLikeEntityService.ts b/packages/backend/src/core/entities/GalleryLikeEntityService.ts index 8b15ffc2bb..db46045db3 100644 --- a/packages/backend/src/core/entities/GalleryLikeEntityService.ts +++ b/packages/backend/src/core/entities/GalleryLikeEntityService.ts @@ -1,12 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { GalleryLikesRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { GalleryLike } from '@/models/entities/GalleryLike.js'; -import { UserEntityService } from './UserEntityService.js'; import { GalleryPostEntityService } from './GalleryPostEntityService.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index ab29e7dba1..632c75304f 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -2,13 +2,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { GalleryPost } from '@/models/entities/GalleryPost.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class GalleryPostEntityService { @@ -41,7 +41,8 @@ export class GalleryPostEntityService { title: post.title, description: post.description, fileIds: post.fileIds, - files: this.driveFileEntityService.packMany(post.fileIds), + // TODO: packMany causes N+1 queries + files: this.driveFileEntityService.packManyByIds(post.fileIds), tags: post.tags.length > 0 ? post.tags : undefined, isSensitive: post.isSensitive, likedCount: post.likedCount, diff --git a/packages/backend/src/core/entities/HashtagEntityService.ts b/packages/backend/src/core/entities/HashtagEntityService.ts index f79b821222..2cd79b8f8c 100644 --- a/packages/backend/src/core/entities/HashtagEntityService.ts +++ b/packages/backend/src/core/entities/HashtagEntityService.ts @@ -1,13 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { HashtagsRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { Hashtag } from '@/models/entities/Hashtag.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class HashtagEntityService { diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 42ea5e23f6..3bf84ed375 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -1,14 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { InstancesRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { Instance } from '@/models/entities/Instance.js'; import { MetaService } from '@/core/MetaService.js'; -import { UtilityService } from '../UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { UtilityService } from '../UtilityService.js'; @Injectable() export class InstanceEntityService { diff --git a/packages/backend/src/core/entities/MessagingMessageEntityService.ts b/packages/backend/src/core/entities/MessagingMessageEntityService.ts index cdb752dd81..31bd79ed7d 100644 --- a/packages/backend/src/core/entities/MessagingMessageEntityService.ts +++ b/packages/backend/src/core/entities/MessagingMessageEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { MessagingMessagesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; diff --git a/packages/backend/src/core/entities/ModerationLogEntityService.ts b/packages/backend/src/core/entities/ModerationLogEntityService.ts index ab61797910..7058e38af9 100644 --- a/packages/backend/src/core/entities/ModerationLogEntityService.ts +++ b/packages/backend/src/core/entities/ModerationLogEntityService.ts @@ -2,9 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { ModerationLogsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { ModerationLog } from '@/models/entities/ModerationLog.js'; import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts index 4f02ef4087..561d53292e 100644 --- a/packages/backend/src/core/entities/MutingEntityService.ts +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { MutingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Muting } from '@/models/entities/Muting.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class MutingEntityService { diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 5886c004e1..0bc0387666 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -1,10 +1,9 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { DataSource, In } from 'typeorm'; import * as mfm from 'mfm-js'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { nyaize } from '@/misc/nyaize.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { User } from '@/models/entities/User.js'; @@ -12,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -183,6 +183,11 @@ export class NoteEntityService implements OnModuleInit { // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない } + // パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない + if (note.createdAt.getTime() + 1000 > Date.now()) { + return undefined; + } + const reaction = await this.noteReactionsRepository.findOneBy({ userId: meId, noteId: note.id, @@ -249,6 +254,21 @@ export class NoteEntityService implements OnModuleInit { return true; } + @bindThis + public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map | null>): Promise[]> { + const missingIds = []; + for (const id of fileIds) { + if (!packedFiles.has(id)) missingIds.push(id); + } + if (missingIds.length) { + const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds); + for (const [k, v] of additionalMap) { + packedFiles.set(k, v); + } + } + return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); + } + @bindThis public async pack( src: Note['id'] | Note, @@ -258,6 +278,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; _hint_?: { myReactions: Map; + packedFiles: Map | null>; }; }, ): Promise> { @@ -267,7 +288,7 @@ export class NoteEntityService implements OnModuleInit { }, options); const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src }); + const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] }); const host = note.userHost; let text = note.text; @@ -285,6 +306,7 @@ export class NoteEntityService implements OnModuleInit { const reactionEmojiNames = Object.keys(note.reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); + const packedFiles = options?._hint_?.packedFiles; const packed: Packed<'Note'> = await awaitAll({ id: note.id, @@ -297,6 +319,7 @@ export class NoteEntityService implements OnModuleInit { cw: note.cw, visibility: note.visibility, localOnly: note.localOnly ?? undefined, + reactionAcceptance: note.reactionAcceptance, visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, disableRightClick: note.disableRightClick || undefined, renoteCount: note.renoteCount, @@ -306,13 +329,14 @@ export class NoteEntityService implements OnModuleInit { emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, - files: this.driveFileEntityService.packMany(note.fileIds), + files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds), replyId: note.replyId, renoteId: note.renoteId, channelId: note.channelId ?? undefined, channel: channel ? { id: channel.id, name: channel.name, + color: channel.color, } : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, @@ -378,7 +402,8 @@ export class NoteEntityService implements OnModuleInit { const myReactionsMap = new Map(); if (meId) { const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); - const targets = [...notes.map(n => n.id), ...renoteIds]; + // パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない + const targets = [...notes.filter(n => n.createdAt.getTime() + 1000 < Date.now()).map(n => n.id), ...renoteIds]; const myReactions = await this.noteReactionsRepository.findBy({ userId: meId, noteId: In(targets), @@ -389,16 +414,44 @@ export class NoteEntityService implements OnModuleInit { } } - await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)); + // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく + const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); + const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, + packedFiles, }, }))); } + @bindThis + public aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; + } + @bindThis public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise { // 指定したユーザーの指定したノートのリノートがいくつあるか数える diff --git a/packages/backend/src/core/entities/NoteFavoriteEntityService.ts b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts index aa5c354b6d..8a7727b4cd 100644 --- a/packages/backend/src/core/entities/NoteFavoriteEntityService.ts +++ b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts @@ -1,12 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { NoteFavoritesRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { NoteFavorite } from '@/models/entities/NoteFavorite.js'; -import { UserEntityService } from './UserEntityService.js'; import { NoteEntityService } from './NoteEntityService.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index eba6f9d908..8f943ba24c 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { NoteReactionsRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; import type { OnModuleInit } from '@nestjs/common'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; @@ -11,7 +11,6 @@ import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import { ModuleRef } from '@nestjs/core'; -import { bindThis } from '@/decorators.js'; @Injectable() export class NoteReactionEntityService implements OnModuleInit { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 4140b3f35e..fe3908c3f6 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -1,20 +1,23 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; +import type { AccessTokensRepository, FollowRequestsRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Notification } from '@/models/entities/Notification.js'; -import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { Note } from '@/models/entities/Note.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; +import { notificationTypes } from '@/types.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import type { UserGroupInvitationEntityService } from './UserGroupInvitationEntityService.js'; +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]); + @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; @@ -25,12 +28,18 @@ export class NotificationEntityService implements OnModuleInit { constructor( private moduleRef: ModuleRef, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, @@ -50,67 +59,50 @@ export class NotificationEntityService implements OnModuleInit { @bindThis public async pack( - src: Notification['id'] | Notification, + src: Notification, + meId: User['id'], + // eslint-disable-next-line @typescript-eslint/ban-types options: { - _hintForEachNotes_?: { - myReactions: Map; - }; + + }, + hint?: { + packedNotes: Map>; + packedUsers: Map>; }, ): Promise> { - const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); + const notification = src; const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; + const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( + hint?.packedNotes != null + ? hint.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.noteId!, { id: meId }, { + detail: true, + }) + ) : undefined; + const userIfNeed = notification.notifierId != null ? ( + hint?.packedUsers != null + ? hint.packedUsers.get(notification.notifierId) + : this.userEntityService.pack(notification.notifierId!, { id: meId }, { + detail: false, + }) + ) : undefined; return await awaitAll({ id: notification.id, - createdAt: notification.createdAt.toISOString(), + createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, - isRead: notification.isRead, userId: notification.notifierId, - user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, - ...(notification.type === 'mention' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'reply' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'renote' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'quote' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), + ...(userIfNeed != null ? { user: userIfNeed } : {}), + ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), reaction: notification.reaction, } : {}), - ...(notification.type === 'pollVote' ? { // TODO: そのうち消す - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - choice: notification.choice, - } : {}), - ...(notification.type === 'pollEnded' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), + // ...(notification.type === 'pollEnded' ? { + // note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + // detail: true, + // _hint_: options._hintForEachNotes_, + // }), + // } : {}), ...(notification.type === 'groupInvited' ? { invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!), } : {}), @@ -132,26 +124,41 @@ export class NotificationEntityService implements OnModuleInit { ) { if (notifications.length === 0) return []; - const notes = notifications.filter(x => x.note != null).map(x => x.note!); - const noteIds = notes.map(n => n.id); - const myReactionsMap = new Map(); - const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); - const targets = [...noteIds, ...renoteIds]; - const myReactions = await this.noteReactionsRepository.findBy({ - userId: meId, - noteId: In(targets), - }); + let validNotifications = notifications; - for (const target of targets) { - myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); + const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull); + const notes = noteIds.length > 0 ? await this.notesRepository.find({ + where: { id: In(noteIds) }, + relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], + }) : []; + const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { + detail: true, + }); + const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); + + validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId)); + + const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull); + const users = userIds.length > 0 ? await this.usersRepository.find({ + where: { id: In(userIds) }, + }) : []; + const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { + detail: false, + }); + const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); + + // 既に解決されたフォローリクエストの通知を除外 + const followRequestNotifications = validNotifications.filter(x => x.type === 'receiveFollowRequest'); + if (followRequestNotifications.length > 0) { + const reqs = await this.followRequestsRepository.find({ + where: { followerId: In(followRequestNotifications.map(x => x.notifierId!)) }, + }); + validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); } - await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); - - return await Promise.all(notifications.map(x => this.pack(x, { - _hintForEachNotes_: { - myReactions: myReactionsMap, - }, + return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, { + packedNotes, + packedUsers, }))); } } diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 48e45dd019..d6da856637 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -2,14 +2,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, PagesRepository, PageLikesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Page } from '@/models/entities/Page.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class PageEntityService { diff --git a/packages/backend/src/core/entities/PageLikeEntityService.ts b/packages/backend/src/core/entities/PageLikeEntityService.ts index d3e45783dd..3460c1e422 100644 --- a/packages/backend/src/core/entities/PageLikeEntityService.ts +++ b/packages/backend/src/core/entities/PageLikeEntityService.ts @@ -1,12 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { PageLikesRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { PageLike } from '@/models/entities/PageLike.js'; -import { UserEntityService } from './UserEntityService.js'; import { PageEntityService } from './PageEntityService.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts new file mode 100644 index 0000000000..f8871e0495 --- /dev/null +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class RenoteMutingEntityService { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: RenoteMuting['id'] | RenoteMuting, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: this.userEntityService.pack(muting.muteeId, me, { + detail: true, + }), + }); + } + + @bindThis + public packMany( + mutings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index dbb89ff19b..54818782dd 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { User } from '@/models/entities/User.js'; import type { Role } from '@/models/entities/Role.js'; import { bindThis } from '@/decorators.js'; @@ -26,19 +26,16 @@ export class RoleEntityService { public async pack( src: Role['id'] | Role, me?: { id: User['id'] } | null | undefined, - options?: { - detail?: boolean; - }, ) { - const opts = Object.assign({ - detail: true, - }, options); - const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); - const assigns = await this.roleAssignmentsRepository.findBy({ - roleId: role.id, - }); + const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') + .where('assign.roleId = :roleId', { roleId: role.id }) + .andWhere(new Brackets(qb => { qb + .where('assign.expiresAt IS NULL') + .orWhere('assign.expiresAt > :now', { now: new Date() }); + })) + .getCount(); const policies = { ...role.policies }; for (const [k, v] of Object.entries(DEFAULT_POLICIES)) { @@ -62,13 +59,12 @@ export class RoleEntityService { isPublic: role.isPublic, isAdministrator: role.isAdministrator, isModerator: role.isModerator, + isExplorable: role.isExplorable, asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, + displayOrder: role.displayOrder, policies: policies, - usersCount: assigns.length, - ...(opts.detail ? { - users: this.userEntityService.packMany(assigns.map(x => x.userId), me), - } : {}), + usersCount: assignedCount, }); } @@ -76,11 +72,8 @@ export class RoleEntityService { public packMany( roles: any[], me: { id: User['id'] }, - options?: { - detail?: boolean; - }, ) { - return Promise.all(roles.map(x => this.pack(x, me, options))); + return Promise.all(roles.map(x => this.pack(x, me))); } } diff --git a/packages/backend/src/core/entities/SigninEntityService.ts b/packages/backend/src/core/entities/SigninEntityService.ts index c402644742..51fa7543d9 100644 --- a/packages/backend/src/core/entities/SigninEntityService.ts +++ b/packages/backend/src/core/entities/SigninEntityService.ts @@ -1,10 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { SigninsRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { Signin } from '@/models/entities/Signin.js'; import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index fa3337c019..65b51db42a 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,20 +1,21 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { In, Not } from 'typeorm'; +import * as Redis from 'ioredis'; import Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; -import { Cache } from '@/misc/cache.js'; -import type { Instance } from '@/models/entities/Instance.js'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, PagesRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { AntennaService } from '../AntennaService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; @@ -24,7 +25,7 @@ import type { PageEntityService } from './PageEntityService.js'; type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsMeAndIsUserDetailed = - Detailed extends true ? + Detailed extends true ? ExpectsMe extends true ? Packed<'MeDetailed'> : ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : Packed<'UserDetailed'> : @@ -33,26 +34,27 @@ type IsMeAndIsUserDetailed(user: T): user is T & { host: null; }; +function isLocalUser(user: T): user is (T & { host: null; }); function isLocalUser(user: User | { host: User['host'] }): boolean { return user.host == null; } function isRemoteUser(user: User): user is RemoteUser; -function isRemoteUser(user: T): user is T & { host: string; }; +function isRemoteUser(user: T): user is (T & { host: string; }); function isRemoteUser(user: User | { host: User['host'] }): boolean { return !isLocalUser(user); } @Injectable() export class UserEntityService implements OnModuleInit { + private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; private driveFileEntityService: DriveFileEntityService; private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; private antennaService: AntennaService; private roleService: RoleService; - private userInstanceCache: Cache; + private federatedInstanceService: FederatedInstanceService; constructor( private moduleRef: ModuleRef, @@ -60,6 +62,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -78,6 +83,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -87,9 +95,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, @@ -111,12 +116,12 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, + @Inject(DI.userMemosRepository) + private userMemosRepository: UserMemoRepository, + //private noteEntityService: NoteEntityService, //private driveFileEntityService: DriveFileEntityService, //private pageEntityService: PageEntityService, @@ -124,16 +129,17 @@ export class UserEntityService implements OnModuleInit { //private antennaService: AntennaService, //private roleService: RoleService, ) { - this.userInstanceCache = new Cache(1000 * 60 * 60 * 3); } onModuleInit() { + this.apPersonService = this.moduleRef.get('ApPersonService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.antennaService = this.moduleRef.get('AntennaService'); this.roleService = this.moduleRef.get('RoleService'); + this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); } //#region Validators @@ -201,6 +207,13 @@ export class UserEntityService implements OnModuleInit { }, take: 1, }).then(n => n > 0), + isRenoteMuted: this.renoteMutingsRepository.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then(n => n > 0), }); } @@ -249,6 +262,7 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getHasUnreadAntenna(userId: User['id']): Promise { + /* const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ @@ -257,37 +271,22 @@ export class UserEntityService implements OnModuleInit { }) : null; return unread != null; - } - - @bindThis - public async getHasUnreadChannel(userId: User['id']): Promise { - const channels = await this.channelFollowingsRepository.findBy({ followerId: userId }); - - const unread = channels.length > 0 ? await this.noteUnreadsRepository.findOneBy({ - userId: userId, - noteChannelId: In(channels.map(x => x.followeeId)), - }) : null; - - return unread != null; + */ + return false; // TODO } @bindThis public async getHasUnreadNotification(userId: User['id']): Promise { - const mute = await this.mutingsRepository.findBy({ - muterId: userId, - }); - const mutedUserIds = mute.map(m => m.muteeId); + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - const count = await this.notificationsRepository.count({ - where: { - notifieeId: userId, - ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), - isRead: false, - }, - take: 1, - }); + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '-', + 'COUNT', 1); + const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - return count > 0; + return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId); } @bindThis @@ -312,34 +311,24 @@ export class UserEntityService implements OnModuleInit { } @bindThis - public async getAvatarUrl(user: User): Promise { - if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); - } else if (user.avatarId) { - const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id); - } else { - return this.getIdenticonUrl(user.id); - } + public getIdenticonUrl(user: User): string { + return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } @bindThis - public getAvatarUrlSync(user: User): string { - if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); - } else { - return this.getIdenticonUrl(user.id); - } + public getUserUri(user: LocalUser | PartialLocalUser | RemoteUser | PartialRemoteUser): string { + return this.isRemoteUser(user) + ? user.uri : this.genLocalUserUri(user.id); } @bindThis - public getIdenticonUrl(userId: User['id']): string { - return `${this.config.url}/identicon/${userId}`; + public genLocalUserUri(userId: string): string { + return `${this.config.url}/users/${userId}`; } public async pack( src: User['id'] | User, - me?: { id: User['id'] } | null | undefined, + me?: { id: User['id']; } | null | undefined, options?: { detail?: D, includeSecrets?: boolean, @@ -351,24 +340,29 @@ export class UserEntityService implements OnModuleInit { includeSecrets: false, }, options); - let user: User; + const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); - if (typeof src === 'object') { - user = src; - if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null; - if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null; - } else { - user = await this.usersRepository.findOneOrFail({ - where: { id: src }, - relations: { - avatar: true, - banner: true, - }, + // migration + if (user.avatarId != null && user.avatarUrl === null) { + const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); + user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + this.usersRepository.update(user.id, { + avatarUrl: user.avatarUrl, + avatarBlurhash: avatar.blurhash, + }); + } + if (user.bannerId != null && user.bannerUrl === null) { + const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId }); + user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + this.usersRepository.update(user.id, { + bannerUrl: user.bannerUrl, + bannerBlurhash: banner.blurhash, }); } const meId = me ? me.id : null; const isMe = meId === user.id; + const iAmModerator = me ? await this.roleService.isModerator(me as User) : false; const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null; const pins = opts.detail ? await this.userNotePiningsRepository.createQueryBuilder('pin') @@ -398,14 +392,11 @@ export class UserEntityService implements OnModuleInit { name: user.name, username: user.username, host: user.host, - avatarUrl: this.getAvatarUrlSync(user), - avatarBlurhash: user.avatar?.blurhash ?? null, + avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), + avatarBlurhash: user.avatarBlurhash, isBot: user.isBot ?? falsy, isCat: user.isCat ?? falsy, - instance: user.host ? this.userInstanceCache.fetch(user.host, - () => this.instancesRepository.findOneBy({ host: user.host! }), - v => v != null, - ).then(instance => instance ? { + instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { name: instance.name, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, @@ -416,19 +407,25 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.map(r => ({ + badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ name: r.name, iconUrl: r.iconUrl, + displayOrder: r.displayOrder, }))) : undefined, ...(opts.detail ? { url: profile!.url, uri: user.uri, + movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, + alsoKnownAs: user.alsoKnownAs + ? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))) + .then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[]) + : null, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, - bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner) : null, - bannerBlurhash: user.banner?.blurhash ?? null, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, isLocked: user.isLocked, isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSuspended: user.isSuspended ?? falsy, @@ -455,7 +452,7 @@ export class UserEntityService implements OnModuleInit { userId: user.id, }).then(result => result >= 1) : false, - roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).map(role => ({ + roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, color: role.color, @@ -463,7 +460,13 @@ export class UserEntityService implements OnModuleInit { description: role.description, isModerator: role.isModerator, isAdministrator: role.isAdministrator, + displayOrder: role.displayOrder, }))), + memo: meId == null ? null : await this.userMemosRepository.findOneBy({ + userId: meId, + targetUserId: user.id, + }).then(row => row?.memo ?? null), + moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), ...(opts.detail && isMe ? { @@ -491,7 +494,7 @@ export class UserEntityService implements OnModuleInit { }).then(count => count > 0), hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), hasUnreadAntenna: this.getHasUnreadAntenna(user.id), - hasUnreadChannel: this.getHasUnreadChannel(user.id), + hasUnreadChannel: false, // 後方互換性のため hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), @@ -530,6 +533,7 @@ export class UserEntityService implements OnModuleInit { isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, + isRenoteMuted: relation.isRenoteMuted, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/core/entities/UserGroupEntityService.ts b/packages/backend/src/core/entities/UserGroupEntityService.ts index 0674a76723..f001f622ad 100644 --- a/packages/backend/src/core/entities/UserGroupEntityService.ts +++ b/packages/backend/src/core/entities/UserGroupEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UserGroupJoiningsRepository, UserGroupsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { UserGroup } from '@/models/entities/UserGroup.js'; diff --git a/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts b/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts index 0fba1426f4..60569bd4ed 100644 --- a/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts +++ b/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UserGroupInvitationsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index f2e0426928..2461cb2c12 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -1,13 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; -import type { User } from '@/models/entities/User.js'; import type { UserList } from '@/models/entities/UserList.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class UserListEntityService { diff --git a/packages/backend/src/daemons/QueueStatsService.ts b/packages/backend/src/daemons/QueueStatsService.ts index 7b47d78a17..b717434e09 100644 --- a/packages/backend/src/daemons/QueueStatsService.ts +++ b/packages/backend/src/daemons/QueueStatsService.ts @@ -1,6 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import Xev from 'xev'; -import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; import { bindThis } from '@/decorators.js'; import type { OnApplicationShutdown } from '@nestjs/common'; diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index 7971f9e810..bb190cf60f 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -1,8 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import si from 'systeminformation'; import Xev from 'xev'; import * as osUtils from 'os-utils'; -import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @@ -41,7 +40,7 @@ export class ServerStatsService implements OnApplicationShutdown { const stats = { cpu: roundCpu(cpu), mem: { - used: round(memStats.used - memStats.buffers - memStats.cached), + used: round(memStats.total - memStats.available), active: round(memStats.active), }, net: { diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 3fb0cd4dae..40e7de0d65 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -1,8 +1,10 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), + meilisearch: Symbol('meilisearch'), redis: Symbol('redis'), - redisSubscriber: Symbol('redisSubscriber'), + redisForPub: Symbol('redisForPub'), + redisForSub: Symbol('redisForSub'), //#region Repositories usersRepository: Symbol('usersRepository'), @@ -36,9 +38,9 @@ export const DI = { emojisRepository: Symbol('emojisRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), - notificationsRepository: Symbol('notificationsRepository'), metasRepository: Symbol('metasRepository'), mutingsRepository: Symbol('mutingsRepository'), + renoteMutingsRepository: Symbol('renoteMutingsRepository'), blockingsRepository: Symbol('blockingsRepository'), swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), hashtagsRepository: Symbol('hashtagsRepository'), @@ -55,15 +57,15 @@ export const DI = { moderationLogsRepository: Symbol('moderationLogsRepository'), clipsRepository: Symbol('clipsRepository'), clipNotesRepository: Symbol('clipNotesRepository'), + clipFavoritesRepository: Symbol('clipFavoritesRepository'), antennasRepository: Symbol('antennasRepository'), - antennaNotesRepository: Symbol('antennaNotesRepository'), promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), relaysRepository: Symbol('relaysRepository'), mutedNotesRepository: Symbol('mutedNotesRepository'), channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), - channelNotePiningsRepository: Symbol('channelNotePiningsRepository'), + channelFavoritesRepository: Symbol('channelFavoritesRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), adsRepository: Symbol('adsRepository'), @@ -73,5 +75,6 @@ export const DI = { roleAssignmentsRepository: Symbol('roleAssignmentsRepository'), flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), + userMemosRepository: Symbol('userMemosRepository'), //#endregion }; diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 43a71a2b57..5610929648 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -1,18 +1,187 @@ +import * as Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; +export class RedisKVCache { + private redisClient: Redis.Redis; + private name: string; + private lifetime: number; + private memoryCache: MemoryKVCache; + private fetcher: (key: string) => Promise; + private toRedisConverter: (value: T) => string; + private fromRedisConverter: (value: string) => T | undefined; + + constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], opts: { + lifetime: RedisKVCache['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisKVCache['fetcher']; + toRedisConverter: RedisKVCache['toRedisConverter']; + fromRedisConverter: RedisKVCache['fromRedisConverter']; + }) { + this.redisClient = redisClient; + this.name = name; + this.lifetime = opts.lifetime; + this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); + this.fetcher = opts.fetcher; + this.toRedisConverter = opts.toRedisConverter; + this.fromRedisConverter = opts.fromRedisConverter; + } + + @bindThis + public async set(key: string, value: T): Promise { + this.memoryCache.set(key, value); + if (this.lifetime === Infinity) { + await this.redisClient.set( + `kvcache:${this.name}:${key}`, + this.toRedisConverter(value), + ); + } else { + await this.redisClient.set( + `kvcache:${this.name}:${key}`, + this.toRedisConverter(value), + 'EX', Math.round(this.lifetime / 1000), + ); + } + } + + @bindThis + public async get(key: string): Promise { + const memoryCached = this.memoryCache.get(key); + if (memoryCached !== undefined) return memoryCached; + + const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); + if (cached == null) return undefined; + return this.fromRedisConverter(cached); + } + + @bindThis + public async delete(key: string): Promise { + this.memoryCache.delete(key); + await this.redisClient.del(`kvcache:${this.name}:${key}`); + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + */ + @bindThis + public async fetch(key: string): Promise { + const cachedValue = await this.get(key); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + const value = await this.fetcher(key); + this.set(key, value); + return value; + } + + @bindThis + public async refresh(key: string) { + const value = await this.fetcher(key); + this.set(key, value); + + // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする + } +} + +export class RedisSingleCache { + private redisClient: Redis.Redis; + private name: string; + private lifetime: number; + private memoryCache: MemorySingleCache; + private fetcher: () => Promise; + private toRedisConverter: (value: T) => string; + private fromRedisConverter: (value: string) => T | undefined; + + constructor(redisClient: RedisSingleCache['redisClient'], name: RedisSingleCache['name'], opts: { + lifetime: RedisSingleCache['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisSingleCache['fetcher']; + toRedisConverter: RedisSingleCache['toRedisConverter']; + fromRedisConverter: RedisSingleCache['fromRedisConverter']; + }) { + this.redisClient = redisClient; + this.name = name; + this.lifetime = opts.lifetime; + this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); + this.fetcher = opts.fetcher; + this.toRedisConverter = opts.toRedisConverter; + this.fromRedisConverter = opts.fromRedisConverter; + } + + @bindThis + public async set(value: T): Promise { + this.memoryCache.set(value); + if (this.lifetime === Infinity) { + await this.redisClient.set( + `singlecache:${this.name}`, + this.toRedisConverter(value), + ); + } else { + await this.redisClient.set( + `singlecache:${this.name}`, + this.toRedisConverter(value), + 'EX', Math.round(this.lifetime / 1000), + ); + } + } + + @bindThis + public async get(): Promise { + const memoryCached = this.memoryCache.get(); + if (memoryCached !== undefined) return memoryCached; + + const cached = await this.redisClient.get(`singlecache:${this.name}`); + if (cached == null) return undefined; + return this.fromRedisConverter(cached); + } + + @bindThis + public async delete(): Promise { + this.memoryCache.delete(); + await this.redisClient.del(`singlecache:${this.name}`); + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + */ + @bindThis + public async fetch(): Promise { + const cachedValue = await this.get(); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + const value = await this.fetcher(); + this.set(value); + return value; + } + + @bindThis + public async refresh() { + const value = await this.fetcher(); + this.set(value); + + // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする + } +} + // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? -export class Cache { - public cache: Map; +export class MemoryKVCache { + public cache: Map; private lifetime: number; - constructor(lifetime: Cache['lifetime']) { + constructor(lifetime: MemoryKVCache['lifetime']) { this.cache = new Map(); this.lifetime = lifetime; } @bindThis - public set(key: string | null, value: T): void { + public set(key: string, value: T): void { this.cache.set(key, { date: Date.now(), value, @@ -20,7 +189,7 @@ export class Cache { } @bindThis - public get(key: string | null): T | undefined { + public get(key: string): T | undefined { const cached = this.cache.get(key); if (cached == null) return undefined; if ((Date.now() - cached.date) > this.lifetime) { @@ -31,7 +200,7 @@ export class Cache { } @bindThis - public delete(key: string | null) { + public delete(key: string) { this.cache.delete(key); } @@ -40,7 +209,7 @@ export class Cache { * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします */ @bindThis - public async fetch(key: string | null, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetch(key: string, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -65,7 +234,7 @@ export class Cache { * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします */ @bindThis - public async fetchMaybe(key: string | null, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetchMaybe(key: string, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -87,3 +256,88 @@ export class Cache { return value; } } + +export class MemorySingleCache { + private cachedAt: number | null = null; + private value: T | undefined; + private lifetime: number; + + constructor(lifetime: MemorySingleCache['lifetime']) { + this.lifetime = lifetime; + } + + @bindThis + public set(value: T): void { + this.cachedAt = Date.now(); + this.value = value; + } + + @bindThis + public get(): T | undefined { + if (this.cachedAt == null) return undefined; + if ((Date.now() - this.cachedAt) > this.lifetime) { + this.value = undefined; + this.cachedAt = null; + return undefined; + } + return this.value; + } + + @bindThis + public delete() { + this.value = undefined; + this.cachedAt = null; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + @bindThis + public async fetch(fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + this.set(value); + return value; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + @bindThis + public async fetchMaybe(fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + if (value !== undefined) { + this.set(value); + } + return value; + } +} diff --git a/packages/backend/src/misc/check-https.ts b/packages/backend/src/misc/check-https.ts new file mode 100644 index 0000000000..b33f019973 --- /dev/null +++ b/packages/backend/src/misc/check-https.ts @@ -0,0 +1,4 @@ +export function checkHttps(url: string) { + return url.startsWith('https://') || + (url.startsWith('http://') && process.env.NODE_ENV !== 'production'); +} diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index e8c66683cc..910bebfcfe 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -1,3 +1,4 @@ +import { AhoCorasick } from 'slacc'; import RE2 from 're2'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; @@ -12,6 +13,8 @@ type UserLike = { id: User['id']; }; +const acCache = new Map(); + export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: Array): Promise { // 自分自身 if (me && (note.userId === me.id)) return false; @@ -21,7 +24,22 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi if (text === '') return false; - const matched = mutedWords.some(filter => { + const acable = mutedWords.filter(filter => Array.isArray(filter) && filter.length === 1).map(filter => filter[0]).sort(); + const unacable = mutedWords.filter(filter => !Array.isArray(filter) || filter.length !== 1); + const acCacheKey = acable.join('\n'); + const ac = acCache.get(acCacheKey) ?? AhoCorasick.withPatterns(acable); + acCache.delete(acCacheKey); + for (const obsoleteKeys of acCache.keys()) { + if (acCache.size > 1000) { + acCache.delete(obsoleteKeys); + } + } + acCache.set(acCacheKey, ac); + if (ac.isMatch(text)) { + return true; + } + + const matched = unacable.some(filter => { if (Array.isArray(filter)) { return filter.every(keyword => text.includes(keyword)); } else { diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts new file mode 100644 index 0000000000..23a0699f39 --- /dev/null +++ b/packages/backend/src/misc/correct-filename.ts @@ -0,0 +1,15 @@ +// 与えられた拡張子とファイル名が一致しているかどうかを確認し、 +// 一致していない場合は拡張子を付与して返す +export function correctFilename(filename: string, ext: string | null) { + const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown'; + if (filename.endsWith(dotExt)) { + return filename; + } + if (ext === 'jpg' && filename.endsWith('.jpeg')) { + return filename; + } + if (ext === 'tif' && filename.endsWith('.tiff')) { + return filename; + } + return `${filename}${dotExt}`; +} diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts index ca224971c5..1c6f5776db 100644 --- a/packages/backend/src/misc/emoji-regex.ts +++ b/packages/backend/src/misc/emoji-regex.ts @@ -1,4 +1,4 @@ -import twemoji from 'twemoji-parser/dist/lib/regex.js'; -const twemojiRegex = twemoji.default; +// taken from twemoji-parser/dist/lib/regex.js +const twemojiRegex = /(?:\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83e\udef1\ud83c\udffb\u200d\ud83e\udef2\ud83c[\udffc-\udfff]|\ud83e\udef1\ud83c\udffc\u200d\ud83e\udef2\ud83c[\udffb\udffd-\udfff]|\ud83e\udef1\ud83c\udffd\u200d\ud83e\udef2\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\udef1\ud83c\udffe\u200d\ud83e\udef2\ud83c[\udffb-\udffd\udfff]|\ud83e\udef1\ud83c\udfff\u200d\ud83e\udef2\ud83c[\udffb-\udffe]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83e\uddd1\u200d\ud83e\udd1d\u200d\ud83e\uddd1|\ud83d\udc6b\ud83c[\udffb-\udfff]|\ud83d\udc6c\ud83c[\udffb-\udfff]|\ud83d\udc6d\ud83c[\udffb-\udfff]|\ud83d\udc8f\ud83c[\udffb-\udfff]|\ud83d\udc91\ud83c[\udffb-\udfff]|\ud83e\udd1d\ud83c[\udffb-\udfff]|\ud83d[\udc6b-\udc6d\udc8f\udc91]|\ud83e\udd1d)|(?:\ud83d[\udc68\udc69]|\ud83e\uddd1)(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf7c\udf84\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddaf-\uddb3\uddbc\uddbd])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc70\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddcd-\uddcf\uddd4\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83d\ude36\u200d\ud83c\udf2b\ufe0f|\u2764\ufe0f\u200d\ud83d\udd25|\u2764\ufe0f\u200d\ud83e\ude79|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc15\u200d\ud83e\uddba|\ud83d\udc3b\u200d\u2744\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83d\ude2e\u200d\ud83d\udca8|\ud83d\ude35\u200d\ud83d\udcab|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f|\ud83d\udc08\u200d\u2b1b)|[#*0-9]\ufe0f?\u20e3|(?:[©®\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd0c\udd0f\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\udd77\uddb5\uddb6\uddb8\uddb9\uddbb\uddcd-\uddcf\uddd1-\udddd\udec3-\udec5\udef0-\udef6]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udc8e\udc90\udc92-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\uded5-\uded7\udedd-\udedf\udeeb\udeec\udef4-\udefc\udfe0-\udfeb\udff0]|\ud83e[\udd0d\udd0e\udd10-\udd17\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd3f-\udd45\udd47-\udd76\udd78-\uddb4\uddb7\uddba\uddbc-\uddcc\uddd0\uddde-\uddff\ude70-\ude74\ude78-\ude7c\ude80-\ude86\ude90-\udeac\udeb0-\udeba\udec0-\udec2\uded0-\uded9\udee0-\udee7]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f/g; export const emojiRegex = new RegExp(`(${twemojiRegex.source})`); diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 85bc2ec94d..964f20b25b 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -1,4 +1,4 @@ -import type { Packed } from './schema.js'; +import type { Packed } from './json-schema.js'; /** * 投稿を表す文字列を取得します。 diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 19c8546f95..9e206ee98f 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -3,6 +3,8 @@ import * as crypto from 'node:crypto'; +export const aidRegExp = /^[0-9a-z]{10}$/; + const TIME2000 = 946684800000; let counter = crypto.randomBytes(2).readUInt16LE(0); @@ -23,3 +25,8 @@ export function genAid(date: Date): string { counter++; return getTime(t) + getNoise(); } + +export function parseAid(id: string): { date: Date; } { + const time = parseInt(id.slice(0, 8), 36) + TIME2000; + return { date: new Date(time) }; +} diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts index 30bbdf1698..337416b059 100644 --- a/packages/backend/src/misc/id/meid.ts +++ b/packages/backend/src/misc/id/meid.ts @@ -1,5 +1,8 @@ const CHARS = '0123456789abcdef'; +// same as object-id +export const meidRegExp = /^[0-9a-f]{24}$/; + function getTime(time: number) { if (time < 0) time = 0; if (time === 0) { @@ -24,3 +27,9 @@ function getRandom() { export function genMeid(date: Date): string { return getTime(date.getTime()) + getRandom(); } + +export function parseMeid(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000), + }; +} diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts index d4aaaea1ba..19d0bc1fd2 100644 --- a/packages/backend/src/misc/id/meidg.ts +++ b/packages/backend/src/misc/id/meidg.ts @@ -3,6 +3,7 @@ const CHARS = '0123456789abcdef'; // 4bit Fixed hex value 'g' // 44bit UNIX Time ms in Hex // 48bit Random value in Hex +export const meidgRegExp = /^g[0-9a-f]{23}$/; function getTime(time: number) { if (time < 0) time = 0; @@ -26,3 +27,9 @@ function getRandom() { export function genMeidg(date: Date): string { return 'g' + getTime(date.getTime()) + getRandom(); } + +export function parseMeidg(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(1, 12), 16)), + }; +} diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts index 392ea43301..aec3447bd7 100644 --- a/packages/backend/src/misc/id/object-id.ts +++ b/packages/backend/src/misc/id/object-id.ts @@ -1,5 +1,8 @@ const CHARS = '0123456789abcdef'; +// same as meid +export const objectIdRegExp = /^[0-9a-f]{24}$/; + function getTime(time: number) { if (time < 0) time = 0; if (time === 0) { @@ -24,3 +27,9 @@ function getRandom() { export function genObjectId(date: Date): string { return getTime(date.getTime()) + getRandom(); } + +export function parseObjectId(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(0, 8), 16) * 1000), + }; +} diff --git a/packages/backend/src/misc/id/ulid.ts b/packages/backend/src/misc/id/ulid.ts new file mode 100644 index 0000000000..e8aa752890 --- /dev/null +++ b/packages/backend/src/misc/id/ulid.ts @@ -0,0 +1,14 @@ +// Crockford's Base32 +// https://github.com/ulid/spec#encoding +const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/; + +export function parseUlid(id: string): { date: Date; } { + const timestamp = id.slice(0, 10); + let time = 0; + for (let i = 0; i < 10; i++) { + time = time * 32 + CHARS.indexOf(timestamp[i]); + } + return { date: new Date(time) }; +} diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts index e11a18bb70..73ad0b3b82 100644 --- a/packages/backend/src/misc/is-instance-muted.ts +++ b/packages/backend/src/misc/is-instance-muted.ts @@ -1,4 +1,4 @@ -import type { Packed } from './schema.js'; +import type { Packed } from './json-schema.js'; export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set): boolean { if (mutedInstances.has(note.user.host ?? '')) return true; diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts index acf5c1ede3..46a66efc0f 100644 --- a/packages/backend/src/misc/is-mime-image.ts +++ b/packages/backend/src/misc/is-mime-image.ts @@ -2,8 +2,10 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; const dictionary = { 'safe-file': FILE_TYPE_BROWSERSAFE, - 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], - 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-animation-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], + 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], }; export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/misc/is-not-null.ts b/packages/backend/src/misc/is-not-null.ts new file mode 100644 index 0000000000..d89a1957be --- /dev/null +++ b/packages/backend/src/misc/is-not-null.ts @@ -0,0 +1,5 @@ +// we are using {} as "any non-nullish value" as expected +// eslint-disable-next-line @typescript-eslint/ban-types +export function isNotNull(input: T | undefined | null): input is T { + return input != null; +} diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/json-schema.ts similarity index 65% rename from packages/backend/src/misc/schema.ts rename to packages/backend/src/misc/json-schema.ts index 7aeb65f296..954f5fd6b2 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -6,29 +6,31 @@ import { packedMeDetailedSchema, packedUserDetailedSchema, packedUserSchema, -} from '@/models/schema/user.js'; -import { packedNoteSchema } from '@/models/schema/note.js'; -import { packedUserListSchema } from '@/models/schema/user-list.js'; -import { packedAppSchema } from '@/models/schema/app.js'; -import { packedMessagingMessageSchema } from '@/models/schema/messaging-message.js'; -import { packedNotificationSchema } from '@/models/schema/notification.js'; -import { packedDriveFileSchema } from '@/models/schema/drive-file.js'; -import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js'; -import { packedFollowingSchema } from '@/models/schema/following.js'; -import { packedMutingSchema } from '@/models/schema/muting.js'; -import { packedBlockingSchema } from '@/models/schema/blocking.js'; -import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js'; -import { packedHashtagSchema } from '@/models/schema/hashtag.js'; -import { packedPageSchema } from '@/models/schema/page.js'; -import { packedUserGroupSchema } from '@/models/schema/user-group.js'; -import { packedNoteFavoriteSchema } from '@/models/schema/note-favorite.js'; -import { packedChannelSchema } from '@/models/schema/channel.js'; -import { packedAntennaSchema } from '@/models/schema/antenna.js'; -import { packedClipSchema } from '@/models/schema/clip.js'; -import { packedFederationInstanceSchema } from '@/models/schema/federation-instance.js'; -import { packedQueueCountSchema } from '@/models/schema/queue.js'; -import { packedGalleryPostSchema } from '@/models/schema/gallery-post.js'; -import { packedEmojiSchema } from '@/models/schema/emoji.js'; +} from '@/models/json-schema/user.js'; +import { packedNoteSchema } from '@/models/json-schema/note.js'; +import { packedUserListSchema } from '@/models/json-schema/user-list.js'; +import { packedAppSchema } from '@/models/json-schema/app.js'; +import { packedMessagingMessageSchema } from '@/models/json-schema/messaging-message.js'; +import { packedNotificationSchema } from '@/models/json-schema/notification.js'; +import { packedDriveFileSchema } from '@/models/json-schema/drive-file.js'; +import { packedDriveFolderSchema } from '@/models/json-schema/drive-folder.js'; +import { packedFollowingSchema } from '@/models/json-schema/following.js'; +import { packedMutingSchema } from '@/models/json-schema/muting.js'; +import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; +import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; +import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; +import { packedHashtagSchema } from '@/models/json-schema/hashtag.js'; +import { packedPageSchema } from '@/models/json-schema/page.js'; +import { packedUserGroupSchema } from '@/models/json-schema/user-group.js'; +import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; +import { packedChannelSchema } from '@/models/json-schema/channel.js'; +import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; +import { packedClipSchema } from '@/models/json-schema/clip.js'; +import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; +import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; +import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; +import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { packedFlashSchema } from '@/models/json-schema/flash.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -51,6 +53,7 @@ export const refs = { DriveFolder: packedDriveFolderSchema, Following: packedFollowingSchema, Muting: packedMutingSchema, + RenoteMuting: packedRenoteMutingSchema, Blocking: packedBlockingSchema, Hashtag: packedHashtagSchema, Page: packedPageSchema, @@ -60,7 +63,9 @@ export const refs = { Clip: packedClipSchema, FederationInstance: packedFederationInstanceSchema, GalleryPost: packedGalleryPostSchema, - Emoji: packedEmojiSchema, + EmojiSimple: packedEmojiSimpleSchema, + EmojiDetailed: packedEmojiDetailedSchema, + Flash: packedFlashSchema, }; export type Packed = SchemaType; @@ -94,7 +99,7 @@ export interface Schema extends OfSchema { readonly example?: any; readonly format?: string; readonly ref?: keyof typeof refs; - readonly enum?: ReadonlyArray; + readonly enum?: ReadonlyArray; readonly default?: (this['type'] extends TypeStringef ? StringDefToType : any) | null; readonly maxLength?: number; readonly minLength?: number; @@ -117,10 +122,10 @@ export type Obj = Record; // https://github.com/misskey-dev/misskey/issues/8535 // To avoid excessive stack depth error, // deceive TypeScript with UnionToIntersection (or more precisely, `infer` expression within it). -export type ObjType = +export type ObjType> = UnionToIntersection< { -readonly [R in RequiredPropertyNames]-?: SchemaType } & - { -readonly [R in RequiredProps]-?: SchemaType } & + { -readonly [R in RequiredProps[number]]-?: SchemaType } & { -readonly [P in keyof s]?: SchemaType } >; @@ -137,18 +142,19 @@ type PartialIntersection = Partial>; // https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552 // To get union, we use `Foo extends any ? Hoge : never` type UnionSchemaType = X extends any ? SchemaType : never; -type UnionObjectSchemaType = X extends any ? ObjectSchemaType : never; +//type UnionObjectSchemaType = X extends any ? ObjectSchemaType : never; +type UnionObjType = a[number]> = X extends any ? ObjType : never; type ArrayUnion = T extends any ? Array : never; type ObjectSchemaTypeDef

= p['ref'] extends keyof typeof refs ? Packed : p['properties'] extends NonNullable ? - p['anyOf'] extends ReadonlyArray ? - ObjType[number]> & UnionObjectSchemaType & PartialIntersection> - : - ObjType[number]> + p['anyOf'] extends ReadonlyArray ? p['anyOf'][number]['required'] extends ReadonlyArray ? + UnionObjType> & ObjType> + : never + : ObjType> : - p['anyOf'] extends ReadonlyArray ? UnionObjectSchemaType & PartialIntersection> : + p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md p['allOf'] extends ReadonlyArray ? UnionToIntersection> : any @@ -159,7 +165,7 @@ export type SchemaTypeDef

= p['type'] extends 'integer' ? number : p['type'] extends 'number' ? number : p['type'] extends 'string' ? ( - p['enum'] extends readonly string[] ? + p['enum'] extends readonly (string | null)[] ? p['enum'][number] : p['format'] extends 'date-time' ? string : // Dateにする?? string diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 2a235bc6fc..69fe32a5de 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -190,12 +190,6 @@ const $driveFoldersRepository: Provider = { inject: [DI.db], }; -const $notificationsRepository: Provider = { - provide: DI.notificationsRepository, - useFactory: (db: DataSource) => db.getRepository(Notification), - inject: [DI.db], -}; - const $metasRepository: Provider = { provide: DI.metasRepository, useFactory: (db: DataSource) => db.getRepository(Meta), @@ -208,6 +202,12 @@ const $mutingsRepository: Provider = { inject: [DI.db], }; +const $renoteMutingsRepository: Provider = { + provide: DI.renoteMutingsRepository, + useFactory: (db: DataSource) => db.getRepository(RenoteMuting), + inject: [DI.db], +}; + const $blockingsRepository: Provider = { provide: DI.blockingsRepository, useFactory: (db: DataSource) => db.getRepository(Blocking), @@ -304,15 +304,15 @@ const $clipNotesRepository: Provider = { inject: [DI.db], }; -const $antennasRepository: Provider = { - provide: DI.antennasRepository, - useFactory: (db: DataSource) => db.getRepository(Antenna), +const $clipFavoritesRepository: Provider = { + provide: DI.clipFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ClipFavorite), inject: [DI.db], }; -const $antennaNotesRepository: Provider = { - provide: DI.antennaNotesRepository, - useFactory: (db: DataSource) => db.getRepository(AntennaNote), +const $antennasRepository: Provider = { + provide: DI.antennasRepository, + useFactory: (db: DataSource) => db.getRepository(Antenna), inject: [DI.db], }; @@ -352,9 +352,9 @@ const $channelFollowingsRepository: Provider = { inject: [DI.db], }; -const $channelNotePiningsRepository: Provider = { - provide: DI.channelNotePiningsRepository, - useFactory: (db: DataSource) => db.getRepository(ChannelNotePining), +const $channelFavoritesRepository: Provider = { + provide: DI.channelFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ChannelFavorite), inject: [DI.db], }; @@ -412,6 +412,12 @@ const $roleAssignmentsRepository: Provider = { inject: [DI.db], }; +const $userMemosRepository: Provider = { + provide: DI.userMemosRepository, + useFactory: (db: DataSource) => db.getRepository(UserMemo), + inject: [DI.db], +}; + @Module({ imports: [ ], @@ -447,9 +453,9 @@ const $roleAssignmentsRepository: Provider = { $emojisRepository, $driveFilesRepository, $driveFoldersRepository, - $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, @@ -466,15 +472,15 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, - $antennaNotesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, - $channelNotePiningsRepository, + $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, @@ -484,6 +490,7 @@ const $roleAssignmentsRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $userMemosRepository, ], exports: [ $usersRepository, @@ -517,9 +524,9 @@ const $roleAssignmentsRepository: Provider = { $emojisRepository, $driveFilesRepository, $driveFoldersRepository, - $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, @@ -536,15 +543,15 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, - $antennaNotesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, - $channelNotePiningsRepository, + $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, @@ -554,6 +561,7 @@ const $roleAssignmentsRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $userMemosRepository, ], }) export class RepositoryModule {} diff --git a/packages/backend/src/models/entities/Ad.ts b/packages/backend/src/models/entities/Ad.ts index c87932adb6..56baf863ca 100644 --- a/packages/backend/src/models/entities/Ad.ts +++ b/packages/backend/src/models/entities/Ad.ts @@ -21,8 +21,9 @@ export class Ad { @Index() @Column('timestamp with time zone', { comment: 'The expired date of the Ad.', + default: () => 'now()', }) - public startAt: Date; + public startsAt: Date; @Column('varchar', { length: 32, nullable: false, diff --git a/packages/backend/src/models/entities/Antenna.ts b/packages/backend/src/models/entities/Antenna.ts index 860fd9cf55..59bf5aa32b 100644 --- a/packages/backend/src/models/entities/Antenna.ts +++ b/packages/backend/src/models/entities/Antenna.ts @@ -14,6 +14,10 @@ export class Antenna { }) public createdAt: Date; + @Index() + @Column('timestamp with time zone') + public lastUsedAt: Date; + @Index() @Column({ ...id(), @@ -96,4 +100,10 @@ export class Antenna { @Column('boolean') public notify: boolean; + + @Index() + @Column('boolean', { + default: true, + }) + public isActive: boolean; } diff --git a/packages/backend/src/models/entities/AntennaNote.ts b/packages/backend/src/models/entities/AntennaNote.ts deleted file mode 100644 index 5524a89367..0000000000 --- a/packages/backend/src/models/entities/AntennaNote.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { id } from '../id.js'; -import { Note } from './Note.js'; -import { Antenna } from './Antenna.js'; - -@Entity() -@Index(['noteId', 'antennaId'], { unique: true }) -export class AntennaNote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The note ID.', - }) - public noteId: Note['id']; - - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; - - @Index() - @Column({ - ...id(), - comment: 'The antenna ID.', - }) - public antennaId: Antenna['id']; - - @ManyToOne(type => Antenna, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public antenna: Antenna | null; - - @Index() - @Column('boolean', { - default: false, - }) - public read: boolean; -} diff --git a/packages/backend/src/models/entities/Channel.ts b/packages/backend/src/models/entities/Channel.ts index a6e32d54f7..d7c4583da3 100644 --- a/packages/backend/src/models/entities/Channel.ts +++ b/packages/backend/src/models/entities/Channel.ts @@ -59,6 +59,23 @@ export class Channel { @JoinColumn() public banner: DriveFile | null; + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public pinnedNoteIds: string[]; + + @Column('varchar', { + length: 16, + default: '#86b300', + }) + public color: string; + + @Index() + @Column('boolean', { + default: false, + }) + public isArchived: boolean; + @Index() @Column('integer', { default: 0, diff --git a/packages/backend/src/models/entities/ChannelNotePining.ts b/packages/backend/src/models/entities/ChannelFavorite.ts similarity index 59% rename from packages/backend/src/models/entities/ChannelNotePining.ts rename to packages/backend/src/models/entities/ChannelFavorite.ts index ab5796626a..cfb2c892cf 100644 --- a/packages/backend/src/models/entities/ChannelNotePining.ts +++ b/packages/backend/src/models/entities/ChannelFavorite.ts @@ -1,21 +1,24 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { id } from '../id.js'; -import { Note } from './Note.js'; +import { User } from './User.js'; import { Channel } from './Channel.js'; @Entity() -@Index(['channelId', 'noteId'], { unique: true }) -export class ChannelNotePining { +@Index(['userId', 'channelId'], { unique: true }) +export class ChannelFavorite { @PrimaryColumn(id()) public id: string; + @Index() @Column('timestamp with time zone', { - comment: 'The created date of the ChannelNotePining.', + comment: 'The created date of the ChannelFavorite.', }) public createdAt: Date; @Index() - @Column(id()) + @Column({ + ...id(), + }) public channelId: Channel['id']; @ManyToOne(type => Channel, { @@ -24,12 +27,15 @@ export class ChannelNotePining { @JoinColumn() public channel: Channel | null; - @Column(id()) - public noteId: Note['id']; + @Index() + @Column({ + ...id(), + }) + public userId: User['id']; - @ManyToOne(type => Note, { + @ManyToOne(type => User, { onDelete: 'CASCADE', }) @JoinColumn() - public note: Note | null; + public user: User | null; } diff --git a/packages/backend/src/models/entities/Clip.ts b/packages/backend/src/models/entities/Clip.ts index 57a310ac03..825a32c981 100644 --- a/packages/backend/src/models/entities/Clip.ts +++ b/packages/backend/src/models/entities/Clip.ts @@ -12,6 +12,12 @@ export class Clip { }) public createdAt: Date; + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public lastClippedAt: Date | null; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/entities/ClipFavorite.ts b/packages/backend/src/models/entities/ClipFavorite.ts new file mode 100644 index 0000000000..623471e671 --- /dev/null +++ b/packages/backend/src/models/entities/ClipFavorite.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { Clip } from './Clip.js'; + +@Entity() +@Index(['userId', 'clipId'], { unique: true }) +export class ClipFavorite { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public clipId: Clip['id']; + + @ManyToOne(type => Clip, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public clip: Clip | null; +} diff --git a/packages/backend/src/models/entities/Emoji.ts b/packages/backend/src/models/entities/Emoji.ts index 7332dd1857..dbb437d439 100644 --- a/packages/backend/src/models/entities/Emoji.ts +++ b/packages/backend/src/models/entities/Emoji.ts @@ -55,4 +55,9 @@ export class Emoji { array: true, length: 128, default: '{}', }) public aliases: string[]; + + @Column('varchar', { + length: 1024, nullable: true, + }) + public license: string | null; } diff --git a/packages/backend/src/models/entities/Flash.ts b/packages/backend/src/models/entities/Flash.ts index 07039d4fa1..4ccc908a6a 100644 --- a/packages/backend/src/models/entities/Flash.ts +++ b/packages/backend/src/models/entities/Flash.ts @@ -1,7 +1,6 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; import { id } from '../id.js'; import { User } from './User.js'; -import { DriveFile } from './DriveFile.js'; @Entity() export class Flash { @@ -44,7 +43,7 @@ export class Flash { public user: User | null; @Column('varchar', { - length: 32768, + length: 65536, }) public script: string; diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index f3b20a1657..aa780f4f28 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -12,7 +12,7 @@ export class Meta { public id: string; @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public name: string | null; @@ -25,7 +25,7 @@ export class Meta { * メンテナの名前 */ @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public maintainerName: string | null; @@ -33,7 +33,7 @@ export class Meta { * メンテナの連絡先 */ @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public maintainerEmail: string | null; @@ -42,82 +42,69 @@ export class Meta { }) public disableRegistration: boolean; - @Column('boolean', { - default: false, - }) - public useStarForReactionFallback: boolean; - @Column('varchar', { - length: 64, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public langs: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public pinnedUsers: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public hiddenTags: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public blockedHosts: string[]; @Column('varchar', { - length: 512, array: true, default: '{/featured,/channels,/explore,/pages,/about-misskey}', + length: 1024, array: true, default: '{}', }) - public pinnedPages: string[]; - - @Column({ - ...id(), - nullable: true, - }) - public pinnedClipId: Clip['id'] | null; + public sensitiveWords: string[]; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public themeColor: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, - default: '/assets/ai.png', }) public mascotImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public bannerUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public backgroundImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public logoImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, - default: 'https://xn--931a.moe/aiart/yubitun.png', }) public errorImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public iconUrl: string | null; @@ -150,13 +137,13 @@ export class Meta { public enableHcaptcha: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public hcaptchaSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public hcaptchaSecretKey: string | null; @@ -167,13 +154,13 @@ export class Meta { public enableRecaptcha: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public recaptchaSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public recaptchaSecretKey: string | null; @@ -184,13 +171,13 @@ export class Meta { public enableTurnstile: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public turnstileSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public turnstileSecretKey: string | null; @@ -218,7 +205,7 @@ export class Meta { public enableSensitiveMediaDetectionForVideos: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public summalyProxy: string | null; @@ -229,7 +216,7 @@ export class Meta { public enableEmail: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public email: string | null; @@ -240,7 +227,7 @@ export class Meta { public smtpSecure: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpHost: string | null; @@ -251,13 +238,13 @@ export class Meta { public smtpPort: number | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpUser: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpPass: string | null; @@ -268,25 +255,25 @@ export class Meta { public enableServiceWorker: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public swPublicKey: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public swPrivateKey: string | null; @Column('varchar', { - length: 32, + length: 1024, nullable: true, }) public translatorType: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public deeplAuthKey: string | null; @@ -297,20 +284,20 @@ export class Meta { public deeplIsPro: boolean; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) - public ToSUrl: string | null; + public termsOfServiceUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, default: 'https://github.com/kokonect-link/cherrypick', nullable: false, }) public repositoryUrl: string; @Column('varchar', { - length: 512, + length: 1024, default: 'https://github.com/kokonect-link/cherrypick/issues/new', nullable: true, }) @@ -334,43 +321,43 @@ export class Meta { public useObjectStorage: boolean; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageBucket: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStoragePrefix: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageBaseUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageEndpoint: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageRegion: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageAccessKey: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageSecretKey: string | null; @@ -410,8 +397,30 @@ export class Meta { }) public enableActiveEmailValidation: boolean; + @Column('boolean', { + default: true, + }) + public enableChartsForRemoteUser: boolean; + + @Column('boolean', { + default: true, + }) + public enableChartsForFederatedInstances: boolean; + @Column('jsonb', { default: { }, }) public policies: Record; + + @Column('varchar', { + length: 280, + array: true, + default: '{}', + }) + public serverRules: string[]; + + @Column('varchar', { + length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }', + }) + public preservedUsernames: string[]; } diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index 9cb5909183..48e36c5f66 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -87,6 +87,11 @@ export class Note { }) public localOnly: boolean; + @Column('varchar', { + length: 64, nullable: true, + }) + public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/entities/NoteThreadMuting.ts b/packages/backend/src/models/entities/NoteThreadMuting.ts index a23176b994..3c884fe615 100644 --- a/packages/backend/src/models/entities/NoteThreadMuting.ts +++ b/packages/backend/src/models/entities/NoteThreadMuting.ts @@ -1,7 +1,6 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { id } from '../id.js'; import { User } from './User.js'; -import { Note } from './Note.js'; @Entity() @Index(['userId', 'threadId'], { unique: true }) diff --git a/packages/backend/src/models/entities/Notification.ts b/packages/backend/src/models/entities/Notification.ts index 66f131d1c0..7dcf8f57e4 100644 --- a/packages/backend/src/models/entities/Notification.ts +++ b/packages/backend/src/models/entities/Notification.ts @@ -1,55 +1,20 @@ -import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm'; import { notificationTypes } from '@/types.js'; -import { id } from '../id.js'; import { User } from './User.js'; import { Note } from './Note.js'; import { FollowRequest } from './FollowRequest.js'; import { UserGroupInvitation } from './UserGroupInvitation.js'; import { AccessToken } from './AccessToken.js'; -@Entity() -export class Notification { - @PrimaryColumn(id()) - public id: string; +export type Notification = { + id: string; - @Index() - @Column('timestamp with time zone', { - comment: 'The created date of the Notification.', - }) - public createdAt: Date; - - /** - * 通知の受信者 - */ - @Index() - @Column({ - ...id(), - comment: 'The ID of recipient user of the Notification.', - }) - public notifieeId: User['id']; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public notifiee: User | null; + // RedisのためDateではなくstring + createdAt: string; /** * 通知の送信者(initiator) */ - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The ID of sender user of the Notification.', - }) - public notifierId: User['id'] | null; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public notifier: User | null; + notifierId: User['id'] | null; /** * 通知の種類。 @@ -59,7 +24,6 @@ export class Notification { * renote - 投稿がRenoteされた * quote - 投稿が引用Renoteされた * reaction - 投稿にリアクションされた - * pollVote - 投稿のアンケートに投票された (廃止) * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した * receiveFollowRequest - フォローリクエストされた * followRequestAccepted - 自分の送ったフォローリクエストが承認された @@ -67,113 +31,41 @@ export class Notification { * achievementEarned - 実績を獲得 * app - アプリ通知 */ - @Index() - @Column('enum', { - enum: notificationTypes, - comment: 'The type of the Notification.', - }) - public type: typeof notificationTypes[number]; + type: typeof notificationTypes[number]; - /** - * 通知が読まれたかどうか - */ - @Index() - @Column('boolean', { - default: false, - comment: 'Whether the Notification is read.', - }) - public isRead: boolean; + noteId: Note['id'] | null; - @Column({ - ...id(), - nullable: true, - }) - public noteId: Note['id'] | null; + followRequestId: FollowRequest['id'] | null; - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; + reaction: string | null; - @Column({ - ...id(), - nullable: true, - }) - public followRequestId: FollowRequest['id'] | null; + userGroupInvitationId: UserGroupInvitation['id'] | null; - @ManyToOne(type => FollowRequest, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public followRequest: FollowRequest | null; + userGroupInvitation: UserGroupInvitation | null; - @Column({ - ...id(), - nullable: true, - }) - public userGroupInvitationId: UserGroupInvitation['id'] | null; + choice: number | null; - @ManyToOne(type => UserGroupInvitation, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public userGroupInvitation: UserGroupInvitation | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public reaction: string | null; - - @Column('integer', { - nullable: true, - }) - public choice: number | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public achievement: string | null; + achievement: string | null; /** * アプリ通知のbody */ - @Column('varchar', { - length: 2048, nullable: true, - }) - public customBody: string | null; + customBody: string | null; /** * アプリ通知のheader * (省略時はアプリ名で表示されることを期待) */ - @Column('varchar', { - length: 256, nullable: true, - }) - public customHeader: string | null; + customHeader: string | null; /** * アプリ通知のicon(URL) * (省略時はアプリアイコンで表示されることを期待) */ - @Column('varchar', { - length: 1024, nullable: true, - }) - public customIcon: string | null; + customIcon: string | null; /** * アプリ通知のアプリ(のトークン) */ - @Index() - @Column({ - ...id(), - nullable: true, - }) - public appAccessTokenId: AccessToken['id'] | null; - - @ManyToOne(type => AccessToken, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public appAccessToken: AccessToken | null; + appAccessTokenId: AccessToken['id'] | null; } diff --git a/packages/backend/src/models/entities/RenoteMuting.ts b/packages/backend/src/models/entities/RenoteMuting.ts new file mode 100644 index 0000000000..2f803a5fa8 --- /dev/null +++ b/packages/backend/src/models/entities/RenoteMuting.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +@Index(['muterId', 'muteeId'], { unique: true }) +export class RenoteMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Muting.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The mutee user ID.', + }) + public muteeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The muter user ID.', + }) + public muterId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/entities/RetentionAggregation.ts b/packages/backend/src/models/entities/RetentionAggregation.ts index c79b762d71..c7bf38b3af 100644 --- a/packages/backend/src/models/entities/RetentionAggregation.ts +++ b/packages/backend/src/models/entities/RetentionAggregation.ts @@ -18,6 +18,12 @@ export class RetentionAggregation { }) public updatedAt: Date; + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: false, + }) + public dateKey: string; + @Column({ ...id(), array: true, diff --git a/packages/backend/src/models/entities/Role.ts b/packages/backend/src/models/entities/Role.ts index 8cf6811863..61f40d59da 100644 --- a/packages/backend/src/models/entities/Role.ts +++ b/packages/backend/src/models/entities/Role.ts @@ -1,4 +1,4 @@ -import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { Entity, Column, PrimaryColumn } from 'typeorm'; import { id } from '../id.js'; type CondFormulaValueAnd = { @@ -54,6 +54,16 @@ type CondFormulaValueFollowingMoreThanOrEq = { value: number; }; +type CondFormulaValueNotesLessThanOrEq = { + type: 'notesLessThanOrEq'; + value: number; +}; + +type CondFormulaValueNotesMoreThanOrEq = { + type: 'notesMoreThanOrEq'; + value: number; +}; + export type RoleCondFormulaValue = CondFormulaValueAnd | CondFormulaValueOr | @@ -65,7 +75,9 @@ export type RoleCondFormulaValue = CondFormulaValueFollowersLessThanOrEq | CondFormulaValueFollowersMoreThanOrEq | CondFormulaValueFollowingLessThanOrEq | - CondFormulaValueFollowingMoreThanOrEq; + CondFormulaValueFollowingMoreThanOrEq | + CondFormulaValueNotesLessThanOrEq | + CondFormulaValueNotesMoreThanOrEq; @Entity() export class Role { @@ -139,11 +151,22 @@ export class Role { }) public isAdministrator: boolean; + @Column('boolean', { + default: false, + }) + public isExplorable: boolean; + @Column('boolean', { default: false, }) public canEditMembersByModerator: boolean; + // UIに表示する際の並び順用(大きいほど先頭) + @Column('integer', { + default: 0, + }) + public displayOrder: number; + @Column('jsonb', { default: { }, }) diff --git a/packages/backend/src/models/entities/RoleAssignment.ts b/packages/backend/src/models/entities/RoleAssignment.ts index e86f2a8999..972810940f 100644 --- a/packages/backend/src/models/entities/RoleAssignment.ts +++ b/packages/backend/src/models/entities/RoleAssignment.ts @@ -39,4 +39,10 @@ export class RoleAssignment { }) @JoinColumn() public role: Role | null; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public expiresAt: Date | null; } diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index 0a8b89ea06..8e10f999b6 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -68,6 +68,25 @@ export class User { }) public followingCount: number; + @Column('varchar', { + length: 512, + nullable: true, + comment: 'The URI of the new account of the User', + }) + public movedToUri: string | null; + + @Column('timestamp with time zone', { + nullable: true, + comment: 'When the user moved to another account', + }) + public movedAt: Date | null; + + @Column('simple-array', { + nullable: true, + comment: 'URIs the user is known as too', + }) + public alsoKnownAs: string[] | null; + @Column('integer', { default: 0, comment: 'The count of notes.', @@ -100,6 +119,26 @@ export class User { @JoinColumn() public banner: DriveFile | null; + @Column('varchar', { + length: 512, nullable: true, + }) + public avatarUrl: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public bannerUrl: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public avatarBlurhash: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public bannerBlurhash: string | null; + @Index() @Column('varchar', { length: 128, array: true, default: '{}', @@ -220,11 +259,23 @@ export type LocalUser = User & { uri: null; } +export type PartialLocalUser = Partial & { + id: User['id']; + host: null; + uri: null; +} + export type RemoteUser = User & { host: string; uri: string; } +export type PartialRemoteUser = Partial & { + id: User['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; diff --git a/packages/backend/src/models/entities/UserIp.ts b/packages/backend/src/models/entities/UserIp.ts index e9afd40d49..628e3d0361 100644 --- a/packages/backend/src/models/entities/UserIp.ts +++ b/packages/backend/src/models/entities/UserIp.ts @@ -1,6 +1,5 @@ -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Entity, Index, Column, PrimaryGeneratedColumn } from 'typeorm'; import { id } from '../id.js'; -import { Note } from './Note.js'; import type { User } from './User.js'; @Entity() diff --git a/packages/backend/src/models/entities/UserMemo.ts b/packages/backend/src/models/entities/UserMemo.ts new file mode 100644 index 0000000000..7dc34b4346 --- /dev/null +++ b/packages/backend/src/models/entities/UserMemo.ts @@ -0,0 +1,42 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +@Index(['userId', 'targetUserId'], { unique: true }) +export class UserMemo { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of target user.', + }) + public targetUserId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public targetUser: User | null; + + @Column('varchar', { + length: 2048, + comment: 'Memo.', + }) + public memo: string; +} diff --git a/packages/backend/src/models/entities/UserProfile.ts b/packages/backend/src/models/entities/UserProfile.ts index 1ff261cda3..7c21602c37 100644 --- a/packages/backend/src/models/entities/UserProfile.ts +++ b/packages/backend/src/models/entities/UserProfile.ts @@ -1,5 +1,5 @@ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { ffVisibility, notificationTypes } from '@/types.js'; +import { obsoleteNotificationTypes, ffVisibility, notificationTypes } from '@/types.js'; import { id } from '../id.js'; import { User } from './User.js'; import { Page } from './Page.js'; @@ -202,7 +202,11 @@ export class UserProfile { public mutedInstances: string[]; @Column('enum', { - enum: notificationTypes, + enum: [ + ...notificationTypes, + // マイグレーションで削除が困難なので古いenumは残しておく + ...obsoleteNotificationTypes, + ], array: true, default: [], }) diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 50697597ad..a915f5ffc4 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -4,15 +4,15 @@ import { Ad } from '@/models/entities/Ad.js'; import { Announcement } from '@/models/entities/Announcement.js'; import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; import { Antenna } from '@/models/entities/Antenna.js'; -import { AntennaNote } from '@/models/entities/AntennaNote.js'; import { App } from '@/models/entities/App.js'; import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; +import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; @@ -27,12 +27,12 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js'; -import { Notification } from '@/models/entities/Notification.js'; import { Page } from '@/models/entities/Page.js'; import { PageLike } from '@/models/entities/PageLike.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; @@ -59,6 +59,7 @@ import { UserPending } from '@/models/entities/UserPending.js'; import { UserProfile } from '@/models/entities/UserProfile.js'; import { UserPublickey } from '@/models/entities/UserPublickey.js'; import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; +import { UserMemo } from '@/models/entities/UserMemo.js'; import { Webhook } from '@/models/entities/Webhook.js'; import { Channel } from '@/models/entities/Channel.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; @@ -75,15 +76,15 @@ export { Announcement, AnnouncementRead, Antenna, - AntennaNote, App, AttestationChallenge, AuthSession, Blocking, ChannelFollowing, - ChannelNotePining, + ChannelFavorite, Clip, ClipNote, + ClipFavorite, DriveFile, DriveFolder, Emoji, @@ -98,12 +99,12 @@ export { ModerationLog, MutedNote, Muting, + RenoteMuting, Note, NoteFavorite, NoteReaction, NoteThreadMuting, NoteUnread, - Notification, Page, PageLike, PasswordResetRequest, @@ -137,6 +138,7 @@ export { RoleAssignment, Flash, FlashLike, + UserMemo, }; export type AbuseUserReportsRepository = Repository; @@ -145,15 +147,15 @@ export type AdsRepository = Repository; export type AnnouncementsRepository = Repository; export type AnnouncementReadsRepository = Repository; export type AntennasRepository = Repository; -export type AntennaNotesRepository = Repository; export type AppsRepository = Repository; export type AttestationChallengesRepository = Repository; export type AuthSessionsRepository = Repository; export type BlockingsRepository = Repository; export type ChannelFollowingsRepository = Repository; -export type ChannelNotePiningsRepository = Repository; +export type ChannelFavoritesRepository = Repository; export type ClipsRepository = Repository; export type ClipNotesRepository = Repository; +export type ClipFavoritesRepository = Repository; export type DriveFilesRepository = Repository; export type DriveFoldersRepository = Repository; export type EmojisRepository = Repository; @@ -168,12 +170,12 @@ export type MetasRepository = Repository; export type ModerationLogsRepository = Repository; export type MutedNotesRepository = Repository; export type MutingsRepository = Repository; +export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; export type NoteThreadMutingsRepository = Repository; export type NoteUnreadsRepository = Repository; -export type NotificationsRepository = Repository; export type PagesRepository = Repository; export type PageLikesRepository = Repository; export type PasswordResetRequestsRepository = Repository; @@ -207,3 +209,4 @@ export type RolesRepository = Repository; export type RoleAssignmentsRepository = Repository; export type FlashsRepository = Repository; export type FlashLikesRepository = Repository; +export type UserMemoRepository = Repository; diff --git a/packages/backend/src/models/schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts similarity index 95% rename from packages/backend/src/models/schema/antenna.ts rename to packages/backend/src/models/json-schema/antenna.ts index 9cf522802c..42c4cf34f0 100644 --- a/packages/backend/src/models/schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -80,6 +80,10 @@ export const packedAntennaSchema = { type: 'boolean', optional: false, nullable: false, }, + isActive: { + type: 'boolean', + optional: false, nullable: false, + }, hasUnreadNote: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/app.ts b/packages/backend/src/models/json-schema/app.ts similarity index 100% rename from packages/backend/src/models/schema/app.ts rename to packages/backend/src/models/json-schema/app.ts diff --git a/packages/backend/src/models/schema/blocking.ts b/packages/backend/src/models/json-schema/blocking.ts similarity index 100% rename from packages/backend/src/models/schema/blocking.ts rename to packages/backend/src/models/json-schema/blocking.ts diff --git a/packages/backend/src/models/schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts similarity index 72% rename from packages/backend/src/models/schema/channel.ts rename to packages/backend/src/models/json-schema/channel.ts index 7f4f2a48b8..fd61a70c0e 100644 --- a/packages/backend/src/models/schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -30,6 +30,10 @@ export const packedChannelSchema = { format: 'url', nullable: true, optional: false, }, + isArchived: { + type: 'boolean', + optional: false, nullable: false, + }, notesCount: { type: 'number', nullable: false, optional: false, @@ -42,10 +46,26 @@ export const packedChannelSchema = { type: 'boolean', optional: true, nullable: false, }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, userId: { type: 'string', nullable: true, optional: false, format: 'id', }, + pinnedNoteIds: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + format: 'id', + }, + }, + color: { + type: 'string', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/schema/clip.ts b/packages/backend/src/models/json-schema/clip.ts similarity index 72% rename from packages/backend/src/models/schema/clip.ts rename to packages/backend/src/models/json-schema/clip.ts index f0ee2ce0c4..7310e59013 100644 --- a/packages/backend/src/models/schema/clip.ts +++ b/packages/backend/src/models/json-schema/clip.ts @@ -12,6 +12,11 @@ export const packedClipSchema = { optional: false, nullable: false, format: 'date-time', }, + lastClippedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, userId: { type: 'string', optional: false, nullable: false, @@ -34,5 +39,13 @@ export const packedClipSchema = { type: 'boolean', optional: false, nullable: false, }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, + favoritedCount: { + type: 'number', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/schema/drive-file.ts b/packages/backend/src/models/json-schema/drive-file.ts similarity index 100% rename from packages/backend/src/models/schema/drive-file.ts rename to packages/backend/src/models/json-schema/drive-file.ts diff --git a/packages/backend/src/models/schema/drive-folder.ts b/packages/backend/src/models/json-schema/drive-folder.ts similarity index 100% rename from packages/backend/src/models/schema/drive-folder.ts rename to packages/backend/src/models/json-schema/drive-folder.ts diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts new file mode 100644 index 0000000000..db4fd62cf6 --- /dev/null +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -0,0 +1,67 @@ +export const packedEmojiSimpleSchema = { + type: 'object', + properties: { + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; + +export const packedEmojiDetailedSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + host: { + type: 'string', + optional: false, nullable: true, + description: 'The local host is represented with `null`.', + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + license: { + type: 'string', + optional: false, nullable: true, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts similarity index 100% rename from packages/backend/src/models/schema/federation-instance.ts rename to packages/backend/src/models/json-schema/federation-instance.ts diff --git a/packages/backend/src/models/json-schema/flash.ts b/packages/backend/src/models/json-schema/flash.ts new file mode 100644 index 0000000000..8471a138ec --- /dev/null +++ b/packages/backend/src/models/json-schema/flash.ts @@ -0,0 +1,51 @@ +export const packedFlashSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + title: { + type: 'string', + optional: false, nullable: false, + }, + summary: { + type: 'string', + optional: false, nullable: false, + }, + script: { + type: 'string', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + likedCount: { + type: 'number', + optional: false, nullable: true, + }, + isLiked: { + type: 'boolean', + optional: true, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/following.ts b/packages/backend/src/models/json-schema/following.ts similarity index 100% rename from packages/backend/src/models/schema/following.ts rename to packages/backend/src/models/json-schema/following.ts diff --git a/packages/backend/src/models/schema/gallery-post.ts b/packages/backend/src/models/json-schema/gallery-post.ts similarity index 100% rename from packages/backend/src/models/schema/gallery-post.ts rename to packages/backend/src/models/json-schema/gallery-post.ts diff --git a/packages/backend/src/models/schema/hashtag.ts b/packages/backend/src/models/json-schema/hashtag.ts similarity index 100% rename from packages/backend/src/models/schema/hashtag.ts rename to packages/backend/src/models/json-schema/hashtag.ts diff --git a/packages/backend/src/models/schema/messaging-message.ts b/packages/backend/src/models/json-schema/messaging-message.ts similarity index 100% rename from packages/backend/src/models/schema/messaging-message.ts rename to packages/backend/src/models/json-schema/messaging-message.ts diff --git a/packages/backend/src/models/schema/muting.ts b/packages/backend/src/models/json-schema/muting.ts similarity index 100% rename from packages/backend/src/models/schema/muting.ts rename to packages/backend/src/models/json-schema/muting.ts diff --git a/packages/backend/src/models/schema/note-favorite.ts b/packages/backend/src/models/json-schema/note-favorite.ts similarity index 100% rename from packages/backend/src/models/schema/note-favorite.ts rename to packages/backend/src/models/json-schema/note-favorite.ts diff --git a/packages/backend/src/models/schema/note-reaction.ts b/packages/backend/src/models/json-schema/note-reaction.ts similarity index 100% rename from packages/backend/src/models/schema/note-reaction.ts rename to packages/backend/src/models/json-schema/note-reaction.ts diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/json-schema/note.ts similarity index 97% rename from packages/backend/src/models/schema/note.ts rename to packages/backend/src/models/json-schema/note.ts index 42c6b0dba4..92a771e5a3 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -145,6 +145,10 @@ export const packedNoteSchema = { type: 'boolean', optional: true, nullable: false, }, + reactionAcceptance: { + type: 'string', + optional: false, nullable: true, + }, reactions: { type: 'object', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts similarity index 93% rename from packages/backend/src/models/schema/notification.ts rename to packages/backend/src/models/json-schema/notification.ts index d3f2405cdd..e88ca61ba0 100644 --- a/packages/backend/src/models/schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -14,10 +14,6 @@ export const packedNotificationSchema = { optional: false, nullable: false, format: 'date-time', }, - isRead: { - type: 'boolean', - optional: false, nullable: false, - }, type: { type: 'string', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/page.ts b/packages/backend/src/models/json-schema/page.ts similarity index 100% rename from packages/backend/src/models/schema/page.ts rename to packages/backend/src/models/json-schema/page.ts diff --git a/packages/backend/src/models/schema/queue.ts b/packages/backend/src/models/json-schema/queue.ts similarity index 100% rename from packages/backend/src/models/schema/queue.ts rename to packages/backend/src/models/json-schema/queue.ts diff --git a/packages/backend/src/models/json-schema/renote-muting.ts b/packages/backend/src/models/json-schema/renote-muting.ts new file mode 100644 index 0000000000..69ed8510da --- /dev/null +++ b/packages/backend/src/models/json-schema/renote-muting.ts @@ -0,0 +1,26 @@ +export const packedRenoteMutingSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + muteeId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + mutee: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user-group.ts b/packages/backend/src/models/json-schema/user-group.ts similarity index 100% rename from packages/backend/src/models/schema/user-group.ts rename to packages/backend/src/models/json-schema/user-group.ts diff --git a/packages/backend/src/models/schema/user-list.ts b/packages/backend/src/models/json-schema/user-list.ts similarity index 100% rename from packages/backend/src/models/schema/user-list.ts rename to packages/backend/src/models/json-schema/user-list.ts diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/json-schema/user.ts similarity index 94% rename from packages/backend/src/models/schema/user.ts rename to packages/backend/src/models/json-schema/user.ts index 0cd1bd41ba..260fb45d3a 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -29,7 +29,7 @@ export const packedUserLiteSchema = { nullable: true, optional: false, }, avatarBlurhash: { - type: 'any', + type: 'string', nullable: true, optional: false, }, isAdmin: { @@ -72,6 +72,23 @@ export const packedUserDetailedNotMeOnlySchema = { format: 'uri', nullable: true, optional: false, }, + movedToUri: { + type: 'string', + format: 'uri', + nullable: true, + optional: false, + }, + alsoKnownAs: { + type: 'array', + nullable: true, + optional: false, + items: { + type: 'string', + format: 'id', + nullable: false, + optional: false, + }, + }, createdAt: { type: 'string', nullable: false, optional: false, @@ -93,7 +110,7 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: true, optional: false, }, bannerBlurhash: { - type: 'any', + type: 'string', nullable: true, optional: false, }, isLocked: { @@ -131,6 +148,7 @@ export const packedUserDetailedNotMeOnlySchema = { fields: { type: 'array', nullable: false, optional: false, + maxItems: 16, items: { type: 'object', nullable: false, optional: false, @@ -144,7 +162,6 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: false, }, }, - maxLength: 4, }, }, followersCount: { @@ -234,6 +251,14 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + isRenoteMuted: { + type: 'boolean', + nullable: false, optional: true, + }, + memo: { + type: 'string', + nullable: false, optional: true, + }, //#endregion }, } as const; @@ -307,7 +332,7 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, - hasUnreadChannel: { + hasUnreadMessagingMessage: { type: 'boolean', nullable: false, optional: false, }, diff --git a/packages/backend/src/models/schema/emoji.ts b/packages/backend/src/models/schema/emoji.ts deleted file mode 100644 index 143f25373c..0000000000 --- a/packages/backend/src/models/schema/emoji.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const packedEmojiSchema = { - type: 'object', - properties: { - id: { - type: 'string', - optional: true, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: true, nullable: true, - description: 'The local host is represented with `null`.', - }, - url: { - type: 'string', - optional: true, nullable: false, - }, - }, -} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 33b924e776..125daf6bea 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -12,15 +12,15 @@ import { Ad } from '@/models/entities/Ad.js'; import { Announcement } from '@/models/entities/Announcement.js'; import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; import { Antenna } from '@/models/entities/Antenna.js'; -import { AntennaNote } from '@/models/entities/AntennaNote.js'; import { App } from '@/models/entities/App.js'; import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; +import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; @@ -35,12 +35,12 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js'; -import { Notification } from '@/models/entities/Notification.js'; import { Page } from '@/models/entities/Page.js'; import { PageLike } from '@/models/entities/PageLike.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; @@ -74,11 +74,11 @@ import { Role } from '@/models/entities/Role.js'; import { RoleAssignment } from '@/models/entities/RoleAssignment.js'; import { Flash } from '@/models/entities/Flash.js'; import { FlashLike } from '@/models/entities/FlashLike.js'; +import { UserMemo } from '@/models/entities/UserMemo.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import { envOption } from './env.js'; export const dbLogger = new MisskeyLogger('db'); @@ -147,6 +147,7 @@ export const entities = [ Following, FollowRequest, Muting, + RenoteMuting, Blocking, Note, NoteFavorite, @@ -161,7 +162,6 @@ export const entities = [ DriveFolder, Poll, PollVote, - Notification, Emoji, Hashtag, SwSubscription, @@ -172,15 +172,15 @@ export const entities = [ ModerationLog, Clip, ClipNote, + ClipFavorite, Antenna, - AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, - ChannelNotePining, + ChannelFavorite, RegistryItem, Ad, PasswordResetRequest, @@ -192,6 +192,7 @@ export const entities = [ RoleAssignment, Flash, FlashLike, + UserMemo, ...charts, ]; @@ -209,6 +210,22 @@ export function createPostgresDataSource(config: Config) { statement_timeout: 1000 * 10, ...config.db.extra, }, + replication: config.dbReplications ? { + master: { + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + }, + slaves: config.dbSlaves!.map(rep => ({ + host: rep.host, + port: rep.port, + username: rep.user, + password: rep.pass, + database: rep.db, + })), + } : undefined, synchronize: process.env.NODE_ENV === 'test', dropSchema: process.env.NODE_ENV === 'test', cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts index df337ad810..df8ac3a301 100644 --- a/packages/backend/src/queue/DbQueueProcessorsService.ts +++ b/packages/backend/src/queue/DbQueueProcessorsService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { DbJobData } from '@/queue/types.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; @@ -10,11 +9,13 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; +import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; +import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import type Bull from 'bull'; @@ -33,11 +34,13 @@ export class DbQueueProcessorsService { private exportMutingProcessorService: ExportMutingProcessorService, private exportBlockingProcessorService: ExportBlockingProcessorService, private exportUserListsProcessorService: ExportUserListsProcessorService, + private exportAntennasProcessorService: ExportAntennasProcessorService, private importFollowingProcessorService: ImportFollowingProcessorService, private importMutingProcessorService: ImportMutingProcessorService, private importBlockingProcessorService: ImportBlockingProcessorService, private importUserListsProcessorService: ImportUserListsProcessorService, private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService, + private importAntennasProcessorService: ImportAntennasProcessorService, private deleteAccountProcessorService: DeleteAccountProcessorService, ) { } @@ -52,11 +55,15 @@ export class DbQueueProcessorsService { q.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done)); q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done)); + q.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done)); q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done)); + q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job)); q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done)); q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done)); + q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job)); q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done)); q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); + q.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done)); q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); } } diff --git a/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts b/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts index c95e1c1ba6..865e47c3f8 100644 --- a/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts +++ b/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { ObjectStorageJobData } from '@/queue/types.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; diff --git a/packages/backend/src/queue/QueueLoggerService.ts b/packages/backend/src/queue/QueueLoggerService.ts index 3a8a734f10..648af893c2 100644 --- a/packages/backend/src/queue/QueueLoggerService.ts +++ b/packages/backend/src/queue/QueueLoggerService.ts @@ -1,7 +1,6 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class QueueLoggerService { diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 6a8f35cdda..3d4cc77321 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -4,6 +4,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; +import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js'; import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -23,15 +24,18 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; +import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; +import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; +import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; @Module({ imports: [ @@ -53,17 +57,21 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro ExportMutingProcessorService, ExportBlockingProcessorService, ExportUserListsProcessorService, + ExportAntennasProcessorService, ImportFollowingProcessorService, ImportMutingProcessorService, ImportBlockingProcessorService, ImportUserListsProcessorService, ImportCustomEmojisProcessorService, + ImportAntennasProcessorService, DeleteAccountProcessorService, DeleteFileProcessorService, CleanRemoteFilesProcessorService, + RelationshipProcessorService, SystemQueueProcessorsService, ObjectStorageQueueProcessorsService, DbQueueProcessorsService, + RelationshipQueueProcessorsService, WebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 2123815c4c..706110f6fc 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; @@ -14,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; +import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js'; @Injectable() export class QueueProcessorService { @@ -28,6 +28,7 @@ export class QueueProcessorService { private systemQueueProcessorsService: SystemQueueProcessorsService, private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService, private dbQueueProcessorsService: DbQueueProcessorsService, + private relationshipQueueProcessorsService: RelationshipQueueProcessorsService, private webhookDeliverProcessorService: WebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private deliverProcessorService: DeliverProcessorService, @@ -53,14 +54,15 @@ export class QueueProcessorService { }; } } - + const systemLogger = this.logger.createSubLogger('system'); const deliverLogger = this.logger.createSubLogger('deliver'); const webhookLogger = this.logger.createSubLogger('webhook'); const inboxLogger = this.logger.createSubLogger('inbox'); const dbLogger = this.logger.createSubLogger('db'); + const relationshipLogger = this.logger.createSubLogger('relationship'); const objectStorageLogger = this.logger.createSubLogger('objectStorage'); - + this.queueService.systemQueue .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) @@ -68,7 +70,7 @@ export class QueueProcessorService { .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); - + this.queueService.deliverQueue .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) @@ -76,7 +78,7 @@ export class QueueProcessorService { .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - + this.queueService.inboxQueue .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) @@ -84,7 +86,7 @@ export class QueueProcessorService { .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); - + this.queueService.dbQueue .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) @@ -92,7 +94,15 @@ export class QueueProcessorService { .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); - + + this.queueService.relationshipQueue + .on('waiting', (jobId) => relationshipLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => relationshipLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => relationshipLogger.warn(`stalled id=${job.id}`)); + this.queueService.objectStorageQueue .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) @@ -100,7 +110,7 @@ export class QueueProcessorService { .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); - + this.queueService.webhookDeliverQueue .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) @@ -108,26 +118,27 @@ export class QueueProcessorService { .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - + this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job)); this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job)); this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); this.dbQueueProcessorsService.start(this.queueService.dbQueue); + this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue); this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue); - + this.queueService.systemQueue.add('tickCharts', { }, { repeat: { cron: '55 * * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('resyncCharts', { }, { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('cleanCharts', { }, { repeat: { cron: '0 0 * * *' }, @@ -139,19 +150,19 @@ export class QueueProcessorService { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('clean', { }, { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('checkExpiredMutings', { }, { repeat: { cron: '*/5 * * * *' }, removeOnComplete: true, }); - + this.systemQueueProcessorsService.start(this.queueService.systemQueue); } } diff --git a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts new file mode 100644 index 0000000000..736b4fa80d --- /dev/null +++ b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; +import type Bull from 'bull'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; + +@Injectable() +export class RelationshipQueueProcessorsService { + constructor( + @Inject(DI.config) + private config: Config, + + private relationshipProcessorService: RelationshipProcessorService, + ) { + } + + @bindThis + public start(q: Bull.Queue): void { + const maxJobs = this.config.relashionshipJobConcurrency ?? 16; + q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job)); + q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job)); + q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job)); + q.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job)); + } +} diff --git a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts index da4ae88557..e2720b4fe0 100644 --- a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts +++ b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In, IsNull, MoreThan } from 'typeorm'; +import { IsNull, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; @@ -7,6 +7,7 @@ import { bindThis } from '@/decorators.js'; import type { RetentionAggregationsRepository, UsersRepository } from '@/models/index.js'; import { deepClone } from '@/misc/clone.js'; import { IdService } from '@/core/IdService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; @@ -49,13 +50,23 @@ export class AggregateRetentionProcessorService { }); const targetUserIds = targetUsers.map(u => u.id); - await this.retentionAggregationsRepository.insert({ - id: this.idService.genId(), - createdAt: now, - updatedAt: now, - userIds: targetUserIds, - usersCount: targetUserIds.length, - }); + try { + await this.retentionAggregationsRepository.insert({ + id: this.idService.genId(), + createdAt: now, + updatedAt: now, + dateKey, + userIds: targetUserIds, + usersCount: targetUserIds.length, + }); + } catch (err) { + if (isDuplicateKeyValueError(err)) { + this.logger.succ('Skip because it has already been processed by another worker.'); + done(); + return; + } + throw err; + } // 今日活動したユーザーを全て取得 const activeUsers = await this.usersRepository.findBy({ diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts index 7a1e3e71be..2476d71a5e 100644 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -1,13 +1,13 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In, MoreThan } from 'typeorm'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MutingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import { bindThis } from '@/decorators.js'; @Injectable() export class CheckExpiredMutingsProcessorService { @@ -20,7 +20,7 @@ export class CheckExpiredMutingsProcessorService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, + private userMutingService: UserMutingService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings'); @@ -37,13 +37,7 @@ export class CheckExpiredMutingsProcessorService { .getMany(); if (expired.length > 0) { - await this.mutingsRepository.delete({ - id: In(expired.map(m => m.id)), - }); - - for (const m of expired) { - this.globalEventService.publishUserEvent(m.muterId, 'unmute', m.mutee!); - } + await this.userMutingService.unmute(expired); } this.logger.succ('All expired mutings checked.'); diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts index 5254d3c7d8..b458167042 100644 --- a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index 4aa1dc15fd..1936e8df23 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In, LessThan, MoreThan } from 'typeorm'; +import { In, LessThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, UserIpsRepository } from '@/models/index.js'; +import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; @@ -20,14 +20,14 @@ export class CleanProcessorService { @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.mutedNotesRepository) private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, private queueLoggerService: QueueLoggerService, private idService: IdService, @@ -43,8 +43,9 @@ export class CleanProcessorService { createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), }); - this.notificationsRepository.delete({ - createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), + this.mutedNotesRepository.delete({ + id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + reason: 'word', }); this.mutedNotesRepository.delete({ @@ -52,10 +53,24 @@ export class CleanProcessorService { reason: 'word', }); - this.antennaNotesRepository.delete({ - id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + // 7日以上使われてないアンテナを停止 + this.antennasRepository.update({ + lastUsedAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 7))), + }, { + isActive: false, }); + const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign') + .where('assign.expiresAt IS NOT NULL') + .andWhere('assign.expiresAt < :now', { now: new Date() }) + .getMany(); + + if (expiredRoleAssignments.length > 0) { + await this.roleAssignmentsRepository.delete({ + id: In(expiredRoleAssignments.map(x => x.id)), + }); + } + this.logger.succ('Cleaned.'); done(); } diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts index fa0c1733f6..604497cf54 100644 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -7,7 +7,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -31,7 +31,7 @@ export class DeleteDriveFilesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Deleting drive files of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 10fcb5684f..f293bd4d7e 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, InstancesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -8,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { Cache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; @@ -23,7 +22,7 @@ import type { DeliverJobData } from '../types.js'; @Injectable() export class DeliverProcessorService { private logger: Logger; - private suspendedHostsCache: Cache; + private suspendedHostsCache: MemorySingleCache; private latest: string | null; constructor( @@ -47,7 +46,7 @@ export class DeliverProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); - this.suspendedHostsCache = new Cache(1000 * 60 * 60); + this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); } @bindThis @@ -61,14 +60,14 @@ export class DeliverProcessorService { } // isSuspendedなら中断 - let suspendedHosts = this.suspendedHostsCache.get(null); + let suspendedHosts = this.suspendedHostsCache.get(); if (suspendedHosts == null) { suspendedHosts = await this.instancesRepository.find({ where: { isSuspended: true, }, }); - this.suspendedHostsCache.set(null, suspendedHosts); + this.suspendedHostsCache.set(suspendedHosts); } if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { return 'skip (suspended)'; @@ -80,19 +79,18 @@ export class DeliverProcessorService { // Update stats this.federatedInstanceService.fetch(host).then(i => { if (i.isNotResponding) { - this.instancesRepository.update(i.id, { - isNotResponding: false, - }); - this.federatedInstanceService.updateCachePartial(host, { + this.federatedInstanceService.update(i.id, { isNotResponding: false, }); } this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - - this.instanceChart.requestSent(i.host, true); this.apRequestChart.deliverSucc(); this.federationChart.deliverd(i.host, true); + + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestSent(i.host, true); + } }); return 'Success'; @@ -100,22 +98,31 @@ export class DeliverProcessorService { // Update stats this.federatedInstanceService.fetch(host).then(i => { if (!i.isNotResponding) { - this.instancesRepository.update(i.id, { - isNotResponding: true, - }); - this.federatedInstanceService.updateCachePartial(host, { + this.federatedInstanceService.update(i.id, { isNotResponding: true, }); } - this.instanceChart.requestSent(i.host, false); this.apRequestChart.deliverFail(); this.federationChart.deliverd(i.host, false); + + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestSent(i.host, false); + } }); if (res instanceof StatusError) { // 4xx if (res.isClientError) { + // 相手が閉鎖していることを明示しているため、配送停止する + if (job.data.isSharedInbox && res.statusCode === 410) { + this.federatedInstanceService.fetch(host).then(i => { + this.federatedInstanceService.update(i.id, { + isSuspended: true, + }); + }); + return `${host} is gone`; + } // HTTPステータスコード4xxはクライアントエラーであり、それはつまり // 何回再送しても成功することはないということなのでエラーにはしないでおく return `${res.statusCode} ${res.statusMessage}`; diff --git a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts index 21d2dc9efc..501ed4090a 100644 --- a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts +++ b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts @@ -1,14 +1,13 @@ import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { PollVotesRepository, NotesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; import type { EndedPollNotificationJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class EndedPollNotificationProcessorService { @@ -24,7 +23,7 @@ export class EndedPollNotificationProcessorService { @Inject(DI.pollVotesRepository) private pollVotesRepository: PollVotesRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('ended-poll-notification'); @@ -48,7 +47,7 @@ export class EndedPollNotificationProcessorService { const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])]; for (const userId of userIds) { - this.createNotificationService.createNotification(userId, 'pollEnded', { + this.notificationService.createNotification(userId, 'pollEnded', { noteId: note.id, }); } diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts new file mode 100644 index 0000000000..894903e79b --- /dev/null +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { format as DateFormat } from 'date-fns'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, User } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import Logger from '@/logger.js'; +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 { QueueLoggerService } from '../QueueLoggerService.js'; +import type { DBExportAntennasData } from '../types.js'; +import type Bull from 'bull'; + +@Injectable() +export class ExportAntennasProcessorService { + private logger: Logger; + + constructor ( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.antennasRepository) + private antennsRepository: AntennasRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private driveService: DriveService, + private utilityService: UtilityService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-antennas'); + } + + @bindThis + public async process(job: Bull.Job, done: () => void): Promise { + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + const [path, cleanup] = await createTemp(); + const stream = fs.createWriteStream(path, { flags: 'a' }); + const write = (input: string): Promise => { + return new Promise((resolve, reject) => { + stream.write(input, err => { + if (err) { + this.logger.error(err); + reject(); + } else { + resolve(); + } + }); + }); + }; + try { + const antennas = await this.antennsRepository.findBy({ userId: job.data.user.id }); + write('['); + for (const [index, antenna] of antennas.entries()) { + let users: User[] | undefined; + if (antenna.userListId !== null) { + const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId }); + users = await this.usersRepository.findBy({ + id: In(joinings.map(j => j.userId)), + }); + } + write(JSON.stringify({ + name: antenna.name, + src: antenna.src, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + users: antenna.users, + userListAccts: typeof users !== 'undefined' ? users.map((u) => { + return this.utilityService.getFullApAccount(u.username, u.host); // acct + }) : null, + caseSensitive: antenna.caseSensitive, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + notify: antenna.notify, + })); + if (antennas.length - 1 !== index) { + write(', '); + } + } + write(']'); + stream.end(); + + 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); + } finally { + cleanup(); + done(); + } + } +} + diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index 5b3c1a415b..c7b54070d6 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { MoreThan } from 'typeorm'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, DriveFilesRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; +import type { UsersRepository, BlockingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; @@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -36,7 +36,7 @@ export class ExportBlockingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting blocking of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -106,7 +106,7 @@ export class ExportBlockingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index df024a8f3c..b50f373ef8 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -1,8 +1,7 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; +import { IsNull } from 'typeorm'; import { format as dateFormat } from 'date-fns'; -import { ulid } from 'ulid'; import mime from 'mime-types'; import archiver from 'archiver'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index 3820705e5c..f2f2383a88 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; +import { MoreThan } from 'typeorm'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; import type { NoteFavorite, NoteFavoritesRepository, NotesRepository, PollsRepository, User, UsersRepository } from '@/models/index.js'; @@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; @Injectable() export class ExportFavoritesProcessorService { @@ -42,7 +42,7 @@ export class ExportFavoritesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting favorites of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -121,7 +121,7 @@ export class ExportFavoritesProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'favorites-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { @@ -148,6 +148,7 @@ function serialize(favorite: NoteFavorite & { note: Note & { user: User } }, pol visibility: favorite.note.visibility, visibleUserIds: favorite.note.visibleUserIds, localOnly: favorite.note.localOnly, + reactionAcceptance: favorite.note.reactionAcceptance, uri: favorite.note.uri, url: favorite.note.url, user: { diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 064b126e44..fa9c1ac1ea 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -12,7 +12,7 @@ import type { Following } from '@/models/entities/Following.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbExportFollowingData } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -40,7 +40,7 @@ export class ExportFollowingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting following of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -110,7 +110,7 @@ export class ExportFollowingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index 94c7ea8a46..b14bf5f5b1 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -39,7 +39,7 @@ export class ExportMutingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting muting of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -110,7 +110,7 @@ export class ExportMutingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 8431829e91..e4f12ad101 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; +import { MoreThan } from 'typeorm'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, PollsRepository, UsersRepository } from '@/models/index.js'; @@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { Poll } from '@/models/entities/Poll.js'; import type { Note } from '@/models/entities/Note.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; +import type { DbJobDataWithUser } from '../types.js'; @Injectable() export class ExportNotesProcessorService { @@ -39,7 +39,7 @@ export class ExportNotesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting notes of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -117,7 +117,7 @@ export class ExportNotesProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { @@ -141,5 +141,6 @@ function serialize(note: Note, poll: Poll | null = null): Record, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting user lists of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -86,7 +86,7 @@ export class ExportUserListsProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts new file mode 100644 index 0000000000..d06131b8c8 --- /dev/null +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -0,0 +1,96 @@ +import { Injectable, Inject } from '@nestjs/common'; +import Ajv from 'ajv'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import Logger from '@/logger.js'; +import type { AntennasRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import { DBAntennaImportJobData } from '../types.js'; +import type Bull from 'bull'; + +const validate = new Ajv().compile({ + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + src: { type: 'string', enum: ['home', 'all', 'users', 'list'] }, + userListAccts: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + keywords: { type: 'array', items: { + type: 'array', items: { + type: 'string', + }, + } }, + excludeKeywords: { type: 'array', items: { + type: 'array', items: { + type: 'string', + }, + } }, + users: { type: 'array', items: { + type: 'string', + } }, + caseSensitive: { type: 'boolean' }, + withReplies: { type: 'boolean' }, + withFile: { type: 'boolean' }, + notify: { type: 'boolean' }, + }, + required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], +}); + +@Injectable() +export class ImportAntennasProcessorService { + private logger: Logger; + + constructor ( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + private queueLoggerService: QueueLoggerService, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('import-antennas'); + } + + @bindThis + public async process(job: Bull.Job, done: () => void): Promise { + const now = new Date(); + try { + for (const antenna of job.data.antenna) { + if (antenna.keywords.length === 0 || antenna.keywords[0].every(x => x === '')) continue; + if (!validate(antenna)) { + this.logger.warn('Validation Failed'); + continue; + } + const result = await this.antennasRepository.insert({ + id: this.idService.genId(), + createdAt: now, + lastUsedAt: now, + userId: job.data.user.id, + name: antenna.name, + src: antenna.src === 'list' && antenna.userListAccts ? 'users' : antenna.src, + userListId: null, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + users: (antenna.src === 'list' && antenna.userListAccts !== null ? antenna.userListAccts : antenna.users).filter(Boolean), + caseSensitive: antenna.caseSensitive, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + notify: antenna.notify, + }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); + this.logger.succ('Antenna created: ' + result.id); + this.globalEventService.publishInternalEvent('antennaCreated', result); + } + } catch (err: any) { + this.logger.error(err); + } finally { + done(); + } + } +} diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts index 2eed420e96..3f075b02d2 100644 --- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts @@ -1,38 +1,31 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, DriveFilesRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; +import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserImportJobData } from '../types.js'; +import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; import { bindThis } from '@/decorators.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class ImportBlockingProcessorService { private logger: Logger; constructor( - @Inject(DI.config) - private config: Config, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + private queueService: QueueService, private utilityService: UtilityService, - private userBlockingService: UserBlockingService, private remoteUserResolveService: RemoteUserResolveService, private downloadService: DownloadService, private queueLoggerService: QueueLoggerService, @@ -59,46 +52,50 @@ export class ImportBlockingProcessorService { } const csv = await this.downloadService.downloadTextFile(file.url); + const targets = csv.trim().split('\n'); + this.queueService.createImportBlockingToDbJob({ id: user.id }, targets); - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - this.logger.info(`Block[${linenum}] ${target.id} ...`); - - await this.userBlockingService.block(user, target); - } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); - } - } - - this.logger.succ('Imported'); + this.logger.succ('Import jobs created'); done(); } + + @bindThis + public async processDb(job: Bull.Job): Promise { + const line = job.data.target; + const user = job.data.user; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + if (!host) return; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) return; + + if (target == null) { + target = await this.remoteUserResolveService.resolveUser(username, host); + } + + if (target == null) { + throw `Unable to resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) return; + + this.logger.info(`Block ${target.id} ...`); + + this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]); + } catch (e) { + this.logger.warn(`Error: ${e}`); + } + } } diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 2d43615e25..cf78d8330c 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan, DataSource } from 'typeorm'; +import { DataSource } from 'typeorm'; import unzipper from 'unzipper'; import { DI } from '@/di-symbols.js'; import type { EmojisRepository, DriveFilesRepository, UsersRepository } from '@/models/index.js'; @@ -86,6 +86,10 @@ export class ImportCustomEmojisProcessorService { continue; } const emojiInfo = record.emoji; + if (!/^[a-zA-Z0-9_]+$/.test(emojiInfo.name)) { + this.logger.error(`invalid emojiname: ${emojiInfo.name}`); + continue; + } const emojiPath = outputPath + '/' + record.fileName; await this.emojisRepository.delete({ name: emojiInfo.name, @@ -102,6 +106,7 @@ export class ImportCustomEmojisProcessorService { host: null, aliases: emojiInfo.aliases, driveFile, + license: emojiInfo.license, }); } diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts index b61846d747..aa5cf12c50 100644 --- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts @@ -1,35 +1,31 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserImportJobData } from '../types.js'; +import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; import { bindThis } from '@/decorators.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class ImportFollowingProcessorService { private logger: Logger; constructor( - @Inject(DI.config) - private config: Config, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + private queueService: QueueService, private utilityService: UtilityService, - private userFollowingService: UserFollowingService, private remoteUserResolveService: RemoteUserResolveService, private downloadService: DownloadService, private queueLoggerService: QueueLoggerService, @@ -56,46 +52,50 @@ export class ImportFollowingProcessorService { } const csv = await this.downloadService.downloadTextFile(file.url); + const targets = csv.trim().split('\n'); + this.queueService.createImportFollowingToDbJob({ id: user.id }, targets); - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - this.logger.info(`Follow[${linenum}] ${target.id} ...`); - - this.userFollowingService.follow(user, target); - } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); - } - } - - this.logger.succ('Imported'); + this.logger.succ('Import jobs created'); done(); } + + @bindThis + public async processDb(job: Bull.Job): Promise { + const line = job.data.target; + const user = job.data.user; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + if (!host) return; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) return; + + if (target == null) { + target = await this.remoteUserResolveService.resolveUser(username, host); + } + + if (target == null) { + throw `Unable to resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) return; + + this.logger.info(`Follow ${target.id} ...`); + + this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true }]); + } catch (e) { + this.logger.warn(`Error: ${e}`); + } + } } diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts index 21236da2ef..379994ee79 100644 --- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -66,11 +66,13 @@ export class ImportMutingProcessorService { const acct = line.split(',')[0].trim(); const { username, host } = Acct.parse(acct); - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ + if (!host) continue; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ host: IsNull(), usernameLower: username.toLowerCase(), }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), + host: this.utilityService.toPuny(host), usernameLower: username.toLowerCase(), }); diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts index a9672250c8..c423863410 100644 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, DriveFilesRepository, UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index fb4ab0c62e..ab8b1e9e22 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -1,6 +1,5 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; import httpSignature from '@peertube/http-signature'; import { DI } from '@/di-symbols.js'; import type { InstancesRepository, DriveFilesRepository } from '@/models/index.js'; @@ -10,8 +9,6 @@ import { MetaService } from '@/core/MetaService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { Cache } from '@/misc/cache.js'; -import type { Instance } from '@/models/entities/Instance.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; import FederationChart from '@/core/chart/charts/federation.js'; @@ -27,7 +24,7 @@ import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DeliverJobData, InboxJobData } from '../types.js'; +import type { InboxJobData } from '../types.js'; // ユーザーのinboxにアクティビティが届いた時の処理 @Injectable() @@ -67,7 +64,7 @@ export class InboxProcessorService { const activity = job.data.activity; //#region Log - const info = Object.assign({}, activity) as any; + const info = Object.assign({}, activity); delete info['@context']; this.logger.debug(JSON.stringify(info, null, 2)); //#endregion @@ -87,9 +84,9 @@ export class InboxProcessorService { // HTTP-Signature keyIdを元にDBから取得 let authUser: { - user: RemoteUser; - key: UserPublickey | null; - } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); + user: RemoteUser; + key: UserPublickey | null; + } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 if (authUser == null) { @@ -177,19 +174,19 @@ export class InboxProcessorService { // Update stats this.federatedInstanceService.fetch(authUser.user.host).then(i => { - this.instancesRepository.update(i.id, { + this.federatedInstanceService.update(i.id, { latestRequestReceivedAt: new Date(), isNotResponding: false, }); - this.federatedInstanceService.updateCachePartial(host, { - isNotResponding: false, - }); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - this.instanceChart.requestReceived(i.host); this.apRequestChart.inbox(); this.federationChart.inbox(i.host); + + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestReceived(i.host); + } }); // アクティビティを処理 diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts new file mode 100644 index 0000000000..ff454df455 --- /dev/null +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -0,0 +1,69 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Bull from 'bull'; + +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { bindThis } from '@/decorators.js'; +import type Logger from '@/logger.js'; + +import { QueueLoggerService } from '../QueueLoggerService.js'; +import { RelationshipJobData } from '../types.js'; +import type { UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; + +@Injectable() +export class RelationshipProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private queueLoggerService: QueueLoggerService, + private userFollowingService: UserFollowingService, + private userBlockingService: UserBlockingService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('follow-block'); + } + + @bindThis + public async processFollow(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id}`); + await this.userFollowingService.follow(job.data.from, job.data.to, job.data.requestId, job.data.silent); + return 'ok'; + } + + @bindThis + public async processUnfollow(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to unfollow ${job.data.to.id}`); + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser]; + await this.userFollowingService.unfollow(follower, followee, job.data.silent); + return 'ok'; + } + + @bindThis + public async processBlock(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to block ${job.data.to.id}`); + const [blockee, blocker] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]); + await this.userBlockingService.block(blockee, blocker, job.data.silent); + return 'ok'; + } + + @bindThis + public async processUnblock(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to unblock ${job.data.to.id}`); + const [blockee, blocker] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]); + await this.userBlockingService.unblock(blockee, blocker); + return 'ok'; + } +} diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts index 74e7c632d5..e5840f3da8 100644 --- a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts index 751e02dc20..7ff84c15a5 100644 --- a/packages/backend/src/queue/processors/TickChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/TickChartsProcessorService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts index 57210b25d2..84a5c21c49 100644 --- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { WebhooksRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -40,6 +39,7 @@ export class WebhookDeliverProcessorService { 'X-Misskey-Host': this.config.host, 'X-Misskey-Hook-Id': job.data.webhookId, 'X-Misskey-Hook-Secret': job.data.secret, + 'Content-Type': 'application/json', }, body: JSON.stringify({ hookId: job.data.webhookId, diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 1214c9eb95..776dd3aa12 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -1,3 +1,4 @@ +import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; @@ -12,6 +13,8 @@ export type DeliverJobData = { content: unknown; /** inbox URL to deliver */ to: string; + /** whether it is sharedInbox */ + isSharedInbox: boolean; }; export type InboxJobData = { @@ -19,14 +22,50 @@ export type InboxJobData = { signature: httpSignature.IParsedSignature; }; -export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobData; +export type RelationshipJobData = { + from: ThinUser; + to: ThinUser; + silent?: boolean; + requestId?: string; +} -export type DbUserJobData = { +export type DbJobData = DbJobMap[T]; + +export type DbJobMap = { + deleteDriveFiles: DbJobDataWithUser; + exportCustomEmojis: DbJobDataWithUser; + exportAntennas: DBExportAntennasData; + exportNotes: DbJobDataWithUser; + exportFavorites: DbJobDataWithUser; + exportFollowing: DbExportFollowingData; + exportMuting: DbJobDataWithUser; + exportBlocking: DbJobDataWithUser; + exportUserLists: DbJobDataWithUser; + importAntennas: DBAntennaImportJobData; + importFollowing: DbUserImportJobData; + importFollowingToDb: DbUserImportToDbJobData; + importMuting: DbUserImportJobData; + importBlocking: DbUserImportJobData; + importBlockingToDb: DbUserImportToDbJobData; + importUserLists: DbUserImportJobData; + importCustomEmojis: DbUserImportJobData; + deleteAccount: DbUserDeleteJobData; +} + +export type DbJobDataWithUser = { + user: ThinUser; +} + +export type DbExportFollowingData = { user: ThinUser; excludeMuting: boolean; excludeInactive: boolean; }; +export type DBExportAntennasData = { + user: ThinUser +} + export type DbUserDeleteJobData = { user: ThinUser; soft?: boolean; @@ -37,6 +76,16 @@ export type DbUserImportJobData = { fileId: DriveFile['id']; }; +export type DBAntennaImportJobData = { + user: ThinUser, + antenna: Antenna +} + +export type DbUserImportToDbJobData = { + user: ThinUser; + target: string; +}; + export type ObjectStorageJobData = ObjectStorageFileJobData | Record; export type ObjectStorageFileJobData = { diff --git a/packages/backend/src/redis.ts b/packages/backend/src/redis.ts deleted file mode 100644 index 690f4715dd..0000000000 --- a/packages/backend/src/redis.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Redis from 'ioredis'; -import { Config } from '@/config.js'; - -export function createRedisConnection(config: Config): Redis.Redis { - return new Redis({ - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - keyPrefix: `${config.redis.prefix}:`, - db: config.redis.db ?? 0, - }); -} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index da8d0114e5..e675d9cf1b 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -6,13 +6,13 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; import accepts from 'accepts'; import vary from 'vary'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; +import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/index.js'; import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { LocalUser, User } from '@/models/entities/User.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import type { Following } from '@/models/entities/Following.js'; import { countIf } from '@/misc/prelude/array.js'; import type { Note } from '@/models/entities/Note.js'; @@ -54,11 +54,14 @@ export class ActivityPubServerService { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + private utilityService: UtilityService, private userEntityService: UserEntityService, private apRendererService: ApRendererService, private queueService: QueueService, - private userKeypairStoreService: UserKeypairStoreService, + private userKeypairService: UserKeypairService, private queryService: QueryService, ) { //this.createServer = this.createServer.bind(this); @@ -205,22 +208,22 @@ export class ActivityPubServerService { reply.code(400); return; } - + const page = request.query.page === 'true'; - + const user = await this.usersRepository.findOneBy({ id: userId, host: IsNull(), }); - + if (user == null) { reply.code(404); return; } - + //#region Check ff visibility const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - + if (profile.ffVisibility === 'private') { reply.code(403); reply.header('Cache-Control', 'public, max-age=30'); @@ -231,31 +234,31 @@ export class ActivityPubServerService { return; } //#endregion - + const limit = 10; const partOf = `${this.config.url}/users/${userId}/following`; - + if (page) { const query = { followerId: user.id, } as FindOptionsWhere; - + // カーソルが指定されている場合 if (cursor) { query.id = LessThan(cursor); } - + // Get followings const followings = await this.followingsRepository.find({ where: query, take: limit + 1, order: { id: -1 }, }); - + // 「次のページ」があるかどうか const inStock = followings.length === limit + 1; if (inStock) followings.pop(); - + const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); const rendered = this.apRendererService.renderOrderedCollectionPage( `${partOf}?${url.query({ @@ -269,7 +272,7 @@ export class ActivityPubServerService { cursor: followings[followings.length - 1].id, })}` : undefined, ); - + this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } else { @@ -330,33 +333,33 @@ export class ActivityPubServerService { reply.code(400); return; } - + const untilId = request.query.until_id; if (untilId != null && typeof untilId !== 'string') { reply.code(400); return; } - + const page = request.query.page === 'true'; - + if (countIf(x => x != null, [sinceId, untilId]) > 1) { reply.code(400); return; } - + const user = await this.usersRepository.findOneBy({ id: userId, host: IsNull(), }); - + if (user == null) { reply.code(404); return; } - + const limit = 20; const partOf = `${this.config.url}/users/${userId}/outbox`; - + if (page) { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) .andWhere('note.userId = :userId', { userId: user.id }) @@ -365,11 +368,11 @@ export class ActivityPubServerService { .orWhere('note.visibility = \'home\''); })) .andWhere('note.localOnly = FALSE'); - + const notes = await query.take(limit).getMany(); - + if (sinceId) notes.reverse(); - + const activities = await Promise.all(notes.map(note => this.packActivity(note))); const rendered = this.apRendererService.renderOrderedCollectionPage( `${partOf}?${url.query({ @@ -387,7 +390,7 @@ export class ActivityPubServerService { until_id: notes[notes.length - 1].id, })}` : undefined, ); - + this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } else { @@ -457,7 +460,7 @@ export class ActivityPubServerService { // note fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { vary(reply.raw, 'Accept'); - + const note = await this.notesRepository.findOneBy({ id: request.params.note, visibility: In(['public', 'home']), @@ -540,7 +543,7 @@ export class ActivityPubServerService { return; } - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); if (this.userEntityService.isLocalUser(user)) { reply.header('Cache-Control', 'public, max-age=180'); @@ -627,7 +630,42 @@ export class ActivityPubServerService { id: request.params.followee, host: Not(IsNull()), }), - ]); + ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null]; + + if (follower == null || followee == null) { + reply.code(404); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); + }); + + // follow + fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => { + // 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. + + const followRequest = await this.followRequestsRepository.findOneBy({ + id: request.params.followRequestId, + }); + + if (followRequest == null) { + reply.code(404); + return; + } + + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneBy({ + id: followRequest.followerId, + host: IsNull(), + }), + this.usersRepository.findOneBy({ + id: followRequest.followeeId, + host: Not(IsNull()), + }), + ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null]; if (follower == null || followee == null) { reply.code(404); diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index f4bc568fdc..98329ddffa 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -2,7 +2,6 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; -import fastifyStatic from '@fastify/static'; import rename from 'rename'; import type { Config } from '@/config.js'; import type { DriveFile, DriveFilesRepository } from '@/models/index.js'; @@ -22,6 +21,8 @@ import { bindThis } from '@/decorators.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import { isMimeImage } from '@/misc/is-mime-image.js'; import sharp from 'sharp'; +import { sharpBmp } from 'sharp-read-bmp'; +import { correctFilename } from '@/misc/correct-filename.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -51,15 +52,6 @@ export class FileServerService { //this.createServer = this.createServer.bind(this); } - @bindThis - public commonReadableHandlerGenerator(reply: FastifyReply) { - return (err: Error): void => { - this.logger.error(err); - reply.code(500); - reply.header('Cache-Control', 'max-age=300'); - }; - } - @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { fastify.addHook('onRequest', (request, reply, done) => { @@ -67,11 +59,6 @@ export class FileServerService { done(); }); - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - fastify.get('/files/app-default.jpg', (request, reply) => { const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); reply.header('Content-Type', 'image/jpeg'); @@ -140,7 +127,7 @@ export class FileServerService { let image: IImageStreamable | null = null; if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image')) { + if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { reply.header('Cache-Control', 'max-age=31536000, immutable'); const url = new URL(`${this.config.mediaProxy}/static.webp`); @@ -190,13 +177,19 @@ export class FileServerService { } reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext) + ) + ); return image.data; } if (file.fileRole !== 'original') { - const filename = rename(file.file.name, { + const filename = rename(file.filename, { suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : undefined, + extname: file.ext ? `.${file.ext}` : '.unknown', }).toString(); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); @@ -204,12 +197,10 @@ export class FileServerService { reply.header('Content-Disposition', contentDisposition('inline', filename)); return fs.createReadStream(file.path); } else { - const stream = fs.createReadStream(file.path); - stream.on('error', this.commonReadableHandlerGenerator(reply)); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.file.name)); - return stream; + reply.header('Content-Disposition', contentDisposition('inline', file.filename)); + return fs.createReadStream(file.path); } } catch (e) { if ('cleanup' in file) file.cleanup(); @@ -226,7 +217,10 @@ export class FileServerService { return; } - if (this.config.externalMediaProxyEnabled) { + // アバタークロップなど、どうしてもオリジンである必要がある場合 + const mustOrigin = 'origin' in request.query; + + if (this.config.externalMediaProxyEnabled && !mustOrigin) { // 外部のメディアプロキシが有効なら、そちらにリダイレクト reply.header('Cache-Control', 'public, max-age=259200'); // 3 days @@ -258,8 +252,8 @@ export class FileServerService { } try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image'); + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); if ( 'emoji' in request.query || @@ -283,7 +277,7 @@ export class FileServerService { type: file.mime, }; } else { - const data = sharp(file.path, { animated: !('static' in request.query) }) + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) .resize({ height: 'emoji' in request.query ? 128 : 320, withoutEnlargement: true, @@ -297,13 +291,14 @@ export class FileServerService { }; } } else if ('static' in request.query) { - image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); } else if ('preview' in request.query) { - image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); } else if ('badge' in request.query) { - const mask = sharp(file.path) + const mask = (await sharpBmp(file.path, file.mime)) .resize(96, 96, { - fit: 'inside', + fit: 'contain', + position: 'centre', withoutEnlargement: false, }) .greyscale() @@ -311,20 +306,20 @@ export class FileServerService { .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast .flatten({ background: '#000' }) .toColorspace('b-w'); - + const stats = await mask.clone().stats(); - + if (stats.entropy < 0.1) { // エントロピーがあまりない場合は404にする throw new StatusError('Skip to provide badge', 404); } - + const data = sharp({ create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, }) .pipelineColorspace('b-w') .boolean(await mask.png().toBuffer(), 'eor'); - + image = { data: await data.png().toBuffer(), ext: 'png', @@ -357,6 +352,12 @@ export class FileServerService { reply.header('Content-Type', image.type); reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext) + ) + ); return image.data; } catch (e) { if ('cleanup' in file) file.cleanup(); @@ -366,8 +367,8 @@ export class FileServerService { @bindThis private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } + { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } + | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' > { @@ -383,19 +384,20 @@ export class FileServerService { @bindThis private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; } + { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } > { const [path, cleanup] = await createTemp(); try { - await this.downloadService.downloadUrl(url, path); + const { filename } = await this.downloadService.downloadUrl(url, path); const { mime, ext } = await this.fileInfoService.detectType(path); - + return { state: 'remote', mime, ext, path, cleanup, - } + filename, + }; } catch (e) { cleanup(); throw e; @@ -404,8 +406,8 @@ export class FileServerService { @bindThis private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } + { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } + | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' > { @@ -429,7 +431,8 @@ export class FileServerService { url: file.uri, fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', file, - } + filename: file.name, + }; } const path = this.internalStorageService.resolvePath(key); @@ -440,6 +443,7 @@ export class FileServerService { state: 'stored_internal', fileRole: isThumbnail ? 'thumbnail' : 'webpublic', file, + filename: file.name, mime, ext, path, }; @@ -449,9 +453,11 @@ export class FileServerService { state: 'stored_internal', fileRole: 'original', file, - mime: file.type, + filename: file.name, + // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる + mime: this.fileInfoService.fixMime(file.type), ext: null, path, - } + }; } } diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 58145dc9d6..a13a3c7c62 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -1,11 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { Cache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import NotesChart from '@/core/chart/charts/notes.js'; @@ -101,7 +100,7 @@ export class NodeinfoServerService { email: meta.maintainerEmail, }, langs: meta.langs, - tosUrl: meta.ToSUrl, + tosUrl: meta.termsOfServiceUrl, repositoryUrl: meta.repositoryUrl, feedbackUrl: meta.feedbackUrl, disableRegistration: meta.disableRegistration, @@ -119,17 +118,17 @@ export class NodeinfoServerService { }; }; - const cache = new Cache>>(1000 * 60 * 10); + const cache = new MemorySingleCache>>(1000 * 60 * 10); fastify.get(nodeinfo2_1path, async (request, reply) => { - const base = await cache.fetch(null, () => nodeinfo2()); + const base = await cache.fetch(() => nodeinfo2()); reply.header('Cache-Control', 'public, max-age=600'); return { version: '2.1', ...base }; }); fastify.get(nodeinfo2_0path, async (request, reply) => { - const base = await cache.fetch(null, () => nodeinfo2()); + const base = await cache.fetch(() => nodeinfo2()); delete (base as any).software.repository; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index b605f3c8ab..275e67fa8a 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -35,6 +35,9 @@ import { MessagingChannelService } from './api/stream/channels/messaging.js'; import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; +import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; +import { ClientLoggerService } from './web/ClientLoggerService.js'; +import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; @Module({ imports: [ @@ -43,6 +46,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; ], providers: [ ClientServerService, + ClientLoggerService, FeedService, UrlPreviewService, ActivityPubServerService, @@ -68,6 +72,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; DriveChannelService, GlobalTimelineChannelService, HashtagChannelService, + RoleTimelineChannelService, HomeTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, @@ -76,6 +81,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; QueueStatsChannelService, ServerStatsChannelService, UserListChannelService, + OpenApiServerService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index f76871c60f..33c5253439 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -1,14 +1,15 @@ import cluster from 'node:cluster'; import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import Fastify from 'fastify'; +import { fileURLToPath } from 'node:url'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import Fastify, { FastifyInstance } from 'fastify'; +import fastifyStatic from '@fastify/static'; import { IsNull } from 'typeorm'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; import type { EmojisRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; -import { envOption } from '@/env.js'; import * as Acct from '@/misc/acct.js'; import { genIdenticon } from '@/misc/gen-identicon.js'; import { createTemp } from '@/misc/create-temp.js'; @@ -22,10 +23,14 @@ import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; +import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; + +const _dirname = fileURLToPath(new URL('.', import.meta.url)); @Injectable() -export class ServerService { +export class ServerService implements OnApplicationShutdown { private logger: Logger; + #fastify: FastifyInstance; constructor( @Inject(DI.config) @@ -42,6 +47,7 @@ export class ServerService { private userEntityService: UserEntityService, private apiServerService: ApiServerService, + private openApiServerService: OpenApiServerService, private streamingApiServerService: StreamingApiServerService, private activityPubServerService: ActivityPubServerService, private wellKnownServerService: WellKnownServerService, @@ -55,11 +61,12 @@ export class ServerService { } @bindThis - public launch() { + public async launch() { const fastify = Fastify({ trustProxy: true, logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''), }); + this.#fastify = fastify; // HSTS // 6months (15552000sec) @@ -67,16 +74,24 @@ export class ServerService { fastify.addHook('onRequest', (request, reply, done) => { reply.header('strict-transport-security', 'max-age=15552000; preload'); done(); - }); + }); } + // Register non-serving static server so that the child services can use reply.sendFile. + // `root` here is just a placeholder and each call must use its own `rootPath`. + fastify.register(fastifyStatic, { + root: _dirname, + serve: false, + }); + fastify.register(this.apiServerService.createServer, { prefix: '/api' }); + fastify.register(this.openApiServerService.createServer); fastify.register(this.fileServerService.createServer); fastify.register(this.activityPubServerService.createServer); fastify.register(this.nodeinfoServerService.createServer); fastify.register(this.wellKnownServerService.createServer); - fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { + fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { const path = request.params.path; reply.header('Cache-Control', 'public, max-age=86400'); @@ -106,11 +121,19 @@ export class ServerService { } } - const url = new URL(`${this.config.mediaProxy}/emoji.webp`); - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); - url.searchParams.set('emoji', '1'); - if ('static' in request.query) url.searchParams.set('static', '1'); + let url: URL; + if ('badge' in request.query) { + url = new URL(`${this.config.mediaProxy}/emoji.png`); + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); + url.searchParams.set('badge', '1'); + } else { + url = new URL(`${this.config.mediaProxy}/emoji.webp`); + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); + url.searchParams.set('emoji', '1'); + if ('static' in request.query) url.searchParams.set('static', '1'); + } return await reply.redirect( 301, @@ -126,13 +149,12 @@ export class ServerService { host: (host == null) || (host === this.config.host) ? IsNull() : host, isSuspended: false, }, - relations: ['avatar'], }); reply.header('Cache-Control', 'public, max-age=86400'); if (user) { - reply.redirect(this.userEntityService.getAvatarUrlSync(user)); + reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); } else { reply.redirect('/static-assets/user-unknown.png'); } @@ -196,5 +218,11 @@ export class ServerService { }); fastify.listen({ port: this.config.port, host: '0.0.0.0' }); + + await fastify.ready(); + } + + async onApplicationShutdown(signal: string): Promise { + await this.#fastify.close(); } } diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index 9bfd216ccb..9bf8deb221 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, MoreThan } from 'typeorm'; +import { IsNull } from 'typeorm'; import vary from 'vary'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; @@ -8,6 +8,7 @@ import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; import type { User } from '@/models/entities/User.js'; import * as Acct from '@/misc/acct.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { FindOptionsWhere } from 'typeorm'; import { bindThis } from '@/decorators.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @@ -23,6 +24,7 @@ export class WellKnownServerService { private usersRepository: UsersRepository, private nodeinfoServerService: NodeinfoServerService, + private userEntityService: UserEntityService, ) { //this.createServer = this.createServer.bind(this); } @@ -130,7 +132,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => { const self = { rel: 'self', type: 'application/activity+json', - href: `${this.config.url}/users/${user.id}`, + href: this.userEntityService.genLocalUserUri(user.id), }; const profilePage = { rel: 'http://webfinger.net/rel/profile-page', diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 4a8b8a1c12..e3483c82c6 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -1,8 +1,8 @@ -import { performance } from 'perf_hooks'; import { pipeline } from 'node:stream'; import * as fs from 'node:fs'; import { promisify } from 'node:util'; import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuid } from 'uuid'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { LocalUser, User } from '@/models/entities/User.js'; @@ -75,7 +75,7 @@ export class ApiCallService implements OnApplicationShutdown { } this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -100,18 +100,21 @@ export class ApiCallService implements OnApplicationShutdown { request: FastifyRequest<{ Body: Record, Querystring: Record }>, reply: FastifyReply, ) { - const multipartData = await request.file(); + const multipartData = await request.file().catch(() => { + /* Fastify throws if the remote didn't send multipart data. Return 400 below. */ + }); if (multipartData == null) { reply.code(400); + reply.send(); return; } const [path] = await createTemp(); await pump(multipartData.file, fs.createWriteStream(path)); - const fields = {} as Record; + const fields = {} as Record; for (const [k, v] of Object.entries(multipartData.fields)) { - fields[k] = v.value; + fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; } const token = fields['i']; @@ -126,7 +129,7 @@ export class ApiCallService implements OnApplicationShutdown { }, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -220,8 +223,8 @@ export class ApiCallService implements OnApplicationShutdown { const limit = Object.assign({}, ep.meta.limit); - if (!limit.key) { - limit.key = ep.name; + if (limit.key == null) { + (limit as any).key = ep.name; } // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい @@ -258,6 +261,17 @@ export class ApiCallService implements OnApplicationShutdown { } } + if (ep.meta.prohibitMoved) { + if (user?.movedToUri) { + throw new ApiError({ + message: 'You have moved your account.', + code: 'YOUR_ACCOUNT_MOVED', + id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', + httpStatusCode: 403, + }); + } + } + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { @@ -318,9 +332,10 @@ export class ApiCallService implements OnApplicationShutdown { // API invoking return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { - if (err instanceof ApiError) { + if (err instanceof ApiError || err instanceof AuthenticationError) { throw err; } else { + const errId = uuid(); this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { ep: ep.name, ps: data, @@ -328,14 +343,15 @@ export class ApiCallService implements OnApplicationShutdown { message: err.message, code: err.name, stack: err.stack, + id: errId, }, }); - console.error(err); + console.error(err, errId); throw new ApiError(null, { e: { message: err.message, code: err.name, - stack: err.stack, + id: errId, }, }); } diff --git a/packages/backend/src/server/api/ApiLoggerService.ts b/packages/backend/src/server/api/ApiLoggerService.ts index cabd65fd3e..7f534b1efd 100644 --- a/packages/backend/src/server/api/ApiLoggerService.ts +++ b/packages/backend/src/server/api/ApiLoggerService.ts @@ -1,7 +1,6 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ApiLoggerService { diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index e406949cd4..d3b1c7786d 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -2,13 +2,13 @@ import { Inject, Injectable } from '@nestjs/common'; import cors from '@fastify/cors'; import multipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; -import { ModuleRef, repl } from '@nestjs/core'; +import { ModuleRef } from '@nestjs/core'; import type { Config } from '@/config.js'; import type { UsersRepository, InstancesRepository, AccessTokensRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import endpoints, { IEndpoint } from './endpoints.js'; +import endpoints from './endpoints.js'; import { ApiCallService } from './ApiCallService.js'; import { SignupApiService } from './SignupApiService.js'; import { SigninApiService } from './SigninApiService.js'; @@ -73,28 +73,32 @@ export class ApiServerService { Params: { endpoint: string; }, Body: Record, Querystring: Record, - }>('/' + endpoint.name, (request, reply) => { + }>('/' + endpoint.name, async (request, reply) => { if (request.method === 'GET' && !endpoint.meta.allowGet) { reply.code(405); reply.send(); return; } - - this.apiCallService.handleMultipartRequest(ep, request, reply); + + // Await so that any error can automatically be translated to HTTP 500 + await this.apiCallService.handleMultipartRequest(ep, request, reply); + return reply; }); } else { fastify.all<{ Params: { endpoint: string; }, Body: Record, Querystring: Record, - }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, (request, reply) => { + }>('/' + endpoint.name, { bodyLimit: 1024 * 1024 }, async (request, reply) => { if (request.method === 'GET' && !endpoint.meta.allowGet) { reply.code(405); reply.send(); return; } - - this.apiCallService.handleRequest(ep, request, reply); + + // Await so that any error can automatically be translated to HTTP 500 + await this.apiCallService.handleRequest(ep, request, reply); + return reply; }); } } @@ -160,6 +164,22 @@ export class ApiServerService { } }); + // Make sure any unknown path under /api returns HTTP 404 Not Found, + // because otherwise ClientServerService will return the base client HTML + // page with HTTP 200. + fastify.get('/*', (request, reply) => { + reply.code(404); + // Mock ApiCallService.send's error handling + reply.send({ + error: { + message: 'Unknown API endpoint.', + code: 'UNKNOWN_API_ENDPOINT', + id: '2ca3b769-540a-4f08-9dd5-b5a825b6d0f1', + kind: 'client', + }, + }); + }); + done(); } } diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 87438c348d..6548c475b2 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -3,9 +3,9 @@ import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js'; import type { LocalUser } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { App } from '@/models/entities/App.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import isNativeToken from '@/misc/is-native-token.js'; import { bindThis } from '@/decorators.js'; @@ -18,7 +18,7 @@ export class AuthenticationError extends Error { @Injectable() export class AuthenticateService { - private appCache: Cache; + private appCache: MemoryKVCache; constructor( @Inject(DI.usersRepository) @@ -30,9 +30,9 @@ export class AuthenticateService { @Inject(DI.appsRepository) private appsRepository: AppsRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, ) { - this.appCache = new Cache(Infinity); + this.appCache = new MemoryKVCache(Infinity); } @bindThis @@ -42,7 +42,7 @@ export class AuthenticateService { } if (isNativeToken(token)) { - const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token, + const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); if (user == null) { @@ -67,7 +67,7 @@ export class AuthenticateService { lastUsedAt: new Date(), }); - const user = await this.userCacheService.localUserByIdCache.fetch(accessToken.userId, + const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId, () => this.usersRepository.findOneBy({ id: accessToken.userId, }) as Promise); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 4a55c6cbe3..0d1af08e95 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -29,6 +29,7 @@ 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'; @@ -42,6 +43,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; +import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; @@ -66,6 +68,7 @@ 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___announcements from './endpoints/announcements.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; @@ -93,6 +96,10 @@ 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'; @@ -113,6 +120,9 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___clips_favorite from './endpoints/clips/favorite.js'; +import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; +import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -170,6 +180,7 @@ 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'; @@ -183,6 +194,7 @@ 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_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'; @@ -191,6 +203,7 @@ 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_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; @@ -213,6 +226,7 @@ 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_userGroupInvites from './endpoints/i/user-group-invites.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'; @@ -225,10 +239,14 @@ import * as ep___messaging_messages_delete from './endpoints/messaging/messages/ import * as ep___messaging_messages_read from './endpoints/messaging/messages/read.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'; @@ -262,7 +280,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -283,6 +300,10 @@ 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'; @@ -330,6 +351,7 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.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___retention from './endpoints/retention.js'; import { GetterService } from './GetterService.js'; @@ -364,6 +386,7 @@ const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass: 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 }; @@ -377,6 +400,7 @@ const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useCla const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default }; +const $admin_queue_promote: Provider = { provide: 'ep:admin/queue/promote', useClass: ep___admin_queue_promote.default }; const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default }; const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default }; const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; @@ -401,6 +425,7 @@ const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useCla 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 $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.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 }; @@ -428,6 +453,10 @@ const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___c const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; +const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; +const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; +const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; +const $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 }; @@ -448,6 +477,9 @@ const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_l const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default }; const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default }; const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default }; +const $clips_favorite: Provider = { provide: 'ep:clips/favorite', useClass: ep___clips_favorite.default }; +const $clips_unfavorite: Provider = { provide: 'ep:clips/unfavorite', useClass: ep___clips_unfavorite.default }; +const $clips_myFavorites: Provider = { provide: 'ep:clips/my-favorites', useClass: ep___clips_myFavorites.default }; const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default }; const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default }; const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default }; @@ -505,6 +537,7 @@ const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___ 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 }; @@ -518,6 +551,7 @@ const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_ const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.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 }; @@ -526,6 +560,7 @@ const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: 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_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 }; @@ -548,6 +583,7 @@ const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.defau 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_userGroupInvites: Provider = { provide: 'ep:i/user-group-invites', useClass: ep___i_userGroupInvites.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 }; @@ -560,10 +596,14 @@ const $messaging_messages_delete: Provider = { provide: 'ep:messaging/messages/d const $messaging_messages_read: Provider = { provide: 'ep:messaging/messages/read', useClass: ep___messaging_messages_read.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 }; @@ -597,7 +637,6 @@ const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep__ const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; -const $notifications_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.default }; const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; @@ -618,6 +657,10 @@ const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___ 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 }; @@ -665,6 +708,7 @@ const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___use const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.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 $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; @@ -703,6 +747,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_removeAliasesBulk, $admin_emoji_setAliasesBulk, $admin_emoji_setCategoryBulk, + $admin_emoji_setLicenseBulk, $admin_emoji_update, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, @@ -716,6 +761,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_queue_clear, $admin_queue_deliverDelayed, $admin_queue_inboxDelayed, + $admin_queue_promote, $admin_queue_stats, $admin_relays_add, $admin_relays_list, @@ -740,6 +786,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_roles_assign, $admin_roles_unassign, $admin_roles_updateDefaultPolicies, + $admin_roles_users, $announcements, $antennas_create, $antennas_delete, @@ -767,6 +814,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_timeline, $channels_unfollow, $channels_update, + $channels_favorite, + $channels_unfavorite, + $channels_myFavorites, + $channels_search, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -787,6 +838,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $clips_notes, $clips_show, $clips_update, + $clips_favorite, + $clips_unfavorite, + $clips_myFavorites, $drive, $drive_files, $drive_files_attachedNotes, @@ -844,6 +898,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_2fa_passwordLess, $i_2fa_registerKey, $i_2fa_register, + $i_2fa_updateKey, $i_2fa_removeKey, $i_2fa_unregister, $i_apps, @@ -857,6 +912,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_exportNotes, $i_exportFavorites, $i_exportUserLists, + $i_exportAntennas, $i_favorites, $i_gallery_likes, $i_gallery_posts, @@ -865,6 +921,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_importFollowing, $i_importMuting, $i_importUserLists, + $i_importAntennas, $i_notifications, $i_pageLikes, $i_pages, @@ -887,6 +944,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_updateEmail, $i_update, $i_userGroupInvites, + $i_move, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -899,10 +957,14 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $messaging_messages_read, $meta, $emojis, + $emoji, $miauth_genToken, $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, @@ -936,7 +998,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_userListTimeline, $notifications_create, $notifications_markAllAsRead, - $notifications_read, $pagePush, $pages_create, $pages_delete, @@ -957,6 +1018,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $ping, $pinnedUsers, $promo_read, + $roles_list, + $roles_show, + $roles_users, + $roles_notes, $requestResetPassword, $resetDb, $resetPassword, @@ -1004,6 +1069,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_show, $users_stats, $users_achievements, + $users_updateMemo, $fetchRss, $retention, ], @@ -1036,6 +1102,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_removeAliasesBulk, $admin_emoji_setAliasesBulk, $admin_emoji_setCategoryBulk, + $admin_emoji_setLicenseBulk, $admin_emoji_update, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, @@ -1049,6 +1116,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_queue_clear, $admin_queue_deliverDelayed, $admin_queue_inboxDelayed, + $admin_queue_promote, $admin_queue_stats, $admin_relays_add, $admin_relays_list, @@ -1073,6 +1141,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_roles_assign, $admin_roles_unassign, $admin_roles_updateDefaultPolicies, + $admin_roles_users, $announcements, $antennas_create, $antennas_delete, @@ -1100,6 +1169,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_timeline, $channels_unfollow, $channels_update, + $channels_favorite, + $channels_unfavorite, + $channels_myFavorites, + $channels_search, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -1120,6 +1193,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $clips_notes, $clips_show, $clips_update, + $clips_favorite, + $clips_unfavorite, + $clips_myFavorites, $drive, $drive_files, $drive_files_attachedNotes, @@ -1177,6 +1253,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_2fa_passwordLess, $i_2fa_registerKey, $i_2fa_register, + $i_2fa_updateKey, $i_2fa_removeKey, $i_2fa_unregister, $i_apps, @@ -1190,6 +1267,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_exportNotes, $i_exportFavorites, $i_exportUserLists, + $i_exportAntennas, $i_favorites, $i_gallery_likes, $i_gallery_posts, @@ -1198,6 +1276,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_importFollowing, $i_importMuting, $i_importUserLists, + $i_importAntennas, $i_notifications, $i_pageLikes, $i_pages, @@ -1219,7 +1298,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, - $i_userGroupInvites, + $i_move, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1232,10 +1311,14 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $messaging_messages_read, $meta, $emojis, + $emoji, $miauth_genToken, $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, @@ -1269,7 +1352,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_userListTimeline, $notifications_create, $notifications_markAllAsRead, - $notifications_read, $pagePush, $pages_create, $pages_delete, @@ -1290,6 +1372,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $ping, $pinnedUsers, $promo_read, + $roles_list, + $roles_show, + $roles_users, + $roles_notes, $requestResetPassword, $resetDb, $resetPassword, @@ -1335,6 +1421,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_show, $users_stats, $users_achievements, + $users_updateMemo, $fetchRss, $retention, ], diff --git a/packages/backend/src/server/api/GetterService.ts b/packages/backend/src/server/api/GetterService.ts index c7f9916f97..c94884a78c 100644 --- a/packages/backend/src/server/api/GetterService.ts +++ b/packages/backend/src/server/api/GetterService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, UsersRepository } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import type { User } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -45,7 +45,7 @@ export class GetterService { throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); } - return user; + return user as LocalUser | RemoteUser; } /** diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index 1f8915ecca..fe2db1d66a 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import Limiter from 'ratelimiter'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index f1164b9957..bd3d8a28da 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from 'otpauth'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js'; @@ -155,19 +155,19 @@ export class SigninApiService { }); } - const verified = (speakeasy as any).totp.verify({ - secret: profile.twoFactorSecret, - encoding: 'base32', - token: token, - window: 2, + const delta = OTPAuth.TOTP.validate({ + secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!), + digits: 6, + token, + window: 1, }); - if (verified) { - return this.signinService.signin(request, reply, user); - } else { + if (delta === null) { return await fail(403, { id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', }); + } else { + return this.signinService.signin(request, reply, user); } } else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) { if (!same && !profile.usePasswordLessLogin) { diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index f821dbb500..aaf1d10b42 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { SigninsRepository, UsersRepository } from '@/models/index.js'; +import type { SigninsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { IdService } from '@/core/IdService.js'; import type { LocalUser } from '@/models/entities/User.js'; diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 41e8365d08..b2bd7d82e7 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -1,8 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; @@ -31,6 +32,9 @@ export class SignupApiService { @Inject(DI.userPendingsRepository) private userPendingsRepository: UserPendingsRepository, + @Inject(DI.usedUsernamesRepository) + private usedUsernamesRepository: UsedUsernamesRepository, + @Inject(DI.registrationTicketsRepository) private registrationTicketsRepository: RegistrationTicketsRepository, @@ -124,12 +128,26 @@ export class SignupApiService { } if (instance.emailRequiredForSignup) { + if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { + throw new FastifyReplyError(400, 'USED_USERNAME'); + } + + const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + if (isPreserved) { + throw new FastifyReplyError(400, 'DENIED_USERNAME'); + } + const code = rndstr('a-z0-9', 16); - + // Generate hash of password const salt = await bcrypt.genSalt(8); const hash = await bcrypt.hash(password, salt); - + await this.userPendingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), @@ -138,13 +156,13 @@ export class SignupApiService { username: username, password: hash, }); - + const link = `${this.config.url}/signup-complete/${code}`; - + this.emailService.sendEmail(emailAddress!, 'Signup', `To complete signup, please click this link:
${link}`, `To complete signup, please click this link: ${link}`); - + reply.code(204); return; } else { diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 487eef2d50..258e8de034 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -1,19 +1,20 @@ import { EventEmitter } from 'events'; import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import * as websocket from 'websocket'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js'; +import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; import { AuthenticateService } from './AuthenticateService.js'; import MainStreamConnection from './stream/index.js'; import { ChannelsService } from './stream/ChannelsService.js'; import type { ParsedUrlQuery } from 'querystring'; import type * as http from 'node:http'; -import { bindThis } from '@/decorators.js'; @Injectable() export class StreamingApiServerService { @@ -21,8 +22,8 @@ export class StreamingApiServerService { @Inject(DI.config) private config: Config, - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -33,6 +34,9 @@ export class StreamingApiServerService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -42,7 +46,7 @@ export class StreamingApiServerService { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private globalEventService: GlobalEventService, + private cacheService: CacheService, private noteReadService: NoteReadService, private authenticateService: AuthenticateService, private channelsService: ChannelsService, @@ -70,8 +74,6 @@ export class StreamingApiServerService { return; } - const connection = request.accept(); - const ev = new EventEmitter(); async function onRedisMessage(_: string, data: string): Promise { @@ -79,21 +81,22 @@ export class StreamingApiServerService { ev.emit(parsed.channel, parsed.message); } - this.redisSubscriber.on('message', onRedisMessage); + this.redisForSub.on('message', onRedisMessage); const main = new MainStreamConnection( - this.followingsRepository, - this.mutingsRepository, - this.blockingsRepository, - this.channelFollowingsRepository, - this.userProfilesRepository, this.channelsService, - this.globalEventService, this.noteReadService, this.notificationService, - connection, ev, user, miapp, + this.cacheService, + ev, user, miapp, ); + await main.init(); + + const connection = request.accept(); + + main.init2(connection); + const intervalId = user ? setInterval(() => { this.usersRepository.update(user.id, { lastActiveDate: new Date(), @@ -108,7 +111,7 @@ export class StreamingApiServerService { connection.once('close', () => { ev.removeAllListeners(); main.dispose(); - this.redisSubscriber.off('message', onRedisMessage); + this.redisForSub.off('message', onRedisMessage); if (intervalId) clearInterval(intervalId); }); diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index 115526d997..1555a3ca46 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import Ajv from 'ajv'; -import type { Schema, SchemaType } from '@/misc/schema.js'; +import type { Schema, SchemaType } from '@/misc/json-schema.js'; import type { LocalUser } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import { ApiError } from './error.js'; @@ -20,14 +20,14 @@ type File = { }; // TODO: paramsの型をT['params']のスキーマ定義から推論する -type executor = +type Executor = (params: SchemaType, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record | null) => Promise>>; export abstract class Endpoint { public exec: (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => Promise; - constructor(meta: T, paramDef: Ps, cb: executor) { + constructor(meta: T, paramDef: Ps, cb: Executor) { const validate = ajv.compile(paramDef); this.exec = (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 55e1900d51..f6e5d23183 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -1,4 +1,5 @@ -import type { Schema } from '@/misc/schema.js'; +import type { Schema } from '@/misc/json-schema.js'; +import { RolePolicies } from '@/core/RoleService.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; @@ -28,6 +29,7 @@ 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'; @@ -41,6 +43,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; +import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; @@ -65,6 +68,7 @@ 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___announcements from './endpoints/announcements.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; @@ -92,6 +96,10 @@ 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'; @@ -112,6 +120,9 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___clips_favorite from './endpoints/clips/favorite.js'; +import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; +import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -169,6 +180,7 @@ 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'; @@ -182,6 +194,7 @@ 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_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'; @@ -190,6 +203,7 @@ 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_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; @@ -212,6 +226,7 @@ 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_userGroupInvites from './endpoints/i/user-group-invites.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'; @@ -224,10 +239,14 @@ import * as ep___messaging_messages_delete from './endpoints/messaging/messages/ import * as ep___messaging_messages_read from './endpoints/messaging/messages/read.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'; @@ -261,7 +280,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -282,6 +300,10 @@ 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'; @@ -329,6 +351,7 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.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___retention from './endpoints/retention.js'; @@ -361,6 +384,7 @@ const eps = [ ['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], @@ -374,6 +398,7 @@ const eps = [ ['admin/queue/clear', ep___admin_queue_clear], ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], ['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed], + ['admin/queue/promote', ep___admin_queue_promote], ['admin/queue/stats', ep___admin_queue_stats], ['admin/relays/add', ep___admin_relays_add], ['admin/relays/list', ep___admin_relays_list], @@ -398,6 +423,7 @@ const eps = [ ['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], ['announcements', ep___announcements], ['antennas/create', ep___antennas_create], ['antennas/delete', ep___antennas_delete], @@ -425,6 +451,10 @@ const eps = [ ['channels/timeline', ep___channels_timeline], ['channels/unfollow', ep___channels_unfollow], ['channels/update', ep___channels_update], + ['channels/favorite', ep___channels_favorite], + ['channels/unfavorite', ep___channels_unfavorite], + ['channels/my-favorites', ep___channels_myFavorites], + ['channels/search', ep___channels_search], ['charts/active-users', ep___charts_activeUsers], ['charts/ap-request', ep___charts_apRequest], ['charts/drive', ep___charts_drive], @@ -445,6 +475,9 @@ const eps = [ ['clips/notes', ep___clips_notes], ['clips/show', ep___clips_show], ['clips/update', ep___clips_update], + ['clips/favorite', ep___clips_favorite], + ['clips/unfavorite', ep___clips_unfavorite], + ['clips/my-favorites', ep___clips_myFavorites], ['drive', ep___drive], ['drive/files', ep___drive_files], ['drive/files/attached-notes', ep___drive_files_attachedNotes], @@ -502,6 +535,7 @@ const eps = [ ['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], @@ -515,6 +549,7 @@ const eps = [ ['i/export-notes', ep___i_exportNotes], ['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], @@ -523,6 +558,7 @@ const eps = [ ['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/page-likes', ep___i_pageLikes], ['i/pages', ep___i_pages], @@ -545,6 +581,7 @@ const eps = [ ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], ['i/user-group-invites', ep___i_userGroupInvites], + ['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], @@ -557,10 +594,14 @@ const eps = [ ['messaging/messages/read', ep___messaging_messages_read], ['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], @@ -594,7 +635,6 @@ const eps = [ ['notes/user-list-timeline', ep___notes_userListTimeline], ['notifications/create', ep___notifications_create], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], - ['notifications/read', ep___notifications_read], ['page-push', ep___pagePush], ['pages/create', ep___pages_create], ['pages/delete', ep___pages_delete], @@ -615,6 +655,10 @@ const eps = [ ['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], @@ -662,6 +706,7 @@ const eps = [ ['users/show', ep___users_show], ['users/stats', ep___users_stats], ['users/achievements', ep___users_achievements], + ['users/update-memo', ep___users_updateMemo], ['fetch-rss', ep___fetchRss], ['retention', ep___retention], ]; @@ -697,7 +742,13 @@ export interface IEndpointMeta { */ readonly requireAdmin?: boolean; - readonly requireRolePolicy?: string; + readonly requireRolePolicy?: keyof RolePolicies; + + /** + * 引っ越し済みのユーザーによるリクエストを禁止するか + * 省略した場合は false として解釈されます。 + */ + readonly prohibitMoved?: boolean; /** * エンドポイントのリミテーションに関するやつ @@ -768,8 +819,8 @@ export interface IEndpoint { const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { return { name: name, - meta: ep.meta ?? {}, - params: ep.paramDef, + get meta() { return ep.meta ?? {}; }, + get params() { return ep.paramDef; }, }; }); 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 bac8ae16e5..8a3541dffe 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -52,6 +52,7 @@ export default class extends Endpoint { const { account, secret } = await this.signupService.signup({ username: ps.username, password: ps.password, + ignorePreservedUsernames: true, }); const res = await this.userEntityService.pack(account, account, { 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 e9f72676f0..16232813a8 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -61,11 +61,6 @@ export default class extends Endpoint { await this.usersRepository.update(user.id, { isDeleted: true, }); - - if (this.userEntityService.isLocalUser(user)) { - // Terminate streaming - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); - } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts index ba128c75a7..917242db3f 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -20,10 +20,10 @@ export const paramDef = { priority: { type: 'string' }, ratio: { type: 'integer' }, expiresAt: { type: 'integer' }, - startAt: { type: 'integer' }, + startsAt: { type: 'integer' }, imageUrl: { type: 'string', minLength: 1 }, }, - required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startAt', 'imageUrl'], + required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl'], } as const; // eslint-disable-next-line import/no-default-export @@ -40,7 +40,7 @@ export default class extends Endpoint { id: this.idService.genId(), createdAt: new Date(), expiresAt: new Date(ps.expiresAt), - startAt: new Date(ps.startAt), + startsAt: new Date(ps.startsAt), url: ps.url, imageUrl: ps.imageUrl, priority: ps.priority, diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index 905036c40f..0b6d006052 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -31,7 +31,7 @@ export default class extends Endpoint { private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId); const ads = await query.take(ps.limit).getMany(); return ads; diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts index 75f6f1d5ad..dbab7e9d4f 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -30,9 +30,9 @@ export const paramDef = { priority: { type: 'string' }, ratio: { type: 'integer' }, expiresAt: { type: 'integer' }, - startAt: { type: 'integer' }, + startsAt: { type: 'integer' }, }, - required: ['id', 'memo', 'url', 'imageUrl', 'place', 'priority', 'ratio', 'expiresAt', 'startAt'], + required: ['id', 'memo', 'url', 'imageUrl', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt'], } as const; // eslint-disable-next-line import/no-default-export @@ -55,7 +55,7 @@ export default class extends Endpoint { memo: ps.memo, imageUrl: ps.imageUrl, expiresAt: new Date(ps.expiresAt), - startAt: new Date(ps.startAt), + startsAt: new Date(ps.startsAt), }); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts index 2cc4e70e55..a8964af449 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index 6376cb153c..1d27ac2137 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository } from '@/models/index.js'; +import type { DriveFilesRepository, UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; @@ -138,19 +138,13 @@ export const meta = { export const paramDef = { type: 'object', + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + url: { type: 'string' }, + }, anyOf: [ - { - properties: { - fileId: { type: 'string', format: 'misskey:id' }, - }, - required: ['fileId'], - }, - { - properties: { - url: { type: 'string' }, - }, - required: ['url'], - }, + { required: ['fileId'] }, + { required: ['url'] }, ], } as const; @@ -161,6 +155,9 @@ export default class extends Endpoint { @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { @@ -178,7 +175,12 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchFile); } - const isModerator = await this.roleService.isModerator(me); + const owner = file.userId ? await this.usersRepository.findOneByOrFail({ + id: file.userId, + }) : null; + + const iAmModerator = await this.roleService.isModerator(me); + const ownerIsModerator = owner ? await this.roleService.isModerator(owner) : false; return { id: file.id, @@ -207,8 +209,8 @@ export default class extends Endpoint { name: file.name, md5: file.md5, createdAt: file.createdAt.toISOString(), - requestIp: isModerator ? file.requestIp : null, - requestHeaders: isModerator ? file.requestHeaders : null, + requestIp: iAmModerator ? file.requestIp : null, + requestHeaders: iAmModerator && !ownerIsModerator ? file.requestHeaders : null, }; }); } 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 c683cd24c1..6e604ed885 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,38 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - aliases: [...new Set(emoji.aliases.concat(ps.aliases))], - }); - } - - await this.db.queryResultCache!.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packMany(ps.ids), - }); + await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 1bb05c15c2..2fb3e489e7 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; -import { DataSource } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; @@ -17,7 +16,7 @@ export const meta = { errors: { noSuchFile: { message: 'No such file.', - code: 'MO_SUCH_FILE', + code: 'NO_SUCH_FILE', id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', }, }, @@ -57,6 +56,7 @@ export default class extends Endpoint { category: null, aliases: [], host: null, + license: null, }); this.moderationLogService.insertModerationLog(me, 'addEmoji', { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index b4fc7fd6f5..82dca9cc70 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -87,12 +87,11 @@ export default class extends Endpoint { originalUrl: driveFile.url, publicUrl: driveFile.webpublicUrl ?? driveFile.url, type: driveFile.webpublicType ?? driveFile.type, + license: emoji.license, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); - await this.db.queryResultCache!.remove(['meta_emojis']); - this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: await this.emojiEntityService.pack(copied.id), + emoji: await this.emojiEntityService.packDetailed(copied.id), }); return { 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 0c337237d3..9f8263629b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -1,11 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -24,38 +19,14 @@ export const paramDef = { required: ['ids'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private moderationLogService: ModerationLogService, - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.delete(emoji.id); - await this.db.queryResultCache!.remove(['meta_emojis']); - this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { - emoji: emoji, - }); - } - - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: await this.emojiEntityService.packMany(emojis), - }); + await this.customEmojiService.deleteBulk(ps.ids); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index c1a60a2773..429c819fe0 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -1,12 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { ApiError } from '../../../error.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -31,38 +25,14 @@ export const paramDef = { required: ['id'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private moderationLogService: ModerationLogService, - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - - await this.emojisRepository.delete(emoji.id); - - await this.db.queryResultCache!.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: [ await this.emojiEntityService.pack(emoji) ], - }); - - this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { - emoji: emoji, - }); + await this.customEmojiService.delete(ps.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts index b4a07324bb..e26f0506ce 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 @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; 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 8e0ea2e117..df3c28deff 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 @@ -101,7 +101,7 @@ export default class extends Endpoint { .take(ps.limit) .getMany(); - return this.emojiEntityService.packMany(emojis, { omitHost: false, omitId: false, withUrl: false }); + return this.emojiEntityService.packDetailedMany(emojis); }); } } 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 1b1931f8e6..4aa4ad82b4 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -87,18 +87,24 @@ export default class extends Endpoint { //const emojis = await q.take(ps.limit).getMany(); emojis = await q.getMany(); + const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g); - emojis = emojis.filter(emoji => - emoji.name.includes(ps.query!) || - emoji.aliases.some(a => a.includes(ps.query!)) || - emoji.category?.includes(ps.query!)); - + if (queryarry) { + emojis = emojis.filter(emoji => + queryarry.includes(`:${emoji.name}:`) + ); + } else { + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query!) || + emoji.aliases.some(a => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!)); + } emojis.splice(ps.limit + 1); } else { emojis = await q.take(ps.limit).getMany(); } - return this.emojiEntityService.packMany(emojis, { omitHost: false, omitId: false, withUrl: false }); + return this.emojiEntityService.packDetailedMany(emojis); }); } } 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 065965f64a..83f882cac5 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,38 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)), - }); - } - - await this.db.queryResultCache!.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packMany(ps.ids), - }); + await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index 51c0f329ac..1d3a432bb7 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,34 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - await this.emojisRepository.update({ - id: In(ps.ids), - }, { - updatedAt: new Date(), - aliases: ps.aliases, - }); - - await this.db.queryResultCache!.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packMany(ps.ids), - }); + await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index 3329cab7b9..453968c7a9 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -28,34 +24,14 @@ export const paramDef = { required: ['ids'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - await this.emojisRepository.update({ - id: In(ps.ids), - }, { - updatedAt: new Date(), - category: ps.category, - }); - - await this.db.queryResultCache!.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packMany(ps.ids), - }); + await this.customEmojiService.setCategoryBulk(ps.ids, ps.category ?? null); }); } } 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 new file mode 100644 index 0000000000..b90b9757be --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', +} as const; + +export const paramDef = { + type: 'object', + properties: { + ids: { type: 'array', items: { + type: 'string', format: 'misskey:id', + } }, + license: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the license.', + }, + }, + required: ['ids'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + private customEmojiService: CustomEmojiService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.customEmojiService.setLicenseBulk(ps.ids, ps.license ?? null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 22bedc7100..f63348b60b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -1,11 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { ApiError } from '../../../error.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; export const meta = { tags: ['admin'], @@ -19,6 +15,11 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', }, + sameNameEmojiExists: { + message: 'Emoji that have same name already exists.', + code: 'SAME_NAME_EMOJI_EXISTS', + id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8', + }, }, } as const; @@ -26,7 +27,7 @@ export const paramDef = { type: 'object', properties: { id: { type: 'string', format: 'misskey:id' }, - name: { type: 'string' }, + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, category: { type: 'string', nullable: true, @@ -35,54 +36,24 @@ export const paramDef = { aliases: { type: 'array', items: { type: 'string', } }, + license: { type: 'string', nullable: true }, }, required: ['id', 'name', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), + await this.customEmojiService.update(ps.id, { name: ps.name, - category: ps.category, + category: ps.category ?? null, aliases: ps.aliases, + license: ps.license ?? null, }); - - await this.db.queryResultCache!.remove(['meta_emojis']); - - const updated = await this.emojiEntityService.pack(emoji.id); - - if (emoji.name === ps.name) { - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: [ updated ], - }); - } else { - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: [ await this.emojiEntityService.pack(emoji) ], - }); - - this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: updated, - }); - } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts index b073209a5b..83f729953a 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -29,7 +29,7 @@ export default class extends Endpoint { @Inject(DI.notesRepository) private followingsRepository: FollowingsRepository, - private userFollowingService: UserFollowingService, + private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { const followings = await this.followingsRepository.findBy({ @@ -39,11 +39,9 @@ export default class extends Endpoint { const pairs = await Promise.all(followings.map(f => Promise.all([ this.usersRepository.findOneByOrFail({ id: f.followerId }), this.usersRepository.findOneByOrFail({ id: f.followeeId }), - ]))); + ]).then(([from, to]) => [{ id: from.id }, { id: to.id }]))); - for (const pair of pairs) { - this.userFollowingService.unfollow(pair[0], pair[1]); - } + this.queueService.createUnfollowJob(pairs.map(p => ({ from: p[0], to: p[1], silent: true }))); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index 0a529ecb08..4fd74e591d 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -3,6 +3,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { InstancesRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; export const meta = { tags: ['admin'], @@ -28,6 +29,7 @@ export default class extends Endpoint { private instancesRepository: InstancesRepository, private utilityService: UtilityService, + private federatedInstanceService: FederatedInstanceService, ) { super(meta, paramDef, async (ps, me) => { const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); @@ -36,7 +38,7 @@ export default class extends Endpoint { throw new Error('instance not found'); } - this.instancesRepository.update({ host: this.utilityService.toPuny(ps.host) }, { + this.federatedInstanceService.update(instance.id, { isSuspended: ps.isSuspended, }); }); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index b077be4574..6aadcfaa2a 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -54,86 +54,22 @@ export const meta = { }, mascotImageUrl: { type: 'string', - optional: false, nullable: false, + optional: false, nullable: true, default: '/assets/ai.png', }, bannerUrl: { type: 'string', - optional: false, nullable: false, + optional: false, nullable: true, }, errorImageUrl: { type: 'string', - optional: false, nullable: false, + optional: false, nullable: true, default: 'https://xn--931a.moe/aiart/yubitun.png', }, iconUrl: { type: 'string', optional: false, nullable: true, }, - maxNoteTextLength: { - type: 'number', - optional: false, nullable: false, - }, - emojis: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: false, nullable: true, - }, - url: { - type: 'string', - optional: false, nullable: false, - format: 'url', - }, - }, - }, - }, - ads: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'object', - optional: false, nullable: false, - properties: { - place: { - type: 'string', - optional: false, nullable: false, - }, - url: { - type: 'string', - optional: false, nullable: false, - format: 'url', - }, - imageUrl: { - type: 'string', - optional: false, nullable: false, - format: 'url', - }, - }, - }, - }, enableEmail: { type: 'boolean', optional: false, nullable: false, @@ -182,6 +118,22 @@ export const meta = { optional: false, nullable: false, }, }, + sensitiveWords: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + preservedUsernames: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, hcaptchaSecretKey: { type: 'string', optional: true, nullable: true, @@ -232,7 +184,7 @@ export const meta = { optional: true, nullable: true, }, smtpPort: { - type: 'string', + type: 'number', optional: true, nullable: true, }, smtpUser: { @@ -303,6 +255,18 @@ export const meta = { type: 'boolean', optional: true, nullable: false, }, + enableChartsForRemoteUser: { + type: 'boolean', + optional: false, nullable: false, + }, + enableChartsForFederatedInstances: { + type: 'boolean', + optional: false, nullable: false, + }, + policies: { + type: 'object', + optional: false, nullable: false, + }, }, }, } as const; @@ -334,7 +298,7 @@ export default class extends Endpoint { uri: this.config.url, description: instance.description, langs: instance.langs, - tosUrl: instance.ToSUrl, + tosUrl: instance.termsOfServiceUrl, repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, @@ -353,7 +317,6 @@ export default class extends Endpoint { iconUrl: instance.iconUrl, backgroundImageUrl: instance.backgroundImageUrl, logoImageUrl: instance.logoImageUrl, - maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため defaultLightTheme: instance.defaultLightTheme, defaultDarkTheme: instance.defaultDarkTheme, enableEmail: instance.enableEmail, @@ -361,13 +324,14 @@ export default class extends Endpoint { // translatorAvailable: instance.deeplAuthKey != null, translatorAvailable: instance.translatorType != null, translatorType: instance.translatorType, - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, + // pinnedPages: instance.pinnedPages, + // pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, - useStarForReactionFallback: instance.useStarForReactionFallback, pinnedUsers: instance.pinnedUsers, hiddenTags: instance.hiddenTags, blockedHosts: instance.blockedHosts, + sensitiveWords: instance.sensitiveWords, + preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, turnstileSecretKey: instance.turnstileSecretKey, @@ -401,6 +365,8 @@ export default class extends Endpoint { deeplIsPro: instance.deeplIsPro, enableIpLogging: instance.enableIpLogging, enableActiveEmailValidation: instance.enableActiveEmailValidation, + enableChartsForRemoteUser: instance.enableChartsForRemoteUser, + enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, policies: { ...DEFAULT_POLICIES, ...instance.policies }, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts index 9129f53f06..099e2ff220 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts new file mode 100644 index 0000000000..4e57e6613e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + type: { type: 'string', enum: ['deliver', 'inbox'] }, + }, + required: ['type'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + let delayedQueues; + + switch (ps.type) { + case 'deliver': + delayedQueues = await this.queueService.deliverQueue.getDelayed(); + for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { + const queue = delayedQueues[queueIndex]; + await queue.promote(); + } + break; + + case 'inbox': + delayedQueues = await this.queueService.inboxQueue.getDelayed(); + for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { + const queue = delayedQueues[queueIndex]; + await queue.promote(); + } + break; + } + + this.moderationLogService.insertModerationLog(me, 'promoteQueue'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts index 32ad79918f..f12738bd3a 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -1,5 +1,5 @@ import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { RelayService } from '@/core/RelayService.js'; import { ApiError } from '../../../error.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/relays/list.ts b/packages/backend/src/server/api/endpoints/admin/relays/list.ts index 079b351add..910c90e78e 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/list.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { RelayService } from '@/core/RelayService.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts index 9dc4105d14..5e26f61fa7 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { RelayService } from '@/core/RelayService.js'; 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 84fcc05edc..aead894611 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -49,7 +49,7 @@ export default class extends Endpoint { const actor = await this.instanceActorService.getInstanceActor(); const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); - this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, [targetUser.uri!], report.comment)), targetUser.inbox); + this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment)), targetUser.inbox, false); } await this.abuseUserReportsRepository.update(report.id, { diff --git a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts index 7bfb2f6625..b80aaba122 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts @@ -1,10 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; +import type { RolesRepository, UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { RoleService } from '@/core/RoleService.js'; export const meta = { @@ -39,6 +37,10 @@ export const paramDef = { properties: { roleId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' }, + expiresAt: { + type: 'integer', + nullable: true, + }, }, required: [ 'roleId', @@ -56,12 +58,7 @@ export default class extends Endpoint { @Inject(DI.rolesRepository) private rolesRepository: RolesRepository, - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private globalEventService: GlobalEventService, private roleService: RoleService, - private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); @@ -78,19 +75,11 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchUser); } - const date = new Date(); - const created = await this.roleAssignmentsRepository.insert({ - id: this.idService.genId(), - createdAt: date, - roleId: role.id, - userId: user.id, - }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); + if (ps.expiresAt && ps.expiresAt <= Date.now()) { + return; + } - this.rolesRepository.update(ps.roleId, { - lastUsedAt: new Date(), - }); - - this.globalEventService.publishInternalEvent('userRoleAssigned', created); + await this.roleService.assign(user.id, role.id, ps.expiresAt ? new Date(ps.expiresAt) : null); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index df60c6be94..916172f54a 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -25,8 +25,10 @@ export const paramDef = { isPublic: { type: 'boolean' }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, + isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, + displayOrder: { type: 'number' }, policies: { type: 'object', }, @@ -43,6 +45,7 @@ export const paramDef = { 'isAdministrator', 'asBadge', 'canEditMembersByModerator', + 'displayOrder', 'policies', ], } as const; @@ -74,11 +77,13 @@ export default class extends Endpoint { isPublic: ps.isPublic, isAdministrator: ps.isAdministrator, isModerator: ps.isModerator, + isExplorable: ps.isExplorable, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, + displayOrder: ps.displayOrder, policies: ps.policies, }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); - + this.globalEventService.publishInternalEvent('roleCreated', created); return await this.roleEntityService.pack(created, me); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/list.ts b/packages/backend/src/server/api/endpoints/admin/roles/list.ts index 458a8d535b..edaf638ea9 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/list.ts @@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { RolesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; export const meta = { @@ -33,7 +32,7 @@ export default class extends Endpoint { const roles = await this.rolesRepository.find({ order: { lastUsedAt: 'DESC' }, }); - return await this.roleEntityService.packMany(roles, me, { detail: false }); + return await this.roleEntityService.packMany(roles, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/show.ts b/packages/backend/src/server/api/endpoints/admin/roles/show.ts index c83f96191d..01028a086f 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/show.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/show.ts @@ -39,12 +39,12 @@ export default class extends Endpoint { private roleEntityService: RoleEntityService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); if (role == null) { throw new ApiError(meta.errors.noSuchRole); } - return await this.roleEntityService.pack(role); + return await this.roleEntityService.pack(role, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts index 141cc5ee89..45c4f76943 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts @@ -1,10 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; +import type { RolesRepository, UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { RoleService } from '@/core/RoleService.js'; export const meta = { @@ -62,12 +60,7 @@ export default class extends Endpoint { @Inject(DI.rolesRepository) private rolesRepository: RolesRepository, - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private globalEventService: GlobalEventService, private roleService: RoleService, - private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); @@ -84,18 +77,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchUser); } - const roleAssignment = await this.roleAssignmentsRepository.findOneBy({ userId: user.id, roleId: role.id }); - if (roleAssignment == null) { - throw new ApiError(meta.errors.notAssigned); - } - - await this.roleAssignmentsRepository.delete(roleAssignment.id); - - this.rolesRepository.update(ps.roleId, { - lastUsedAt: new Date(), - }); - - this.globalEventService.publishInternalEvent('userRoleUnassigned', roleAssignment); + await this.roleService.unassign(user.id, role.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts index 6006816bcb..5a34eee96c 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts @@ -1,9 +1,6 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RolesRepository } from '@/models/index.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '@/server/api/error.js'; import { MetaService } from '@/core/MetaService.js'; export const meta = { diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index b939ccdbf9..467f157a61 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -33,8 +33,10 @@ export const paramDef = { isPublic: { type: 'boolean' }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, + isExplorable: { type: 'boolean' }, asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, + displayOrder: { type: 'number' }, policies: { type: 'object', }, @@ -52,6 +54,7 @@ export const paramDef = { 'isAdministrator', 'asBadge', 'canEditMembersByModerator', + 'displayOrder', 'policies', ], } as const; @@ -83,8 +86,10 @@ export default class extends Endpoint { isPublic: ps.isPublic, isModerator: ps.isModerator, isAdministrator: ps.isAdministrator, + isExplorable: ps.isExplorable, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, + displayOrder: ps.displayOrder, policies: ps.policies, }); const updated = await this.rolesRepository.findOneByOrFail({ id: ps.roleId }); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts new file mode 100644 index 0000000000..35edca5460 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -0,0 +1,78 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin', 'role', 'users'], + + requireCredential: false, + requireAdmin: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: '224eff5e-2488-4b18-b3e7-f50d94421648', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { 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 }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + + private queryService: QueryService, + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) + .andWhere('assign.roleId = :roleId', { roleId: role.id }) + .andWhere(new Brackets(qb => { qb + .where('assign.expiresAt IS NULL') + .orWhere('assign.expiresAt > :now', { now: new Date() }); + })) + .innerJoinAndSelect('assign.user', 'user'); + + const assigns = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(assigns.map(async assign => ({ + id: assign.id, + createdAt: assign.createdAt, + user: await this.userEntityService.pack(assign.user!, me, { detail: true }), + expiresAt: assign.expiresAt, + }))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/send-email.ts b/packages/backend/src/server/api/endpoints/admin/send-email.ts index 7434bf4c91..5ddc62f476 100644 --- a/packages/backend/src/server/api/endpoints/admin/send-email.ts +++ b/packages/backend/src/server/api/endpoints/admin/send-email.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { EmailService } from '@/core/EmailService.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts index 9c576dffe9..4ef4fdc665 100644 --- a/packages/backend/src/server/api/endpoints/admin/server-info.ts +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -2,7 +2,7 @@ import * as os from 'node:os'; import si from 'systeminformation'; import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; 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 823af6d8be..42229c8f23 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -59,12 +59,6 @@ export default class extends Endpoint { throw new Error('cannot show info of admin'); } - if (!await this.roleService.isAdministrator(_me)) { - return { - isSuspended: user.isSuspended, - }; - } - const signins = await this.signinsRepository.findBy({ userId: user.id }); const roles = await this.roleService.getUserRoles(user.id); @@ -86,10 +80,10 @@ export default class extends Endpoint { isSilenced: isSilenced, isSuspended: user.isSuspended, lastActiveDate: user.lastActiveDate, - moderationNote: profile.moderationNote, + moderationNote: profile.moderationNote ?? '', signins, policies: await this.roleService.getUserPolicies(user.id), - roles: await this.roleEntityService.packMany(roles, me, { detail: false }), + roles: await this.roleEntityService.packMany(roles, me), }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 3ad6c7c484..eabbceac0e 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,15 +1,15 @@ +import { IsNull, Not } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, FollowingsRepository, NotificationsRepository } from '@/models/index.js'; +import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { RelationshipJobData } from '@/queue/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -36,15 +36,10 @@ export default class extends Endpoint { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - - private userEntityService: UserEntityService, - private userFollowingService: UserFollowingService, private userSuspendService: UserSuspendService, private roleService: RoleService, private moderationLogService: ModerationLogService, - private globalEventService: GlobalEventService, + private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -65,45 +60,32 @@ export default class extends Endpoint { targetId: user.id, }); - // Terminate streaming - if (this.userEntityService.isLocalUser(user)) { - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); - } - (async () => { await this.userSuspendService.doPostSuspend(user).catch(e => {}); await this.unFollowAll(user).catch(e => {}); - await this.readAllNotify(user).catch(e => {}); })(); }); } @bindThis private async unFollowAll(follower: User) { - const followings = await this.followingsRepository.findBy({ - followerId: follower.id, + const followings = await this.followingsRepository.find({ + where: { + followerId: follower.id, + followeeId: Not(IsNull()), + }, }); - + + const jobs: RelationshipJobData[] = []; for (const following of followings) { - const followee = await this.usersRepository.findOneBy({ - id: following.followeeId, - }); - - if (followee == null) { - throw `Cant find followee ${following.followeeId}`; + if (following.followeeId && following.followerId) { + jobs.push({ + from: { id: following.followerId }, + to: { id: following.followeeId }, + silent: true, + }); } - - await this.userFollowingService.unfollow(follower, followee, true); } - } - - @bindThis - private async readAllNotify(notifier: User) { - await this.notificationsRepository.update({ - notifierId: notifier.id, - isRead: false, - }, { - isRead: true, - }); + this.queueService.createUnfollowJob(jobs); } } 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 48590c7a9e..dbac745395 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -2,10 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import type { Meta } from '@/models/entities/Meta.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { MetaService } from '@/core/MetaService.js'; export const meta = { @@ -19,7 +17,6 @@ export const paramDef = { type: 'object', properties: { disableRegistration: { type: 'boolean', nullable: true }, - useStarForReactionFallback: { type: 'boolean', nullable: true }, pinnedUsers: { type: 'array', nullable: true, items: { type: 'string', } }, @@ -29,6 +26,9 @@ export const paramDef = { blockedHosts: { type: 'array', nullable: true, items: { type: 'string', } }, + sensitiveWords: { type: 'array', nullable: true, items: { + type: 'string', + } }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, @@ -58,10 +58,6 @@ export const paramDef = { proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, maintainerName: { type: 'string', nullable: true }, maintainerEmail: { type: 'string', nullable: true }, - pinnedPages: { type: 'array', items: { - type: 'string', - } }, - pinnedClipId: { type: 'string', format: 'misskey:id', nullable: true }, langs: { type: 'array', items: { type: 'string', } }, @@ -97,6 +93,10 @@ export const paramDef = { objectStorageS3ForcePathStyle: { type: 'boolean' }, enableIpLogging: { type: 'boolean' }, enableActiveEmailValidation: { type: 'boolean' }, + enableChartsForRemoteUser: { type: 'boolean' }, + enableChartsForFederatedInstances: { type: 'boolean' }, + serverRules: { type: 'array', items: { type: 'string' } }, + preservedUsernames: { type: 'array', items: { type: 'string' } }, }, required: [], } as const; @@ -118,10 +118,6 @@ export default class extends Endpoint { set.disableRegistration = ps.disableRegistration; } - if (typeof ps.useStarForReactionFallback === 'boolean') { - set.useStarForReactionFallback = ps.useStarForReactionFallback; - } - if (Array.isArray(ps.pinnedUsers)) { set.pinnedUsers = ps.pinnedUsers.filter(Boolean); } @@ -134,6 +130,10 @@ export default class extends Endpoint { set.blockedHosts = ps.blockedHosts.filter(Boolean).map(x => x.toLowerCase()); } + if (Array.isArray(ps.sensitiveWords)) { + set.sensitiveWords = ps.sensitiveWords.filter(Boolean); + } + if (ps.themeColor !== undefined) { set.themeColor = ps.themeColor; } @@ -250,14 +250,6 @@ export default class extends Endpoint { set.langs = ps.langs.filter(Boolean); } - if (Array.isArray(ps.pinnedPages)) { - set.pinnedPages = ps.pinnedPages.filter(Boolean); - } - - if (ps.pinnedClipId !== undefined) { - set.pinnedClipId = ps.pinnedClipId; - } - if (ps.summalyProxy !== undefined) { set.summalyProxy = ps.summalyProxy; } @@ -307,7 +299,7 @@ export default class extends Endpoint { } if (ps.tosUrl !== undefined) { - set.ToSUrl = ps.tosUrl; + set.termsOfServiceUrl = ps.tosUrl; } if (ps.repositoryUrl !== undefined) { @@ -398,6 +390,22 @@ export default class extends Endpoint { set.enableActiveEmailValidation = ps.enableActiveEmailValidation; } + if (ps.enableChartsForRemoteUser !== undefined) { + set.enableChartsForRemoteUser = ps.enableChartsForRemoteUser; + } + + if (ps.enableChartsForFederatedInstances !== undefined) { + set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; + } + + if (ps.serverRules !== undefined) { + set.serverRules = ps.serverRules; + } + + if (ps.preservedUsernames !== undefined) { + set.preservedUsernames = ps.preservedUsernames; + } + await this.metaService.update(set); this.moderationLogService.insertModerationLog(me, 'updateMeta'); }); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index a1553b6a80..e3d5d73a20 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { @@ -89,6 +91,10 @@ export default class extends Endpoint { private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { + if ((ps.keywords.length === 0) || ps.keywords[0].every(x => x === '')) { + throw new Error('invalid param'); + } + const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id, }); @@ -119,9 +125,12 @@ export default class extends Endpoint { } } + const now = new Date(); + const antenna = await this.antennasRepository.insert({ id: this.idService.genId(), - createdAt: new Date(), + createdAt: now, + lastUsedAt: now, userId: me.id, name: ps.name, src: ps.src, diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index fbb5acf617..dca0f443b7 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NotesRepository, AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; +import type { NotesRepository, AntennasRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -50,15 +52,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - + private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, private noteReadService: NoteReadService, @@ -73,34 +76,46 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchAntenna); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .innerJoin(this.antennaNotesRepository.metadata.targetName, 'antennaNote', 'antennaNote.noteId = note.id') + const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const noteIdsRes = await this.redisClient.xrevrange( + `antennaTimeline:${antenna.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', + 'COUNT', limit); + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id }); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); - const notes = await query - .take(ps.limit) - .getMany(); + const notes = await query.getMany(); + notes.sort((a, b) => a.id > b.id ? -1 : 1); if (notes.length > 0) { this.noteReadService.read(me.id, notes); } + this.antennasRepository.update(antenna.id, { + lastUsedAt: new Date(), + }); + return await this.noteEntityService.packMany(notes, me); }); } diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 1955eac949..0abdaf4252 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { @@ -79,9 +81,6 @@ export default class extends Endpoint { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userGroupJoiningsRepository) - private userGroupJoiningsRepository: UserGroupJoiningsRepository, - private antennaEntityService: AntennaEntityService, private globalEventService: GlobalEventService, ) { diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 8bafb3b122..c45a86761c 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -1,8 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; -import { ApiError } from '../../error.js'; export const meta = { tags: ['federation'], diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 61e05531e6..a103d4196a 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -5,7 +5,7 @@ import type { UsersRepository, NotesRepository } from '@/models/index.js'; import type { Note } from '@/models/entities/Note.js'; import type { LocalUser, User } from '@/models/entities/User.js'; import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; -import type { SchemaType } from '@/misc/schema.js'; +import type { SchemaType } from '@/misc/json-schema.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { MetaService } from '@/core/MetaService.js'; diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index dff8a9d10d..69e2f2504c 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', limit: { @@ -41,6 +43,7 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + color: { type: 'string', minLength: 1, maxLength: 16 }, }, required: ['name'], } as const; @@ -78,6 +81,7 @@ export default class extends Endpoint { name: ps.name, description: ps.description ?? null, bannerId: banner ? banner.id : null, + ...(ps.color !== undefined ? { color: ps.color } : {}), } as Channel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); return await this.channelEntityService.pack(channel, me); diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts new file mode 100644 index 0000000000..c8544273a1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '4938f5f3-6167-4c04-9149-6607b7542861', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + channelId: channel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts index d25faae38d..1a8d1164c7 100644 --- a/packages/backend/src/server/api/endpoints/channels/featured.ts +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -38,6 +38,7 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const query = this.channelsRepository.createQueryBuilder('channel') .where('channel.lastNotedAt IS NOT NULL') + .andWhere('channel.isArchived = FALSE') .orderBy('channel.lastNotedAt', 'DESC'); const channels = await query.take(10).getMany(); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 91693918f2..f3ca66cfd2 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { @@ -41,7 +43,6 @@ export default class extends Endpoint { private channelFollowingsRepository: ChannelFollowingsRepository, private idService: IdService, - private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -58,8 +59,6 @@ export default class extends Endpoint { followerId: me.id, followeeId: channel.id, }); - - this.globalEventService.publishUserEvent(me.id, 'followChannel', channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/my-favorites.ts b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts new file mode 100644 index 0000000000..60525ed060 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['channels', 'account'], + + requireCredential: true, + + kind: 'read:channels', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.channelFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.channel', 'channel'); + + const favorites = await query + .getMany(); + + return await Promise.all(favorites.map(x => this.channelEntityService.pack(x.channel!, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts index 59df0616be..8fae972cb1 100644 --- a/packages/backend/src/server/api/endpoints/channels/owned.ts +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -44,7 +44,8 @@ export default class extends Endpoint { private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId) + .andWhere('channel.isArchived = FALSE') .andWhere({ userId: me.id }); const channels = await query diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts new file mode 100644 index 0000000000..a3b40b0bbd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import type { ChannelsRepository } from '@/models/index.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string' }, + type: { type: 'string', enum: ['nameAndDescription', 'nameOnly'], default: 'nameAndDescription' }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 }, + }, + required: ['query'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId) + .andWhere('channel.isArchived = FALSE'); + + if (ps.query !== '') { + if (ps.type === 'nameAndDescription') { + query.andWhere(new Brackets(qb => { qb + .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) + .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + })); + } else { + query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + } + } + + const channels = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts index 8718615db2..070d14631e 100644 --- a/packages/backend/src/server/api/endpoints/channels/show.ts +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - return await this.channelEntityService.pack(channel, me); + return await this.channelEntityService.pack(channel, me, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 58f8835279..c881074bab 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, NotesRepository } from '@/models/index.js'; +import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -48,12 +50,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, + private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, private activeUsersChart: ActiveUsersChart, @@ -67,24 +73,66 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.channelId = :channelId', { channelId: channel.id }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .leftJoinAndSelect('note.channel', 'channel'); - //#endregion + let timeline: Note[] = []; - const timeline = await query.take(ps.limit).getMany(); + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; + + if (!ps.sinceId && !ps.sinceDate) { + noteIdsRes = await this.redisClient.xrevrange( + `channelTimeline:${channel.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); + } + + // redis から取得していないとき・取得数が足りないとき + if (noteIdsRes.length < limit) { + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.channelId = :channelId', { channelId: channel.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + timeline = await query.take(ps.limit).getMany(); + } else { + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + //#region Construct query + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + timeline = await query.getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); + } if (me) this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts new file mode 100644 index 0000000000..67fb1ea03e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '353c68dd-131a-476c-aa99-88a345e83668', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFavoritesRepository.delete({ + userId: me.id, + channelId: channel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index ac2ef825be..f46ff9f286 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { @@ -38,8 +40,6 @@ export default class extends Endpoint { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - - private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -54,8 +54,6 @@ export default class extends Endpoint { followerId: me.id, followeeId: channel.id, }); - - this.globalEventService.publishUserEvent(me.id, 'unfollowChannel', channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index d006e89bd2..30d7f8b244 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -3,6 +3,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository, ChannelsRepository } from '@/models/index.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -46,6 +47,14 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + isArchived: { type: 'boolean', nullable: true }, + pinnedNoteIds: { + type: 'array', + items: { + type: 'string', format: 'misskey:id', + }, + }, + color: { type: 'string', minLength: 1, maxLength: 16 }, }, required: ['channelId'], } as const; @@ -61,6 +70,8 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private channelEntityService: ChannelEntityService, + + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -71,7 +82,8 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - if (channel.userId !== me.id) { + const iAmModerator = await this.roleService.isModerator(me); + if (channel.userId !== me.id && !iAmModerator) { throw new ApiError(meta.errors.accessDenied); } @@ -93,6 +105,9 @@ export default class extends Endpoint { await this.channelsRepository.update(channel.id, { ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), + ...(ps.color !== undefined ? { color: ps.color } : {}), + ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), ...(banner ? { bannerId: banner.id } : {}), }); diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts index 862ef89268..2ab58e4309 100644 --- a/packages/backend/src/server/api/endpoints/charts/active-users.ts +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/ap-request.ts b/packages/backend/src/server/api/endpoints/charts/ap-request.ts index 1d5b8f05f8..e40a53d82e 100644 --- a/packages/backend/src/server/api/endpoints/charts/ap-request.ts +++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts index ec28fa75de..9a5aff4af9 100644 --- a/packages/backend/src/server/api/endpoints/charts/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import DriveChart from '@/core/chart/charts/drive.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts index 6c24cbbb77..ed3a968681 100644 --- a/packages/backend/src/server/api/endpoints/charts/federation.ts +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import FederationChart from '@/core/chart/charts/federation.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts index a6a538ea5c..c992d525c9 100644 --- a/packages/backend/src/server/api/endpoints/charts/instance.ts +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import InstanceChart from '@/core/chart/charts/instance.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts index 8d03f2eaf1..5750cd5b78 100644 --- a/packages/backend/src/server/api/endpoints/charts/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import NotesChart from '@/core/chart/charts/notes.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts index 87d56f38b7..5e372294b7 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts index 7a61544aea..3f50918fa7 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/following.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { getJsonSchema } from '@/core/chart/core.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts index fdc385191f..0517b3283f 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/user/pv.ts b/packages/backend/src/server/api/endpoints/charts/user/pv.ts index 33652c3adf..8d1a9aee10 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/pv.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/pv.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts index f0f3e520da..f2ff413195 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts index d09f2512e5..1374f02046 100644 --- a/packages/backend/src/server/api/endpoints/charts/users.ts +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getJsonSchema } from '@/core/chart/core.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import UsersChart from '@/core/chart/charts/users.js'; diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index f3f9c3477f..c3561e2a71 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', limit: { @@ -106,6 +108,10 @@ export default class extends Endpoint { noteId: note.id, clipId: clip.id, }); + + await this.clipsRepository.update(clip.id, { + lastClippedAt: new Date(), + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index c095de702c..5395a5c373 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', res: { @@ -57,7 +59,7 @@ export default class extends Endpoint { if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) { throw new ApiError(meta.errors.tooManyClips); } - + const clip = await this.clipsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), @@ -67,7 +69,7 @@ export default class extends Endpoint { description: ps.description, }).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0])); - return await this.clipEntityService.pack(clip); + return await this.clipEntityService.pack(clip, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts new file mode 100644 index 0000000000..f08caaf8d7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -0,0 +1,78 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['clip'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:clip-favorite', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + }, + + alreadyFavorited: { + message: 'The clip has already been favorited.', + code: 'ALREADY_FAVORITED', + id: '92658936-c625-4273-8326-2d790129256e', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + if ((clip.userId !== me.id) && !clip.isPublic) { + throw new ApiError(meta.errors.noSuchClip); + } + + const exist = await this.clipFavoritesRepository.findOneBy({ + clipId: clip.id, + userId: me.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + await this.clipFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + clipId: clip.id, + userId: me.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts index 63ca069364..3b8deab709 100644 --- a/packages/backend/src/server/api/endpoints/clips/list.ts +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -42,7 +42,7 @@ export default class extends Endpoint { userId: me.id, }); - return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts new file mode 100644 index 0000000000..fc727e93bd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ClipFavoritesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; + +export const meta = { + tags: ['account', 'clip'], + + requireCredential: true, + + kind: 'read:clip-favorite', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Clip', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + + private clipEntityService: ClipEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.clipFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.clip', 'clip'); + + const favorites = await query + .getMany(); + + return this.clipEntityService.packMany(favorites.map(x => x.clip!), me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 6818d31cc4..dcb415b752 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -75,16 +75,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); if (me) { diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts index 5d88870ed2..50c5d758bd 100644 --- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts index e6d3f4f1f8..99d630a9b5 100644 --- a/packages/backend/src/server/api/endpoints/clips/show.ts +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -58,7 +58,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchClip); } - return await this.clipEntityService.pack(clip); + return await this.clipEntityService.pack(clip, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts new file mode 100644 index 0000000000..3da252a226 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['clip'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:clip-favorite', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '2603966e-b865-426c-94a7-af4a01241dc1', + }, + + notFavorited: { + message: 'You have not favorited the clip.', + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const exist = await this.clipFavoritesRepository.findOneBy({ + clipId: clip.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + await this.clipFavoritesRepository.delete(exist.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index 597b67c442..70f1959353 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { @@ -64,7 +66,7 @@ export default class extends Endpoint { isPublic: ps.isPublic, }); - return await this.clipEntityService.pack(clip.id); + return await this.clipEntityService.pack(clip.id, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts index e5bbfecbcf..a6ece0311b 100644 --- a/packages/backend/src/server/api/endpoints/drive.ts +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +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'; diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index f6fad50fd9..4609307774 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -31,6 +31,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, + sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size'] }, }, required: [], } as const; @@ -63,6 +64,15 @@ export default class extends Endpoint { } } + switch (ps.sort) { + case '+createdAt': query.orderBy('file.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('file.createdAt', 'ASC'); break; + case '+name': query.orderBy('file.name', 'DESC'); break; + case '-name': query.orderBy('file.name', 'ASC'); break; + case '+size': query.orderBy('file.size', 'DESC'); break; + case '-size': query.orderBy('file.size', 'ASC'); break; + } + const files = await query.take(ps.limit).getMany(); return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index b3bdef41d3..a1c1f9325e 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -15,6 +15,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + limit: { duration: ms('1hour'), max: 120, diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts index e0a07a3640..271b33ef4b 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/show.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts @@ -39,19 +39,13 @@ export const meta = { export const paramDef = { type: 'object', + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + url: { type: 'string' }, + }, anyOf: [ - { - properties: { - fileId: { type: 'string', format: 'misskey:id' }, - }, - required: ['fileId'], - }, - { - properties: { - url: { type: 'string' }, - }, - required: ['url'], - }, + { required: ['fileId'] }, + { required: ['url'] }, ], } as const; diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index 0fe57de6a8..3ecbba22b5 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/index.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -41,8 +40,13 @@ export const meta = { code: 'NO_SUCH_FOLDER', id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73', }, + + restrictedByRole: { + message: 'This feature is restricted by your role.', + code: 'RESTRICTED_BY_ROLE', + id: '7f59dccb-f465-75ab-5cf4-3ce44e3282f7', + }, }, - res: { type: 'object', optional: false, nullable: false, @@ -78,7 +82,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - + const alwaysMarkNsfw = (await this.roleService.getUserPolicies(me.id)).alwaysMarkNsfw; if (file == null) { throw new ApiError(meta.errors.noSuchFile); } @@ -94,6 +98,10 @@ export default class extends Endpoint { if (ps.comment !== undefined) file.comment = ps.comment; + if (ps.isSensitive !== undefined && ps.isSensitive !== file.isSensitive && alwaysMarkNsfw && !ps.isSensitive) { + throw new ApiError(meta.errors.restrictedByRole); + } + if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; if (ps.folderId !== undefined) { diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index a17bca5aba..c835587c4a 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -1,7 +1,6 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; import type { DriveFilesRepository } from '@/models/index.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; @@ -20,6 +19,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:drive', } as const; diff --git a/packages/backend/src/server/api/endpoints/email-address/available.ts b/packages/backend/src/server/api/endpoints/email-address/available.ts index 8a497a514e..0f13b14d01 100644 --- a/packages/backend/src/server/api/endpoints/email-address/available.ts +++ b/packages/backend/src/server/api/endpoints/email-address/available.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { EmailService } from '@/core/EmailService.js'; diff --git a/packages/backend/src/server/api/endpoints/emoji.ts b/packages/backend/src/server/api/endpoints/emoji.ts new file mode 100644 index 0000000000..681d3e649e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji.ts @@ -0,0 +1,56 @@ +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { EmojisRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: false, + allowGet: true, + cacheSec: 3600, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'EmojiDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const emoji = await this.emojisRepository.findOneOrFail({ + where: { + name: ps.name, + host: IsNull(), + }, + }); + + return this.emojiEntityService.packDetailed(emoji); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 77854afb33..13cc709d31 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -1,4 +1,4 @@ -import { IsNull, MoreThan } from 'typeorm'; +import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { EmojisRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -23,24 +23,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - properties: { - name: { - type: 'string', - optional: false, nullable: false, - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - }, + ref: 'EmojiSimple', }, }, }, @@ -75,18 +58,10 @@ export default class extends Endpoint { category: 'ASC', name: 'ASC', }, - cache: { - id: 'meta_emojis', - milliseconds: 3600000, // 1 hour - }, }); return { - emojis: await this.emojiEntityService.packMany(emojis, { - omitId: true, - omitHost: true, - withUrl: true, - }), + emojis: await this.emojiEntityService.packSimpleMany(emojis), }; }); } diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts index 13b91685a4..b38c97f60a 100644 --- a/packages/backend/src/server/api/endpoints/endpoint.ts +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import endpoints from '../endpoints.js'; diff --git a/packages/backend/src/server/api/endpoints/endpoints.ts b/packages/backend/src/server/api/endpoints/endpoints.ts index 91fc3ec98d..9e706db747 100644 --- a/packages/backend/src/server/api/endpoints/endpoints.ts +++ b/packages/backend/src/server/api/endpoints/endpoints.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import endpoints from '../endpoints.js'; diff --git a/packages/backend/src/server/api/endpoints/export-custom-emojis.ts b/packages/backend/src/server/api/endpoints/export-custom-emojis.ts index ead6b037cc..6b6079ad51 100644 --- a/packages/backend/src/server/api/endpoints/export-custom-emojis.ts +++ b/packages/backend/src/server/api/endpoints/export-custom-emojis.ts @@ -1,5 +1,5 @@ import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index e5d1df0018..061c6eb5be 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -10,6 +10,8 @@ export const meta = { tags: ['federation'], requireCredential: false, + allowGet: true, + cacheSec: 3600, res: { type: 'array', @@ -74,9 +76,9 @@ export default class extends Endpoint { if (typeof ps.blocked === 'boolean') { const meta = await this.metaService.fetch(true); if (ps.blocked) { - query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere(meta.blockedHosts.length === 0 ? '1=0' : 'instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); } else { - query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); } } diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts index c19252f198..4596e0c0b5 100644 --- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { GetterService } from '@/server/api/GetterService.js'; diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts index a652047d98..3172bdbfda 100644 --- a/packages/backend/src/server/api/endpoints/flash/create.ts +++ b/packages/backend/src/server/api/endpoints/flash/create.ts @@ -1,19 +1,18 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository, FlashsRepository, PagesRepository } from '@/models/index.js'; +import type { FlashsRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { Page } from '@/models/entities/Page.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { DI } from '@/di-symbols.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; -import { ApiError } from '../../error.js'; export const meta = { tags: ['flash'], requireCredential: true, + prohibitMoved: true, + kind: 'write:flash', limit: { diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts index 5581b8ec60..23de2f3970 100644 --- a/packages/backend/src/server/api/endpoints/flash/like.ts +++ b/packages/backend/src/server/api/endpoints/flash/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/flash/show.ts b/packages/backend/src/server/api/endpoints/flash/show.ts index 48114c5a60..14720a8c8d 100644 --- a/packages/backend/src/server/api/endpoints/flash/show.ts +++ b/packages/backend/src/server/api/endpoints/flash/show.ts @@ -1,7 +1,5 @@ -import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, FlashsRepository } from '@/models/index.js'; -import type { Flash } from '@/models/entities/Flash.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts index b994f5d347..696512b06c 100644 --- a/packages/backend/src/server/api/endpoints/flash/unlike.ts +++ b/packages/backend/src/server/api/endpoints/flash/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts index 9ab17a61e8..78dfd4a06a 100644 --- a/packages/backend/src/server/api/endpoints/flash/update.ts +++ b/packages/backend/src/server/api/endpoints/flash/update.ts @@ -1,5 +1,4 @@ import ms from 'ms'; -import { Not } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { FlashsRepository, DriveFilesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -11,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash', limit: { diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts index 411c39110a..4ad16de911 100644 --- a/packages/backend/src/server/api/endpoints/following/create.ts +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -19,6 +19,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:following', errors: { diff --git a/packages/backend/src/server/api/endpoints/following/requests/accept.ts b/packages/backend/src/server/api/endpoints/following/requests/accept.ts index dcb98485da..cca3e60614 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/accept.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/accept.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; diff --git a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts index f39c4e3767..7325e73cac 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import type { FollowingsRepository } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GetterService } from '@/server/api/GetterService.js'; diff --git a/packages/backend/src/server/api/endpoints/following/requests/reject.ts b/packages/backend/src/server/api/endpoints/following/requests/reject.ts index ab5706e8ef..a8fdc44876 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/reject.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/reject.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index 3d9d471502..ca6bfa7e0f 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -7,13 +7,14 @@ import type { DriveFile } from '@/models/entities/DriveFile.js'; import { IdService } from '@/core/IdService.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; export const meta = { tags: ['gallery'], requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery', limit: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index 519e56ed6a..6ac5fa8606 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts index cfbedcc4d9..513089217d 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index d261aaa966..a2a10d8400 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -2,17 +2,17 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository, GalleryPostsRepository } from '@/models/index.js'; -import { GalleryPost } from '@/models/entities/GalleryPost.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; export const meta = { tags: ['gallery'], requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery', limit: { diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index c3f2ea9ea7..dd3549020e 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -44,7 +44,8 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const query = this.usersRepository.createQueryBuilder('user') - .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }); + .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }) + .andWhere('user.isSuspended = FALSE'); const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 6beef5ab85..a3e3e02a12 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -3,6 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '../error.js'; export const meta = { tags: ['account'], @@ -14,6 +15,15 @@ export const meta = { optional: false, nullable: false, ref: 'MeDetailed', }, + + errors: { + userIsDeleted: { + message: 'User is deleted.', + code: 'USER_IS_DELETED', + id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a', + kind: 'permission', + }, + } } as const; export const paramDef = { @@ -41,13 +51,17 @@ export default class extends Endpoint { const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 - const userProfile = await this.userProfilesRepository.findOneOrFail({ + const userProfile = await this.userProfilesRepository.findOne({ where: { userId: user.id, }, relations: ['user'], }); + if (userProfile == null) { + throw new ApiError(meta.errors.userIsDeleted); + } + if (!userProfile.loggedInDates.includes(today)) { this.userProfilesRepository.update({ userId: user.id }, { loggedInDates: [...userProfile.loggedInDates, today], diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index ec9ac1ef90..6c31075e05 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -1,7 +1,10 @@ -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from 'otpauth'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; export const meta = { @@ -22,8 +25,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const token = ps.token.replace(/\s/g, ''); @@ -34,13 +43,14 @@ export default class extends Endpoint { throw new Error('二段階認証の設定が開始されていません'); } - const verified = (speakeasy as any).totp.verify({ - secret: profile.twoFactorTempSecret, - encoding: 'base32', - token: token, + const delta = OTPAuth.TOTP.validate({ + secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret), + digits: 6, + token, + window: 1, }); - if (!verified) { + if (delta === null) { throw new Error('not verified'); } @@ -48,6 +58,12 @@ export default class extends Endpoint { twoFactorSecret: profile.twoFactorTempSecret, twoFactorEnabled: true, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 6e0849f2b2..ad33398da6 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -25,7 +25,7 @@ export const paramDef = { attestationObject: { type: 'string' }, password: { type: 'string' }, challengeId: { type: 'string' }, - name: { type: 'string' }, + name: { type: 'string', minLength: 1, maxLength: 30 }, }, required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'], } as const; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts index 0655a86350..0ee9f556a8 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -1,12 +1,23 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, secure: true, + + errors: { + noKey: { + message: 'No security key.', + code: 'NO_SECURITY_KEY', + id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512', + }, + }, } as const; export const paramDef = { @@ -23,11 +34,45 @@ export default class extends Endpoint { constructor( @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { + if (ps.value === true) { + // セキュリティキーがなければパスワードレスを有効にはできない + const keyCount = await this.userSecurityKeysRepository.count({ + where: { + userId: me.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }); + + if (keyCount === 0) { + await this.userProfilesRepository.update(me.id, { + usePasswordLessLogin: false, + }); + + throw new ApiError(meta.errors.noKey); + } + } + await this.userProfilesRepository.update(me.id, { usePasswordLessLogin: ps.value, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index a539c5c221..eb4d7f9c14 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs'; -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; import { Inject, Injectable } from '@nestjs/common'; import type { UserProfilesRepository } from '@/models/index.js'; @@ -42,25 +42,24 @@ export default class extends Endpoint { } // Generate user's secret key - const secret = speakeasy.generateSecret({ - length: 32, - }); + const secret = new OTPAuth.Secret(); await this.userProfilesRepository.update(me.id, { twoFactorTempSecret: secret.base32, }); // Get the data URL of the authenticator URL - const url = speakeasy.otpauthURL({ - secret: secret.base32, - encoding: 'base32', + const totp = new OTPAuth.TOTP({ + secret, + digits: 6, label: me.username, issuer: this.config.host, }); - const dataUrl = await QRCode.toDataURL(url); + const url = totp.toString(); + const qr = await QRCode.toDataURL(url); return { - qr: dataUrl, + qr, url, secret: secret.base32, label: me.username, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index f40ec9797d..4b726aed80 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -2,7 +2,6 @@ import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; -import type { UsersRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -51,6 +50,24 @@ export default class extends Endpoint { id: ps.credentialId, }); + // 使われているキーがなくなったらパスワードレスログインをやめる + const keyCount = await this.userSecurityKeysRepository.count({ + where: { + userId: me.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }); + + if (keyCount === 0) { + await this.userProfilesRepository.update(me.id, { + usePasswordLessLogin: false, + }); + } + // Publish meUpdated event this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { detail: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 4c5b151f78..e0e7ba6658 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -1,7 +1,9 @@ import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; export const meta = { @@ -24,6 +26,9 @@ export default class extends Endpoint { constructor( @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); @@ -38,7 +43,14 @@ export default class extends Endpoint { await this.userProfilesRepository.update(me.id, { twoFactorSecret: null, twoFactorEnabled: false, + usePasswordLessLogin: false, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts new file mode 100644 index 0000000000..d98f60fa5f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -0,0 +1,78 @@ +import bcrypt from 'bcryptjs'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + requireCredential: true, + + secure: true, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: 'f9c5467f-d492-4d3c-9a8g-a70dacc86512', + }, + + accessDenied: { + message: 'You do not have edit privilege of the channel.', + code: 'ACCESS_DENIED', + id: '1fb7cb09-d46a-4fff-b8df-057708cce513', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 30 }, + credentialId: { type: 'string' }, + }, + required: ['name', 'credentialId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const key = await this.userSecurityKeysRepository.findOneBy({ + id: ps.credentialId, + }); + + if (key == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + if (key.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await this.userSecurityKeysRepository.update(key.id, { + name: ps.name, + }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); + + return {}; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts index d7109c6953..4eef496385 100644 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -1,10 +1,10 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DI } from '@/di-symbols.js'; import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; export const meta = { requireCredential: true, + prohibitMoved: true, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/i/export-antennas.ts b/packages/backend/src/server/api/endpoints/i/export-antennas.ts new file mode 100644 index 0000000000..4182c1b247 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-antennas.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: ms('1hour'), + max: 1, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor ( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportAntennasJob(me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/export-blocking.ts b/packages/backend/src/server/api/endpoints/i/export-blocking.ts index 770708e685..4be88cbc2b 100644 --- a/packages/backend/src/server/api/endpoints/i/export-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/export-blocking.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/i/export-favorites.ts b/packages/backend/src/server/api/endpoints/i/export-favorites.ts index b32f39d3e5..f522d4c409 100644 --- a/packages/backend/src/server/api/endpoints/i/export-favorites.ts +++ b/packages/backend/src/server/api/endpoints/i/export-favorites.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/i/export-following.ts b/packages/backend/src/server/api/endpoints/i/export-following.ts index fcaa59b12d..1741781c0f 100644 --- a/packages/backend/src/server/api/endpoints/i/export-following.ts +++ b/packages/backend/src/server/api/endpoints/i/export-following.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/i/export-mute.ts b/packages/backend/src/server/api/endpoints/i/export-mute.ts index 37bef0a117..8e8042b1f9 100644 --- a/packages/backend/src/server/api/endpoints/i/export-mute.ts +++ b/packages/backend/src/server/api/endpoints/i/export-mute.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/i/export-notes.ts b/packages/backend/src/server/api/endpoints/i/export-notes.ts index 9d2505e403..ed54c9991c 100644 --- a/packages/backend/src/server/api/endpoints/i/export-notes.ts +++ b/packages/backend/src/server/api/endpoints/i/export-notes.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts index 0f8e4bca76..5c2be38b71 100644 --- a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts new file mode 100644 index 0000000000..efb5ce4223 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -0,0 +1,84 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; +import type { AntennasRepository, DriveFilesRepository, UsersRepository, Antenna as _Antenna } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + secure: true, + requireCredential: true, + prohibitMoved: true, + + limit: { + duration: ms('1hour'), + max: 1, + }, + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '3b71d086-c3fa-431c-b01d-ded65a777172', + }, + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'e842c379-8ac7-4cf7-b07a-4d4de7e4671c', + }, + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '7f60115d-8d93-4b0f-bd0e-3815dcbb389f', + }, + tooManyAntennas: { + message: 'You cannot create antenna any more.', + code: 'TOO_MANY_ANTENNAS', + id: '600917d4-a4cb-4cc5-8ba8-7ac8ea3c7779', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + }, + required: ['fileId'], +} as const; + +@Injectable() // eslint-disable-next-line import/no-default-export +export default class extends Endpoint { + constructor ( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private roleService: RoleService, + private queueService: QueueService, + private downloadService: DownloadService, + ) { + super(meta, paramDef, async (ps, me) => { + const users = await this.usersRepository.findOneBy({ id: me.id }); + if (users === null) throw new ApiError(meta.errors.noSuchUser); + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (file === null) throw new ApiError(meta.errors.noSuchFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const antennas: (_Antenna & { userListAccts: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url)); + const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id }); + if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { + throw new ApiError(meta.errors.tooManyAntennas); + } + this.queueService.createImportAntennasJob(me, antennas); + }); + } +} + +export type Antenna = (_Antenna & { userListAccts: string[] | null })[]; 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 8c1c158ab1..811971591a 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), @@ -58,15 +60,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportBlockingJob(me, file.id); }); } 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 383bdc02b5..8af278c883 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), max: 1, @@ -57,15 +59,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportFollowingJob(me, file.id); }); } 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 345ad916cb..eb0f9ba474 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), @@ -58,15 +60,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportMutingJob(me, file.id); }); } 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 875af7ec23..4568e93901 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 @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), max: 1, @@ -57,15 +59,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportUserListsJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts new file mode 100644 index 0000000000..261dd527c0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -0,0 +1,130 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; + +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +import * as Acct from '@/misc/acct.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + prohibitMoved: true, + limit: { + duration: ms('1day'), + max: 5, + }, + + errors: { + destinationAccountForbids: { + message: + 'Destination account doesn\'t have proper \'Known As\' alias, or has already moved.', + code: 'DESTINATION_ACCOUNT_FORBIDS', + id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', + }, + rootForbidden: { + message: 'The root can\'t migrate.', + code: 'NOT_ROOT_FORBIDDEN', + id: '4362e8dc-731f-4ad8-a694-be2a88922a24', + }, + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + localUriNull: { + message: 'Local User ActivityPup URI is null.', + code: 'URI_NULL', + id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71', + }, + alreadyMoved: { + message: 'Account was already moved to another account.', + code: 'ALREADY_MOVED', + id: 'b234a14e-9ebe-4581-8000-074b3c215962', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + moveToAccount: { type: 'string' }, + }, + required: ['moveToAccount'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + private getterService: GetterService, + private apPersonService: ApPersonService, + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // 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); + // abort if user has already moved + if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); + + // parse user's input into the destination account + const { username, host } = Acct.parse(ps.moveToAccount); + // retrieve the destination account + let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + const destination = await this.getterService.getUser(moveTo.id) as LocalUser | RemoteUser; + const newUri = this.userEntityService.getUserUri(destination); + + // update local db + await this.apPersonService.updatePerson(newUri); + // retrieve updated user + moveTo = await this.apPersonService.resolvePerson(newUri); + + // make sure that the user has indicated the old account as an alias + const fromUrl = this.userEntityService.genLocalUserUri(me.id); + let allowed = false; + if (moveTo.alsoKnownAs) { + for (const knownAs of moveTo.alsoKnownAs) { + if (knownAs.includes(fromUrl)) { + allowed = true; + break; + } + } + } + + // abort if unintended + if (!allowed || moveTo.movedToUri) throw new ApiError(meta.errors.destinationAccountForbids); + + return await this.accountMoveService.moveFromLocal(me, moveTo); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 706e0d2089..e141be764a 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,13 +1,16 @@ -import { Brackets } from 'typeorm'; +import { Brackets, In } from 'typeorm'; +import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js'; -import { notificationTypes } from '@/types.js'; +import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; +import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { Notification } from '@/models/entities/Notification.js'; export const meta = { tags: ['account', 'notifications'], @@ -38,14 +41,13 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - following: { type: 'boolean', default: false }, - unreadOnly: { type: 'boolean', default: false }, markAsRead: { type: 'boolean', default: true }, + // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { - type: 'string', enum: notificationTypes, + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], } }, excludeTypes: { type: 'array', items: { - type: 'string', enum: notificationTypes, + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], } }, }, required: [], @@ -55,21 +57,22 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, private queryService: QueryService, @@ -84,85 +87,44 @@ export default class extends Endpoint { if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { return []; } - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); + const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: me.id }); + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', limit); - const suspendedQuery = this.usersRepository.createQueryBuilder('users') - .select('users.id') - .where('users.isSuspended = TRUE'); - - const query = this.queryService.makePaginationQuery(this.notificationsRepository.createQueryBuilder('notification'), ps.sinceId, ps.untilId) - .andWhere('notification.notifieeId = :meId', { meId: me.id }) - .leftJoinAndSelect('notification.notifier', 'notifier') - .leftJoinAndSelect('notification.note', 'note') - .leftJoinAndSelect('notifier.avatar', 'notifierAvatar') - .leftJoinAndSelect('notifier.banner', 'notifierBanner') - .leftJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - - // muted users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - query.setParameters(mutingQuery.getParameters()); - - // muted instances - query.andWhere(new Brackets(qb => { qb - .andWhere('notifier.host IS NULL') - .orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`); - })); - query.setParameters(mutingInstanceQuery.getParameters()); - - // suspended users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - - if (ps.following) { - query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: me.id }); - query.setParameters(followingQuery.getParameters()); + if (notificationsRes.length === 0) { + return []; } - if (ps.includeTypes && ps.includeTypes.length > 0) { - query.andWhere('notification.type IN (:...includeTypes)', { includeTypes: ps.includeTypes }); - } else if (ps.excludeTypes && ps.excludeTypes.length > 0) { - query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes: ps.excludeTypes }); + let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[]; + + if (includeTypes && includeTypes.length > 0) { + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); + } else if (excludeTypes && excludeTypes.length > 0) { + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); } - if (ps.unreadOnly) { - query.andWhere('notification.isRead = false'); + if (notifications.length === 0) { + return []; } - const notifications = await query.take(ps.limit).getMany(); - // Mark all as read - if (notifications.length > 0 && ps.markAsRead) { - this.notificationService.readNotification(me.id, notifications.map(x => x.id)); + if (ps.markAsRead) { + this.notificationService.readAllNotification(me.id); } - const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + const noteIds = notifications + .filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId!); - if (notes.length > 0) { + if (noteIds.length > 0) { + const notes = await this.notesRepository.findBy({ id: In(noteIds) }); this.noteReadService.read(me.id, notes); } diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts index f31b0dc35e..2293500945 100644 --- a/packages/backend/src/server/api/endpoints/i/pin.ts +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -1,6 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NotePiningService } from '@/core/NotePiningService.js'; import { ApiError } from '../../error.js'; @@ -9,6 +8,7 @@ export const meta = { tags: ['account', 'notes'], requireCredential: true, + prohibitMoved: true, kind: 'write:account', diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts index cb5b4b0a60..b8922b91e5 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { IdService } from '@/core/IdService.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; -import type { UsersRepository } from '@/models/index.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; 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 f942f43cc8..23ff63f5e9 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -34,7 +34,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const freshUser = await this.usersRepository.findOneByOrFail({ id: me.id }); - const oldToken = freshUser.token; + const oldToken = freshUser.token!; const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); @@ -54,11 +54,6 @@ export default class extends Endpoint { // Publish event this.globalEventService.publishInternalEvent('userTokenRegenerated', { id: me.id, oldToken, newToken }); this.globalEventService.publishMainStream(me.id, 'myTokenRegenerated'); - - // Terminate streaming - setTimeout(() => { - this.globalEventService.publishUserEvent(me.id, 'terminate', {}); - }, 5000); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index 5e1dddb6b7..93daeb0cd7 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -35,9 +35,6 @@ export default class extends Endpoint { id: ps.tokenId, userId: me.id, }); - - // Terminate streaming - this.globalEventService.publishUserEvent(me.id, 'terminate'); } }); } diff --git a/packages/backend/src/server/api/endpoints/i/unpin.ts b/packages/backend/src/server/api/endpoints/i/unpin.ts index 9a735e1168..db239dc284 100644 --- a/packages/backend/src/server/api/endpoints/i/unpin.ts +++ b/packages/backend/src/server/api/endpoints/i/unpin.ts @@ -1,6 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NotePiningService } from '@/core/NotePiningService.js'; import { ApiError } from '../../error.js'; 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 b656c5c51d..4f543a6472 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -73,8 +73,8 @@ export default class extends Endpoint { } if (ps.email != null) { - const available = await this.emailService.validateEmailForAccount(ps.email); - if (!available) { + const res = await this.emailService.validateEmailForAccount(ps.email); + if (!res.available) { throw new ApiError(meta.errors.unavailable); } } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b1eaab3908..6c66300bb7 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -3,6 +3,7 @@ import * as mfm from 'mfm-js'; import { Inject, Injectable } from '@nestjs/common'; 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/index.js'; import type { User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/entities/User.js'; @@ -18,6 +19,11 @@ import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -69,6 +75,30 @@ export const meta = { code: 'TOO_MANY_MUTED_WORDS', id: '010665b1-a211-42d2-bc64-8f6609d79785', }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + + forbiddenToSetYourself: { + message: 'You can\'t set yourself as your own alias.', + code: 'FORBIDDEN_TO_SET_YOURSELF', + id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4', + }, + + restrictedByRole: { + message: 'This feature is restricted by your role.', + code: 'RESTRICTED_BY_ROLE', + id: '8feff0ba-5ab5-585b-31f4-4df816663fad', + } }, res: { @@ -127,6 +157,12 @@ export const paramDef = { emailNotificationTypes: { type: 'array', items: { type: 'string', } }, + alsoKnownAs: { + type: 'array', + maxItems: 10, + uniqueItems: true, + items: { type: 'string' }, + }, }, } as const; @@ -147,11 +183,16 @@ export default class extends Endpoint { private pagesRepository: PagesRepository, private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, private globalEventService: GlobalEventService, private userFollowingService: UserFollowingService, private accountUpdateService: AccountUpdateService, + private accountMoveService: AccountMoveService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, private hashtagService: HashtagService, private roleService: RoleService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }); @@ -168,8 +209,6 @@ export default class extends Endpoint { if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; - if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; - if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; if (ps.mutedWords !== undefined) { // TODO: ちゃんと数える const length = JSON.stringify(ps.mutedWords).length; @@ -206,7 +245,10 @@ export default class extends Endpoint { 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; - if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (typeof ps.alwaysMarkNsfw === 'boolean') { + if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); + profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + } if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; @@ -215,6 +257,14 @@ export default class extends Endpoint { if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + + updates.avatarId = avatar.id; + updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + updates.avatarBlurhash = avatar.blurhash; + } else if (ps.avatarId === null) { + updates.avatarId = null; + updates.avatarUrl = null; + updates.avatarBlurhash = null; } if (ps.bannerId) { @@ -222,6 +272,14 @@ export default class extends Endpoint { if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + + updates.bannerId = banner.id; + updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + updates.bannerBlurhash = banner.blurhash; + } else if (ps.bannerId === null) { + updates.bannerId = null; + updates.bannerUrl = null; + updates.bannerBlurhash = null; } if (ps.pinnedPageId) { @@ -242,6 +300,38 @@ export default class extends Endpoint { }); } + if (ps.alsoKnownAs) { + if (_user.movedToUri) { + throw new ApiError({ + message: 'You have moved your account.', + code: 'YOUR_ACCOUNT_MOVED', + id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', + httpStatusCode: 403, + }); + } + + // Parse user's input into the old account + const newAlsoKnownAs = new Set(); + for (const line of ps.alsoKnownAs) { + if (!line) throw new ApiError(meta.errors.noSuchUser); + const { username, host } = Acct.parse(line); + + // Retrieve the old account + const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself); + + const toUrl = this.userEntityService.getUserUri(knownAs); + if (!toUrl) throw new ApiError(meta.errors.uriNull); + + newAlsoKnownAs.add(toUrl); + } + + updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null; + } + //#region emojis/tags let emojis = [] as string[]; @@ -269,6 +359,9 @@ export default class extends Endpoint { //#endregion if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates); + if (Object.keys(updates).includes('alsoKnownAs')) { + this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates }); + } if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates); const iObj = await this.userEntityService.pack(user.id, user, { @@ -276,9 +369,12 @@ export default class extends Endpoint { includeSecrets: isSecure, }); + const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + this.cacheService.userProfileCache.set(user.id, updatedProfile); + // Publish meUpdated event this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); - this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', await this.userProfilesRepository.findOneByOrFail({ userId: user.id })); // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 if (user.isLocked && ps.isLocked === false) { diff --git a/packages/backend/src/server/api/endpoints/messaging/messages.ts b/packages/backend/src/server/api/endpoints/messaging/messages.ts index 3673e252ae..55ea653b22 100644 --- a/packages/backend/src/server/api/endpoints/messaging/messages.ts +++ b/packages/backend/src/server/api/endpoints/messaging/messages.ts @@ -115,6 +115,10 @@ export default class extends Endpoint { const messages = await query.take(ps.limit).getMany(); + if (ps.sinceId !== undefined) { + messages.reverse(); + } + // Mark all as read if (ps.markAsRead) { this.messagingService.readUserMessagingMessage(me.id, recipient.id, messages.filter(m => m.recipientId === me.id).map(x => x.id)); @@ -151,6 +155,10 @@ export default class extends Endpoint { const messages = await query.take(ps.limit).getMany(); + if (ps.sinceId) { + messages.reverse(); + } + // Mark all as read if (ps.markAsRead) { this.messagingService.readGroupMessagingMessage(me.id, recipientGroup.id, messages.map(x => x.id)); diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 5f8d6ca938..b4b7fce8d1 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -1,7 +1,7 @@ import { IsNull, LessThanOrEqual, MoreThan } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { AdsRepository, EmojisRepository, UsersRepository } from '@/models/index.js'; -import { MAX_NOTE_TEXT_LENGTH, DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import type { AdsRepository, UsersRepository } from '@/models/index.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { MetaService } from '@/core/MetaService.js'; @@ -201,10 +201,6 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - elasticsearch: { - type: 'boolean', - optional: false, nullable: false, - }, hcaptcha: { type: 'boolean', optional: false, nullable: false, @@ -262,7 +258,7 @@ export default class extends Endpoint { const ads = await this.adsRepository.find({ where: { expiresAt: MoreThan(new Date()), - startAt: LessThanOrEqual(new Date()), + startsAt: LessThanOrEqual(new Date()), }, }); @@ -276,7 +272,7 @@ export default class extends Endpoint { uri: this.config.url, description: instance.description, langs: instance.langs, - tosUrl: instance.ToSUrl, + tosUrl: instance.termsOfServiceUrl, repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, @@ -295,7 +291,7 @@ export default class extends Endpoint { iconUrl: instance.iconUrl, backgroundImageUrl: instance.backgroundImageUrl, logoImageUrl: instance.logoImageUrl, - maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, defaultLightTheme: instance.defaultLightTheme, defaultDarkTheme: instance.defaultDarkTheme, ads: ads.map(ad => ({ @@ -311,13 +307,13 @@ export default class extends Endpoint { // translatorAvailable: instance.deeplAuthKey != null, translatorAvailable: instance.translatorType != null, + serverRules: instance.serverRules, + policies: { ...DEFAULT_POLICIES, ...instance.policies }, mediaProxy: this.config.mediaProxy, ...(ps.detail ? { - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, requireSetup: (await this.usersRepository.countBy({ host: IsNull(), @@ -332,7 +328,6 @@ export default class extends Endpoint { response.features = { registration: !instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, - elasticsearch: this.config.elasticsearch ? true : false, hcaptcha: instance.enableHcaptcha, recaptcha: instance.enableRecaptcha, turnstile: instance.enableTurnstile, diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index 9099eea52e..ee358d5c6c 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -1,18 +1,17 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; import type { MutingsRepository } from '@/models/index.js'; -import type { Muting } from '@/models/entities/Muting.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['account'], requireCredential: true, + prohibitMoved: true, kind: 'write:mutes', @@ -62,9 +61,8 @@ export default class extends Endpoint { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, private getterService: GetterService, - private idService: IdService, + private userMutingService: UserMutingService, ) { super(meta, paramDef, async (ps, me) => { const muter = me; @@ -94,16 +92,7 @@ export default class extends Endpoint { return; } - // Create mute - await this.mutingsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, - muterId: muter.id, - muteeId: mutee.id, - } as Muting); - - this.globalEventService.publishUserEvent(me.id, 'mute', mutee); + await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null); }); } } diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts index 612c4a4c04..90b74590be 100644 --- a/packages/backend/src/server/api/endpoints/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -1,10 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MutingsRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['account'], @@ -49,7 +49,7 @@ export default class extends Endpoint { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, + private userMutingService: UserMutingService, private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { @@ -76,12 +76,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.notMuting); } - // Delete mute - await this.mutingsRepository.delete({ - id: exist.id, - }); - - this.globalEventService.publishUserEvent(me.id, 'unmute', mutee); + await this.userMutingService.unmute([exist]); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index 0a8f2292ac..5fbc7aba58 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -49,16 +49,10 @@ export default class extends Endpoint { .andWhere('note.visibility = \'public\'') .andWhere('note.localOnly = FALSE') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.local) { query.andWhere('note.userHost IS NULL'); diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index ea7a825f9d..26f2d6772d 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -57,16 +57,10 @@ export default class extends Endpoint { })); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts index d5caec6e1d..0a5542f497 100644 --- a/packages/backend/src/server/api/endpoints/notes/clips.ts +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -4,8 +4,8 @@ import type { ClipNotesRepository, ClipsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['clips', 'notes'], @@ -67,7 +67,7 @@ export default class extends Endpoint { isPublic: true, }); - return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts new file mode 100644 index 0000000000..6bff7fc0c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts @@ -0,0 +1,263 @@ +process.env.NODE_ENV = 'test'; + +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { describe, test, expect } from '@jest/globals'; +import { getValidator } from '../../../../../test/prelude/get-api-validator.js'; +import { paramDef } from './create.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const VALID = true; +const INVALID = false; + +describe('api:notes/create', () => { + describe('validation', () => { + const v = getValidator(paramDef); + const tooLong = readFile(_dirname + '/../../../../../test/resources/misskey.svg', 'utf-8'); + + test('reject empty', () => { + const valid = v({ }); + expect(valid).toBe(INVALID); + }); + + describe('text', () => { + test('simple post', () => { + expect(v({ text: 'Hello, world!' })) + .toBe(VALID); + }); + + test('null post', () => { + expect(v({ text: null })) + .toBe(INVALID); + }); + + test('0 characters post', () => { + expect(v({ text: '' })) + .toBe(INVALID); + }); + + test('over 3000 characters post', async () => { + expect(v({ text: await tooLong })) + .toBe(INVALID); + }); + }); + + describe('cw', () => { + test('simple cw', () => { + expect(v({ text: 'Hello, world!', cw: 'Hello, world!' })) + .toBe(VALID); + }); + + test('null cw', () => { + expect(v({ text: 'Body', cw: null })) + .toBe(VALID); + }); + + test('0 characters cw', () => { + expect(v({ text: 'Body', cw: '' })) + .toBe(VALID); + }); + + test('reject only cw', () => { + expect(v({ cw: 'Hello, world!' })) + .toBe(INVALID); + }); + + test('over 100 characters cw', async () => { + expect(v({ text: 'Body', cw: await tooLong })) + .toBe(INVALID); + }); + }); + + describe('visibility', () => { + test('public', () => { + expect(v({ text: 'Hello, world!', visibility: 'public' })) + .toBe(VALID); + }); + + test('home', () => { + expect(v({ text: 'Hello, world!', visibility: 'home' })) + .toBe(VALID); + }); + + test('followers', () => { + expect(v({ text: 'Hello, world!', visibility: 'followers' })) + .toBe(VALID); + }); + + test('reject only visibility', () => { + expect(v({ visibility: 'public' })) + .toBe(INVALID); + }); + + test('reject invalid visibility', () => { + expect(v({ text: 'Hello, world!', visibility: 'invalid' })) + .toBe(INVALID); + }); + + test('reject null visibility', () => { + expect(v({ text: 'Hello, world!', visibility: null })) + .toBe(INVALID); + }); + + describe('visibility:specified', () => { + test('specified without visibleUserIds', () => { + expect(v({ text: 'Hello, world!', visibility: 'specified' })) + .toBe(VALID); + }); + + test('specified with empty visibleUserIds', () => { + expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: [] })) + .toBe(VALID); + }); + + test('reject specified with non unique visibleUserIds', () => { + expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: ['1', '1', '2'] })) + .toBe(INVALID); + }); + + test('reject specified with null visibleUserIds', () => { + expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: null })) + .toBe(INVALID); + }); + }); + }); + + describe('fileIds', () => { + test('only fileIds', () => { + expect(v({ fileIds: ['1', '2', '3'] })) + .toBe(VALID); + }); + + test('text and fileIds', () => { + expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'] })) + .toBe(VALID); + }); + + test('reject null fileIds', () => { + expect(v({ fileIds: null })) + .toBe(INVALID); + }); + + test('reject text and null fileIds (複合的なanyOfのバリデーションが正しく動作する)', () => { + expect(v({ text: 'Hello, world!', fileIds: null })) + .toBe(INVALID); + }); + + test('reject 0 files', () => { + expect(v({ fileIds: [] })) + .toBe(INVALID); + }); + + test('reject non unique', () => { + expect(v({ fileIds: ['1', '1', '2'] })) + .toBe(INVALID); + }); + + test('reject invalid id', () => { + expect(v({ fileIds: ['あ'] })) + .toBe(INVALID); + }); + + test('reject over 17 files', () => { + const valid = v({ text: 'Hello, world!', fileIds: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18'] }); + expect(valid).toBe(INVALID); + }); + }); + + describe('poll', () => { + test('note with poll', () => { + expect(v({ text: 'Hello, world!', poll: { choices: ['a', 'b', 'c'] } })) + .toBe(VALID); + }); + + test('null poll', () => { + expect(v({ text: 'Hello, world!', poll: null })) + .toBe(VALID); + }); + + test('allow only poll', () => { + expect(v({ poll: { choices: ['a', 'b', 'c'] } })) + .toBe(VALID); + }); + + test('poll with expiresAt', async () => { + expect(v({ poll: { choices: ['a', 'b', 'c'], expiresAt: 1 } })) + .toBe(VALID); + }); + + test('poll with expiredAfter', async () => { + expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 1 } })) + .toBe(VALID); + }); + + test('reject poll without choices', () => { + expect(v({ poll: { } })) + .toBe(INVALID); + }); + + test('reject poll with empty choices', () => { + expect(v({ poll: { choices: [] } })) + .toBe(INVALID); + }); + + test('reject poll with null choices', () => { + expect(v({ poll: { choices: null } })) + .toBe(INVALID); + }); + + test('reject poll with 1 choice', () => { + expect(v({ poll: { choices: ['a'] } })) + .toBe(INVALID); + }); + + test('reject poll with too long choice', async () => { + expect(v({ poll: { choices: [await tooLong, '2'] } })) + .toBe(INVALID); + }); + + test('reject poll with too many choices', () => { + expect(v({ poll: { choices: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'] } })) + .toBe(INVALID); + }); + + test('reject poll with non unique choices', () => { + expect(v({ poll: { choices: ['a', 'a', 'b', 'c'] } })) + .toBe(INVALID); + }); + + test('reject poll with expiredAfter 0', async () => { + expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 0 } })) + .toBe(INVALID); + }); + }); + + describe('renote', () => { + test('just a renote', () => { + expect(v({ renoteId: '1' })) + .toBe(VALID); + }); + test('just a quote', () => { + expect(v({ text: 'Hello, world!', renoteId: '1' })) + .toBe(VALID); + }); + test('reject invalid renoteId', () => { + expect(v({ renoteId: 'あ' })) + .toBe(INVALID); + }); + }); + + test('text, fileIds and poll', () => { + expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'], poll: { choices: ['a', 'b', 'c'] } })) + .toBe(VALID); + }); + + test('text, invalid fileIds and invalid poll', () => { + expect(v({ text: 'Hello, world!', fileIds: ['あ'], poll: { choices: ['a'] } })) + .toBe(INVALID); + }); + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 196f76a554..b2371ca37d 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -11,7 +11,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.js'; -import { noteVisibilities } from '../../../../types.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -19,6 +18,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + limit: { duration: ms('1hour'), max: 300, @@ -80,6 +81,12 @@ export const meta = { code: 'YOU_HAVE_BEEN_BLOCKED', id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', }, + + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, }, } as const; @@ -92,79 +99,62 @@ export const paramDef = { } }, cw: { type: 'string', nullable: true, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null }, disableRightClick: { type: 'boolean', default: false }, noExtractMentions: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false }, replyId: { type: 'string', format: 'misskey:id', nullable: true }, + renoteId: { type: 'string', format: 'misskey:id', nullable: true }, channelId: { type: 'string', format: 'misskey:id', nullable: true }, + + // anyOf内にバリデーションを書いても最初の一つしかチェックされない + // See https://github.com/misskey-dev/misskey/pull/10082 + text: { + type: 'string', + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: false, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + mediaIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, }, + // (re)note with text, files and poll are optional anyOf: [ - { - // (re)note with text, files and poll are optional - properties: { - text: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false }, - }, - required: ['text'], - }, - { - // (re)note with files, text and poll are optional - properties: { - fileIds: { - type: 'array', - uniqueItems: true, - minItems: 1, - maxItems: 16, - items: { type: 'string', format: 'misskey:id' }, - }, - }, - required: ['fileIds'], - }, - { - // (re)note with files, text and poll are optional - properties: { - mediaIds: { - deprecated: true, - description: 'Use `fileIds` instead. If both are specified, this property is discarded.', - type: 'array', - uniqueItems: true, - minItems: 1, - maxItems: 16, - items: { type: 'string', format: 'misskey:id' }, - }, - }, - required: ['mediaIds'], - }, - { - // (re)note with poll, text and files are optional - properties: { - poll: { - type: 'object', - nullable: true, - properties: { - choices: { - type: 'array', - uniqueItems: true, - minItems: 2, - maxItems: 10, - items: { type: 'string', minLength: 1, maxLength: 50 }, - }, - multiple: { type: 'boolean' }, - expiresAt: { type: 'integer', nullable: true }, - expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, - }, - required: ['choices'], - }, - }, - required: ['poll'], - }, - { - // pure renote - properties: { - renoteId: { type: 'string', format: 'misskey:id', nullable: true }, - }, - required: ['renoteId'], - }, + { required: ['text'] }, + { required: ['renoteId'] }, + { required: ['fileIds'] }, + { required: ['mediaIds'] }, + { required: ['poll'] }, ], } as const; @@ -209,6 +199,10 @@ export default class extends Endpoint { .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') .setParameters({ fileIds }) .getMany(); + + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchFile); + } } let renote: Note | null = null; @@ -269,7 +263,7 @@ export default class extends Endpoint { let channel: Channel | null = null; if (ps.channelId != null) { - channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); if (channel == null) { throw new ApiError(meta.errors.noSuchChannel); @@ -282,7 +276,7 @@ export default class extends Endpoint { files: files, poll: ps.poll ? { choices: ps.poll.choices, - multiple: ps.poll.multiple || false, + multiple: ps.poll.multiple ?? false, expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, } : undefined, text: ps.text ?? undefined, @@ -290,6 +284,7 @@ export default class extends Endpoint { renote, cw: ps.cw, localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, disableRightClick: ps.disableRightClick, visibility: ps.visibility, visibleUsers, diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts index 0ce80a1a63..611ea19560 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -12,6 +12,7 @@ export const meta = { tags: ['notes', 'favorites'], requireCredential: true, + prohibitMoved: true, kind: 'write:favorites', diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 76834cfde9..bdb06498bc 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -9,6 +9,8 @@ export const meta = { tags: ['notes'], requireCredential: false, + allowGet: true, + cacheSec: 3600, res: { type: 'array', @@ -26,6 +28,7 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, + channelId: { type: 'string', nullable: true, format: 'misskey:id' }, }, required: [], } as const; @@ -41,7 +44,6 @@ export default class extends Endpoint { private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const max = 30; const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで const query = this.notesRepository.createQueryBuilder('note') @@ -51,23 +53,19 @@ export default class extends Endpoint { .andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) }) .andWhere('note.visibility = \'public\'') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); let notes = await query .orderBy('note.score', 'DESC') - .take(max) + .take(100) .getMany(); notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 5d0cdc3fca..c11c1eac40 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -73,22 +73,17 @@ export default class extends Endpoint { .andWhere('note.visibility = \'public\'') .andWhere('note.channelId IS NULL') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateRepliesQuery(query, me); if (me) { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 2819abb125..89abd91c7e 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -8,6 +8,7 @@ import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -69,6 +70,7 @@ export default class extends Endpoint { private metaService: MetaService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me.id); @@ -83,22 +85,16 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .andWhere(new Brackets(qb => { qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .setParameters(followingQuery.getParameters()); this.queryService.generateChannelQuery(query, me); @@ -107,6 +103,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index f396f7e584..afdafc7c55 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -1,6 +1,6 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, UsersRepository } from '@/models/index.js'; +import type { NotesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -8,6 +8,7 @@ import { MetaService } from '@/core/MetaService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -65,6 +66,7 @@ export default class extends Endpoint { private metaService: MetaService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -75,19 +77,13 @@ export default class extends Endpoint { //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateChannelQuery(query, me); this.queryService.generateRepliesQuery(query, me); @@ -95,6 +91,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateMutedNoteQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 92b82eb5de..4e9f604d8d 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -4,7 +4,6 @@ import type { NotesRepository, FollowingsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; @@ -61,16 +60,10 @@ export default class extends Endpoint { .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index f734e4e778..3a33b037f8 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -1,4 +1,3 @@ -import { Not } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, PollsRepository, PollVotesRepository } from '@/models/index.js'; import type { RemoteUser } from '@/models/entities/User.js'; @@ -9,7 +8,6 @@ import { QueueService } from '@/core/QueueService.js'; import { PollService } from '@/core/PollService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; import { DI } from '@/di-symbols.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { ApiError } from '../../../error.js'; @@ -19,6 +17,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:votes', errors: { @@ -90,7 +90,6 @@ export default class extends Endpoint { private pollService: PollService, private apRendererService: ApRendererService, private globalEventService: GlobalEventService, - private createNotificationService: CreateNotificationService, private userBlockingService: UserBlockingService, ) { super(meta, paramDef, async (ps, me) => { @@ -162,7 +161,7 @@ export default class extends Endpoint { if (note.userHost != null) { const pollOwner = await this.usersRepository.findOneByOrFail({ id: note.userId }) as RemoteUser; - this.queueService.deliver(me, this.apRendererService.addContext(await this.apRendererService.renderVote(me, vote, note, poll, pollOwner)), pollOwner.inbox); + this.queueService.deliver(me, this.apRendererService.addContext(await this.apRendererService.renderVote(me, vote, note, poll, pollOwner)), pollOwner.inbox, false); } // リモートフォロワーにUpdate配信 diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index 02ae212a30..4772c4f809 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -1,11 +1,9 @@ -import { DeepPartial } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { NoteReactionsRepository } from '@/models/index.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import type { FindOptionsWhere } from 'typeorm'; export const meta = { @@ -77,7 +75,7 @@ export default class extends Endpoint { order: { id: -1, }, - relations: ['user', 'user.avatar', 'user.banner', 'note'], + relations: ['user', 'note'], }); return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me))); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index 839f893db2..97cb026779 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ReactionService } from '@/core/ReactionService.js'; @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:reactions', errors: { diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts index cf90d7b5f6..207f0b4cf2 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts @@ -1,5 +1,5 @@ import ms from 'ms'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ReactionService } from '@/core/ReactionService.js'; diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 026a1baa3e..d406855660 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -4,8 +4,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -62,16 +62,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere('note.renoteId = :renoteId', { renoteId: note.id }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 4df95962c8..f2af71d55f 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -46,16 +46,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index bcd793ac43..2956bf1cbd 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 @@ -36,32 +36,25 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + + tag: { type: 'string', minLength: 1 }, + query: { + type: 'array', + description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.', + items: { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + minItems: 1, + }, + minItems: 1, + }, }, anyOf: [ - { - properties: { - tag: { type: 'string', minLength: 1 }, - }, - required: ['tag'], - }, - { - properties: { - query: { - type: 'array', - description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.', - items: { - type: 'array', - items: { - type: 'string', - minLength: 1, - }, - minItems: 1, - }, - minItems: 1, - }, - }, - required: ['query'], - }, + { required: ['tag'] }, + { required: ['query'] }, ], } as const; @@ -78,16 +71,10 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index 8eb031dfe3..f6385400c3 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -1,12 +1,12 @@ -import { In } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; +import { SearchService } from '@/core/SearchService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -24,6 +24,11 @@ export const meta = { }, errors: { + unavailable: { + message: 'Search of notes unavailable.', + code: 'UNAVAILABLE', + id: '0b44998d-77aa-4427-80d0-d2c9b8523011', + }, }, } as const; @@ -37,8 +42,7 @@ export const paramDef = { offset: { type: 'integer', default: 0 }, host: { type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', + description: 'The local host is represented with `.`.', }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, channelId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, @@ -55,40 +59,25 @@ export default class extends Endpoint { @Inject(DI.config) private config: Config, - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, - private queryService: QueryService, + private searchService: SearchService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); - - if (ps.userId) { - query.andWhere('note.userId = :userId', { userId: ps.userId }); - } else if (ps.channelId) { - query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); + const policies = await this.roleService.getUserPolicies(me ? me.id : null); + if (!policies.canSearchNotes) { + throw new ApiError(meta.errors.unavailable); } - - query - .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - - const notes = await query.take(ps.limit).getMany(); + + const notes = await this.searchService.searchNote(ps.query, me, { + userId: ps.userId, + channelId: ps.channelId, + host: ps.host, + }, { + untilId: ps.untilId, + sinceId: ps.sinceId, + limit: ps.limit, + }); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index d0036f0fb7..93517ab10c 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -59,7 +59,7 @@ export default class extends Endpoint { this.noteThreadMutingsRepository.count({ where: { userId: me.id, - threadId: note.threadId || note.id, + threadId: note.threadId ?? note.id, }, take: 1, }), diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 145d3f5c83..c6ee1e5c2b 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,8 +5,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['notes'], @@ -57,39 +57,31 @@ export default class extends Endpoint { private noteEntityService: NoteEntityService, private queryService: QueryService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - const hasFollowing = (await this.followingsRepository.count({ - where: { - followerId: me.id, - }, - take: 1, - })) !== 0; + const followees = await this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }) + .getMany(); //#region Construct query - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで - .andWhere(new Brackets(qb => { qb - .where('note.userId = :meId', { meId: me.id }); - if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`); - })) + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .setParameters(followingQuery.getParameters()); + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (followees.length > 0) { + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + + query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + } else { + query.andWhere('note.userId = :meId', { meId: me.id }); + } this.queryService.generateChannelQuery(query, me); this.queryService.generateRepliesQuery(query, me); @@ -97,6 +89,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 5fa5f2c03b..94d1d7fbde 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -1,4 +1,5 @@ import { URLSearchParams } from 'node:url'; +import googletr from '@vitalets/google-translate-api'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -9,7 +10,6 @@ import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApiError } from '../../error.js'; -import googletr from '@vitalets/google-translate-api'; export const meta = { tags: ['notes'], @@ -80,42 +80,6 @@ export default class extends Endpoint { const instance = await this.metaService.fetch(); - /* - if (instance.deeplAuthKey == null) { - return 204; // TODO: 良い感じのエラー返す - } - */ - -// let targetLang = ps.targetLang; -// if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; - -// const params = new URLSearchParams(); -// params.append('auth_key', instance.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 res = await this.httpRequestService.send(endpoint, { -// method: 'POST', -// headers: { -// 'Content-Type': 'application/x-www-form-urlencoded', -// Accept: 'application/json, */*', -// }, -// body: params.toString(), -// }); - -// const json = (await res.json()) as { -// translations: { -// detected_source_language: string; -// text: string; -// }[]; -// }; - - if (instance.translatorType == null || !translatorServices.includes(instance.translatorType)) { - throw new ApiError(meta.errors.noTranslateService); - } - if (instance.translatorType === 'DeepL') { if (instance.deeplAuthKey == null) { return 204; // TODO: 良い感じのエラー返す @@ -163,14 +127,9 @@ export default class extends Endpoint { text: json.text, translator: translatorServices, }; + } else { + throw new ApiError(meta.errors.noTranslateService); } - - /* - return { - sourceLang: json.translations[0].detected_source_language, - text: json.translations[0].text, - }; - */ }); } } 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 9b23103fd4..afc9bc4213 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -84,16 +84,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); this.queryService.generateVisibilityQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts index 3427a3eb5c..4102a924ad 100644 --- a/packages/backend/src/server/api/endpoints/notifications/create.ts +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -1,6 +1,6 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { tags: ['notifications'], @@ -27,10 +27,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, user, token) => { - this.createNotificationService.createNotification(user.id, 'app', { + this.notificationService.createNotification(user.id, 'app', { appAccessTokenId: token ? token.id : null, customBody: ps.body, customHeader: ps.header, diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts index 09134cf48f..e601bf9d5b 100644 --- a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -1,9 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { NotificationsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { PushNotificationService } from '@/core/PushNotificationService.js'; import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { tags: ['notifications', 'account'], @@ -23,24 +21,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - - private globalEventService: GlobalEventService, - private pushNotificationService: PushNotificationService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { - // Update documents - await this.notificationsRepository.update({ - notifieeId: me.id, - isRead: false, - }, { - isRead: true, - }); - - // 全ての通知を読みましたよというイベントを発行 - this.globalEventService.publishMainStream(me.id, 'readAllNotifications'); - this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined); + this.notificationService.readAllNotification(me.id, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts deleted file mode 100644 index cdf8d09f9e..0000000000 --- a/packages/backend/src/server/api/endpoints/notifications/read.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NotificationService } from '@/core/NotificationService.js'; - -export const meta = { - tags: ['notifications', 'account'], - - requireCredential: true, - - kind: 'write:notifications', - - description: 'Mark a notification as read.', - - errors: { - noSuchNotification: { - message: 'No such notification.', - code: 'NO_SUCH_NOTIFICATION', - id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e', - }, - }, -} as const; - -export const paramDef = { - oneOf: [ - { - type: 'object', - properties: { - notificationId: { type: 'string', format: 'misskey:id' }, - }, - required: ['notificationId'], - }, - { - type: 'object', - properties: { - notificationIds: { - type: 'array', - items: { type: 'string', format: 'misskey:id' }, - maxItems: 100, - }, - }, - required: ['notificationIds'], - }, - ], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - private notificationService: NotificationService, - ) { - super(meta, paramDef, async (ps, me) => { - if ('notificationId' in ps) return this.notificationService.readNotification(me.id, [ps.notificationId]); - return this.notificationService.readNotification(me.id, ps.notificationIds); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/page-push.ts b/packages/backend/src/server/api/endpoints/page-push.ts index 1841a84539..1d6fb567f0 100644 --- a/packages/backend/src/server/api/endpoints/page-push.ts +++ b/packages/backend/src/server/api/endpoints/page-push.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import type { PagesRepository } from '@/models/index.js'; -import type { UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index 4015bf1f29..e08ab399f8 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:pages', limit: { diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts index d27990f7e1..543c126d9c 100644 --- a/packages/backend/src/server/api/endpoints/pages/like.ts +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:page-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts index 651252afbb..bf2b2a431e 100644 --- a/packages/backend/src/server/api/endpoints/pages/show.ts +++ b/packages/backend/src/server/api/endpoints/pages/show.ts @@ -29,20 +29,14 @@ export const meta = { export const paramDef = { type: 'object', + properties: { + pageId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string' }, + username: { type: 'string' }, + }, anyOf: [ - { - properties: { - pageId: { type: 'string', format: 'misskey:id' }, - }, - required: ['pageId'], - }, - { - properties: { - name: { type: 'string' }, - username: { type: 'string' }, - }, - required: ['name', 'username'], - }, + { required: ['pageId'] }, + { required: ['name', 'username'] }, ], } as const; diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts index e397e2a23b..f0c0198460 100644 --- a/packages/backend/src/server/api/endpoints/pages/unlike.ts +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:page-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index 35b402ec56..751274067e 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:pages', limit: { diff --git a/packages/backend/src/server/api/endpoints/ping.ts b/packages/backend/src/server/api/endpoints/ping.ts index 4bb62b298e..5807bf101e 100644 --- a/packages/backend/src/server/api/endpoints/ping.ts +++ b/packages/backend/src/server/api/endpoints/ping.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; export const meta = { diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts new file mode 100644 index 0000000000..beb5850d78 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -0,0 +1,98 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + prohibitMoved: true, + + kind: 'write:mutes', + + limit: { + duration: ms('1hour'), + max: 20, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '5e0a5dff-1e94-4202-87ae-4d9c89eb2271', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '37285718-52f7-4aef-b7de-c38b8e8a8420', + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING', + id: 'ccfecbe4-1f1c-4fc2-8a3d-c3ffee61cb7b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // 自分自身 + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check if already muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await this.renoteMutingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as RenoteMuting); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts new file mode 100644 index 0000000000..70901a1406 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -0,0 +1,85 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9b6728cf-638c-4aa1-bedb-e07d8101474d', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '619b1314-0850-4597-a242-e245f3da42af', + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '2e4ef874-8bf0-4b4b-b069-4598f6d05817', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // Check if the mutee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await this.renoteMutingsRepository.delete({ + id: exist.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts new file mode 100644 index 0000000000..b2d7addb64 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RenoteMutingEntityService } from '@/core/entities/RenoteMutingEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'read:mutes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'RenoteMuting', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private renoteMutingEntityService: RenoteMutingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere('muting.muterId = :meId', { meId: me.id }); + + const mutings = await query + .take(ps.limit) + .getMany(); + + return await this.renoteMutingEntityService.packMany(mutings, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/request-reset-password.ts b/packages/backend/src/server/api/endpoints/request-reset-password.ts index 42b10a4fb3..3b6ebfe281 100644 --- a/packages/backend/src/server/api/endpoints/request-reset-password.ts +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -8,7 +8,6 @@ import { IdService } from '@/core/IdService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { EmailService } from '@/core/EmailService.js'; -import { ApiError } from '../error.js'; export const meta = { tags: ['reset password'], diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index 526efbc2f6..4ced6d3ff1 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -1,10 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { resetDb } from '@/misc/reset-db.js'; -import { ApiError } from '../error.js'; export const meta = { tags: ['non-productive'], diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts index cf7fcb7afd..e6f1af7b22 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -1,10 +1,8 @@ import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import type { UserProfilesRepository, PasswordResetRequestsRepository } from '@/models/index.js'; -import type { UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../error.js'; export const meta = { tags: ['reset password'], diff --git a/packages/backend/src/server/api/endpoints/retention.ts b/packages/backend/src/server/api/endpoints/retention.ts index e3c2249cdd..e9c0fd4dcd 100644 --- a/packages/backend/src/server/api/endpoints/retention.ts +++ b/packages/backend/src/server/api/endpoints/retention.ts @@ -1,4 +1,3 @@ -import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { RetentionAggregationsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; diff --git a/packages/backend/src/server/api/endpoints/roles/list.ts b/packages/backend/src/server/api/endpoints/roles/list.ts new file mode 100644 index 0000000000..d61c6b8dc6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/list.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RolesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; + +export const meta = { + tags: ['role'], + + requireCredential: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [ + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private roleEntityService: RoleEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const roles = await this.rolesRepository.findBy({ + isPublic: true, + }); + return await this.roleEntityService.packMany(roles, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts new file mode 100644 index 0000000000..6202c740f1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -0,0 +1,112 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { NotesRepository, RolesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['role', 'notes'], + + requireCredential: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e', + }, + }, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private idService: IdService, + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + isPublic: true, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + if (!role.isExplorable) { + return []; + } + const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const noteIdsRes = await this.redisClient.xrevrange( + `roleTimeline:${role.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', + 'COUNT', limit); + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + + const notes = await query.getMany(); + notes.sort((a, b) => a.id > b.id ? -1 : 1); + + return await this.noteEntityService.packMany(notes, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/roles/show.ts b/packages/backend/src/server/api/endpoints/roles/show.ts new file mode 100644 index 0000000000..cc755dcc76 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/show.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { RolesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['role', 'users'], + + requireCredential: false, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: 'de5502bf-009a-4639-86c1-fec349e46dcb', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private roleEntityService: RoleEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + isPublic: true, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + return await this.roleEntityService.pack(role, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts new file mode 100644 index 0000000000..607dc24206 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['role', 'users'], + + requireCredential: false, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: '30aaaee3-4792-48dc-ab0d-cf501a575ac5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { 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 }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + + private queryService: QueryService, + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + isPublic: true, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) + .andWhere('assign.roleId = :roleId', { roleId: role.id }) + .andWhere(new Brackets(qb => { qb + .where('assign.expiresAt IS NULL') + .orWhere('assign.expiresAt > :now', { now: new Date() }); + })) + .innerJoinAndSelect('assign.user', 'user'); + + const assigns = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(assigns.map(async assign => ({ + id: assign.id, + user: await this.userEntityService.pack(assign.user!, me, { detail: true }), + }))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index 8989a3073d..1620e8ae52 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -1,6 +1,6 @@ import * as os from 'node:os'; import si from 'systeminformation'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; export const meta = { diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts index 8bd0311dce..48a85758a0 100644 --- a/packages/backend/src/server/api/endpoints/stats.ts +++ b/packages/backend/src/server/api/endpoints/stats.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; import type { InstancesRepository, NoteReactionsRepository, NotesRepository, UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/server/api/endpoints/test.ts b/packages/backend/src/server/api/endpoints/test.ts index 39ea1f2171..c88f7f2daf 100644 --- a/packages/backend/src/server/api/endpoints/test.ts +++ b/packages/backend/src/server/api/endpoints/test.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; export const meta = { diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts index c80b6efdcd..6293c5cb50 100644 --- a/packages/backend/src/server/api/endpoints/username/available.ts +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -4,6 +4,7 @@ import type { UsedUsernamesRepository, UsersRepository } from '@/models/index.js import { Endpoint } from '@/server/api/endpoint-base.js'; import { localUsernameSchema } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; +import { MetaService } from '@/core/MetaService.js'; export const meta = { tags: ['users'], @@ -39,9 +40,10 @@ export default class extends Endpoint { @Inject(DI.usedUsernamesRepository) private usedUsernamesRepository: UsedUsernamesRepository, + + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { - // Get exist const exist = await this.usersRepository.countBy({ host: IsNull(), usernameLower: ps.username.toLowerCase(), @@ -49,8 +51,11 @@ export default class extends Endpoint { 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()); + return { - available: exist === 0 && exist2 === 0, + available: exist === 0 && exist2 === 0 && !isPreserved, }; }); } diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index 8becb68a34..28cd9f6ce5 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -50,8 +50,9 @@ export default class extends Endpoint { private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.usersRepository.createQueryBuilder('user'); - query.where('user.isExplorable = TRUE'); + const query = this.usersRepository.createQueryBuilder('user') + .where('user.isExplorable = TRUE') + .andWhere('user.isSuspended = FALSE'); switch (ps.state) { case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts index e3fd0920c9..c5aa93baaf 100644 --- a/packages/backend/src/server/api/endpoints/users/clips.ts +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { .take(ps.limit) .getMany(); - return await this.clipEntityService.packMany(clips); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index 17ce920011..97f1310c36 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -46,25 +46,18 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + + userId: { type: 'string', format: 'misskey:id' }, + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, }, anyOf: [ - { - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], - }, - { - properties: { - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', - }, - }, - required: ['username', 'host'], - }, + { required: ['userId'] }, + { required: ['username', 'host'] }, ], } as const; diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 6dbda0d72f..d406594a2e 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -46,25 +46,18 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + + userId: { type: 'string', format: 'misskey:id' }, + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, }, anyOf: [ - { - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], - }, - { - properties: { - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', - }, - }, - required: ['username', 'host'], - }, + { required: ['userId'] }, + { required: ['username', 'host'] }, ], } as const; diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts index 2e040c0601..13d67e39fc 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/invite.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts @@ -4,7 +4,7 @@ import { IdService } from '@/core/IdService.js'; import type { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -68,7 +68,7 @@ export default class extends Endpoint { private idService: IdService, private getterService: GetterService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { // Fetch the group @@ -113,7 +113,7 @@ export default class extends Endpoint { } as UserGroupInvitation).then(x => this.userGroupInvitationsRepository.findOneByOrFail(x.identifiers[0])); // 通知を作成 - this.createNotificationService.createNotification(user.id, 'groupInvited', { + this.notificationService.createNotification(user.id, 'groupInvited', { notifierId: me.id, userGroupInvitationId: invitation.id, }); diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index a840c1a04e..7510889526 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Create a new list of users.', @@ -58,7 +60,7 @@ export default class extends Endpoint { if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } - + const userList = await this.userListsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index d2dd5731ee..d50b70efc2 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Remove a user from a list.', diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 1c1fdc23f1..925037e484 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Add a user to an existing list.', diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index aab32cc58c..aaf94734a3 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -74,16 +74,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.userId = :userId', { userId: user.id }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) { diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index ac9104bf92..3267c18846 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -50,6 +50,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, { @@ -91,6 +95,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, }, diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index b176e6c65d..b001159ee8 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 @@ -1,7 +1,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; -import { USER_ACTIVE_THRESHOLD } from '@/const.js'; +import type { Config } from '@/config.js'; import type { User } from '@/models/entities/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -31,29 +31,23 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, detail: { type: 'boolean', default: true }, + + username: { type: 'string', nullable: true }, + host: { type: 'string', nullable: true }, }, anyOf: [ - { - properties: { - username: { type: 'string', nullable: true }, - }, - required: ['username'] - }, - { - properties: { - host: { type: 'string', nullable: true }, - }, - required: ['host'] - }, + { required: ['username'] }, + { required: ['host'] }, ], } as const; -// TODO: avatar,bannerをJOINしたいけどエラーになる - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -63,79 +57,76 @@ export default class extends Endpoint { private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - - if (ps.host) { - const q = this.usersRepository.createQueryBuilder('user') - .where('user.isSuspended = FALSE') - .andWhere('user.host LIKE :host', { host: sqlLikeEscape(ps.host.toLowerCase()) + '%' }); - + const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => { if (ps.username) { - q.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); + query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); } - q.andWhere('user.updatedAt IS NOT NULL'); - q.orderBy('user.updatedAt', 'DESC'); - - const users = await q.take(ps.limit).getMany(); - - return await this.userEntityService.packMany(users, me, { detail: ps.detail }); - } else if (ps.username) { - let users: User[] = []; - - if (me) { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - - const query = this.usersRepository.createQueryBuilder('user') - .where(`user.id IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) - .andWhere(new Brackets(qb => { qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })); - - query.setParameters(followingQuery.getParameters()); - - users = await query - .orderBy('user.usernameLower', 'ASC') - .take(ps.limit) - .getMany(); - - if (users.length < ps.limit) { - const otherQuery = await this.usersRepository.createQueryBuilder('user') - .where(`user.id NOT IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) - .andWhere('user.updatedAt IS NOT NULL'); - - otherQuery.setParameters(followingQuery.getParameters()); - - const otherUsers = await otherQuery - .orderBy('user.updatedAt', 'DESC') - .take(ps.limit - users.length) - .getMany(); - - users = users.concat(otherUsers); + if (ps.host) { + if (ps.host === this.config.hostname || ps.host === '.') { + query.andWhere('user.host IS NULL'); + } else { + query.andWhere('user.host LIKE :host', { + host: sqlLikeEscape(ps.host.toLowerCase()) + '%', + }); } - } else { - users = await this.usersRepository.createQueryBuilder('user') - .where('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) - .andWhere('user.updatedAt IS NOT NULL') + } + + return query; + }; + + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + let users: User[] = []; + + if (me) { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const query = setUsernameAndHostQuery() + .andWhere(`user.id IN (${ followingQuery.getQuery() })`) + .andWhere('user.id != :meId', { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })); + + query.setParameters(followingQuery.getParameters()); + + users = await query + .orderBy('user.usernameLower', 'ASC') + .take(ps.limit) + .getMany(); + + if (users.length < ps.limit) { + const otherQuery = setUsernameAndHostQuery() + .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.updatedAt IS NOT NULL'); + + otherQuery.setParameters(followingQuery.getParameters()); + + const otherUsers = await otherQuery .orderBy('user.updatedAt', 'DESC') .take(ps.limit - users.length) .getMany(); - } - return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); + users = users.concat(otherUsers); + } + } else { + const query = setUsernameAndHostQuery() + .andWhere('user.isSuspended = FALSE') + .andWhere('user.updatedAt IS NOT NULL'); + + users = await query + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit - users.length) + .getMany(); } - return []; + return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 70258ef009..ba432c273b 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -48,38 +48,29 @@ export const meta = { message: 'No such user.', code: 'NO_SUCH_USER', id: '4362f8dc-731f-4ad8-a694-be5a88922a24', + httpStatusCode: 404, }, }, } as const; export const paramDef = { type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + userIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, + }, anyOf: [ - { - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], - }, - { - properties: { - userIds: { type: 'array', uniqueItems: true, items: { - type: 'string', format: 'misskey:id', - } }, - }, - required: ['userIds'], - }, - { - properties: { - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', - }, - }, - required: ['username'], - }, + { required: ['userId'] }, + { required: ['userIds'] }, + { required: ['username'] }, ], } as const; diff --git a/packages/backend/src/server/api/endpoints/users/update-memo.ts b/packages/backend/src/server/api/endpoints/users/update-memo.ts new file mode 100644 index 0000000000..ca7756ef75 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/update-memo.ts @@ -0,0 +1,85 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserMemoRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '6fef56f3-e765-4957-88e5-c6f65329b8a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + memo: { + type: 'string', + nullable: true, + description: 'A personal memo for the target user. If null or empty, delete the memo.', + }, + }, + required: ['userId', 'memo'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userMemosRepository) + private userMemosRepository: UserMemoRepository, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get target + const target = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // 引数がnullか空文字であれば、パーソナルメモを削除する + if (ps.memo === '' || ps.memo == null) { + await this.userMemosRepository.delete({ + userId: me.id, + targetUserId: target.id, + }); + return; + } + + // 以前に作成されたパーソナルメモがあるかどうか確認 + const previousMemo = await this.userMemosRepository.findOneBy({ + userId: me.id, + targetUserId: target.id, + }); + + if (!previousMemo) { + await this.userMemosRepository.insert({ + id: this.idService.genId(), + userId: me.id, + targetUserId: target.id, + memo: ps.memo, + }); + } else { + await this.userMemosRepository.update(previousMemo.id, { + userId: me.id, + targetUserId: target.id, + memo: ps.memo, + }); + } + }); + } +} diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 347d5650ad..34f4521606 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -1,4 +1,4 @@ -type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number }; +type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: number }; export class ApiError extends Error { public message: string; diff --git a/packages/backend/src/server/api/openapi/OpenApiServerService.ts b/packages/backend/src/server/api/openapi/OpenApiServerService.ts new file mode 100644 index 0000000000..e804ba276c --- /dev/null +++ b/packages/backend/src/server/api/openapi/OpenApiServerService.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { genOpenapiSpec } from './gen-spec.js'; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; + +const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); + +@Injectable() +export class OpenApiServerService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + @bindThis + public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.get('/api-doc', async (_request, reply) => { + reply.header('Cache-Control', 'public, max-age=86400'); + return await reply.sendFile('/redoc.html', staticAssets); + }); + fastify.get('/api.json', (_request, reply) => { + reply.header('Cache-Control', 'public, max-age=600'); + reply.send(genOpenapiSpec(this.config)); + }); + done(); + } +} diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts new file mode 100644 index 0000000000..fa62480c02 --- /dev/null +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -0,0 +1,193 @@ +import type { Config } from '@/config.js'; +import endpoints from '../endpoints.js'; +import { errors as basicErrors } from './errors.js'; +import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; + +export function genOpenapiSpec(config: Config) { + const spec = { + openapi: '3.0.0', + + info: { + version: config.version, + title: 'Misskey API', + 'x-logo': { url: '/static-assets/api-doc.png' }, + }, + + externalDocs: { + description: 'Repository', + url: 'https://github.com/misskey-dev/misskey', + }, + + servers: [{ + url: config.apiUrl, + }], + + paths: {} as any, + + components: { + schemas: schemas, + + securitySchemes: { + ApiKeyAuth: { + type: 'apiKey', + in: 'body', + name: 'i', + }, + }, + }, + }; + + for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { + const errors = {} as any; + + if (endpoint.meta.errors) { + for (const e of Object.values(endpoint.meta.errors)) { + errors[e.code] = { + value: { + error: e, + }, + }; + } + } + + const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; + + let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n'; + desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; + if (endpoint.meta.kind) { + const kind = endpoint.meta.kind; + desc += ` / **Permission**: *${kind}*`; + } + + const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json'; + const schema = { ...endpoint.params }; + + if (endpoint.meta.requireFile) { + schema.properties = { + ...schema.properties, + file: { + type: 'string', + format: 'binary', + description: 'The file contents.', + }, + }; + schema.required = [...schema.required ?? [], 'file']; + } + + const info = { + operationId: endpoint.name, + summary: endpoint.name, + description: desc, + externalDocs: { + description: 'Source code', + url: `https://github.com/misskey-dev/misskey/blob/develop/packages/backend/src/server/api/endpoints/${endpoint.name}.ts`, + }, + ...(endpoint.meta.tags ? { + tags: [endpoint.meta.tags[0]], + } : {}), + ...(endpoint.meta.requireCredential ? { + security: [{ + ApiKeyAuth: [], + }], + } : {}), + requestBody: { + required: true, + content: { + [requestType]: { + schema, + }, + }, + }, + responses: { + ...(endpoint.meta.res ? { + '200': { + description: 'OK (with results)', + content: { + 'application/json': { + schema: resSchema, + }, + }, + }, + } : { + '204': { + description: 'OK (without any results)', + }, + }), + '400': { + description: 'Client error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: { ...errors, ...basicErrors['400'] }, + }, + }, + }, + '401': { + description: 'Authentication error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['401'], + }, + }, + }, + '403': { + description: 'Forbidden error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['403'], + }, + }, + }, + '418': { + description: 'I\'m Ai', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['418'], + }, + }, + }, + ...(endpoint.meta.limit ? { + '429': { + description: 'To many requests', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['429'], + }, + }, + }, + } : {}), + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['500'], + }, + }, + }, + }, + }; + + spec.paths['/' + endpoint.name] = { + post: info, + }; + } + + return spec; +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 796383f5e3..0cef361caf 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -1,5 +1,5 @@ -import type { Schema } from '@/misc/schema.js'; -import { refs } from '@/misc/schema.js'; +import type { Schema } from '@/misc/json-schema.js'; +import { refs } from '@/misc/json-schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema) { const res: any = schema; diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 198fc190d4..59bbde7616 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -1,5 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './channels/local-timeline.js'; import { HomeTimelineChannelService } from './channels/home-timeline.js'; @@ -15,7 +15,7 @@ import { MessagingChannelService } from './channels/messaging.js'; import { MessagingIndexChannelService } from './channels/messaging-index.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; -import { bindThis } from '@/decorators.js'; +import { RoleTimelineChannelService } from './channels/role-timeline.js'; @Injectable() export class ChannelsService { @@ -27,6 +27,7 @@ export class ChannelsService { private globalTimelineChannelService: GlobalTimelineChannelService, private userListChannelService: UserListChannelService, private hashtagChannelService: HashtagChannelService, + private roleTimelineChannelService: RoleTimelineChannelService, private antennaChannelService: AntennaChannelService, private channelChannelService: ChannelChannelService, private messagingChannelService: MessagingChannelService, @@ -48,6 +49,7 @@ export class ChannelsService { case 'globalTimeline': return this.globalTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; + case 'roleTimeline': return this.roleTimelineChannelService; case 'antenna': return this.antennaChannelService; case 'channel': return this.channelChannelService; case 'messaging': return this.messagingChannelService; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 3e67880b45..e67aec9ecd 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -23,12 +23,16 @@ export default abstract class Channel { return this.connection.following; } - protected get muting() { - return this.connection.muting; + protected get userIdsWhoMeMuting() { + return this.connection.userIdsWhoMeMuting; } - protected get blocking() { - return this.connection.blocking; + protected get userIdsWhoMeMutingRenotes() { + return this.connection.userIdsWhoMeMutingRenotes; + } + + protected get userIdsWhoBlockingMe() { + return this.connection.userIdsWhoBlockingMe; } protected get followingChannels() { diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts index 210e016a7e..157fcd6aa3 100644 --- a/packages/backend/src/server/api/stream/channels/admin.ts +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 44beef2da2..d48dea7258 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -1,5 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/index.js'; +import { Injectable } from '@nestjs/common'; import { isUserRelated } from '@/misc/is-user-related.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -36,9 +35,11 @@ class AntennaChannel extends Channel { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 5ba84e43c4..a2dd5a044c 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -1,8 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, UsersRepository } from '@/models/index.js'; +import { Injectable } from '@nestjs/common'; import { isUserRelated } from '@/misc/is-user-related.js'; import type { User } from '@/models/entities/User.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -57,9 +56,11 @@ class ChannelChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts index cfcb125b6b..52bb29fabe 100644 --- a/packages/backend/src/server/api/stream/channels/drive.ts +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 43d8907fc9..5454836fe1 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/index.js'; +import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -65,9 +64,11 @@ class GlobalTimelineChannel extends Channel { if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 073b737079..0268fdedde 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -1,8 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/index.js'; +import { Injectable } from '@nestjs/common'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; @@ -47,9 +46,11 @@ class HashtagChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 5707ddd821..ee874ad81e 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/index.js'; +import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; @@ -25,7 +24,6 @@ class HomeTimelineChannel extends Channel { @bindThis public async init(params: any) { - // Subscribe events this.subscriber.on('notesStream', this.onNote); } @@ -39,7 +37,7 @@ class HomeTimelineChannel extends Channel { } // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; if (['followers', 'specified'].includes(note.visibility)) { note = await this.noteEntityService.pack(note.id, this.user!, { @@ -72,16 +70,18 @@ class HomeTimelineChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる - if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + if (await checkWordMute(note, this.user, this.userProfile!.mutedWords)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 340f677815..4f7b4e78b6 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -1,10 +1,8 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/index.js'; +import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import type { Packed } from '@/misc/schema.js'; -import { DI } from '@/di-symbols.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -74,7 +72,7 @@ class HybridTimelineChannel extends Channel { } // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; // 関係ない返信は除外 if (note.reply && !this.user!.showTimelineReplies) { @@ -84,9 +82,11 @@ class HybridTimelineChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index ea29e30d63..09b0005ac1 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -1,8 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/index.js'; +import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -55,16 +54,18 @@ class LocalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.user!.showTimelineReplies) { + if (note.reply && this.user && !this.user.showTimelineReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 42f255b8fe..139320ce35 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -1,5 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/index.js'; +import { Injectable } from '@nestjs/common'; import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -27,7 +26,7 @@ class MainChannel extends Channel { case 'notification': { // Ignore notifications from instances the user has muted if (isUserFromMutedInstance(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; - if (data.body.userId && this.muting.has(data.body.userId)) return; + if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { const note = await this.noteEntityService.pack(data.body.note.id, this.user, { @@ -41,7 +40,7 @@ class MainChannel extends Channel { case 'mention': { if (isInstanceMuted(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; - if (this.muting.has(data.body.userId)) return; + if (this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.isHidden) { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true, diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index c773916103..7f48c54999 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -1,5 +1,5 @@ import Xev from 'xev'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts new file mode 100644 index 0000000000..9d106c8b2f --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import Channel from '../channel.js'; +import { StreamMessages } from '../types.js'; + +class RoleTimelineChannel extends Channel { + public readonly chName = 'roleTimeline'; + public static shouldShare = false; + public static requireCredential = false; + private roleId: string; + + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any) { + this.roleId = params.roleId as string; + + this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: StreamMessages['roleTimeline']['payload']) { + if (data.type === 'note') { + const note = data.body; + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + + this.send('note', note); + } else { + this.send(data.type, data.body); + } + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); + } +} + +@Injectable() +export class RoleTimelineChannelService { + public readonly shouldShare = RoleTimelineChannel.shouldShare; + public readonly requireCredential = RoleTimelineChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): RoleTimelineChannel { + return new RoleTimelineChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index 492912dbe6..9eae0cf2d3 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -1,5 +1,5 @@ import Xev from 'xev'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 16af32868c..8802fc5ab8 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListJoiningsRepository, UserListsRepository, NotesRepository } from '@/models/index.js'; +import type { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -89,9 +89,11 @@ class UserListChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.send('note', note); } diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 6763953f9d..3084355e79 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -1,14 +1,12 @@ import type { User } from '@/models/entities/User.js'; -import type { Channel as ChannelModel } from '@/models/entities/Channel.js'; -import type { FollowingsRepository, MutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; -import type { UserProfile } from '@/models/entities/UserProfile.js'; -import type { UserGroup } from '@/models/entities/UserGroup.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { GlobalEventService } from '@/core/GlobalEventService.js'; import type { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserProfile } from '@/models/index.js'; import type { ChannelsService } from './ChannelsService.js'; import type * as websocket from 'websocket'; import type { EventEmitter } from 'events'; @@ -20,102 +18,72 @@ import type { StreamEventEmitter, StreamMessages } from './types.js'; */ export default class Connection { public user?: User; - public userProfile?: UserProfile | null; - public following: Set = new Set(); - public muting: Set = new Set(); - public blocking: Set = new Set(); // "被"blocking - public followingChannels: Set = new Set(); public token?: AccessToken; private wsConnection: websocket.connection; public subscriber: StreamEventEmitter; private channels: Channel[] = []; private subscribingNotes: any = {}; private cachedNotes: Packed<'Note'>[] = []; + public userProfile: UserProfile | null = null; + public following: Set = new Set(); + public followingChannels: Set = new Set(); + public userIdsWhoMeMuting: Set = new Set(); + public userIdsWhoBlockingMe: Set = new Set(); + public userIdsWhoMeMutingRenotes: Set = new Set(); + private fetchIntervalId: NodeJS.Timer | null = null; constructor( - private followingsRepository: FollowingsRepository, - private mutingsRepository: MutingsRepository, - private blockingsRepository: BlockingsRepository, - private channelFollowingsRepository: ChannelFollowingsRepository, - private userProfilesRepository: UserProfilesRepository, private channelsService: ChannelsService, private globalEventService: GlobalEventService, private noteReadService: NoteReadService, private notificationService: NotificationService, + private cacheService: CacheService, - wsConnection: websocket.connection, subscriber: EventEmitter, user: User | null | undefined, token: AccessToken | null | undefined, ) { - this.wsConnection = wsConnection; this.subscriber = subscriber; if (user) this.user = user; if (token) this.token = token; + } - //this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this); - //this.onUserEvent = this.onUserEvent.bind(this); - //this.onNoteStreamMessage = this.onNoteStreamMessage.bind(this); - //this.onBroadcastMessage = this.onBroadcastMessage.bind(this); + @bindThis + public async fetch() { + if (this.user == null) return; + const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ + this.cacheService.userProfileCache.fetch(this.user.id), + this.cacheService.userFollowingsCache.fetch(this.user.id), + this.cacheService.userFollowingChannelsCache.fetch(this.user.id), + this.cacheService.userMutingsCache.fetch(this.user.id), + this.cacheService.userBlockedCache.fetch(this.user.id), + this.cacheService.renoteMutingsCache.fetch(this.user.id), + ]); + this.userProfile = userProfile; + this.following = following; + this.followingChannels = followingChannels; + this.userIdsWhoMeMuting = userIdsWhoMeMuting; + this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; + this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; + } + @bindThis + public async init() { + if (this.user != null) { + await this.fetch(); + + this.fetchIntervalId = setInterval(this.fetch, 1000 * 10); + } + } + + @bindThis + public async init2(wsConnection: websocket.connection) { + this.wsConnection = wsConnection; this.wsConnection.on('message', this.onWsConnectionMessage); this.subscriber.on('broadcast', data => { this.onBroadcastMessage(data); }); - - if (this.user) { - this.updateFollowing(); - this.updateMuting(); - this.updateBlocking(); - this.updateFollowingChannels(); - this.updateUserProfile(); - - this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); - } - } - - @bindThis - private onUserEvent(data: StreamMessages['user']['payload']) { // { type, body }と展開するとそれぞれ型が分離してしまう - switch (data.type) { - case 'follow': - this.following.add(data.body.id); - break; - - case 'unfollow': - this.following.delete(data.body.id); - break; - - case 'mute': - this.muting.add(data.body.id); - break; - - case 'unmute': - this.muting.delete(data.body.id); - break; - - // TODO: block events - - case 'followChannel': - this.followingChannels.add(data.body.id); - break; - - case 'unfollowChannel': - this.followingChannels.delete(data.body.id); - break; - - case 'updateUserProfile': - this.userProfile = data.body; - break; - - case 'terminate': - this.wsConnection.close(); - this.dispose(); - break; - - default: - break; - } } /** @@ -151,7 +119,6 @@ export default class Connection { // 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、 // クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別 // なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。 - case 'typingOnChannel': this.typingOnChannel(body.channel); break; case 'typingOnMessaging': this.typingOnMessaging(body); break; } } @@ -189,17 +156,13 @@ export default class Connection { if (note == null) return; if (this.user && (note.userId !== this.user.id)) { - this.noteReadService.read(this.user.id, [note], { - following: this.following, - followingChannels: this.followingChannels, - }); + this.noteReadService.read(this.user.id, [note]); } } @bindThis private onReadNotification(payload: any) { - if (!payload.id) return; - this.notificationService.readNotification(this.user!.id, [payload.id]); + this.notificationService.readAllNotification(this.user!.id); } /** @@ -325,13 +288,6 @@ export default class Connection { } } - @bindThis - private typingOnChannel(channel: ChannelModel['id']) { - if (this.user) { - this.globalEventService.publishChannelStream(channel, 'typing', this.user.id); - } - } - @bindThis private typingOnMessaging(param: { partner?: User['id']; group?: UserGroup['id']; }) { if (this.user) { @@ -343,66 +299,12 @@ export default class Connection { } } - @bindThis - private async updateFollowing() { - const followings = await this.followingsRepository.find({ - where: { - followerId: this.user!.id, - }, - select: ['followeeId'], - }); - - this.following = new Set(followings.map(x => x.followeeId)); - } - - @bindThis - private async updateMuting() { - const mutings = await this.mutingsRepository.find({ - where: { - muterId: this.user!.id, - }, - select: ['muteeId'], - }); - - this.muting = new Set(mutings.map(x => x.muteeId)); - } - - @bindThis - private async updateBlocking() { // ここでいうBlockingは被Blockingの意 - const blockings = await this.blockingsRepository.find({ - where: { - blockeeId: this.user!.id, - }, - select: ['blockerId'], - }); - - this.blocking = new Set(blockings.map(x => x.blockerId)); - } - - @bindThis - private async updateFollowingChannels() { - const followings = await this.channelFollowingsRepository.find({ - where: { - followerId: this.user!.id, - }, - select: ['followeeId'], - }); - - this.followingChannels = new Set(followings.map(x => x.followeeId)); - } - - @bindThis - private async updateUserProfile() { - this.userProfile = await this.userProfilesRepository.findOneBy({ - userId: this.user!.id, - }); - } - /** * ストリームが切れたとき */ @bindThis public dispose() { + if (this.fetchIntervalId) clearInterval(this.fetchIntervalId); for (const c of this.channels.filter(c => c.dispose)) { if (c.dispose) c.dispose(); } diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 8bb4147b43..657a274e66 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -11,17 +11,17 @@ import type { UserGroup } from '@/models/entities/UserGroup.js'; import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; import type { Signin } from '@/models/entities/Signin.js'; import type { Page } from '@/models/entities/Page.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import type { Meta } from '@/models/entities/Meta.js'; -import { Following, Role, RoleAssignment } from '@/models'; +import { Role, RoleAssignment } from '@/models'; import type Emitter from 'strict-event-emitter-types'; import type { EventEmitter } from 'events'; //#region Stream type-body definitions export interface InternalStreamTypes { userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; }; - userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; }; + userTokenRegenerated: { id: User['id']; oldToken: string; newToken: string; }; remoteUserUpdated: { id: User['id']; }; follow: { followerId: User['id']; followeeId: User['id']; }; unfollow: { followerId: User['id']; followeeId: User['id']; }; @@ -40,14 +40,19 @@ export interface InternalStreamTypes { antennaDeleted: Antenna; antennaUpdated: Antenna; metaUpdated: Meta; + followChannel: { userId: User['id']; channelId: Channel['id']; }; + unfollowChannel: { userId: User['id']; channelId: Channel['id']; }; + updateUserProfile: UserProfile; + mute: { muterId: User['id']; muteeId: User['id']; }; + unmute: { muterId: User['id']; muteeId: User['id']; }; } export interface BroadcastTypes { emojiAdded: { - emoji: Packed<'Emoji'>; + emoji: Packed<'EmojiDetailed'>; }; emojiUpdated: { - emojis: Packed<'Emoji'>[]; + emojis: Packed<'EmojiDetailed'>[]; }; emojiDeleted: { emojis: { @@ -58,18 +63,6 @@ export interface BroadcastTypes { }; } -export interface UserStreamTypes { - terminate: Record; - followChannel: Channel; - unfollowChannel: Channel; - updateUserProfile: UserProfile; - mute: User; - unmute: User; - follow: Packed<'UserDetailedNotMe'>; - unfollow: Packed<'User'>; - userAdded: Packed<'User'>; -} - export interface MainStreamTypes { notification: Packed<'Notification'>; mention: Packed<'Note'>; @@ -102,8 +95,6 @@ export interface MainStreamTypes { readAllAntennas: undefined; unreadAntenna: Antenna; readAllAnnouncements: undefined; - readAllChannels: undefined; - unreadChannel: Note['id']; myTokenRegenerated: undefined; signin: Signin; registryUpdated: { @@ -188,6 +179,10 @@ export interface MessagingIndexStreamTypes { message: Packed<'MessagingMessage'>; } +export interface RoleTimelineStreamTypes { + note: Packed<'Note'>; +} + export interface AdminStreamTypes { newAbuseUserReport: { id: AbuseUserReport['id']; @@ -208,8 +203,15 @@ type EventUnionFromDictionary< > = U[keyof U]; // redis通すとDateのインスタンスはstringに変換されるので -type Serialized = { - [K in keyof T]: T[K] extends Date ? string : T[K] extends Record ? Serialized : T[K]; +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]; }; type SerializedAll = { @@ -226,10 +228,6 @@ export type StreamMessages = { name: 'broadcast'; payload: EventUnionFromDictionary>; }; - user: { - name: `user:${User['id']}`; - payload: EventUnionFromDictionary>; - }; main: { name: `mainStream:${User['id']}`; payload: EventUnionFromDictionary>; @@ -250,6 +248,10 @@ export type StreamMessages = { name: `userListStream:${UserList['id']}`; payload: EventUnionFromDictionary>; }; + roleTimeline: { + name: `roleTimelineStream:${Role['id']}`; + payload: EventUnionFromDictionary>; + }; antenna: { name: `antennaStream:${Antenna['id']}`; payload: EventUnionFromDictionary>; diff --git a/packages/backend/src/server/web/ClientLoggerService.ts b/packages/backend/src/server/web/ClientLoggerService.ts new file mode 100644 index 0000000000..6a882aa766 --- /dev/null +++ b/packages/backend/src/server/web/ClientLoggerService.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class ClientLoggerService { + public logger: Logger; + + constructor( + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('client'); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 1af7d01400..783d1c784c 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -1,6 +1,7 @@ import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuid } from 'uuid'; import { createBullBoard } from '@bull-board/api'; import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { FastifyAdapter } from '@bull-board/fastify'; @@ -25,7 +26,8 @@ import { PageEntityService } from '@/core/entities/PageEntityService.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, EmojisRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type Logger from '@/logger.js'; import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; @@ -34,6 +36,7 @@ import manifest from './manifest.json' assert { type: 'json' }; import { FeedService } from './FeedService.js'; import { UrlPreviewService } from './UrlPreviewService.js'; import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; +import { ClientLoggerService } from './ClientLoggerService.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -46,6 +49,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`; @Injectable() export class ClientServerService { + private logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -85,6 +90,7 @@ export class ClientServerService { private urlPreviewService: UrlPreviewService, private feedService: FeedService, private roleService: RoleService, + private clientLoggerService: ClientLoggerService, @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @@ -194,11 +200,6 @@ export class ClientServerService { //#region static assets - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - fastify.register(fastifyStatic, { root: staticAssets, prefix: '/static-assets/', @@ -424,7 +425,7 @@ export class ClientServerService { reply.header('Cache-Control', 'public, max-age=15'); return await reply.view('user', { user, profile, me, - avatarUrl: await this.userEntityService.getAvatarUrl(user), + avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), sub: request.params.sub, instanceName: meta.name ?? 'CherryPick', icon: meta.iconUrl, @@ -469,7 +470,7 @@ export class ClientServerService { return await reply.view('note', { note: _note, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })), + avatarUrl: _note.user.avatarUrl, // TODO: Let locale changeable by instance setting summary: getNoteSummary(_note), instanceName: meta.name ?? 'CherryPick', @@ -508,7 +509,7 @@ export class ClientServerService { return await reply.view('page', { page: _page, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })), + avatarUrl: _page.user.avatarUrl, instanceName: meta.name ?? 'CherryPick', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -532,7 +533,7 @@ export class ClientServerService { return await reply.view('flash', { flash: _flash, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: flash.userId })), + avatarUrl: _flash.user.avatarUrl, instanceName: meta.name ?? 'CherryPick', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -556,7 +557,7 @@ export class ClientServerService { return await reply.view('clip', { clip: _clip, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })), + avatarUrl: _clip.user.avatarUrl, instanceName: meta.name ?? 'CherryPick', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -578,7 +579,7 @@ export class ClientServerService { return await reply.view('gallery-post', { post: _post, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })), + avatarUrl: _post.user.avatarUrl, instanceName: meta.name ?? 'CherryPick', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -654,6 +655,24 @@ export class ClientServerService { return await renderBase(reply); }); + fastify.setErrorHandler(async (error, request, reply) => { + const errId = uuid(); + this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, { + path: request.routerPath, + params: request.params, + query: request.query, + code: error.name, + stack: error.stack, + id: errId, + }); + reply.code(500); + reply.header('Cache-Control', 'max-age=10, must-revalidate'); + return await reply.view('error', { + code: error.code, + id: errId, + }); + }); + done(); } } diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 7f532972ab..a004c8cbdc 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -58,7 +58,7 @@ export class FeedService { generator: 'CherryPick', description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, link: author.link, - image: await this.userEntityService.getAvatarUrl(user), + image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), feedLinks: { json: `${author.link}.json`, atom: `${author.link}.atom`, diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 2ce7293a52..e61e92c623 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { summaly } from 'summaly'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -9,6 +8,7 @@ import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { ApiError } from '@/server/api/error.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() @@ -19,9 +19,6 @@ export class UrlPreviewService { @Inject(DI.config) private config: Config, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - private metaService: MetaService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, @@ -43,23 +40,23 @@ export class UrlPreviewService { @bindThis public async handle( - request: FastifyRequest<{ Querystring: { url: string; lang: string; } }>, + request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>, reply: FastifyReply, - ) { + ): Promise { const url = request.query.url; if (typeof url !== 'string') { reply.code(400); return; } - + const lang = request.query.lang; if (Array.isArray(lang)) { reply.code(400); return; } - + const meta = await this.metaService.fetch(); - + this.logger.info(meta.summalyProxy ? `(Proxy) Getting preview of ${url}@${lang} ...` : `Getting preview of ${url}@${lang} ...`); @@ -73,34 +70,40 @@ export class UrlPreviewService { await summaly(url, { followRedirects: false, lang: lang ?? 'ja-JP', - agent: { + agent: this.config.proxy ? { http: this.httpRequestService.httpAgent, https: this.httpRequestService.httpsAgent, - }, + } : undefined, }); this.logger.succ(`Got preview of ${url}: ${summary.title}`); - if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { + if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { throw new Error('unsupported schema included'); } - if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { + if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { throw new Error('unsupported schema included'); } - + summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); - + // Cache 7days reply.header('Cache-Control', 'max-age=604800, immutable'); - + return summary; } catch (err) { this.logger.warn(`Failed to get preview of ${url}: ${err}`); - reply.code(200); + reply.code(422); reply.header('Cache-Control', 'max-age=86400, immutable'); - return {}; + return { + error: new ApiError({ + message: 'Failed to get preview', + code: 'URL_PREVIEW_FAILED', + id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', + }), + }; } } } diff --git a/packages/backend/src/server/web/bios.css b/packages/backend/src/server/web/bios.css index 72b2e3dc3e..096c4542ed 100644 --- a/packages/backend/src/server/web/bios.css +++ b/packages/backend/src/server/web/bios.css @@ -1,5 +1,35 @@ -@import url("https://cdn.jsdelivr.net/npm/jetbrains-mono/css/jetbrains-mono.css"); -@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard-jp.css"); +@font-face { + font-family: 'Pretendard JP'; + font-weight: 400; + font-display: swap; + src: local('Pretendard JP Regular'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff2/PretendardJP-Regular.woff2') format('woff2'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff/PretendardJP-Regular.woff') format('woff'); +} +@font-face { + font-family: 'Pretendard JP'; + font-weight: 700; + font-display: swap; + src: local('Pretendard JP Bold'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff2/PretendardJP-Bold.woff2') format('woff2'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff/PretendardJP-Bold.woff') format('woff'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + src: local("JetBrains Mono Regular"), local("JetBrainsMono-Regular"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono@master/fonts/webfonts/JetBrainsMono-Regular.woff2") format("woff2"); + font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + src: local("JetBrains Mono Bold"), local("JetBrainsMono-Bold"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono@master/fonts/webfonts/JetBrainsMono-Bold.woff2") format("woff2"); + font-display: swap; +} * { font-family: "JetBrains Mono", "Pretendard JP", Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index d8a03f28e8..95b3aa5cfe 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -61,6 +61,13 @@ renderError('META_FETCH_V'); return; } + + // for https://github.com/misskey-dev/misskey/issues/10202 + if (lang == null || lang.toString == null || lang.toString() === 'null') { + console.error('invalid lang value detected!!!', typeof lang, lang); + lang = 'en-US'; + } + const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); if (localRes.status === 200) { localStorage.setItem('lang', lang); @@ -192,9 +199,31 @@ ${JSON.stringify(details)}`; errorsElement.appendChild(detailsElement); addStyle(` - @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard-jp.css"); - @import url("https://cdn.jsdelivr.net/npm/jetbrains-mono/css/jetbrains-mono.css"); - + @font-face { + font-family: 'Pretendard JP'; + font-weight: 400; + font-display: swap; + src: local('Pretendard JP Regular'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff2/PretendardJP-Regular.woff2') format('woff2'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff/PretendardJP-Regular.woff') format('woff'); + } + @font-face { + font-family: 'Pretendard JP'; + font-weight: 700; + font-display: swap; + src: local('Pretendard JP Bold'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff2/PretendardJP-Bold.woff2') format('woff2'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff/PretendardJP-Bold.woff') format('woff'); + } + @font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + src: local("JetBrains Mono Regular"), local("JetBrainsMono-Regular"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono@master/fonts/webfonts/JetBrainsMono-Regular.woff2") format("woff2"); + font-display: swap; } + } + * { font-family: "Pretendard JP", BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; } diff --git a/packages/backend/src/server/web/cli.css b/packages/backend/src/server/web/cli.css index 462608f61c..a678c745b7 100644 --- a/packages/backend/src/server/web/cli.css +++ b/packages/backend/src/server/web/cli.css @@ -1,8 +1,38 @@ -@import url("https://cdn.jsdelivr.net/npm/jetbrains-mono/css/jetbrains-mono.css"); -@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard-jp.css"); +@font-face { + font-family: 'Pretendard JP'; + font-weight: 400; + font-display: swap; + src: local('Pretendard JP Regular'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff2/PretendardJP-Regular.woff2') format('woff2'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff/PretendardJP-Regular.woff') format('woff'); +} +@font-face { + font-family: 'Pretendard JP'; + font-weight: 700; + font-display: swap; + src: local('Pretendard JP Bold'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff2/PretendardJP-Bold.woff2') format('woff2'), + url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/packages/pretendard-jp/dist/web/static/woff/PretendardJP-Bold.woff') format('woff'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + src: local("JetBrains Mono Regular"), local("JetBrainsMono-Regular"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono@master/fonts/webfonts/JetBrainsMono-Regular.woff2") format("woff2"); + font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + src: local("JetBrains Mono Bold"), local("JetBrainsMono-Bold"), + url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono@master/fonts/webfonts/JetBrainsMono-Bold.woff2") format("woff2"); + font-display: swap; +} * { - font-family: "Pretendard JP", "JetBrains Mono", Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; + font-family: "JetBrains Mono", "Pretendard JP", Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; } html { diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js index 3467f7ac2a..44f8adbeb8 100644 --- a/packages/backend/src/server/web/cli.js +++ b/packages/backend/src/server/web/cli.js @@ -46,10 +46,22 @@ window.onload = async () => { const tl = document.getElementById('tl'); for (const note of notes) { const el = document.createElement('div'); + const name = document.createElement('header'); name.textContent = `${note.user.name} @${note.user.username}`; + const text = document.createElement('div'); - text.textContent = `${note.text}`; + if (note.renoteId !== null) { + const renoteTextShrinked = note.renote.text.slice(0, 30).concat('...'); + text.textContent += `Renote of @${note.renote.user.username}'s note: ${renoteTextShrinked}
`; + } + if (note.replyId !== null) { + const replyTextShrinked = note.reply.text.slice(0, 30).concat('...'); + text.textContent += `Reply of @${note.reply.user.username}'s note: ${replyTextShrinked}
`; + } + text.textContent += `${note.text || ''}`; + + el.appendChild(name); el.appendChild(text); tl.appendChild(el); diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css new file mode 100644 index 0000000000..ab913f7a9f --- /dev/null +++ b/packages/backend/src/server/web/error.css @@ -0,0 +1,110 @@ +* { + font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; +} + +#misskey_app, +#splash { + display: none !important; +} + +body, +html { + 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; +} + +.button-big { + background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); + line-height: 50px; +} + +.button-big:hover { + background: rgb(153, 204, 0); +} + +.button-small { + background: #444; + line-height: 40px; +} + +.button-small:hover { + background: #555; +} + +.button-label-big { + color: #222; + font-weight: bold; + font-size: 20px; + padding: 12px; +} + +.button-label-small { + color: rgb(153, 204, 0); + font-size: 16px; + padding: 12px; +} + +a { + color: rgb(134, 179, 0); + text-decoration: none; +} + +p, +li { + font-size: 16px; +} + +.dont-worry, +#msg { + font-size: 18px; +} + +.icon-warning { + color: #dec340; + height: 4rem; + padding-top: 2rem; +} + +h1 { + font-size: 32px; +} + +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; +} + +summary { + cursor: pointer; +} + +summary > * { + display: inline; + white-space: pre-wrap; +} + +@media screen and (max-width: 500px) { + details { + width: 50%; + } +} \ No newline at end of file diff --git a/packages/backend/src/server/web/manifest.json b/packages/backend/src/server/web/manifest.json index 585cab8503..fac94dfe7a 100644 --- a/packages/backend/src/server/web/manifest.json +++ b/packages/backend/src/server/web/manifest.json @@ -9,16 +9,26 @@ { "src": "/static-assets/icons/192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "maskable" }, { "src": "/static-assets/icons/512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/static-assets/splash.png", + "sizes": "300x300", + "type": "image/png", + "purpose": "any" } ], "share_target": { "action": "/share/", + "method": "GET", + "enctype": "application/x-www-form-urlencoded", "params": { "title": "title", "text": "text", diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 5e86576dec..be97e5e0ae 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -36,7 +36,7 @@ html link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.2.0') + link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.17.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug new file mode 100644 index 0000000000..b177ae4110 --- /dev/null +++ b/packages/backend/src/server/web/views/error.pug @@ -0,0 +1,65 @@ +doctype html + +// + - + _____ _ _ + | |_|___ ___| |_ ___ _ _ + | | | | |_ -|_ -| '_| -_| | | + |_|_|_|_|___|___|_,_|___|_ | + |___| + Thank you for using Misskey! + If you are reading this message... how about joining the development? + https://github.com/misskey-dev/misskey + + +html + + head + meta(charset='utf-8') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='application-name' content='Misskey') + meta(name='referrer' content='origin') + + title + block title + = 'An error has occurred... | Misskey' + + style + include ../error.css + +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! + + button.button-big(onclick="location.reload();") + span.button-label-big Refresh + + 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. + + div#errors + code. + ERROR CODE: #{code} + ERROR ID: #{id} + + p You may also try the following options: + + p Update your os and browser. + p Disable an adblocker. + + 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 diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 7e9e193362..287b960fd1 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -1,4 +1,5 @@ -export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app'] as const; +export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app'] as const; +export const obsoleteNotificationTypes = ['pollVote'/*, 'groupInvited'*/] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; diff --git a/packages/backend/test/_e2e/fetch-resource.ts b/packages/backend/test/_e2e/fetch-resource.ts deleted file mode 100644 index b8ba3f2477..0000000000 --- a/packages/backend/test/_e2e/fetch-resource.ts +++ /dev/null @@ -1,205 +0,0 @@ -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import * as openapi from '@redocly/openapi-core'; -import { startServer, signup, post, request, simpleGet, port, shutdownServer } from '../utils.js'; - -// Request Accept -const ONLY_AP = 'application/activity+json'; -const PREFER_AP = 'application/activity+json, */*'; -const PREFER_HTML = 'text/html, */*'; -const UNSPECIFIED = '*/*'; - -// Response Content-Type -const AP = 'application/activity+json; charset=utf-8'; -const JSON = 'application/json; charset=utf-8'; -const HTML = 'text/html; charset=utf-8'; - -describe('Fetch resource', () => { - let p: childProcess.ChildProcess; - - let alice: any; - let alicesPost: any; - - beforeAll(async () => { - p = await startServer(); - alice = await signup({ username: 'alice' }); - alicesPost = await post(alice, { - text: 'test', - }); - }, 1000 * 30); - - afterAll(async () => { - await shutdownServer(p); - }); - - describe('Common', () => { - test('meta', async () => { - const res = await request('/meta', { - }); - - assert.strictEqual(res.status, 200); - }); - - test('GET root', async () => { - const res = await simpleGet('/'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - - test('GET docs', async () => { - const res = await simpleGet('/docs/ja-JP/about'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - - test('GET api-doc', async () => { - const res = await simpleGet('/api-doc'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - - test('GET api.json', async () => { - const res = await simpleGet('/api.json'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, JSON); - }); - - test('Validate api.json', async () => { - const config = await openapi.loadConfig(); - const result = await openapi.bundle({ - config, - ref: `http://localhost:${port}/api.json`, - }); - - for (const problem of result.problems) { - console.log(`${problem.message} - ${problem.location[0]?.pointer}`); - } - - assert.strictEqual(result.problems.length, 0); - }); - - test('GET favicon.ico', async () => { - const res = await simpleGet('/favicon.ico'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/x-icon'); - }); - - test('GET apple-touch-icon.png', async () => { - const res = await simpleGet('/apple-touch-icon.png'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/png'); - }); - - test('GET twemoji svg', async () => { - const res = await simpleGet('/twemoji/2764.svg'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/svg+xml'); - }); - - test('GET twemoji svg with hyphen', async () => { - const res = await simpleGet('/twemoji/2764-fe0f-200d-1f525.svg'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/svg+xml'); - }); - }); - - describe('/@:username', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/@${alice.username}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); - - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/@${alice.username}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); - - test('Prefer HTML => HTML', async () => { - const res = await simpleGet(`/@${alice.username}`, PREFER_HTML); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - - test('Unspecified => HTML', async () => { - const res = await simpleGet(`/@${alice.username}`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - }); - - describe('/users/:id', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/users/${alice.id}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); - - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/users/${alice.id}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); - - test('Prefer HTML => Redirect to /@:username', async () => { - const res = await simpleGet(`/users/${alice.id}`, PREFER_HTML); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.location, `/@${alice.username}`); - }); - - test('Undecided => HTML', async () => { - const res = await simpleGet(`/users/${alice.id}`, UNSPECIFIED); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.location, `/@${alice.username}`); - }); - }); - - describe('/notes/:id', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); - - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); - - test('Prefer HTML => HTML', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_HTML); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - - test('Unspecified => HTML', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - }); - - describe('Feeds', () => { - test('RSS', async () => { - const res = await simpleGet(`/@${alice.username}.rss`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/rss+xml; charset=utf-8'); - }); - - test('ATOM', async () => { - const res = await simpleGet(`/@${alice.username}.atom`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/atom+xml; charset=utf-8'); - }); - - test('JSON', async () => { - const res = await simpleGet(`/@${alice.username}.json`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/json; charset=utf-8'); - }); - }); -}); diff --git a/packages/backend/test/_e2e/note.ts b/packages/backend/test/_e2e/note.ts deleted file mode 100644 index e2353655ff..0000000000 --- a/packages/backend/test/_e2e/note.ts +++ /dev/null @@ -1,370 +0,0 @@ -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { Note } from '../../src/models/entities/note.js'; -import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from '../utils.js'; - -describe('Note', () => { - let p: childProcess.ChildProcess; - let Notes: any; - - let alice: any; - let bob: any; - - beforeAll(async () => { - p = await startServer(); - const connection = await initTestDb(true); - Notes = connection.getRepository(Note); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - }, 1000 * 30); - - afterAll(async () => { - await shutdownServer(p); - }); - - test('投稿できる', async () => { - const post = { - text: 'test', - }; - - const res = await request('/notes/create', post, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, post.text); - }); - - test('ファイルを添付できる', async () => { - const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/kokonect-link/cherrypick/develop/packages/backend/test/resources/Lenna.jpg'); - - const res = await request('/notes/create', { - fileIds: [file.id], - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]); - }, 1000 * 10); - - test('他人のファイルは無視', async () => { - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/kokonect-link/cherrypick/develop/packages/backend/test/resources/Lenna.jpg'); - - const res = await request('/notes/create', { - text: 'test', - fileIds: [file.id], - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.deepStrictEqual(res.body.createdNote.fileIds, []); - }, 1000 * 10); - - test('存在しないファイルは無視', async () => { - const res = await request('/notes/create', { - text: 'test', - fileIds: ['000000000000000000000000'], - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.deepStrictEqual(res.body.createdNote.fileIds, []); - }); - - test('不正なファイルIDは無視', async () => { - const res = await request('/notes/create', { - fileIds: ['kyoppie'], - }, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.deepStrictEqual(res.body.createdNote.fileIds, []); - }); - - test('返信できる', async () => { - const bobPost = await post(bob, { - text: 'foo', - }); - - const alicePost = { - text: 'bar', - replyId: bobPost.id, - }; - - const res = await request('/notes/create', alicePost, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, alicePost.text); - assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); - assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); - }); - - test('renoteできる', async () => { - const bobPost = await post(bob, { - text: 'test', - }); - - const alicePost = { - renoteId: bobPost.id, - }; - - const res = await request('/notes/create', alicePost, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); - assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); - }); - - test('引用renoteできる', async () => { - const bobPost = await post(bob, { - text: 'test', - }); - - const alicePost = { - text: 'test', - renoteId: bobPost.id, - }; - - const res = await request('/notes/create', alicePost, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, alicePost.text); - assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); - assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); - }); - - test('文字数ぎりぎりで怒られない', async () => { - const post = { - text: '!'.repeat(3000), - }; - const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 200); - }); - - test('文字数オーバーで怒られる', async () => { - const post = { - text: '!'.repeat(3001), - }; - const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('存在しないリプライ先で怒られる', async () => { - const post = { - text: 'test', - replyId: '000000000000000000000000', - }; - const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('存在しないrenote対象で怒られる', async () => { - const post = { - renoteId: '000000000000000000000000', - }; - const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('不正なリプライ先IDで怒られる', async () => { - const post = { - text: 'test', - replyId: 'foo', - }; - const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('不正なrenote対象IDで怒られる', async () => { - const post = { - renoteId: 'foo', - }; - const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); - }); - - test('存在しないユーザーにメンションできる', async () => { - const post = { - text: '@ghost yo', - }; - - const res = await request('/notes/create', post, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, post.text); - }); - - test('同じユーザーに複数メンションしても内部的にまとめられる', async () => { - const post = { - text: '@bob @bob @bob yo', - }; - - const res = await request('/notes/create', post, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.text, post.text); - - const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); - assert.deepStrictEqual(noteDoc.mentions, [bob.id]); - }); - - describe('notes/create', () => { - test('投票を添付できる', async () => { - const res = await request('/notes/create', { - text: 'test', - poll: { - choices: ['foo', 'bar'], - }, - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.createdNote.poll != null, true); - }); - - test('投票の選択肢が無くて怒られる', async () => { - const res = await request('/notes/create', { - poll: {}, - }, alice); - assert.strictEqual(res.status, 400); - }); - - test('投票の選択肢が無くて怒られる (空の配列)', async () => { - const res = await request('/notes/create', { - poll: { - choices: [], - }, - }, alice); - assert.strictEqual(res.status, 400); - }); - - test('投票の選択肢が1つで怒られる', async () => { - const res = await request('/notes/create', { - poll: { - choices: ['Strawberry Pasta'], - }, - }, alice); - assert.strictEqual(res.status, 400); - }); - - test('投票できる', async () => { - const { body } = await request('/notes/create', { - text: 'test', - poll: { - choices: ['sakura', 'izumi', 'ako'], - }, - }, alice); - - const res = await request('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 1, - }, alice); - - assert.strictEqual(res.status, 204); - }); - - test('複数投票できない', async () => { - const { body } = await request('/notes/create', { - text: 'test', - poll: { - choices: ['sakura', 'izumi', 'ako'], - }, - }, alice); - - await request('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 0, - }, alice); - - const res = await request('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 2, - }, alice); - - assert.strictEqual(res.status, 400); - }); - - test('許可されている場合は複数投票できる', async () => { - const { body } = await request('/notes/create', { - text: 'test', - poll: { - choices: ['sakura', 'izumi', 'ako'], - multiple: true, - }, - }, alice); - - await request('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 0, - }, alice); - - await request('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 1, - }, alice); - - const res = await request('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 2, - }, alice); - - assert.strictEqual(res.status, 204); - }); - - test('締め切られている場合は投票できない', async () => { - const { body } = await request('/notes/create', { - text: 'test', - poll: { - choices: ['sakura', 'izumi', 'ako'], - expiredAfter: 1, - }, - }, alice); - - await new Promise(x => setTimeout(x, 2)); - - const res = await request('/notes/polls/vote', { - noteId: body.createdNote.id, - choice: 1, - }, alice); - - assert.strictEqual(res.status, 400); - }); - }); - - describe('notes/delete', () => { - test('delete a reply', async () => { - const mainNoteRes = await api('notes/create', { - text: 'main post', - }, alice); - const replyOneRes = await api('notes/create', { - text: 'reply one', - replyId: mainNoteRes.body.createdNote.id, - }, alice); - const replyTwoRes = await api('notes/create', { - text: 'reply two', - replyId: mainNoteRes.body.createdNote.id, - }, alice); - - const deleteOneRes = await api('notes/delete', { - noteId: replyOneRes.body.createdNote.id, - }, alice); - - assert.strictEqual(deleteOneRes.status, 204); - let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); - assert.strictEqual(mainNote.repliesCount, 1); - - const deleteTwoRes = await api('notes/delete', { - noteId: replyTwoRes.body.createdNote.id, - }, alice); - - assert.strictEqual(deleteTwoRes.status, 204); - mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); - assert.strictEqual(mainNote.repliesCount, 0); - }); - }); -}); diff --git a/packages/backend/test/docker-compose.yml b/packages/backend/test/docker-compose.yml index 3cafc561ba..2e97e4d179 100644 --- a/packages/backend/test/docker-compose.yml +++ b/packages/backend/test/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: redistest: - image: redis:6 + image: redis:7 ports: - "127.0.0.1:56312:6379" diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts new file mode 100644 index 0000000000..0addb430c9 --- /dev/null +++ b/packages/backend/test/e2e/2fa.ts @@ -0,0 +1,439 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as crypto from 'node:crypto'; +import * as cbor from 'cbor'; +import * as OTPAuth from 'otpauth'; +import { loadConfig } from '../../src/config.js'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('2要素認証', () => { + let app: INestApplicationContext; + let alice: unknown; + + const config = loadConfig(); + const password = 'test'; + const username = 'alice'; + + // https://datatracker.ietf.org/doc/html/rfc8152 + // 各値の定義は上記規格に基づく。鍵ペアは適当に生成したやつ + const coseKtyEc2 = 2; + const coseKid = 'meriadoc.brandybuck@buckland.example'; + const coseAlgEs256 = -7; + const coseEc2CrvP256 = 1; + const coseEc2X = '4932eaacc657565705e4287e7870ce3aad55545d99d35a98a472dc52880cfc8f'; + const coseEc2Y = '5ca68303bf2c0433473e3d5cb8586bc2c8c43a4945a496fce8dbeda8b23ab0b1'; + + // private key only for testing + const pemToSign = '-----BEGIN EC PRIVATE KEY-----\n' + + 'MHcCAQEEIHqe/keuXyolbXzgLOu+YFJjDBGWVgXc3QCXfyqwDPf2oAoGCCqGSM49\n' + + 'AwEHoUQDQgAESTLqrMZXVlcF5Ch+eHDOOq1VVF2Z01qYpHLcUogM/I9cpoMDvywE\n' + + 'M0c+PVy4WGvCyMQ6SUWklvzo2+2osjqwsQ==\n' + + '-----END EC PRIVATE KEY-----\n'; + + const otpToken = (secret: string): string => { + return OTPAuth.TOTP.generate({ + secret: OTPAuth.Secret.fromBase32(secret), + digits: 6, + }); + }; + + const rpIdHash = (): Buffer => { + return crypto.createHash('sha256') + .update(Buffer.from(config.hostname, 'utf-8')) + .digest(); + }; + + const keyDoneParam = (param: { + keyName: string, + challengeId: string, + challenge: string, + credentialId: Buffer, + }): { + attestationObject: string, + challengeId: string, + clientDataJSON: string, + password: string, + name: string, + } => { + // A COSE encoded public key + const credentialPublicKey = cbor.encode(new Map([ + [-1, coseEc2CrvP256], + [-2, Buffer.from(coseEc2X, 'hex')], + [-3, Buffer.from(coseEc2Y, 'hex')], + [1, coseKtyEc2], + [2, coseKid], + [3, coseAlgEs256], + ])); + + // AuthenticatorAssertionResponse.authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + const credentialIdLength = Buffer.allocUnsafe(2); + credentialIdLength.writeUInt16BE(param.credentialId.length); + const authData = Buffer.concat([ + rpIdHash(), // rpIdHash(32) + Buffer.from([0x45]), // flags(1) + Buffer.from([0x00, 0x00, 0x00, 0x00]), // signCount(4) + Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // AAGUID(16) + credentialIdLength, + param.credentialId, + credentialPublicKey, + ]); + + return { + attestationObject: cbor.encode({ + fmt: 'none', + attStmt: {}, + authData, + }).toString('hex'), + challengeId: param.challengeId, + clientDataJSON: JSON.stringify({ + type: 'webauthn.create', + challenge: param.challenge, + origin: config.scheme + '://' + config.host, + androidPackageName: 'org.mozilla.firefox', + }), + password, + name: param.keyName, + }; + }; + + const signinParam = (): { + username: string, + password: string, + 'g-recaptcha-response'?: string | null, + 'hcaptcha-response'?: string | null, + } => { + return { + username, + password, + 'g-recaptcha-response': null, + 'hcaptcha-response': null, + }; + }; + + const signinWithSecurityKeyParam = (param: { + keyName: string, + challengeId: string, + challenge: string, + credentialId: Buffer, + }): { + authenticatorData: string, + credentialId: string, + challengeId: string, + clientDataJSON: string, + username: string, + password: string, + signature: string, + 'g-recaptcha-response'?: string | null, + 'hcaptcha-response'?: string | null, + } => { + // AuthenticatorAssertionResponse.authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + const authenticatorData = Buffer.concat([ + rpIdHash(), + Buffer.from([0x05]), // flags(1) + Buffer.from([0x00, 0x00, 0x00, 0x01]), // signCount(4) + ]); + const clientDataJSONBuffer = Buffer.from(JSON.stringify({ + type: 'webauthn.get', + challenge: param.challenge, + origin: config.scheme + '://' + config.host, + androidPackageName: 'org.mozilla.firefox', + })); + const hashedclientDataJSON = crypto.createHash('sha256') + .update(clientDataJSONBuffer) + .digest(); + const privateKey = crypto.createPrivateKey(pemToSign); + const signature = crypto.createSign('SHA256') + .update(Buffer.concat([authenticatorData, hashedclientDataJSON])) + .sign(privateKey); + return { + authenticatorData: authenticatorData.toString('hex'), + credentialId: param.credentialId.toString('base64'), + challengeId: param.challengeId, + clientDataJSON: clientDataJSONBuffer.toString('hex'), + username, + password, + signature: signature.toString('hex'), + 'g-recaptcha-response': null, + 'hcaptcha-response': null, + }; + }; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username, password }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + test('が設定でき、OTPでログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + assert.notEqual(registerResponse.body.qr, undefined); + assert.notEqual(registerResponse.body.url, undefined); + assert.notEqual(registerResponse.body.secret, undefined); + assert.strictEqual(registerResponse.body.label, username); + assert.strictEqual(registerResponse.body.issuer, config.host); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }, alice); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + token: otpToken(registerResponse.body.secret), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); + + test('が設定でき、セキュリティキーでログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + assert.notEqual(registerKeyResponse.body.challengeId, undefined); + assert.notEqual(registerKeyResponse.body.challenge, undefined); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex')); + assert.strictEqual(keyDoneResponse.body.name, keyName); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.securityKeys, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + }); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.i, undefined); + assert.notEqual(signinResponse.body.challengeId, undefined); + assert.notEqual(signinResponse.body.challenge, undefined); + assert.notEqual(signinResponse.body.securityKeys, undefined); + assert.strictEqual(signinResponse.body.securityKeys[0].id, credentialId.toString('hex')); + + const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({ + keyName, + challengeId: signinResponse.body.challengeId, + challenge: signinResponse.body.challenge, + credentialId, + })); + assert.strictEqual(signinResponse2.status, 200); + assert.notEqual(signinResponse2.body.i, undefined); + }); + + test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + const passwordLessResponse = await api('/i/2fa/password-less', { + value: true, + }, alice); + assert.strictEqual(passwordLessResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.usePasswordLessLogin, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + password: '', + }); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.i, undefined); + + const signinResponse2 = await api('/signin', { + ...signinWithSecurityKeyParam({ + keyName, + challengeId: signinResponse.body.challengeId, + challenge: signinResponse.body.challenge, + credentialId, + }), + password: '', + }); + assert.strictEqual(signinResponse2.status, 200); + assert.notEqual(signinResponse2.body.i, undefined); + }); + + test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + const renamedKey = 'other-key'; + const updateKeyResponse = await api('/i/2fa/update-key', { + name: renamedKey, + credentialId: credentialId.toString('hex'), + }, alice); + assert.strictEqual(updateKeyResponse.status, 200); + + const iResponse = await api('/i', { + }, alice); + assert.strictEqual(iResponse.status, 200); + const securityKeys = iResponse.body.securityKeysList.filter(s => s.id === credentialId.toString('hex')); + assert.strictEqual(securityKeys.length, 1); + assert.strictEqual(securityKeys[0].name, renamedKey); + assert.notEqual(securityKeys[0].lastUsed, undefined); + }); + + test('が設定でき、設定したセキュリティキーを削除できる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + // テストの実行順によっては複数残ってるので全部消す + const iResponse = await api('/i', { + }, alice); + assert.strictEqual(iResponse.status, 200); + for (const key of iResponse.body.securityKeysList) { + const removeKeyResponse = await api('/i/2fa/remove-key', { + password, + credentialId: key.id, + }, alice); + assert.strictEqual(removeKeyResponse.status, 200); + } + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.securityKeys, false); + + const signinResponse = await api('/signin', { + ...signinParam(), + token: otpToken(registerResponse.body.secret), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); + + test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + + const unregisterResponse = await api('/i/2fa/unregister', { + password, + }, alice); + assert.strictEqual(unregisterResponse.status, 204); + + const signinResponse = await api('/signin', { + ...signinParam(), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); +}); diff --git a/packages/backend/test/_e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts similarity index 94% rename from packages/backend/test/_e2e/api-visibility.ts rename to packages/backend/test/e2e/api-visibility.ts index d29b9acb3d..3af0d35182 100644 --- a/packages/backend/test/_e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -1,18 +1,18 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, startServer, shutdownServer } from '../utils.js'; +import { signup, api, post, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('API visibility', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; beforeAll(async () => { - p = await startServer(); - }, 1000 * 30); + app = await startServer(); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await app.close(); }); describe('Note visibility', () => { @@ -60,7 +60,7 @@ describe('API visibility', () => { //#endregion const show = async (noteId: any, by: any) => { - return await request('/notes/show', { + return await api('/notes/show', { noteId, }, by); }; @@ -75,7 +75,7 @@ describe('API visibility', () => { target2 = await signup({ username: 'target2' }); // follow alice <= follower - await request('/following/create', { userId: alice.id }, follower); + await api('/following/create', { userId: alice.id }, follower); // normal posts pub = await post(alice, { text: 'x', visibility: 'public' }); @@ -413,21 +413,21 @@ describe('API visibility', () => { //#region HTL test('[HTL] public-post が 自分が見れる', async () => { - const res = await request('/notes/timeline', { limit: 100 }, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === pub.id); assert.strictEqual(notes[0].text, 'x'); }); test('[HTL] public-post が 非フォロワーから見れない', async () => { - const res = await request('/notes/timeline', { limit: 100 }, other); + const res = await api('/notes/timeline', { limit: 100 }, other); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === pub.id); assert.strictEqual(notes.length, 0); }); test('[HTL] followers-post が フォロワーから見れる', async () => { - const res = await request('/notes/timeline', { limit: 100 }, follower); + const res = await api('/notes/timeline', { limit: 100 }, follower); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === fol.id); assert.strictEqual(notes[0].text, 'x'); @@ -436,21 +436,21 @@ describe('API visibility', () => { //#region RTL test('[replies] followers-reply が フォロワーから見れる', async () => { - const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); + const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { - const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other); + const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, other); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes.length, 0); }); test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { - const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target); + const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); @@ -459,14 +459,14 @@ describe('API visibility', () => { //#region MTL test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { - const res = await request('/notes/mentions', { limit: 100 }, target); + const res = await api('/notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { - const res = await request('/notes/mentions', { limit: 100 }, target); + const res = await api('/notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folM.id); assert.strictEqual(notes[0].text, '@target x'); @@ -474,4 +474,4 @@ describe('API visibility', () => { //#endregion }); }); -*/ + diff --git a/packages/backend/test/_e2e/api.ts b/packages/backend/test/e2e/api.ts similarity index 53% rename from packages/backend/test/_e2e/api.ts rename to packages/backend/test/e2e/api.ts index 7542c34db0..a46f336a70 100644 --- a/packages/backend/test/_e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -1,83 +1,83 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from '../utils.js'; +import { signup, api, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('API', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; let alice: any; let bob: any; let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await app.close(); }); describe('General validation', () => { - test('wrong type', async(async () => { - const res = await request('/test', { + test('wrong type', async () => { + const res = await api('/test', { required: true, string: 42, }); assert.strictEqual(res.status, 400); - })); + }); - test('missing require param', async(async () => { - const res = await request('/test', { + test('missing require param', async () => { + const res = await api('/test', { string: 'a', }); assert.strictEqual(res.status, 400); - })); + }); - test('invalid misskey:id (empty string)', async(async () => { - const res = await request('/test', { + test('invalid misskey:id (empty string)', async () => { + const res = await api('/test', { required: true, id: '', }); assert.strictEqual(res.status, 400); - })); + }); - test('valid misskey:id', async(async () => { - const res = await request('/test', { + test('valid misskey:id', async () => { + const res = await api('/test', { required: true, id: '8wvhjghbxu', }); assert.strictEqual(res.status, 200); - })); + }); - test('default value', async(async () => { - const res = await request('/test', { + test('default value', async () => { + const res = await api('/test', { required: true, string: 'a', }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.default, 'hello'); - })); + }); - test('can set null even if it has default value', async(async () => { - const res = await request('/test', { + test('can set null even if it has default value', async () => { + const res = await api('/test', { required: true, nullableDefault: null, }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.nullableDefault, null); - })); + }); - test('cannot set undefined if it has default value', async(async () => { - const res = await request('/test', { + test('cannot set undefined if it has default value', async () => { + const res = await api('/test', { required: true, nullableDefault: undefined, }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.nullableDefault, 'hello'); - })); + }); }); }); diff --git a/packages/backend/test/_e2e/block.ts b/packages/backend/test/e2e/block.ts similarity index 71% rename from packages/backend/test/_e2e/block.ts rename to packages/backend/test/e2e/block.ts index c5f43e153c..57a46ab38a 100644 --- a/packages/backend/test/_e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -1,11 +1,11 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, startServer, shutdownServer } from '../utils.js'; +import { signup, api, post, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Block', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; // alice blocks bob let alice: any; @@ -13,18 +13,18 @@ describe('Block', () => { let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await app.close(); }); test('Block作成', async () => { - const res = await request('/blocking/create', { + const res = await api('/blocking/create', { userId: bob.id, }, alice); @@ -32,7 +32,7 @@ describe('Block', () => { }); test('ブロックされているユーザーをフォローできない', async () => { - const res = await request('/following/create', { userId: alice.id }, bob); + const res = await api('/following/create', { userId: alice.id }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); @@ -41,7 +41,7 @@ describe('Block', () => { test('ブロックされているユーザーにリアクションできない', async () => { const note = await post(alice, { text: 'hello' }); - const res = await request('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); + const res = await api('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); @@ -50,7 +50,7 @@ describe('Block', () => { test('ブロックされているユーザーに返信できない', async () => { const note = await post(alice, { text: 'hello' }); - const res = await request('/notes/create', { replyId: note.id, text: 'yo' }, bob); + const res = await api('/notes/create', { replyId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); @@ -59,7 +59,7 @@ describe('Block', () => { test('ブロックされているユーザーのノートをRenoteできない', async () => { const note = await post(alice, { text: 'hello' }); - const res = await request('/notes/create', { renoteId: note.id, text: 'yo' }, bob); + const res = await api('/notes/create', { renoteId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); @@ -70,11 +70,11 @@ describe('Block', () => { // TODO: ユーザーリストから除外されるテスト test('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); - const res = await request('/notes/local-timeline', {}, bob); + const res = await api('/notes/local-timeline', {}, bob); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts new file mode 100644 index 0000000000..f35aae9dc6 --- /dev/null +++ b/packages/backend/test/e2e/clips.ts @@ -0,0 +1,962 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { JTDDataType } from 'ajv/dist/jtd'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { paramDef as CreateParamDef } from '@/server/api/endpoints/clips/create.js'; +import { paramDef as UpdateParamDef } from '@/server/api/endpoints/clips/update.js'; +import { paramDef as DeleteParamDef } from '@/server/api/endpoints/clips/delete.js'; +import { paramDef as ShowParamDef } from '@/server/api/endpoints/clips/show.js'; +import { paramDef as FavoriteParamDef } from '@/server/api/endpoints/clips/favorite.js'; +import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unfavorite.js'; +import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js'; +import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js'; +import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js'; +import { + signup, + post, + startServer, + api, + successfulApiCall, + failedApiCall, + ApiRequest, + hiddenNote, +} from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('クリップ', () => { + type User = Packed<'User'>; + type Note = Packed<'Note'>; + type Clip = Packed<'Clip'>; + + let app: INestApplicationContext; + + let alice: User; + let bob: User; + let aliceNote: Note; + let aliceHomeNote: Note; + let aliceFollowersNote: Note; + let aliceSpecifiedNote: Note; + let bobNote: Note; + let bobHomeNote: Note; + let bobFollowersNote: Note; + let bobSpecifiedNote: Note; + + const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { + return selector(a).localeCompare(selector(b)); + }; + + type CreateParam = JTDDataType; + const defaultCreate = (): Partial => ({ + name: 'test', + }); + const create = async (parameters: Partial = {}, request: Partial = {}): Promise => { + const clip = await successfulApiCall({ + endpoint: '/clips/create', + parameters: { + ...defaultCreate(), + ...parameters, + }, + user: alice, + ...request, + }); + + // 入力が結果として入っていること + assert.deepStrictEqual(clip, { + ...clip, + ...defaultCreate(), + ...parameters, + }); + return clip; + }; + + const createMany = async (parameters: Partial, count = 10, user = alice): Promise => { + return await Promise.all([...Array(count)].map((_, i) => create({ + name: `test${i}`, + ...parameters, + }, { user }))); + }; + + type UpdateParam = JTDDataType; + const update = async (parameters: Partial, request: Partial = {}): Promise => { + const clip = await successfulApiCall({ + endpoint: '/clips/update', + parameters: { + name: 'updated', + ...parameters, + }, + user: alice, + ...request, + }); + + // 入力が結果として入っていること。clipIdはidになるので消しておく + delete (parameters as { clipId?: string }).clipId; + assert.deepStrictEqual(clip, { + ...clip, + ...parameters, + }); + return clip; + }; + + type DeleteParam = JTDDataType; + const deleteClip = async (parameters: DeleteParam, request: Partial = {}): Promise => { + return await successfulApiCall({ + endpoint: '/clips/delete', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type ShowParam = JTDDataType; + const show = async (parameters: ShowParam, request: Partial = {}): Promise => { + return await successfulApiCall({ + endpoint: '/clips/show', + parameters, + user: alice, + ...request, + }); + }; + + const list = async (request: Partial): Promise => { + return successfulApiCall({ + endpoint: '/clips/list', + parameters: {}, + user: alice, + ...request, + }); + }; + + const usersClips = async (request: Partial): Promise => { + return await successfulApiCall({ + endpoint: '/users/clips', + parameters: {}, + user: alice, + ...request, + }); + }; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + + // FIXME: misskey-jsのNoteはoutdatedなので直接変換できない + aliceNote = await post(alice, { text: 'test' }) as any; + aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any; + aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any; + aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any; + bobNote = await post(bob, { text: 'test' }) as any; + bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any; + bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any; + bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any; + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + // テスト間で影響し合わないように毎回全部消す。 + for (const user of [alice, bob]) { + const list = await api('/clips/list', { limit: 11 }, user); + for (const clip of list.body) { + await api('/clips/delete', { clipId: clip.id }, user); + } + } + }); + + test('の作成ができる', async () => { + const res = await create(); + // ISO 8601で日付が返ってくること + assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.lastClippedAt, null); + assert.strictEqual(res.name, 'test'); + assert.strictEqual(res.description, null); + assert.strictEqual(res.isPublic, false); + assert.strictEqual(res.favoritedCount, 0); + assert.strictEqual(res.isFavorited, false); + }); + + test('の作成はポリシーで定められた数以上はできない。', async () => { + // ポリシー + 1まで作れるという所がミソ + const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + for (let i = 0; i < clipLimit; i++) { + await create(); + } + + await failedApiCall({ + endpoint: '/clips/create', + parameters: defaultCreate(), + user: alice, + }, { + status: 400, + code: 'TOO_MANY_CLIPS', + id: '920f7c2d-6208-4b76-8082-e632020f5883', + }); + }); + + const createClipAllowedPattern = [ + { label: 'nameが最大長', parameters: { name: 'x'.repeat(100) } }, + { label: 'private', parameters: { isPublic: false } }, + { label: 'public', parameters: { isPublic: true } }, + { label: 'descriptionがnull', parameters: { description: null } }, + { label: 'descriptionが最大長', parameters: { description: 'a'.repeat(2048) } }, + ]; + test.each(createClipAllowedPattern)('の作成は$labelでもできる', async ({ parameters }) => await create(parameters)); + + const createClipDenyPattern = [ + { label: 'nameがnull', parameters: { name: null } }, + { label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } }, + { label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } }, + { label: 'descriptionがゼロ長', parameters: { description: '' } }, + { label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } }, + ]; + test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({ + endpoint: '/clips/create', + parameters: { + ...defaultCreate(), + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + })); + + test('の更新ができる', async () => { + const res = await update({ + clipId: (await create()).id, + name: 'updated', + description: 'new description', + isPublic: true, + }); + + // ISO 8601で日付が返ってくること + assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.lastClippedAt, null); + assert.strictEqual(res.name, 'updated'); + assert.strictEqual(res.description, 'new description'); + assert.strictEqual(res.isPublic, true); + assert.strictEqual(res.favoritedCount, 0); + assert.strictEqual(res.isFavorited, false); + }); + + test.each(createClipAllowedPattern)('の更新は$labelでもできる', async ({ parameters }) => await update({ + clipId: (await create()).id, + name: 'updated', + ...parameters, + })); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', + } }, + ...createClipDenyPattern as any, + ])('の更新は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/update', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + name: 'updated', + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('の削除ができる', async () => { + await deleteClip({ + clipId: (await create()).id, + }); + assert.deepStrictEqual(await list({}), []); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754', + } }, + ])('の削除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/delete', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('のID指定取得ができる', async () => { + const clip = await create(); + const res = await show({ clipId: clip.id }); + assert.deepStrictEqual(res, clip); + }); + + test('のID指定取得は他人のPrivateなクリップは取得できない', async () => { + const clip = await create({ isPublic: false }, { user: bob } ); + failedApiCall({ + endpoint: '/clips/show', + parameters: { clipId: clip.id }, + user: alice, + }, { + status: 400, + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', + }); + }); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', + } }, + ])('のID指定取得は$labelならできない', async ({ parameters, assetion }) => failedApiCall({ + endpoint: '/clips/show', + parameters: { + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('の一覧(clips/list)が取得できる(空)', async () => { + const res = await list({}); + assert.deepStrictEqual(res, []); + }); + + test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => { + const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clips = await createMany({}, clipLimit); + const res = await list({ + parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる + }); + + // 返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + clips.sort(compareBy(s => s.id)), + ); + }); + + test('の一覧が取得できる(空)', async () => { + const res = await usersClips({ + parameters: { + userId: alice.id, + }, + }); + assert.deepStrictEqual(res, []); + }); + + test.each([ + { label: '' }, + { label: '他人アカウントから', user: (): User => bob }, + ])('の一覧が$label取得できる', async () => { + const clips = await createMany({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + }, + }); + + // 返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + clips.sort(compareBy(s => s.id))); + + // 認証状態で見たときだけisFavoritedが入っている + for (const clip of res) { + assert.strictEqual(clip.isFavorited, false); + } + }); + + test.each([ + { label: '未認証', user: (): undefined => undefined }, + { label: '存在しないユーザーのもの', parameters: { userId: 'xxxxxxx' } }, + ])('の一覧は$labelでも取得できる', async ({ parameters, user }) => { + const clips = await createMany({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + limit: clips.length, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }); + + // 未認証で見たときはisFavoritedは入らない + for (const clip of res) { + assert.strictEqual('isFavorited' in clip, false); + } + }); + + test('の一覧はPrivateなクリップを含まない(自分のものであっても。)', async () => { + await create({ isPublic: false }); + const aliceClip = await create({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + limit: 2, + }, + }); + assert.deepStrictEqual(res, [aliceClip]); + }); + + test('の一覧はID指定で範囲選択ができる', async () => { + const clips = await createMany({ isPublic: true }, 7); + clips.sort(compareBy(s => s.id)); + const res = await usersClips({ + parameters: { + userId: alice.id, + sinceId: clips[1].id, + untilId: clips[5].id, + limit: 4, + }, + }); + + // Promise.allで返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + [clips[2], clips[3], clips[4]], // sinceIdとuntilId自体は結果に含まれない + clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id)); + }); + + test.each([ + { label: 'userId未指定', parameters: { userId: undefined } }, + { label: 'limitゼロ', parameters: { limit: 0 } }, + { label: 'limit最大+1', parameters: { limit: 101 } }, + ])('の一覧は$labelだと取得できない', async ({ parameters }) => failedApiCall({ + endpoint: '/users/clips', + parameters: { + userId: alice.id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + })); + + test.each([ + { label: '作成', endpoint: '/clips/create' }, + { label: '更新', endpoint: '/clips/update' }, + { label: '削除', endpoint: '/clips/delete' }, + { label: '取得', endpoint: '/clips/list' }, + { label: 'お気に入り設定', endpoint: '/clips/favorite' }, + { label: 'お気に入り解除', endpoint: '/clips/unfavorite' }, + { label: 'お気に入り取得', endpoint: '/clips/my-favorites' }, + { label: 'ノート追加', endpoint: '/clips/add-note' }, + { label: 'ノート削除', endpoint: '/clips/remove-note' }, + ])('の$labelは未認証ではできない', async ({ endpoint }) => await failedApiCall({ + endpoint: endpoint, + parameters: {}, + user: undefined, + }, { + status: 401, + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + })); + + describe('のお気に入り', () => { + let aliceClip: Clip; + + type FavoriteParam = JTDDataType; + const favorite = async (parameters: FavoriteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/favorite', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type UnfavoriteParam = JTDDataType; + const unfavorite = async (parameters: UnfavoriteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/unfavorite', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + const myFavorites = async (request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/my-favorites', + parameters: {}, + user: alice, + ...request, + }); + }; + + beforeEach(async () => { + aliceClip = await create(); + }); + + test('を設定できる。', async () => { + await favorite({ clipId: aliceClip.id }); + const clip = await show({ clipId: aliceClip.id }); + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + }); + + test('はPublicな他人のクリップに設定できる。', async () => { + const publicClip = await create({ isPublic: true }); + await favorite({ clipId: publicClip.id }, { user: bob }); + const clip = await show({ clipId: publicClip.id }, { user: bob }); + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + + // isFavoritedは見る人によって切り替わる。 + const clip2 = await show({ clipId: publicClip.id }); + assert.strictEqual(clip2.favoritedCount, 1); + assert.strictEqual(clip2.isFavorited, false); + }); + + test('は1つのクリップに対して複数人が設定できる。', async () => { + const publicClip = await create({ isPublic: true }); + await favorite({ clipId: publicClip.id }, { user: bob }); + await favorite({ clipId: publicClip.id }); + const clip = await show({ clipId: publicClip.id }, { user: bob }); + assert.strictEqual(clip.favoritedCount, 2); + assert.strictEqual(clip.isFavorited, true); + + const clip2 = await show({ clipId: publicClip.id }); + assert.strictEqual(clip2.favoritedCount, 2); + assert.strictEqual(clip2.isFavorited, true); + }); + + test('は11を超えて設定できる。', async () => { + const clips = [ + aliceClip, + ...await createMany({}, 10, alice), + ...await createMany({ isPublic: true }, 10, bob), + ]; + for (const clip of clips) { + await favorite({ clipId: clip.id }); + } + + // pagenationはない。全部一気にとれる。 + const favorited = await myFavorites(); + assert.strictEqual(favorited.length, clips.length); + for (const clip of favorited) { + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + } + }); + + test('は同じクリップに対して二回設定できない。', async () => { + await favorite({ clipId: aliceClip.id }); + await failedApiCall({ + endpoint: '/clips/favorite', + parameters: { + clipId: aliceClip.id, + }, + user: alice, + }, { + status: 400, + code: 'ALREADY_FAVORITED', + id: '92658936-c625-4273-8326-2d790129256e', + }); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + } }, + ])('の設定は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/favorite', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('を設定解除できる。', async () => { + await favorite({ clipId: aliceClip.id }); + await unfavorite({ clipId: aliceClip.id }); + const clip = await show({ clipId: aliceClip.id }); + assert.strictEqual(clip.favoritedCount, 0); + assert.strictEqual(clip.isFavorited, false); + assert.deepStrictEqual(await myFavorites(), []); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '2603966e-b865-426c-94a7-af4a01241dc1', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + } }, + { label: 'お気に入りしていないクリップ', assertion: { + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + } }, + ])('の設定解除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/unfavorite', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('を取得できる。', async () => { + await favorite({ clipId: aliceClip.id }); + const favorited = await myFavorites(); + assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]); + }); + + test('を取得したとき他人のお気に入りは含まない。', async () => { + await favorite({ clipId: aliceClip.id }); + const favorited = await myFavorites({ user: bob }); + assert.deepStrictEqual(favorited, []); + }); + }); + + describe('に紐づくノート', () => { + let aliceClip: Clip; + + const sampleNotes = (): Note[] => [ + aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, + bobNote, bobHomeNote, bobFollowersNote, bobSpecifiedNote, + ]; + + type AddNoteParam = JTDDataType; + const addNote = async (parameters: AddNoteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/add-note', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type RemoveNoteParam = JTDDataType; + const removeNote = async (parameters: RemoveNoteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/remove-note', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type NotesParam = JTDDataType; + const notes = async (parameters: Partial, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/notes', + parameters, + user: alice, + ...request, + }); + }; + + beforeEach(async () => { + aliceClip = await create(); + }); + + test('を追加できる。', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + const res = await show({ clipId: aliceClip.id }); + assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString()); + assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]); + + // 他人の非公開ノートも突っ込める + await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id }); + await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id }); + await addNote({ clipId: aliceClip.id, noteId: bobSpecifiedNote.id }); + }); + + test('として同じノートを二回紐づけることはできない', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: alice, + }, { + status: 400, + code: 'ALREADY_CLIPPED', + id: '734806c4-542c-463a-9311-15c512803965', + }); + }); + + // TODO: 17000msくらいかかる... + test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => { + const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1; + const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, { + text: `test ${i}`, + }) as unknown)) as Note[]; + await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id }))); + + await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: alice, + }, { + status: 400, + code: 'TOO_MANY_CLIP_NOTES', + id: 'f0dba960-ff73-4615-8df4-d6ac5d9dc118', + }); + }); + + test('は他人のクリップへ追加できない。', async () => await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + })); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + } }, + { label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_NOTE', + id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b', + } }, + { label: '他人のクリップ', user: (): object => bob, assetion: { + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + } }, + ])('の追加は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('を削除できる。', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []); + }); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる + } }, + { label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_NOTE', + id: 'aff017de-190e-434b-893e-33a9ff5049d8', // add-noteと異なる + } }, + { label: '他人のクリップ', user: (): object => bob, assetion: { + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる + } }, + ])('の削除は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ + endpoint: '/clips/remove-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('を取得できる。', async () => { + const noteList = sampleNotes(); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ clipId: aliceClip.id }); + + // 自分のノートは非公開でも入れられるし、見える + // 他人の非公開ノートは入れられるけど、除外される + const expects = [ + aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, + bobNote, bobHomeNote, + ]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test('を始端IDとlimitで取得できる。', async () => { + const noteList = sampleNotes(); + noteList.sort(compareBy(s => s.id)); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ + clipId: aliceClip.id, + sinceId: noteList[2].id, + limit: 3, + }); + + // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 + const expects = [noteList[3], noteList[4], noteList[5]]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test('をID範囲指定で取得できる。', async () => { + const noteList = sampleNotes(); + noteList.sort(compareBy(s => s.id)); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ + clipId: aliceClip.id, + sinceId: noteList[1].id, + untilId: noteList[4].id, + }); + + // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 + const expects = [noteList[2], noteList[3]]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test.todo('Remoteのノートもクリップできる。どうテストしよう?'); + + test('は他人のPublicなクリップからも取得できる。', async () => { + const bobClip = await create({ isPublic: true }, { user: bob } ); + await addNote({ clipId: bobClip.id, noteId: aliceNote.id }, { user: bob }); + const res = await notes({ clipId: bobClip.id }); + assert.deepStrictEqual(res, [aliceNote]); + }); + + test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => { + const publicClip = await create({ isPublic: true }); + await addNote({ clipId: publicClip.id, noteId: aliceNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceFollowersNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceSpecifiedNote.id }); + + const res = await notes({ clipId: publicClip.id }, { user: undefined }); + const expects = [ + aliceNote, aliceHomeNote, + // 認証なしだと非公開ノートは結果には含むけどhideされる。 + hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote), + ]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test.todo('ブロック、ミュートされたユーザーからの設定&取得etc.'); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'limitゼロ', parameters: { limit: 0 } }, + { label: 'limit最大+1', parameters: { limit: 101 } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + { label: '他人のPrivateなクリップから', user: (): object => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + { label: '未認証でPrivateなクリップから', user: (): undefined => undefined, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + ])('は$labelだと取得できない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/notes', + parameters: { + clipId: aliceClip.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + }); +}); diff --git a/packages/backend/test/_e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts similarity index 59% rename from packages/backend/test/_e2e/endpoints.ts rename to packages/backend/test/e2e/endpoints.ts index ea8433dfa2..a1f364779e 100644 --- a/packages/backend/test/_e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -1,29 +1,36 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import * as openapi from '@redocly/openapi-core'; -import { startServer, signup, post, request, simpleGet, port, shutdownServer, api } from '../utils.js'; +// node-fetch only supports it's own Blob yet +// https://github.com/node-fetch/node-fetch/pull/1664 +import { Blob } from 'node-fetch'; +import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; +import { User } from '@/models/index.js'; describe('Endpoints', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; let alice: any; let bob: any; + let carol: any; + let dave: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); - }, 1000 * 30); + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await app.close(); }); describe('signup', () => { test('不正なユーザー名でアカウントが作成できない', async () => { - const res = await request('api/signup', { + const res = await api('signup', { username: 'test.', password: 'test', }); @@ -31,7 +38,7 @@ describe('Endpoints', () => { }); test('空のパスワードでアカウントが作成できない', async () => { - const res = await request('api/signup', { + const res = await api('signup', { username: 'test', password: '', }); @@ -44,7 +51,7 @@ describe('Endpoints', () => { password: 'test1', }; - const res = await request('api/signup', me); + const res = await api('signup', me); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); @@ -52,7 +59,7 @@ describe('Endpoints', () => { }); test('同じユーザー名のアカウントは作成できない', async () => { - const res = await request('api/signup', { + const res = await api('signup', { username: 'test1', password: 'test1', }); @@ -63,7 +70,7 @@ describe('Endpoints', () => { describe('signin', () => { test('間違ったパスワードでサインインできない', async () => { - const res = await request('api/signin', { + const res = await api('signin', { username: 'test1', password: 'bar', }); @@ -72,7 +79,7 @@ describe('Endpoints', () => { }); test('クエリをインジェクションできない', async () => { - const res = await request('api/signin', { + const res = await api('signin', { username: 'test1', password: { $gt: '', @@ -83,7 +90,7 @@ describe('Endpoints', () => { }); test('正しい情報でサインインできる', async () => { - const res = await request('api/signin', { + const res = await api('signin', { username: 'test1', password: 'test1', }); @@ -111,11 +118,12 @@ describe('Endpoints', () => { assert.strictEqual(res.body.birthday, myBirthday); }); - test('名前を空白にできない', async () => { + test('名前を空白にできる', async () => { const res = await api('/i/update', { name: ' ', }, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, ' '); }); test('誕生日の設定を削除できる', async () => { @@ -155,14 +163,14 @@ describe('Endpoints', () => { const res = await api('/users/show', { userId: '000000000000000000000000', }); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); }); test('間違ったIDで怒られる', async () => { const res = await api('/users/show', { userId: 'kyoppie', }); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); }); }); @@ -199,9 +207,8 @@ describe('Endpoints', () => { describe('notes/reactions/create', () => { test('リアクションできる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); - const alice = await signup({ username: 'alice' }); const res = await api('/notes/reactions/create', { noteId: bobPost.id, reaction: '🚀', @@ -214,11 +221,11 @@ describe('Endpoints', () => { }, alice); assert.strictEqual(resNote.status, 200); - assert.strictEqual(resNote.body.reactions['🚀'], [alice.id]); + assert.strictEqual(resNote.body.reactions['🚀'], 1); }); test('自分の投稿にもリアクションできる', async () => { - const myPost = await post(alice); + const myPost = await post(alice, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: myPost.id, @@ -228,8 +235,8 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 204); }); - test('二重にリアクションできない', async () => { - const bobPost = await post(bob); + test('二重にリアクションすると上書きされる', async () => { + const bobPost = await post(bob, { text: 'hi' }); await api('/notes/reactions/create', { noteId: bobPost.id, @@ -241,7 +248,14 @@ describe('Endpoints', () => { reaction: '🚀', }, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 204); + + const resNote = await api('/notes/show', { + noteId: bobPost.id, + }, alice); + + assert.strictEqual(resNote.status, 200); + assert.deepStrictEqual(resNote.body.reactions, { '🚀': 1 }); }); test('存在しない投稿にはリアクションできない', async () => { @@ -276,6 +290,16 @@ describe('Endpoints', () => { }, bob); assert.strictEqual(res.status, 200); + + const connection = await initTestDb(true); + const Users = connection.getRepository(User); + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.followersCount, 0); + assert.strictEqual(newBob.followingCount, 1); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + assert.strictEqual(newAlice.followersCount, 1); + assert.strictEqual(newAlice.followingCount, 0); + connection.destroy(); }); test('既にフォローしている場合は怒る', async () => { @@ -328,6 +352,16 @@ describe('Endpoints', () => { }, bob); assert.strictEqual(res.status, 200); + + const connection = await initTestDb(true); + const Users = connection.getRepository(User); + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.followersCount, 0); + assert.strictEqual(newBob.followingCount, 0); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + assert.strictEqual(newAlice.followersCount, 0); + assert.strictEqual(newAlice.followingCount, 0); + connection.destroy(); }); test('フォローしていない場合は怒る', async () => { @@ -369,57 +403,116 @@ describe('Endpoints', () => { }); }); - /* - describe('/i', () => { - test('', async () => { + describe('channels/search', () => { + test('空白検索で一覧を取得できる', async () => { + await api('/channels/create', { + name: 'aaa', + description: 'bbb', + }, bob); + await api('/channels/create', { + name: 'ccc1', + description: 'ddd1', + }, bob); + await api('/channels/create', { + name: 'ccc2', + description: 'ddd2', + }, bob); + + const res = await api('/channels/search', { + query: '', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 3); }); - }); - */ -}); + test('名前のみの検索で名前を検索できる', async () => { + const res = await api('/channels/search', { + query: 'aaa', + type: 'nameOnly', + }, bob); -/* -process.env.NODE_ENV = 'test'; + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].name, 'aaa'); + }); + test('名前のみの検索で名前を複数検索できる', async () => { + const res = await api('/channels/search', { + query: 'ccc', + type: 'nameOnly', + }, bob); -import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js'; + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 2); + }); + test('名前のみの検索で説明は検索できない', async () => { + const res = await api('/channels/search', { + query: 'bbb', + type: 'nameOnly', + }, bob); -describe('API: Endpoints', () => { - let p: childProcess.ChildProcess; - let alice: any; - let bob: any; - let carol: any; + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 0); + }); + test('名前と説明の検索で名前を検索できる', async () => { + const res = await api('/channels/search', { + query: 'ccc1', + }, bob); - before(async () => { - p = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }); + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].name, 'ccc1'); + }); + test('名前と説明での検索で説明を検索できる', async () => { + const res = await api('/channels/search', { + query: 'ddd1', + }, bob); - after(async () => { - await shutdownServer(p); + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].name, 'ccc1'); + }); + test('名前と説明の検索で名前を複数検索できる', async () => { + const res = await api('/channels/search', { + query: 'ccc', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 2); + }); + test('名前と説明での検索で説明を複数検索できる', async () => { + const res = await api('/channels/search', { + query: 'ddd', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 2); + }); }); describe('drive', () => { test('ドライブ情報を取得できる', async () => { - await uploadFile({ - userId: alice.id, - size: 256 + await uploadFile(alice, { + blob: new Blob([new Uint8Array(256)]), }); - await uploadFile({ - userId: alice.id, - size: 512 + await uploadFile(alice, { + blob: new Blob([new Uint8Array(512)]), }); - await uploadFile({ - userId: alice.id, - size: 1024 + await uploadFile(alice, { + blob: new Blob([new Uint8Array(1024)]), }); const res = await api('/drive', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - expect(res.body).have.property('usage').eql(1792); - })); + expect(res.body).toHaveProperty('usage', 1792); + }); }); describe('drive/files/create', () => { @@ -428,354 +521,454 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'Lenna.png'); - })); + assert.strictEqual(res.body.name, 'Lenna.jpg'); + }); test('ファイルに名前を付けられる', async () => { - const res = await assert.request(server) - .post('/drive/files/create') - .field('i', alice.token) - .field('name', 'Belmond.png') - .attach('file', fs.readFileSync(__dirname + '/resources/Lenna.png'), 'Lenna.png'); + const res = await uploadFile(alice, { name: 'Belmond.jpg' }); - expect(res).have.status(200); - expect(res.body).be.a('object'); - expect(res.body).have.property('name').eql('Belmond.png'); - })); + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'Belmond.jpg'); + }); + + test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => { + const res = await uploadFile(alice, { name: 'Belmond.png' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'Belmond.png.jpg'); + }); test('ファイル無しで怒られる', async () => { const res = await api('/drive/files/create', {}, alice); assert.strictEqual(res.status, 400); - })); + }); test('SVGファイルを作成できる', async () => { - const res = await uploadFile(alice, __dirname + '/resources/image.svg'); + const res = await uploadFile(alice, { path: 'image.svg' }); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.name, 'image.svg'); assert.strictEqual(res.body.type, 'image/svg+xml'); - })); + }); + + for (const type of ['webp', 'avif']) { + const mediaType = `image/${type}`; + + const getWebpublicType = async (user: any, fileId: string): Promise => { + // drive/files/create does not expose webpublicType directly, so get it by posting it + const res = await post(user, { + text: mediaType, + fileIds: [fileId], + }); + const apRes = await simpleGet(`notes/${res.id}`, 'application/activity+json'); + assert.strictEqual(apRes.status, 200); + assert.ok(Array.isArray(apRes.body.attachment)); + return apRes.body.attachment[0].mediaType; + }; + + test(`透明な${type}ファイルを作成できる`, async () => { + const path = `with-alpha.${type}`; + const res = await uploadFile(alice, { path }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, path); + assert.strictEqual(res.body.type, mediaType); + + const webpublicType = await getWebpublicType(alice, res.body.id); + assert.strictEqual(webpublicType, 'image/webp'); + }); + + test(`透明じゃない${type}ファイルを作成できる`, async () => { + const path = `without-alpha.${type}`; + const res = await uploadFile(alice, { path }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, path); + assert.strictEqual(res.body.type, mediaType); + + const webpublicType = await getWebpublicType(alice, res.body.id); + assert.strictEqual(webpublicType, 'image/webp'); + }); + } }); describe('drive/files/update', () => { test('名前を更新できる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const newName = 'いちごパスタ.png'; const res = await api('/drive/files/update', { fileId: file.id, - name: newName + name: newName, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.name, newName); - })); + }); test('他人のファイルは更新できない', async () => { - const file = await uploadFile(bob); + const file = (await uploadFile(alice)).body; const res = await api('/drive/files/update', { fileId: file.id, - name: 'いちごパスタ.png' - }, alice); + name: 'いちごパスタ.png', + }, bob); assert.strictEqual(res.status, 400); - })); + }); test('親フォルダを更新できる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/files/update', { fileId: file.id, - folderId: folder.id + folderId: folder.id, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.folderId, folder.id); - })); + }); test('親フォルダを無しにできる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; await api('/drive/files/update', { fileId: file.id, - folderId: folder.id + folderId: folder.id, }, alice); const res = await api('/drive/files/update', { fileId: file.id, - folderId: null + folderId: null, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.folderId, null); - })); + }); test('他人のフォルダには入れられない', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, bob)).body; const res = await api('/drive/files/update', { fileId: file.id, - folderId: folder.id + folderId: folder.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('存在しないフォルダで怒られる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const res = await api('/drive/files/update', { fileId: file.id, - folderId: '000000000000000000000000' + folderId: '000000000000000000000000', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('不正なフォルダIDで怒られる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const res = await api('/drive/files/update', { fileId: file.id, - folderId: 'foo' + folderId: 'foo', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('ファイルが存在しなかったら怒る', async () => { const res = await api('/drive/files/update', { fileId: '000000000000000000000000', - name: 'いちごパスタ.png' + name: 'いちごパスタ.png', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('間違ったIDで怒られる', async () => { const res = await api('/drive/files/update', { fileId: 'kyoppie', - name: 'いちごパスタ.png' + name: 'いちごパスタ.png', }, alice); assert.strictEqual(res.status, 400); - })); + }); }); describe('drive/folders/create', () => { test('フォルダを作成できる', async () => { const res = await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.name, 'test'); - })); + }); }); describe('drive/folders/update', () => { test('名前を更新できる', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - name: 'new name' + name: 'new name', }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.name, 'new name'); - })); + }); test('他人のフォルダを更新できない', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, bob)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - name: 'new name' + name: 'new name', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('親フォルダを更新できる', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const parentFolder = (await api('/drive/folders/create', { - name: 'parent' + name: 'parent', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: parentFolder.id + parentId: parentFolder.id, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.parentId, parentFolder.id); - })); + }); test('親フォルダを無しに更新できる', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const parentFolder = (await api('/drive/folders/create', { - name: 'parent' + name: 'parent', }, alice)).body; await api('/drive/folders/update', { folderId: folder.id, - parentId: parentFolder.id + parentId: parentFolder.id, }, alice); const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: null + parentId: null, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.parentId, null); - })); + }); test('他人のフォルダを親フォルダに設定できない', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const parentFolder = (await api('/drive/folders/create', { - name: 'parent' + name: 'parent', }, bob)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: parentFolder.id + parentId: parentFolder.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('フォルダが循環するような構造にできない', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const parentFolder = (await api('/drive/folders/create', { - name: 'parent' + name: 'parent', }, alice)).body; await api('/drive/folders/update', { folderId: parentFolder.id, - parentId: folder.id + parentId: folder.id, }, alice); const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: parentFolder.id + parentId: parentFolder.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('フォルダが循環するような構造にできない(再帰的)', async () => { const folderA = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const folderB = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const folderC = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; await api('/drive/folders/update', { folderId: folderB.id, - parentId: folderA.id + parentId: folderA.id, }, alice); await api('/drive/folders/update', { folderId: folderC.id, - parentId: folderB.id + parentId: folderB.id, }, alice); const res = await api('/drive/folders/update', { folderId: folderA.id, - parentId: folderC.id + parentId: folderC.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('フォルダが循環するような構造にできない(自身)', async () => { const folderA = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folderA.id, - parentId: folderA.id + parentId: folderA.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('存在しない親フォルダを設定できない', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: '000000000000000000000000' + parentId: '000000000000000000000000', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('不正な親フォルダIDで怒られる', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: 'foo' + parentId: 'foo', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('存在しないフォルダを更新できない', async () => { const res = await api('/drive/folders/update', { - folderId: '000000000000000000000000' + folderId: '000000000000000000000000', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('不正なフォルダIDで怒られる', async () => { const res = await api('/drive/folders/update', { - folderId: 'foo' + folderId: 'foo', }, alice); assert.strictEqual(res.status, 400); - })); + }); + }); + + describe('messaging/messages/create', () => { + test('メッセージを送信できる', async () => { + const res = await api('/messaging/messages/create', { + userId: bob.id, + text: 'test', + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.text, 'test'); + }); + + test('自分自身にはメッセージを送信できない', async () => { + const res = await api('/messaging/messages/create', { + userId: alice.id, + text: 'Yo', + }, alice); + + assert.strictEqual(res.status, 400); + }); + + test('存在しないユーザーにはメッセージを送信できない', async () => { + const res = await api('/messaging/messages/create', { + userId: '000000000000000000000000', + text: 'test', + }, alice); + + assert.strictEqual(res.status, 400); + }); + + test('不正なユーザーIDで怒られる', async () => { + const res = await api('/messaging/messages/create', { + userId: 'foo', + text: 'test', + }, alice); + + assert.strictEqual(res.status, 400); + }); + + test('テキストが無くて怒られる', async () => { + const res = await api('/messaging/messages/create', { + userId: bob.id, + }, alice); + + assert.strictEqual(res.status, 400); + }); + + test('文字数オーバーで怒られる', async () => { + const res = await api('/messaging/messages/create', { + userId: bob.id, + text: '!'.repeat(1001), + }, alice); + + assert.strictEqual(res.status, 400); + }); }); describe('messaging/messages/create', () => { @@ -838,44 +1031,132 @@ describe('API: Endpoints', () => { describe('notes/replies', () => { test('自分に閲覧権限のない投稿は含まれない', async () => { const alicePost = await post(alice, { - text: 'foo' + text: 'foo', }); await post(bob, { replyId: alicePost.id, text: 'bar', visibility: 'specified', - visibleUserIds: [alice.id] + visibleUserIds: [alice.id], }); const res = await api('/notes/replies', { - noteId: alicePost.id + noteId: alicePost.id, }, carol); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(res.body.length, 0); - })); + }); }); describe('notes/timeline', () => { test('フォロワー限定投稿が含まれる', async () => { await api('/following/create', { - userId: alice.id - }, bob); + userId: carol.id, + }, dave); - const alicePost = await post(alice, { + const carolPost = await post(carol, { text: 'foo', - visibility: 'followers' + visibility: 'followers', }); - const res = await api('/notes/timeline', {}, bob); + const res = await api('/notes/timeline', {}, dave); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body[0].id, alicePost.id); - })); + assert.strictEqual(res.body[0].id, carolPost.id); + }); + }); + + describe('URL preview', () => { + test('Error from summaly becomes HTTP 422', async () => { + const res = await simpleGet('/url?url=https://e:xample.com'); + assert.strictEqual(res.status, 422); + assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED'); + }); + }); + + describe('パーソナルメモ機能のテスト', () => { + test('他者に関するメモを更新できる', async () => { + const memo = '10月まで低浮上とのこと。'; + + const res1 = await api('/users/update-memo', { + memo, + userId: bob.id, + }, alice); + + const res2 = await api('/users/show', { + userId: bob.id, + }, alice); + assert.strictEqual(res1.status, 204); + assert.strictEqual(res2.body?.memo, memo); + }); + + test('自分に関するメモを更新できる', async () => { + const memo = 'チケットを月末までに買う。'; + + const res1 = await api('/users/update-memo', { + memo, + userId: alice.id, + }, alice); + + const res2 = await api('/users/show', { + userId: alice.id, + }, alice); + assert.strictEqual(res1.status, 204); + assert.strictEqual(res2.body?.memo, memo); + }); + + test('メモを削除できる', async () => { + const memo = '10月まで低浮上とのこと。'; + + await api('/users/update-memo', { + memo, + userId: bob.id, + }, alice); + + await api('/users/update-memo', { + memo: '', + userId: bob.id, + }, alice); + + const res = await api('/users/show', { + userId: bob.id, + }, alice); + + // memoには常に文字列かnullが入っている(5cac151) + assert.strictEqual(res.body.memo, null); + }); + + test('メモは個人ごとに独立して保存される', async () => { + const memoAliceToBob = '10月まで低浮上とのこと。'; + const memoCarolToBob = '例の件について今度問いただす。'; + + await Promise.all([ + api('/users/update-memo', { + memo: memoAliceToBob, + userId: bob.id, + }, alice), + api('/users/update-memo', { + memo: memoCarolToBob, + userId: bob.id, + }, carol), + ]); + + const [resAlice, resCarol] = await Promise.all([ + api('/users/show', { + userId: bob.id, + }, alice), + api('/users/show', { + userId: bob.id, + }, carol), + ]); + + assert.strictEqual(resAlice.body.memo, memoAliceToBob); + assert.strictEqual(resCarol.body.memo, memoCarolToBob); + }); }); }); -*/ diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts new file mode 100644 index 0000000000..78ca8b43ba --- /dev/null +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -0,0 +1,461 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js'; +import type { SimpleGetResponse } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +// Request Accept +const ONLY_AP = 'application/activity+json'; +const PREFER_AP = 'application/activity+json, */*'; +const PREFER_HTML = 'text/html, */*'; +const UNSPECIFIED = '*/*'; + +// Response Content-Type +const AP = 'application/activity+json; charset=utf-8'; +const HTML = 'text/html; charset=utf-8'; +const JSON_UTF8 = 'application/json; charset=utf-8'; + +describe('Webリソース', () => { + let app: INestApplicationContext; + + let alice: any; + let aliceUploadedFile: any; + let alicesPost: any; + let alicePage: any; + let alicePlay: any; + let aliceClip: any; + let aliceGalleryPost: any; + let aliceChannel: any; + + type Request = { + path: string, + accept?: string, + cookie?: string, + }; + const ok = async (param: Request & { + type?: string, + }):Promise => { + const { path, accept, cookie, type } = param; + const res = await simpleGet(path, accept, cookie); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, type ?? HTML); + return res; + }; + + const notOk = async (param: Request & { + status?: number, + code?: string, + }): Promise => { + const { path, accept, cookie, status, code } = param; + const res = await simpleGet(path, accept, cookie); + assert.notStrictEqual(res.status, 200); + if (status != null) { + assert.strictEqual(res.status, status); + } + if (code != null) { + assert.strictEqual(res.body.error.code, code); + } + return res; + }; + + const notFound = async (param: Request): Promise => { + return await notOk({ + ...param, + status: 404, + }); + }; + + const metaTag = (res: SimpleGetResponse, key: string, superkey = 'name'): string => { + return res.body.window.document.querySelector('meta[' + superkey + '="' + key + '"]')?.content; + }; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + aliceUploadedFile = await uploadFile(alice); + alicesPost = await post(alice, { + text: 'test', + }); + alicePage = await page(alice, {}); + alicePlay = await play(alice, {}); + aliceClip = await clip(alice, {}); + aliceGalleryPost = await galleryPost(alice, { + fileIds: [aliceUploadedFile.body.id], + }); + aliceChannel = await channel(alice, {}); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + describe.each([ + { path: '/', type: HTML }, + { path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。" + // fastify-static gives charset=UTF-8 instead of utf-8 and that's okay + { path: '/api-doc', type: 'text/html; charset=UTF-8' }, + { path: '/api.json', type: JSON_UTF8 }, + { path: '/api-console', type: HTML }, + { path: '/_info_card_', type: HTML }, + { path: '/bios', type: HTML }, + { path: '/cli', type: HTML }, + { path: '/flush', type: HTML }, + { path: '/robots.txt', type: 'text/plain; charset=UTF-8' }, + { path: '/favicon.ico', type: 'image/vnd.microsoft.icon' }, + { path: '/opensearch.xml', type: 'application/opensearchdescription+xml' }, + { path: '/apple-touch-icon.png', type: 'image/png' }, + { path: '/twemoji/2764.svg', type: 'image/svg+xml' }, + { path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' }, + { path: '/twemoji-badge/2764.png', type: 'image/png' }, + { path: '/twemoji-badge/2764-fe0f-200d-1f525.png', type: 'image/png' }, + { path: '/fluent-emoji/2764.png', type: 'image/png' }, + { path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' }, + ])('$path', (p) => { + test('がGETできる。', async () => await ok({ ...p })); + + // 注意: Webページが200で取得できても、実際のHTMLが正しく表示できるとは限らない + // 例えば、 /@xxx/pages/yyy に存在しないIDを渡した場合、HTTPレスポンスではエラーを区別できない + // こういったアサーションはフロントエンドE2EやAPI Endpointのテストで担保する。 + }); + + describe.each([ + { path: '/twemoji/2764.png' }, + { path: '/twemoji/2764-fe0f-200d-1f525.png' }, + { path: '/twemoji-badge/2764.svg' }, + { path: '/twemoji-badge/2764-fe0f-200d-1f525.svg' }, + { path: '/fluent-emoji/2764.svg' }, + { path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' }, + ])('$path', ({ path }) => { + test('はGETできない。', async () => await notFound({ path })); + }); + + describe.each([ + { ext: 'rss', type: 'application/rss+xml; charset=utf-8' }, + { ext: 'atom', type: 'application/atom+xml; charset=utf-8' }, + { ext: 'json', type: 'application/json; charset=utf-8' }, + ])('/@:username.$ext', ({ ext, type }) => { + const path = (username: string): string => `/@${username}.${ext}`; + + test('がGETできる。', async () => await ok({ + path: path(alice.username), + type, + })); + + test('は存在しないユーザーはGETできない。', async () => await notOk({ + path: path('nonexisting'), + status: 404, + })); + }); + + describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { + test('はGETできない。', async () => await notOk({ + path, + status: 404, + code: 'UNKNOWN_API_ENDPOINT', + })); + }); + + describe.each([{ path: '/queue' }])('$path', ({ path }) => { + test('はadminでなければGETできない。', async () => await notOk({ + path, + status: 500, // FIXME? 403ではない。 + })); + + test('はadminならGETできる。', async () => await ok({ + path, + cookie: cookie(alice), + })); + }); + + describe.each([{ path: '/streaming' }])('$path', ({ path }) => { + test('はGETできない。', async () => await notOk({ + path, + status: 503, + })); + }); + + describe('/@:username', () => { + const path = (username: string): string => `/@${username}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + accept, + type: HTML, + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + // TODO の検証 + }); + test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + type: HTML, + })); + }); + + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Person'); + }); + + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ + path: path('xxxxxxxxxx'), + accept, + })); + }); + }); + + describe.each([ + // 実際のハンドルはフロントエンド(index.vue)で行われる + { sub: 'home' }, + { sub: 'notes' }, + { sub: 'activity' }, + { sub: 'achievements' }, + { sub: 'reactions' }, + { sub: 'clips' }, + { sub: 'pages' }, + { sub: 'gallery' }, + ])('/@:username/$sub', ({ sub }) => { + const path = (username: string): string => `/@${username}/${sub}`; + + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + }); + }); + + describe('/@:user/pages/:page', () => { + const path = (username: string, pagename: string): string => `/@${username}/pages/${pagename}`; + + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username, alicePage.name), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:page-id'), alicePage.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('はGETできる。(存在しないIDでも。)', async () => await ok({ + path: path(alice.username, 'xxxxxxxxxx'), + })); + }); + + describe('/users/:id', () => { + const path = (id: string): string => `/users/${id}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('は/@:usernameにリダイレクトする', async () => { + const res = await simpleGet(path(alice.id), accept); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.location, `/@${alice.username}`); + }); + + test('は存在しないユーザーはGETできない。', async () => await notFound({ + path: path('xxxxxxxx'), + })); + }); + + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.id), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Person'); + }); + + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({ + path: path('xxxxxxxx'), + accept, + status: 404, + })); + }); + }); + + describe('/users/inbox', () => { + test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ + path: '/inbox', + })); + + // test.todo('POSTできる?'); + }); + + describe('/users/:id/inbox', () => { + const path = (id: string): string => `/users/${id}/inbox`; + + test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ + path: path(alice.id), + })); + + // test.todo('POSTできる?'); + }); + + describe('/users/:id/outbox', () => { + const path = (id: string): string => `/users/${id}/outbox`; + + test('がGETできる。', async () => { + const res = await ok({ + path: path(alice.id), + type: AP, + }); + assert.strictEqual(res.body.type, 'OrderedCollection'); + }); + }); + + describe('/notes/:id', () => { + const path = (noteId: string): string => `/notes/${noteId}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alicesPost.id), + accept, + type: HTML, + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alicesPost.id), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Note'); + }); + + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ + path: path('xxxxxxxxxx'), + accept, + })); + }); + }); + + describe('/play/:id', () => { + const path = (playid: string): string => `/play/${playid}`; + + test('がGETできる。', async () => { + const res = await ok({ + path: path(alicePlay.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:flash-id'), alicePlay.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/clips/:clip', () => { + const path = (clip: string): string => `/clips/${clip}`; + + test('がGETできる。', async () => { + const res = await ok({ + path: path(aliceClip.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:clip-id'), aliceClip.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/gallery/:post', () => { + const path = (post: string): string => `/gallery/${post}`; + + test('がGETできる。', async () => { + const res = await ok({ + path: path(aliceGalleryPost.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + + // FIXME: misskey:gallery-post-idみたいなmetaタグの設定がない + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/channels/:channel', () => { + const path = (channel: string): string => `/channels/${channel}`; + + test('はGETできる。', async () => { + const res = await ok({ + path: path(aliceChannel.id), + }); + + // FIXME: misskey関連のmetaタグの設定がない + // TODO ogタグの検証 + }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); +}); diff --git a/packages/backend/test/_e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts similarity index 69% rename from packages/backend/test/_e2e/ff-visibility.ts rename to packages/backend/test/e2e/ff-visibility.ts index 84a5b5ef28..7b75005a39 100644 --- a/packages/backend/test/_e2e/ff-visibility.ts +++ b/packages/backend/test/e2e/ff-visibility.ts @@ -1,36 +1,34 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from '../utils.js'; +import { signup, api, startServer, simpleGet } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('FF visibility', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; let alice: any; let bob: any; - let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await app.close(); }); test('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'public', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, bob); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, bob); @@ -41,14 +39,14 @@ describe('FF visibility', () => { }); test('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'followers', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, alice); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, alice); @@ -59,14 +57,14 @@ describe('FF visibility', () => { }); test('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'followers', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, bob); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, bob); @@ -75,18 +73,18 @@ describe('FF visibility', () => { }); test('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'followers', }, alice); - await request('/following/create', { + await api('/following/create', { userId: alice.id, }, bob); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, bob); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, bob); @@ -97,14 +95,14 @@ describe('FF visibility', () => { }); test('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'private', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, alice); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, alice); @@ -115,14 +113,14 @@ describe('FF visibility', () => { }); test('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'private', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, bob); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, bob); @@ -133,7 +131,7 @@ describe('FF visibility', () => { describe('AP', () => { test('ffVisibility が public 以外ならばAPからは取得できない', async () => { { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'public', }, alice); @@ -143,22 +141,22 @@ describe('FF visibility', () => { assert.strictEqual(followersRes.status, 200); } { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'followers', }, alice); - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); assert.strictEqual(followingRes.status, 403); assert.strictEqual(followersRes.status, 403); } { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'private', }, alice); - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); assert.strictEqual(followingRes.status, 403); assert.strictEqual(followersRes.status, 403); } diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts new file mode 100644 index 0000000000..7d6c646090 --- /dev/null +++ b/packages/backend/test/e2e/move.ts @@ -0,0 +1,456 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import rndstr from 'rndstr'; +import { loadConfig } from '@/config.js'; +import { User, UsersRepository } from '@/models/index.js'; +import { jobQueue } from '@/boot/common.js'; +import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Account Move', () => { + let app: INestApplicationContext; + let jq: INestApplicationContext; + let url: URL; + + let root: any; + let alice: any; + let bob: any; + let carol: any; + let dave: any; + let eve: any; + let frank: any; + + let Users: UsersRepository; + + beforeAll(async () => { + app = await startServer(); + jq = await jobQueue(); + const config = loadConfig(); + url = new URL(config.url); + const connection = await initTestDb(false); + root = await signup({ username: 'root' }); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + eve = await signup({ username: 'eve' }); + frank = await signup({ username: 'frank' }); + Users = connection.getRepository(User); + }, 1000 * 60 * 2); + + afterAll(async () => { + await Promise.all([app.close(), jq.close()]); + }); + + describe('Create Alias', () => { + afterEach(async () => { + await Users.update(bob.id, { alsoKnownAs: null }); + }, 1000 * 10); + + test('Able to create an alias', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + assert.strictEqual(res.body.alsoKnownAs?.length, 1); + assert.strictEqual(res.body.alsoKnownAs[0], alice.id); + }); + + test('Able to create a local alias without hostname', async () => { + await api('/i/update', { + alsoKnownAs: ['@alice'], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + }); + + test('Able to create a local alias without @', async () => { + await api('/i/update', { + alsoKnownAs: ['alice'], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + }); + + test('Able to set remote user (but may fail)', async () => { + const res = await api('/i/update', { + alsoKnownAs: ['@syuilo@example.com'], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Unable to add duplicated aliases to alsoKnownAs', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`, `@alice@${url.hostname}`], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); + assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); + }); + + test('Unable to add itself', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@bob@${url.hostname}`], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); + assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); + }); + + test('Unable to add a nonexisting local account to alsoKnownAs', async () => { + const res1 = await api('/i/update', { + alsoKnownAs: [`@nonexist@${url.hostname}`], + }, bob); + + assert.strictEqual(res1.status, 400); + assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + + const res2 = await api('/i/update', { + alsoKnownAs: ['@alice', 'nonexist'], + }, bob); + + assert.strictEqual(res2.status, 400); + assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Able to add two existing local account to alsoKnownAs', async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`, `@carol@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 2); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${carol.id}`); + }); + + test('Able to properly overwrite alsoKnownAs', async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + await api('/i/update', { + alsoKnownAs: [`@carol@${url.hostname}`, `@dave@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 2); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${carol.id}`); + assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${dave.id}`); + }); + }); + + describe('Local to Local', () => { + let antennaId = ''; + + beforeAll(async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, root); + const listRoot = await api('/users/lists/create', { + name: rndstr('0-9a-z', 8), + }, root); + await api('/users/lists/push', { + listId: listRoot.body.id, + userId: alice.id, + }, root); + + await api('/following/create', { + userId: root.id, + }, alice); + await api('/following/create', { + userId: eve.id, + }, alice); + const antenna = await api('/antennas/create', { + name: rndstr('0-9a-z', 8), + src: 'home', + keywords: [rndstr('0-9a-z', 8)], + excludeKeywords: [], + users: [], + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + }, alice); + antennaId = antenna.body.id; + + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + + await api('/following/create', { + userId: alice.id, + }, carol); + + await api('/mute/create', { + userId: alice.id, + }, dave); + await api('/blocking/create', { + userId: alice.id, + }, dave); + await api('/following/create', { + userId: eve.id, + }, dave); + + await api('/following/create', { + userId: dave.id, + }, eve); + const listEve = await api('/users/lists/create', { + name: rndstr('0-9a-z', 8), + }, eve); + await api('/users/lists/push', { + listId: listEve.body.id, + userId: bob.id, + }, eve); + + await api('/i/update', { + isLocked: true, + }, frank); + await api('/following/create', { + userId: frank.id, + }, alice); + await api('/following/requests/accept', { + userId: alice.id, + }, frank); + }, 1000 * 10); + + test('Prohibit the root account from moving', async () => { + const res = await api('/i/move', { + moveToAccount: `@bob@${url.hostname}`, + }, root); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); + assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); + }); + + test('Unable to move to a nonexisting local account', async () => { + const res = await api('/i/move', { + moveToAccount: `@nonexist@${url.hostname}`, + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Unable to move if alsoKnownAs is invalid', async () => { + const res = await api('/i/move', { + moveToAccount: `@carol@${url.hostname}`, + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + }); + + test('Relationships have been properly migrated', async () => { + const move = await api('/i/move', { + moveToAccount: `@bob@${url.hostname}`, + }, alice); + + assert.strictEqual(move.status, 200); + + await sleep(1000 * 3); // wait for jobs to finish + + // Unfollow delayed? + const aliceFollowings = await api('/users/following', { + userId: alice.id, + }, alice); + assert.strictEqual(aliceFollowings.status, 200); + assert.strictEqual(aliceFollowings.body.length, 3); + + const carolFollowings = await api('/users/following', { + userId: carol.id, + }, carol); + assert.strictEqual(carolFollowings.status, 200); + assert.strictEqual(carolFollowings.body.length, 2); + assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); + assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); + + const blockings = await api('/blocking/list', {}, dave); + assert.strictEqual(blockings.status, 200); + assert.strictEqual(blockings.body.length, 2); + assert.strictEqual(blockings.body[0].blockeeId, bob.id); + assert.strictEqual(blockings.body[1].blockeeId, alice.id); + + const mutings = await api('/mute/list', {}, dave); + assert.strictEqual(mutings.status, 200); + assert.strictEqual(mutings.body.length, 2); + assert.strictEqual(mutings.body[0].muteeId, bob.id); + assert.strictEqual(mutings.body[1].muteeId, alice.id); + + const rootLists = await api('/users/lists/list', {}, root); + assert.strictEqual(rootLists.status, 200); + assert.strictEqual(rootLists.body[0].userIds.length, 2); + assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id)); + assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); + + const eveLists = await api('/users/lists/list', {}, eve); + assert.strictEqual(eveLists.status, 200); + assert.strictEqual(eveLists.body[0].userIds.length, 1); + assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); + }); + + test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => { + await successfulApiCall({ + endpoint: '/following/create', + parameters: { + userId: frank.id, + }, + user: bob, + }); + const followers = await api('/users/followers', { + userId: frank.id, + }, frank); + + assert.strictEqual(followers.status, 200); + assert.strictEqual(followers.body.length, 2); + assert.strictEqual(followers.body[0].followerId, bob.id); + }); + + test('Unfollowed after 10 sec (24 hours in production).', async () => { + await sleep(1000 * 8); + + const following = await api('/users/following', { + userId: alice.id, + }, alice); + + assert.strictEqual(following.status, 200); + assert.strictEqual(following.body.length, 0); + }); + + test('Unable to move if the destination account has already moved.', async () => { + const res = await api('/i/move', { + moveToAccount: `@alice@${url.hostname}`, + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + }); + + test('Follow and follower counts are properly adjusted', async () => { + await api('/following/create', { + userId: alice.id, + }, eve); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + const newCarol = await Users.findOneByOrFail({ id: carol.id }); + let newEve = await Users.findOneByOrFail({ id: eve.id }); + assert.strictEqual(newAlice.movedToUri, `${url.origin}/users/${bob.id}`); + assert.strictEqual(newAlice.followingCount, 0); + assert.strictEqual(newAlice.followersCount, 0); + assert.strictEqual(newCarol.followingCount, 1); + assert.strictEqual(newEve.followingCount, 1); + assert.strictEqual(newEve.followersCount, 1); + + await api('/following/delete', { + userId: alice.id, + }, eve); + newEve = await Users.findOneByOrFail({ id: eve.id }); + assert.strictEqual(newEve.followingCount, 1); + assert.strictEqual(newEve.followersCount, 1); + }); + + test.each([ + '/antennas/create', + '/channels/create', + '/channels/favorite', + '/channels/follow', + '/channels/unfavorite', + '/channels/unfollow', + '/clips/add-note', + '/clips/create', + '/clips/favorite', + '/clips/remove-note', + '/clips/unfavorite', + '/clips/update', + '/drive/files/upload-from-url', + '/flash/create', + '/flash/like', + '/flash/unlike', + '/flash/update', + '/following/create', + '/gallery/posts/create', + '/gallery/posts/like', + '/gallery/posts/unlike', + '/gallery/posts/update', + '/i/claim-achievement', + '/i/move', + '/i/import-blocking', + '/i/import-following', + '/i/import-muting', + '/i/import-user-lists', + '/i/pin', + '/mute/create', + '/notes/create', + '/notes/favorites/create', + '/notes/polls/vote', + '/notes/reactions/create', + '/pages/create', + '/pages/like', + '/pages/unlike', + '/pages/update', + '/renote-mute/create', + '/users/lists/create', + '/users/lists/pull', + '/users/lists/push', + ])('Prohibit access after moving: %s', async (endpoint) => { + const res = await api(endpoint, {}, alice); + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit access after moving: /antennas/update', async () => { + const res = await api('/antennas/update', { + antennaId, + name: rndstr('0-9a-z', 8), + src: 'users', + keywords: [rndstr('0-9a-z', 8)], + excludeKeywords: [], + users: [eve.id], + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + }, alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit access after moving: /drive/files/create', async () => { + const res = await uploadFile(alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit updating alsoKnownAs after moving', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@eve@${url.hostname}`], + }, alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + }); +}); diff --git a/packages/backend/test/_e2e/mute.ts b/packages/backend/test/e2e/mute.ts similarity index 76% rename from packages/backend/test/_e2e/mute.ts rename to packages/backend/test/e2e/mute.ts index 8f7f72bb97..25bd532cfb 100644 --- a/packages/backend/test/_e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -1,11 +1,11 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, react, startServer, shutdownServer, waitFire } from '../utils.js'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Mute', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; // alice mutes carol let alice: any; @@ -13,18 +13,18 @@ describe('Mute', () => { let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await app.close(); }); test('ミュート作成', async () => { - const res = await request('/mute/create', { + const res = await api('/mute/create', { userId: carol.id, }, alice); @@ -35,7 +35,7 @@ describe('Mute', () => { const bobNote = await post(bob, { text: '@alice hi' }); const carolNote = await post(carol, { text: '@alice hi' }); - const res = await request('/notes/mentions', {}, alice); + const res = await api('/notes/mentions', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); @@ -45,11 +45,11 @@ describe('Mute', () => { test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); await post(carol, { text: '@alice hi' }); - const res = await request('/i', {}, alice); + const res = await api('/i', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.hasUnreadMentions, false); @@ -57,7 +57,7 @@ describe('Mute', () => { test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention'); @@ -66,8 +66,8 @@ describe('Mute', () => { test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); - await request('/notifications/mark-all-as-read', {}, alice); + 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'); @@ -76,11 +76,11 @@ describe('Mute', () => { describe('Timeline', () => { test('タイムラインにミュートしているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); - const res = await request('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); @@ -90,13 +90,13 @@ describe('Mute', () => { }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { - const aliceNote = await post(alice); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { renoteId: carolNote.id, }); - const res = await request('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); @@ -108,11 +108,11 @@ describe('Mute', () => { describe('Notification', () => { test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => { - const aliceNote = await post(alice); + const aliceNote = await post(alice, { text: 'hi' }); await react(bob, aliceNote, 'like'); await react(carol, aliceNote, 'like'); - const res = await request('/i/notifications', {}, alice); + const res = await api('/i/notifications', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts new file mode 100644 index 0000000000..9c851a5dd6 --- /dev/null +++ b/packages/backend/test/e2e/note.ts @@ -0,0 +1,577 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { Note } from '@/models/entities/Note.js'; +import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Note', () => { + let app: INestApplicationContext; + let Notes: any; + + let alice: any; + let bob: any; + + beforeAll(async () => { + app = await startServer(); + const connection = await initTestDb(true); + Notes = connection.getRepository(Note); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + test('投稿できる', async () => { + const post = { + text: 'test', + }; + + const res = await api('/notes/create', post, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, post.text); + }); + + test('ファイルを添付できる', async () => { + const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + + const res = await api('/notes/create', { + fileIds: [file.id], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]); + }, 1000 * 10); + + test('他人のファイルで怒られる', async () => { + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + + const res = await api('/notes/create', { + text: 'test', + fileIds: [file.id], + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); + assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + }, 1000 * 10); + + test('存在しないファイルで怒られる', async () => { + const res = await api('/notes/create', { + text: 'test', + fileIds: ['000000000000000000000000'], + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); + assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + }); + + test('不正なファイルIDで怒られる', async () => { + const res = await api('/notes/create', { + fileIds: ['kyoppie'], + }, alice); + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); + assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + }); + + test('返信できる', async () => { + const bobPost = await post(bob, { + text: 'foo', + }); + + const alicePost = { + text: 'bar', + replyId: bobPost.id, + }; + + const res = await api('/notes/create', alicePost, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, alicePost.text); + assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); + assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); + }); + + test('renoteできる', async () => { + const bobPost = await post(bob, { + text: 'test', + }); + + const alicePost = { + renoteId: bobPost.id, + }; + + const res = await api('/notes/create', alicePost, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); + assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); + }); + + test('引用renoteできる', async () => { + const bobPost = await post(bob, { + text: 'test', + }); + + const alicePost = { + text: 'test', + renoteId: bobPost.id, + }; + + const res = await api('/notes/create', alicePost, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, alicePost.text); + assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); + assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); + }); + + test('visibility: followersでrenoteできる', async () => { + const createRes = await api('/notes/create', { + text: 'test', + visibility: 'followers', + }, alice); + + assert.strictEqual(createRes.status, 200); + + const renoteId = createRes.body.createdNote.id; + const renoteRes = await api('/notes/create', { + visibility: 'followers', + renoteId, + }, alice); + + assert.strictEqual(renoteRes.status, 200); + assert.strictEqual(renoteRes.body.createdNote.renoteId, renoteId); + assert.strictEqual(renoteRes.body.createdNote.visibility, 'followers'); + + const deleteRes = await api('/notes/delete', { + noteId: renoteRes.body.createdNote.id, + }, alice); + + assert.strictEqual(deleteRes.status, 204); + }); + + test('文字数ぎりぎりで怒られない', async () => { + const post = { + text: '!'.repeat(3000), + }; + const res = await api('/notes/create', post, alice); + assert.strictEqual(res.status, 200); + }); + + test('文字数オーバーで怒られる', async () => { + const post = { + text: '!'.repeat(3001), + }; + const res = await api('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + }); + + test('存在しないリプライ先で怒られる', async () => { + const post = { + text: 'test', + replyId: '000000000000000000000000', + }; + const res = await api('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + }); + + test('存在しないrenote対象で怒られる', async () => { + const post = { + renoteId: '000000000000000000000000', + }; + const res = await api('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + }); + + test('不正なリプライ先IDで怒られる', async () => { + const post = { + text: 'test', + replyId: 'foo', + }; + const res = await api('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + }); + + test('不正なrenote対象IDで怒られる', async () => { + const post = { + renoteId: 'foo', + }; + const res = await api('/notes/create', post, alice); + assert.strictEqual(res.status, 400); + }); + + test('存在しないユーザーにメンションできる', async () => { + const post = { + text: '@ghost yo', + }; + + const res = await api('/notes/create', post, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, post.text); + }); + + test('同じユーザーに複数メンションしても内部的にまとめられる', async () => { + const post = { + text: '@bob @bob @bob yo', + }; + + const res = await api('/notes/create', post, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.text, post.text); + + const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); + assert.deepStrictEqual(noteDoc.mentions, [bob.id]); + }); + + describe('添付ファイル情報', () => { + test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const res = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.files.length, 1); + assert.strictEqual(res.body.createdNote.files[0].id, file.body.id); + }); + + test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const res = await api('/notes', { + withFiles: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.files.length, 1); + assert.strictEqual(myNote.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const renoted = await api('/notes/create', { + renoteId: createdNote.body.createdNote.id, + }, alice); + assert.strictEqual(renoted.status, 200); + + const res = await api('/notes', { + renote: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.renote.files.length, 1); + assert.strictEqual(myNote.renote.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const reply = await api('/notes/create', { + replyId: createdNote.body.createdNote.id, + text: 'this is reply', + }, alice); + assert.strictEqual(reply.status, 200); + + const res = await api('/notes', { + reply: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.reply.files.length, 1); + assert.strictEqual(myNote.reply.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const reply = await api('/notes/create', { + replyId: createdNote.body.createdNote.id, + text: 'this is reply', + }, alice); + assert.strictEqual(reply.status, 200); + + const renoted = await api('/notes/create', { + renoteId: reply.body.createdNote.id, + }, alice); + assert.strictEqual(renoted.status, 200); + + const res = await api('/notes', { + renote: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.renote.reply.files.length, 1); + assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id); + }); + + test('NSFWが強制されている場合変更できない', async () => { + const file = await uploadFile(alice); + + const res = await api('admin/roles/create', { + name: 'test', + description: '', + color: null, + iconUrl: null, + displayOrder: 0, + target: 'manual', + condFormula: {}, + isAdministrator: false, + isModerator: false, + isPublic: false, + isExplorable: false, + asBadge: false, + canEditMembersByModerator: false, + policies: { + alwaysMarkNsfw: { + useDefault: false, + priority: 0, + value: true, + }, + }, + }, alice); + + assert.strictEqual(res.status, 200); + + const assign = await api('admin/roles/assign', { + userId: alice.id, + roleId: res.body.id, + }, alice); + + assert.strictEqual(assign.status, 204); + assert.strictEqual(file.body.isSensitive, false); + + const nsfwfile = await uploadFile(alice); + + assert.strictEqual(nsfwfile.status, 200); + assert.strictEqual(nsfwfile.body.isSensitive, true); + + const liftnsfw = await api('drive/files/update', { + fileId: nsfwfile.body.id, + isSensitive: false, + }, alice); + + assert.strictEqual(liftnsfw.status, 400); + assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE'); + + const oldaddnsfw = await api('drive/files/update', { + fileId: file.body.id, + isSensitive: true, + }, alice); + + assert.strictEqual(oldaddnsfw.status, 200); + + await api('admin/roles/unassign', { + userId: alice.id, + roleId: res.body.id, + }); + + await api('admin/roles/delete', { + roleId: res.body.id, + }, alice); + }); + }); + + describe('notes/create', () => { + test('投票を添付できる', async () => { + const res = await api('/notes/create', { + text: 'test', + poll: { + choices: ['foo', 'bar'], + }, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.poll != null, true); + }); + + test('投票の選択肢が無くて怒られる', async () => { + const res = await api('/notes/create', { + poll: {}, + }, alice); + assert.strictEqual(res.status, 400); + }); + + test('投票の選択肢が無くて怒られる (空の配列)', async () => { + const res = await api('/notes/create', { + poll: { + choices: [], + }, + }, alice); + assert.strictEqual(res.status, 400); + }); + + test('投票の選択肢が1つで怒られる', async () => { + const res = await api('/notes/create', { + poll: { + choices: ['Strawberry Pasta'], + }, + }, alice); + assert.strictEqual(res.status, 400); + }); + + test('投票できる', async () => { + const { body } = await api('/notes/create', { + text: 'test', + poll: { + choices: ['sakura', 'izumi', 'ako'], + }, + }, alice); + + const res = await api('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 1, + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('複数投票できない', async () => { + const { body } = await api('/notes/create', { + text: 'test', + poll: { + choices: ['sakura', 'izumi', 'ako'], + }, + }, alice); + + await api('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 0, + }, alice); + + const res = await api('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 2, + }, alice); + + assert.strictEqual(res.status, 400); + }); + + test('許可されている場合は複数投票できる', async () => { + const { body } = await api('/notes/create', { + text: 'test', + poll: { + choices: ['sakura', 'izumi', 'ako'], + multiple: true, + }, + }, alice); + + await api('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 0, + }, alice); + + await api('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 1, + }, alice); + + const res = await api('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 2, + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('締め切られている場合は投票できない', async () => { + const { body } = await api('/notes/create', { + text: 'test', + poll: { + choices: ['sakura', 'izumi', 'ako'], + expiredAfter: 1, + }, + }, alice); + + await new Promise(x => setTimeout(x, 2)); + + const res = await api('/notes/polls/vote', { + noteId: body.createdNote.id, + choice: 1, + }, alice); + + assert.strictEqual(res.status, 400); + }); + }); + + describe('notes/delete', () => { + test('delete a reply', async () => { + const mainNoteRes = await api('notes/create', { + text: 'main post', + }, alice); + const replyOneRes = await api('notes/create', { + text: 'reply one', + replyId: mainNoteRes.body.createdNote.id, + }, alice); + const replyTwoRes = await api('notes/create', { + text: 'reply two', + replyId: mainNoteRes.body.createdNote.id, + }, alice); + + const deleteOneRes = await api('notes/delete', { + noteId: replyOneRes.body.createdNote.id, + }, alice); + + assert.strictEqual(deleteOneRes.status, 204); + let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); + assert.strictEqual(mainNote.repliesCount, 1); + + const deleteTwoRes = await api('notes/delete', { + noteId: replyTwoRes.body.createdNote.id, + }, alice); + + assert.strictEqual(deleteTwoRes.status, 204); + mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); + assert.strictEqual(mainNote.repliesCount, 0); + }); + }); +}); diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts new file mode 100644 index 0000000000..0f73b8d09f --- /dev/null +++ b/packages/backend/test/e2e/renote-mute.ts @@ -0,0 +1,85 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Renote Mute', () => { + let app: INestApplicationContext; + + // alice mutes carol + let alice: any; + let bob: any; + let carol: any; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + test('ミュート作成', async () => { + const res = await api('/renote-mute/create', { + userId: carol.id, + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('タイムラインにリノートミュートしているユーザーのリノートが含まれない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, false); + }); + + test('ストリームにリノートミュートしているユーザーの引用が流れる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id, text: 'kore' }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, true); + }); +}); diff --git a/packages/backend/test/_e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts similarity index 97% rename from packages/backend/test/_e2e/streaming.ts rename to packages/backend/test/e2e/streaming.ts index 790451d9b4..d1394ef7a8 100644 --- a/packages/backend/test/_e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -1,12 +1,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { Following } from '../../src/models/entities/following.js'; -import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from '../utils.js'; +import { Following } from '@/models/entities/Following.js'; +import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Streaming', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; let Followings: any; const follow = async (follower: any, followee: any) => { @@ -38,7 +38,7 @@ describe('Streaming', () => { let list: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); const connection = await initTestDb(true); Followings = connection.getRepository(Following); @@ -71,10 +71,10 @@ describe('Streaming', () => { listId: list.id, userId: kyoko.id, }, chitose); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await app.close(); }); describe('Events', () => { @@ -172,6 +172,7 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + /* TODO test('リモートユーザーの投稿は流れない', async () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local @@ -191,6 +192,7 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + */ test('ホーム指定の投稿は流れない', async () => { const fired = await waitFire( @@ -244,6 +246,7 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + /* TODO test('フォローしているリモートユーザーの投稿が流れる', async () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid @@ -263,6 +266,7 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + */ test('フォローしているユーザーのダイレクト投稿が流れる', async () => { const fired = await waitFire( @@ -316,6 +320,7 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + /* TODO test('フォローしていないリモートユーザーの投稿が流れる', async () => { const fired = await waitFire( ayano, 'globalTimeline', // ayano:Global @@ -325,6 +330,7 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + */ test('ホーム投稿は流れない', async () => { const fired = await waitFire( @@ -385,6 +391,8 @@ describe('Streaming', () => { }); }); + // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" + /* describe('Hashtag Timeline', () => { test('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { @@ -408,7 +416,7 @@ describe('Streaming', () => { let fooCount = 0; let barCount = 0; let fooBarCount = 0; - + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { if (type === 'note') { if (body.text === '#foo') fooCount++; @@ -420,19 +428,19 @@ describe('Streaming', () => { ['foo', 'bar'], ], }); - + post(chitose, { text: '#foo', }); - + post(chitose, { text: '#bar', }); - + post(chitose, { text: '#foo #bar', }); - + setTimeout(() => { assert.strictEqual(fooCount, 0); assert.strictEqual(barCount, 0); @@ -541,5 +549,6 @@ describe('Streaming', () => { }, 3000); })); }); + */ }); }); diff --git a/packages/backend/test/_e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts similarity index 77% rename from packages/backend/test/_e2e/thread-mute.ts rename to packages/backend/test/e2e/thread-mute.ts index 890b52a8c1..2ae2eb67c1 100644 --- a/packages/backend/test/_e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -1,37 +1,37 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, react, connectStream, startServer, shutdownServer } from '../utils.js'; +import { signup, api, post, connectStream, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Note thread mute', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; let alice: any; let bob: any; let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await app.close(); }); test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); - await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); - const res = await request('/notes/mentions', {}, alice); + const res = await api('/notes/mentions', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); @@ -42,27 +42,27 @@ describe('Note thread mute', () => { test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); const bobNote = await post(bob, { text: '@alice @carol root note' }); - await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + 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 request('/i', {}, alice); + const res = await api('/i', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.hasUnreadMentions, false); }); - test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { + test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); const bobNote = await post(bob, { text: '@alice @carol root note' }); - await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); let fired = false; @@ -86,12 +86,12 @@ describe('Note thread mute', () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); - await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); - const res = await request('/i/notifications', {}, alice); + const res = await api('/i/notifications', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); diff --git a/packages/backend/test/_e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts similarity index 83% rename from packages/backend/test/_e2e/user-notes.ts rename to packages/backend/test/e2e/user-notes.ts index 41cf5fb9b8..8ae7a240cb 100644 --- a/packages/backend/test/_e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -1,11 +1,11 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, uploadUrl, startServer, shutdownServer } from '../utils.js'; +import { signup, api, post, uploadUrl, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('users/notes', () => { - let p: childProcess.ChildProcess; + let app: INestApplicationContext; let alice: any; let jpgNote: any; @@ -13,7 +13,7 @@ describe('users/notes', () => { let jpgPngNote: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/kokonect-link/cherrypick/develop/packages/backend/test/resources/Lenna.jpg'); const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/kokonect-link/cherrypick/develop/packages/backend/test/resources/Lenna.png'); @@ -26,14 +26,14 @@ describe('users/notes', () => { jpgPngNote = await post(alice, { fileIds: [jpg.id, png.id], }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async() => { - await shutdownServer(p); + await app.close(); }); test('ファイルタイプ指定 (jpg)', async () => { - const res = await request('/users/notes', { + const res = await api('/users/notes', { userId: alice.id, fileType: ['image/jpeg'], }, alice); @@ -46,7 +46,7 @@ describe('users/notes', () => { }); test('ファイルタイプ指定 (jpg or png)', async () => { - const res = await request('/users/notes', { + const res = await api('/users/notes', { userId: alice.id, fileType: ['image/jpeg', 'image/png'], }, alice); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts new file mode 100644 index 0000000000..51537dda16 --- /dev/null +++ b/packages/backend/test/e2e/users.ts @@ -0,0 +1,887 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { inspect } from 'node:util'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { + signup, + post, + page, + role, + startServer, + api, + successfulApiCall, + failedApiCall, + uploadFile, +} from '../utils.js'; +import type * as misskey from 'misskey-js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('ユーザー', () => { + // エンティティとしてのユーザーを主眼においたテストを記述する + // (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする) + + const stripUndefined = (orig: T): Partial => { + return Object.entries({ ...orig }) + .filter(([, value]) => value !== undefined) + .reduce((obj: Partial, [key, value]) => { + obj[key as keyof T] = value; + return obj; + }, {}); + }; + + // BUG misskey-jsとjson-schemaと実際に返ってくるデータが全部違う + type UserLite = misskey.entities.UserLite & { + badgeRoles: any[], + }; + + type UserDetailedNotMe = UserLite & + misskey.entities.UserDetailed & { + roles: any[], + }; + + type MeDetailed = UserDetailedNotMe & + misskey.entities.MeDetailed & { + showTimelineReplies: boolean, + achievements: object[], + loggedInDays: number, + policies: object, + }; + + type User = MeDetailed & { token: string }; + + const show = async (id: string, me = root): Promise => { + return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any; + }; + + // UserLiteのキーが過不足なく入っている? + const userLite = (user: User): Partial => { + return stripUndefined({ + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarUrl, + avatarBlurhash: user.avatarBlurhash, + isBot: user.isBot, + isCat: user.isCat, + instance: user.instance, + emojis: user.emojis, + onlineStatus: user.onlineStatus, + badgeRoles: user.badgeRoles, + + // BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。 + isAdmin: undefined, + isModerator: undefined, + }); + }; + + // UserDetailedNotMeのキーが過不足なく入っている? + const userDetailedNotMe = (user: User): Partial => { + return stripUndefined({ + ...userLite(user), + url: user.url, + uri: user.uri, + movedTo: user.movedTo, + alsoKnownAs: user.alsoKnownAs, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastFetchedAt: user.lastFetchedAt, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, + isLocked: user.isLocked, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + description: user.description, + location: user.location, + birthday: user.birthday, + lang: user.lang, + fields: user.fields, + followersCount: user.followersCount, + followingCount: user.followingCount, + notesCount: user.notesCount, + pinnedNoteIds: user.pinnedNoteIds, + pinnedNotes: user.pinnedNotes, + pinnedPageId: user.pinnedPageId, + pinnedPage: user.pinnedPage, + publicReactions: user.publicReactions, + ffVisibility: user.ffVisibility, + twoFactorEnabled: user.twoFactorEnabled, + usePasswordLessLogin: user.usePasswordLessLogin, + securityKeys: user.securityKeys, + roles: user.roles, + memo: user.memo, + }); + }; + + // Relations関連のキーが過不足なく入っている? + const userDetailedNotMeWithRelations = (user: User): Partial => { + return stripUndefined({ + ...userDetailedNotMe(user), + isFollowing: user.isFollowing ?? false, + isFollowed: user.isFollowed ?? false, + hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false, + hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false, + isBlocking: user.isBlocking ?? false, + isBlocked: user.isBlocked ?? false, + isMuted: user.isMuted ?? false, + isRenoteMuted: user.isRenoteMuted ?? false, + }); + }; + + // MeDetailedのキーが過不足なく入っている? + const meDetailed = (user: User, security = false): Partial => { + return stripUndefined({ + ...userDetailedNotMe(user), + avatarId: user.avatarId, + bannerId: user.bannerId, + isModerator: user.isModerator, + isAdmin: user.isAdmin, + injectFeaturedNote: user.injectFeaturedNote, + receiveAnnouncementEmail: user.receiveAnnouncementEmail, + alwaysMarkNsfw: user.alwaysMarkNsfw, + autoSensitive: user.autoSensitive, + carefulBot: user.carefulBot, + autoAcceptFollowed: user.autoAcceptFollowed, + noCrawle: user.noCrawle, + isExplorable: user.isExplorable, + isDeleted: user.isDeleted, + hideOnlineStatus: user.hideOnlineStatus, + hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes, + hasUnreadMentions: user.hasUnreadMentions, + hasUnreadAnnouncement: user.hasUnreadAnnouncement, + hasUnreadAntenna: user.hasUnreadAntenna, + hasUnreadChannel: user.hasUnreadChannel, + hasUnreadNotification: user.hasUnreadNotification, + hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, + mutedWords: user.mutedWords, + mutedInstances: user.mutedInstances, + mutingNotificationTypes: user.mutingNotificationTypes, + emailNotificationTypes: user.emailNotificationTypes, + showTimelineReplies: user.showTimelineReplies, + achievements: user.achievements, + loggedInDays: user.loggedInDays, + policies: user.policies, + ...(security ? { + email: user.email, + emailVerified: user.emailVerified, + securityKeysList: user.securityKeysList, + } : {}), + }); + }; + + let app: INestApplicationContext; + + let root: User; + let alice: User; + let aliceNote: misskey.entities.Note; + let alicePage: misskey.entities.Page; + let aliceList: misskey.entities.UserList; + + let bob: User; + let bobNote: misskey.entities.Note; + + let carol: User; + let dave: User; + let ellen: User; + let frank: User; + + let usersReplying: User[]; + + let userNoNote: User; + let userNotExplorable: User; + let userLocking: User; + let userAdmin: User; + let roleAdmin: any; + let userModerator: User; + let roleModerator: any; + let userRolePublic: User; + let rolePublic: any; + let userRoleBadge: User; + let roleBadge: any; + let userSilenced: User; + let roleSilenced: any; + let userSuspended: User; + let userDeletedBySelf: User; + let userDeletedByAdmin: User; + let userFollowingAlice: User; + let userFollowedByAlice: User; + let userBlockingAlice: User; + let userBlockedByAlice: User; + let userMutingAlice: User; + let userMutedByAlice: User; + let userRnMutingAlice: User; + let userRnMutedByAlice: User; + let userFollowRequesting: User; + let userFollowRequested: User; + + beforeAll(async () => { + app = await startServer(); + }, 1000 * 60 * 2); + + beforeAll(async () => { + root = await signup({ username: 'root' }); + alice = await signup({ username: 'alice' }); + aliceNote = await post(alice, { text: 'test' }) as any; + alicePage = await page(alice); + aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body; + bob = await signup({ username: 'bob' }); + bobNote = await post(bob, { text: 'test' }) as any; + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + ellen = await signup({ username: 'ellen' }); + frank = await signup({ username: 'frank' }); + + // @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする + usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => { + const u = await signup({ username: `replying${i}` }); + for (let j = 0; j < 10 - i; j++) { + const p = await post(u, { text: `test${j}` }); + await post(alice, { text: `@${u.username} test${j}`, replyId: p.id }); + } + + return (await acc).concat(u); + }, Promise.resolve([] as User[])); + + userNoNote = await signup({ username: 'userNoNote' }); + userNotExplorable = await signup({ username: 'userNotExplorable' }); + await post(userNotExplorable, { text: 'test' }); + await api('i/update', { isExplorable: false }, userNotExplorable); + userLocking = await signup({ username: 'userLocking' }); + await post(userLocking, { text: 'test' }); + await api('i/update', { isLocked: true }, userLocking); + userAdmin = await signup({ username: 'userAdmin' }); + roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' }); + await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root); + userModerator = await signup({ username: 'userModerator' }); + roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' }); + await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root); + userRolePublic = await signup({ username: 'userRolePublic' }); + rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); + await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); + userRoleBadge = await signup({ username: 'userRoleBadge' }); + roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); + await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); + userSilenced = await signup({ username: 'userSilenced' }); + await post(userSilenced, { text: 'test' }); + roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } }); + await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root); + userSuspended = await signup({ username: 'userSuspended' }); + await post(userSuspended, { text: 'test' }); + await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended }); + await api('admin/suspend-user', { userId: userSuspended.id }, root); + userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' }); + await post(userDeletedBySelf, { text: 'test' }); + await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); + userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); + await post(userDeletedByAdmin, { text: 'test' }); + await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); + userFollowingAlice = await signup({ username: 'userFollowingAlice' }); + await post(userFollowingAlice, { text: 'test' }); + await api('following/create', { userId: alice.id }, userFollowingAlice); + userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); + await post(userFollowedByAlice, { text: 'test' }); + await api('following/create', { userId: userFollowedByAlice.id }, alice); + userBlockingAlice = await signup({ username: 'userBlockingAlice' }); + await post(userBlockingAlice, { text: 'test' }); + await api('blocking/create', { userId: alice.id }, userBlockingAlice); + userBlockedByAlice = await signup({ username: 'userBlockedByAlice' }); + await post(userBlockedByAlice, { text: 'test' }); + await api('blocking/create', { userId: userBlockedByAlice.id }, alice); + userMutingAlice = await signup({ username: 'userMutingAlice' }); + await post(userMutingAlice, { text: 'test' }); + await api('mute/create', { userId: alice.id }, userMutingAlice); + userMutedByAlice = await signup({ username: 'userMutedByAlice' }); + await post(userMutedByAlice, { text: 'test' }); + await api('mute/create', { userId: userMutedByAlice.id }, alice); + userRnMutingAlice = await signup({ username: 'userRnMutingAlice' }); + await post(userRnMutingAlice, { text: 'test' }); + await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice); + userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' }); + await post(userRnMutedByAlice, { text: 'test' }); + await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice); + userFollowRequesting = await signup({ username: 'userFollowRequesting' }); + await post(userFollowRequesting, { text: 'test' }); + userFollowRequested = userLocking; + await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting); + }, 1000 * 60 * 10); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = { + ...alice, + ...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any, + }; + aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice }); + }); + + //#region サインアップ(signup) + + test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => { + // SignupApiService.ts + const response = await successfulApiCall({ + endpoint: 'signup', + parameters: { username: 'zoe', password: 'password' }, + user: undefined, + }) as unknown as User; // BUG MeDetailedに足りないキーがある + + // signupの時はtokenが含まれる特別なMeDetailedが返ってくる + assert.match(response.token, /[a-zA-Z0-9]{16}/); + + // UserLite + assert.match(response.id, /[0-9a-z]{10}/); + assert.strictEqual(response.name, null); + assert.strictEqual(response.username, 'zoe'); + assert.strictEqual(response.host, null); + assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.strictEqual(response.avatarBlurhash, null); + assert.strictEqual(response.isBot, false); + assert.strictEqual(response.isCat, false); + assert.strictEqual(response.instance, undefined); + assert.deepStrictEqual(response.emojis, {}); + assert.strictEqual(response.onlineStatus, 'unknown'); + assert.deepStrictEqual(response.badgeRoles, []); + // UserDetailedNotMeOnly + assert.strictEqual(response.url, null); + assert.strictEqual(response.uri, null); + assert.strictEqual(response.movedTo, null); + assert.strictEqual(response.alsoKnownAs, null); + assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString()); + assert.strictEqual(response.updatedAt, null); + assert.strictEqual(response.lastFetchedAt, null); + assert.strictEqual(response.bannerUrl, null); + assert.strictEqual(response.bannerBlurhash, null); + assert.strictEqual(response.isLocked, false); + assert.strictEqual(response.isSilenced, false); + assert.strictEqual(response.isSuspended, false); + assert.strictEqual(response.description, null); + assert.strictEqual(response.location, null); + assert.strictEqual(response.birthday, null); + assert.strictEqual(response.lang, null); + assert.deepStrictEqual(response.fields, []); + assert.strictEqual(response.followersCount, 0); + assert.strictEqual(response.followingCount, 0); + assert.strictEqual(response.notesCount, 0); + assert.deepStrictEqual(response.pinnedNoteIds, []); + assert.deepStrictEqual(response.pinnedNotes, []); + assert.strictEqual(response.pinnedPageId, null); + assert.strictEqual(response.pinnedPage, null); + assert.strictEqual(response.publicReactions, false); + assert.strictEqual(response.ffVisibility, 'public'); + assert.strictEqual(response.twoFactorEnabled, false); + assert.strictEqual(response.usePasswordLessLogin, false); + assert.strictEqual(response.securityKeys, false); + assert.deepStrictEqual(response.roles, []); + assert.strictEqual(response.memo, null); + + // MeDetailedOnly + assert.strictEqual(response.avatarId, null); + assert.strictEqual(response.bannerId, null); + assert.strictEqual(response.isModerator, false); + assert.strictEqual(response.isAdmin, false); + assert.strictEqual(response.injectFeaturedNote, true); + assert.strictEqual(response.receiveAnnouncementEmail, true); + assert.strictEqual(response.alwaysMarkNsfw, false); + assert.strictEqual(response.autoSensitive, false); + assert.strictEqual(response.carefulBot, false); + assert.strictEqual(response.autoAcceptFollowed, true); + assert.strictEqual(response.noCrawle, false); + assert.strictEqual(response.isExplorable, true); + assert.strictEqual(response.isDeleted, false); + assert.strictEqual(response.hideOnlineStatus, false); + assert.strictEqual(response.hasUnreadSpecifiedNotes, false); + assert.strictEqual(response.hasUnreadMentions, false); + assert.strictEqual(response.hasUnreadAnnouncement, false); + assert.strictEqual(response.hasUnreadAntenna, false); + assert.strictEqual(response.hasUnreadChannel, false); + assert.strictEqual(response.hasUnreadNotification, false); + assert.strictEqual(response.hasPendingReceivedFollowRequest, false); + assert.deepStrictEqual(response.mutedWords, []); + assert.deepStrictEqual(response.mutedInstances, []); + assert.deepStrictEqual(response.mutingNotificationTypes, []); + assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); + assert.strictEqual(response.showTimelineReplies, false); + assert.deepStrictEqual(response.achievements, []); + assert.deepStrictEqual(response.loggedInDays, 0); + assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); + assert.notStrictEqual(response.email, undefined); + assert.strictEqual(response.emailVerified, false); + assert.deepStrictEqual(response.securityKeysList, []); + }); + + //#endregion + //#region 自分の情報(i) + + test('を読み取ることができること(自分)、キーが過不足なく入っていること。', async () => { + const response = await successfulApiCall({ + endpoint: 'i', + parameters: {}, + user: userNoNote, + }); + const expected = meDetailed(userNoNote, true); + expected.loggedInDays = 1; // iはloggedInDaysを更新する + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region 自分の情報の更新(i/update) + + test.each([ + { parameters: (): object => ({ name: null }) }, + { parameters: (): object => ({ name: 'x'.repeat(50) }) }, + { parameters: (): object => ({ name: 'x' }) }, + { parameters: (): object => ({ name: 'My name' }) }, + { parameters: (): object => ({ description: null }) }, + { parameters: (): object => ({ description: 'x'.repeat(1500) }) }, + { parameters: (): object => ({ description: 'x' }) }, + { parameters: (): object => ({ description: 'My description' }) }, + { parameters: (): object => ({ location: null }) }, + { parameters: (): object => ({ location: 'x'.repeat(50) }) }, + { parameters: (): object => ({ location: 'x' }) }, + { parameters: (): object => ({ location: 'My location' }) }, + { parameters: (): object => ({ birthday: '0000-00-00' }) }, + { parameters: (): object => ({ birthday: '9999-99-99' }) }, + { parameters: (): object => ({ lang: 'en-US' }) }, + { parameters: (): object => ({ fields: [] }) }, + { parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) }, + { parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない + { parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) }, + { parameters: (): object => ({ isLocked: true }) }, + { parameters: (): object => ({ isLocked: false }) }, + { parameters: (): object => ({ isExplorable: false }) }, + { parameters: (): object => ({ isExplorable: true }) }, + { parameters: (): object => ({ hideOnlineStatus: true }) }, + { parameters: (): object => ({ hideOnlineStatus: false }) }, + { parameters: (): object => ({ publicReactions: false }) }, + { parameters: (): object => ({ publicReactions: true }) }, + { parameters: (): object => ({ autoAcceptFollowed: true }) }, + { parameters: (): object => ({ autoAcceptFollowed: false }) }, + { parameters: (): object => ({ noCrawle: true }) }, + { parameters: (): object => ({ noCrawle: false }) }, + { parameters: (): object => ({ isBot: true }) }, + { parameters: (): object => ({ isBot: false }) }, + { parameters: (): object => ({ isCat: true }) }, + { parameters: (): object => ({ isCat: false }) }, + { parameters: (): object => ({ showTimelineReplies: true }) }, + { parameters: (): object => ({ showTimelineReplies: false }) }, + { parameters: (): object => ({ injectFeaturedNote: true }) }, + { parameters: (): object => ({ injectFeaturedNote: false }) }, + { parameters: (): object => ({ receiveAnnouncementEmail: true }) }, + { parameters: (): object => ({ receiveAnnouncementEmail: false }) }, + { parameters: (): object => ({ alwaysMarkNsfw: true }) }, + { parameters: (): object => ({ alwaysMarkNsfw: false }) }, + { parameters: (): object => ({ autoSensitive: true }) }, + { parameters: (): object => ({ autoSensitive: false }) }, + { parameters: (): object => ({ ffVisibility: 'private' }) }, + { parameters: (): object => ({ ffVisibility: 'followers' }) }, + { parameters: (): object => ({ ffVisibility: 'public' }) }, + { parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) }, + { parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) }, + { parameters: (): object => ({ mutedWords: [] }) }, + { parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) }, + { parameters: (): object => ({ mutedInstances: [] }) }, + { parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) }, + { parameters: (): object => ({ mutingNotificationTypes: [] }) }, + { parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) }, + { parameters: (): object => ({ emailNotificationTypes: [] }) }, + ] as const)('を書き換えることができる($#)', async ({ parameters }) => { + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice }); + const expected = { ...meDetailed(alice, true), ...parameters() }; + assert.deepStrictEqual(response, expected, inspect(parameters())); + }); + + test('を書き換えることができる(Avatar)', async () => { + const aliceFile = (await uploadFile(alice)).body; + const parameters = { avatarId: aliceFile.id }; + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); + assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/); + const expected = { + ...meDetailed(alice, true), + avatarId: aliceFile.id, + avatarBlurhash: response.avatarBlurhash, + avatarUrl: response.avatarUrl, + }; + assert.deepStrictEqual(response, expected, inspect(parameters)); + + const parameters2 = { avatarId: null }; + const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); + const expected2 = { + ...meDetailed(alice, true), + avatarId: null, + avatarBlurhash: null, + avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる + }; + assert.deepStrictEqual(response2, expected2, inspect(parameters)); + }); + + test('を書き換えることができる(Banner)', async () => { + const aliceFile = (await uploadFile(alice)).body; + const parameters = { bannerId: aliceFile.id }; + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); + assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/); + const expected = { + ...meDetailed(alice, true), + bannerId: aliceFile.id, + bannerBlurhash: response.bannerBlurhash, + bannerUrl: response.bannerUrl, + }; + assert.deepStrictEqual(response, expected, inspect(parameters)); + + const parameters2 = { bannerId: null }; + const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); + const expected2 = { + ...meDetailed(alice, true), + bannerId: null, + bannerBlurhash: null, + bannerUrl: null, + }; + assert.deepStrictEqual(response2, expected2, inspect(parameters)); + }); + + //#endregion + //#region 自分の情報の更新(i/pin, i/unpin) + + test('を書き換えることができる(ピン止めノート)', async () => { + const parameters = { noteId: aliceNote.id }; + const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice }); + const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] }; + assert.deepStrictEqual(response, expected); + + const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice }); + const expected2 = meDetailed(alice, false); + assert.deepStrictEqual(response2, expected2); + }); + + //#endregion + //#region メモの更新(users/update-memo) + + test.each([ + { label: '最大長', memo: 'x'.repeat(2048) }, + { label: '空文字', memo: '', expects: null }, + { label: 'null', memo: null }, + ])('を書き換えることができる(メモを$labelに)', async ({ memo, expects }) => { + const expected = { ...await show(bob.id, alice), memo: expects === undefined ? memo : expects }; + const parameters = { userId: bob.id, memo }; + await successfulApiCall({ endpoint: 'users/update-memo', parameters, user: alice }); + const response = await show(bob.id, alice); + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region ユーザー(users) + + test.each([ + { label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id }, + { label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + { label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + ] as const)('をリスト形式で取得することができる($label)', async ({ parameters, selector }) => { + const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); + + // 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する + const users = await Promise.all(response.map(u => show(u.id, alice))); + const expected = users.sort((x, y) => { + const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; + return index * (parameters.sort?.startsWith('+') ? -1 : 1); + }); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true }, + { label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true }, + { label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => { + const parameters = { limit: 100 }; + const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; + assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected); + }); + test.todo('をリスト形式で取得することができる(リモート, hostname指定)'); + test.todo('をリスト形式で取得することができる(pagenation)'); + + //#endregion + //#region ユーザー情報(users/show) + + test.each([ + { label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed }, + { label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations }, + { label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe }, + { label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed }, + { label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations }, + { label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe }, + ] as const)('を取得することができる($label)', async ({ parameters, user, type }) => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() }); + const expected = type(alice); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin }, + { label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined }, + { label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator }, + { label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined }, + { label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced }, + //{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended }, + { label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted }, + { label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, + { label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted }, + { label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, + { label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing }, + { label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed }, + { label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking }, + { label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked }, + { label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted }, + { label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted }, + { label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou }, + { label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou }, + ] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice }); + assert.strictEqual(selector(response), (expected ?? ((): true => true))()); + }); + test('を取得することができ、Publicなロールがセットされていること', async () => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice }); + assert.deepStrictEqual(response.badgeRoles, []); + assert.deepStrictEqual(response.roles, [{ + id: rolePublic.id, + name: rolePublic.name, + color: rolePublic.color, + iconUrl: rolePublic.iconUrl, + description: rolePublic.description, + isModerator: rolePublic.isModerator, + isAdministrator: rolePublic.isAdministrator, + displayOrder: rolePublic.displayOrder, + }]); + }); + test('を取得することができ、バッヂロールがセットされていること', async () => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice }); + assert.deepStrictEqual(response.badgeRoles, [{ + name: roleBadge.name, + iconUrl: roleBadge.iconUrl, + displayOrder: roleBadge.displayOrder, + }]); + assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない + }); + test('をID指定のリスト形式で取得することができる(空)', async () => { + const parameters = { userIds: [] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); + const expected: [] = []; + assert.deepStrictEqual(response, expected); + }); + test('をID指定のリスト形式で取得することができる', async() => { + const parameters = { userIds: [bob.id, alice.id, carol.id] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); + const expected = [ + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }), + ]; + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root }, + // BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる + //{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => { + const parameters = { userIds: [user().id] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をID指定のリスト形式で取得することができる(リモート)'); + + //#endregion + //#region 検索(users/search) + + test('を検索することができる', async () => { + const parameters = { query: 'carol', limit: 10 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = [await show(carol.id, alice)]; + assert.deepStrictEqual(response, expected); + }); + test('を検索することができる(UserLite)', async () => { + const parameters = { query: 'carol', detail: false, limit: 10 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = [userLite(await show(carol.id, alice))]; + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => { + const parameters = { query: user().username, limit: 1 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('を検索することができる(リモート)'); + test.todo('を検索することができる(pagenation)'); + + //#endregion + //#region ID指定検索(users/search-by-username-and-host) + + test.each([ + { label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] }, + { label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] }, + { label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] }, + { label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] }, + { label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] }, + { label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] }, + { label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] }, + { label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] }, + { label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] }, + ])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => { + const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); + const expected = await Promise.all(user().map(u => show(u.id, alice))); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => { + const parameters = { username: user().username }; + const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をID&ホスト指定で検索できる(リモート)'); + + //#endregion + //#region ID指定検索(users/get-frequently-replied-users) + + test('がよくリプライをするユーザーのリストを取得できる', async () => { + const parameters = { userId: alice.id, limit: 5 }; + const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); + const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({ + user: await show(s.id, alice), + weight: (usersReplying.length - i) / usersReplying.length, + }))); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + //{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => { + const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0]; + await post(alice, { text: `@${user().username} test`, replyId: replyTo.id }); + const parameters = { userId: alice.id, limit: 100 }; + const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; + assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected); + }); + + //#endregion + //#region ハッシュタグ(hashtags/users) + + test.each([ + { label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + { label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + ] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => { + const hashtag = 'test_hashtag'; + await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice }); + const parameters = { tag: hashtag, limit: 5, ...sort }; + const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); + const users = await Promise.all(response.map(u => show(u.id, alice))); + const expected = users.sort((x, y) => { + const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; + return index * (parameters.sort.startsWith('+') ? -1 : 1); + }); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user, excluded }) => { + const hashtag = `user_test${user().username}`; + if (user() !== userSuspended) { + // サスペンドユーザーはupdateできない。 + await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() }); + } + const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const; + const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をハッシュタグ指定で取得することができる(リモート)'); + + //#endregion + //#region オススメユーザー(users/recommendation) + + // BUG users/recommendationは壊れている? > QueryFailedError: missing FROM-clause entry for table "note" + test.skip('のオススメを取得することができる', async () => { + const parameters = {}; + const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice }); + const expected = await Promise.all(response.map(u => show(u.id))); + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region ピン止めユーザー(pinned-users) + + test('のピン止めユーザーを取得することができる', async () => { + await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root }); + const parameters = {} as const; + const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice }); + const expected = await Promise.all([bob, carol].map(u => show(u.id, alice))); + assert.deepStrictEqual(response, expected); + }); + + //#endregion + + test.todo('を管理人として確認することができる(admin/show-user)'); + test.todo('を管理人として確認することができる(admin/show-users)'); + test.todo('をサーバー向けに取得することができる(federation/users)'); +}); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index 9efed267ea..6b31e68616 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -1,5 +1,16 @@ -import Resolver from '../../src/activitypub/resolver.js'; -import { IObject } from '../../src/activitypub/type.js'; +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 { NoteReactionsRepository, NotesRepository, PollsRepository, UsersRepository } from '@/models/index.js'; type MockResponse = { type: string; @@ -8,6 +19,25 @@ type MockResponse = { export class MockResolver extends Resolver { private _rs = new Map(); + + constructor(loggerService: LoggerService) { + super( + {} as Config, + {} as UsersRepository, + {} as NotesRepository, + {} as PollsRepository, + {} as NoteReactionsRepository, + {} as UtilityService, + {} as InstanceActorService, + {} as MetaService, + {} as ApRequestService, + {} as HttpRequestService, + {} as ApRendererService, + {} as ApDbResolverService, + loggerService, + ); + } + public async _register(uri: string, content: string | Record, type = 'application/activity+json') { this._rs.set(uri, { type, diff --git a/packages/backend/test/prelude/get-api-validator.ts b/packages/backend/test/prelude/get-api-validator.ts new file mode 100644 index 0000000000..1f4a2dbc95 --- /dev/null +++ b/packages/backend/test/prelude/get-api-validator.ts @@ -0,0 +1,11 @@ +import { Schema } from '@/misc/schema'; +import Ajv from 'ajv'; + +export const getValidator = (paramDef: Schema) => { + const ajv = new Ajv({ + useDefaults: true, + }); + ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); + + return ajv.compile(paramDef); +} diff --git a/packages/backend/test/resources/kick_gaba7.aac b/packages/backend/test/resources/kick_gaba7.aac new file mode 100644 index 0000000000..4644542f96 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.aac differ diff --git a/packages/backend/test/resources/kick_gaba7.flac b/packages/backend/test/resources/kick_gaba7.flac new file mode 100644 index 0000000000..7512812018 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.flac differ diff --git a/packages/backend/test/resources/kick_gaba7.mp3 b/packages/backend/test/resources/kick_gaba7.mp3 new file mode 100644 index 0000000000..6ba317deb1 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.mp3 differ diff --git a/packages/backend/test/resources/kick_gaba7.wav b/packages/backend/test/resources/kick_gaba7.wav new file mode 100644 index 0000000000..2cd280148e Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.wav differ diff --git a/packages/backend/test/resources/kick_gaba7.webm b/packages/backend/test/resources/kick_gaba7.webm new file mode 100644 index 0000000000..82c5349cd4 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.webm differ diff --git a/packages/backend/test/resources/misskey.svg b/packages/backend/test/resources/misskey.svg new file mode 100644 index 0000000000..3fcb2d3ecb --- /dev/null +++ b/packages/backend/test/resources/misskey.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/backend/test/resources/with-alpha.avif b/packages/backend/test/resources/with-alpha.avif new file mode 100644 index 0000000000..05f494212e Binary files /dev/null and b/packages/backend/test/resources/with-alpha.avif differ diff --git a/packages/backend/test/resources/with-alpha.webp b/packages/backend/test/resources/with-alpha.webp new file mode 100644 index 0000000000..d7b0d70b7f Binary files /dev/null and b/packages/backend/test/resources/with-alpha.webp differ diff --git a/packages/backend/test/resources/without-alpha.avif b/packages/backend/test/resources/without-alpha.avif new file mode 100644 index 0000000000..9ea23608b8 Binary files /dev/null and b/packages/backend/test/resources/without-alpha.avif differ diff --git a/packages/backend/test/resources/without-alpha.webp b/packages/backend/test/resources/without-alpha.webp new file mode 100644 index 0000000000..a51091efe2 Binary files /dev/null and b/packages/backend/test/resources/without-alpha.webp differ diff --git a/packages/backend/test/tests/activitypub.ts b/packages/backend/test/tests/activitypub.ts deleted file mode 100644 index 19fb5d90d7..0000000000 --- a/packages/backend/test/tests/activitypub.ts +++ /dev/null @@ -1,89 +0,0 @@ -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import rndstr from 'rndstr'; - -describe('ActivityPub', () => { - describe('Parse minimum object', () => { - const host = 'https://host1.test'; - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; - const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; - - const actor = { - '@context': 'https://www.w3.org/ns/activitystreams', - id: actorId, - type: 'Person', - preferredUsername, - inbox: `${actorId}/inbox`, - outbox: `${actorId}/outbox`, - }; - - const post = { - '@context': 'https://www.w3.org/ns/activitystreams', - id: `${host}/users/${rndstr('0-9a-z', 8)}`, - type: 'Note', - attributedTo: actor.id, - to: 'https://www.w3.org/ns/activitystreams#Public', - content: 'あ', - }; - - test('Minimum Actor', async () => { - const { MockResolver } = await import('../misc/mock-resolver.js'); - const { createPerson } = await import('../../src/activitypub/models/person.js'); - - const resolver = new MockResolver(); - resolver._register(actor.id, actor); - - const user = await createPerson(actor.id, resolver); - - assert.deepStrictEqual(user.uri, actor.id); - assert.deepStrictEqual(user.username, actor.preferredUsername); - assert.deepStrictEqual(user.inbox, actor.inbox); - }); - - test('Minimum Note', async () => { - const { MockResolver } = await import('../misc/mock-resolver.js'); - const { createNote } = await import('../../src/activitypub/models/note.js'); - - const resolver = new MockResolver(); - resolver._register(actor.id, actor); - resolver._register(post.id, post); - - const note = await createNote(post.id, resolver, true); - - assert.deepStrictEqual(note?.uri, post.id); - assert.deepStrictEqual(note.visibility, 'public'); - assert.deepStrictEqual(note.text, post.content); - }); - }); - - describe('Truncate long name', () => { - const host = 'https://host1.test'; - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; - const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; - - const name = rndstr('0-9a-z', 129); - - const actor = { - '@context': 'https://www.w3.org/ns/activitystreams', - id: actorId, - type: 'Person', - preferredUsername, - name, - inbox: `${actorId}/inbox`, - outbox: `${actorId}/outbox`, - }; - - test('Actor', async () => { - const { MockResolver } = await import('../misc/mock-resolver.js'); - const { createPerson } = await import('../../src/activitypub/models/person.js'); - - const resolver = new MockResolver(); - resolver._register(actor.id, actor); - - const user = await createPerson(actor.id, resolver); - - assert.deepStrictEqual(user.name, actor.name.substr(0, 128)); - }); - }); -}); diff --git a/packages/backend/test/tests/mfm.ts b/packages/backend/test/tests/mfm.ts deleted file mode 100644 index 884f39d7fb..0000000000 --- a/packages/backend/test/tests/mfm.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as assert from 'assert'; -import * as mfm from 'mfm-js'; - -import { toHtml } from '../../src/mfm/to-html.js'; -import { fromHtml } from '../../src/mfm/from-html.js'; - -describe('toHtml', () => { - test('br', () => { - const input = 'foo\nbar\nbaz'; - const output = '

foo
bar
baz

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

foo
bar
baz

'; - assert.equal(toHtml(mfm.parse(input)), output); - }); -}); - -describe('fromHtml', () => { - test('p', () => { - assert.deepStrictEqual(fromHtml('

a

b

'), 'a\n\nb'); - }); - - test('block element', () => { - assert.deepStrictEqual(fromHtml('
a
b
'), 'a\nb'); - }); - - test('inline element', () => { - assert.deepStrictEqual(fromHtml('
  • a
  • b
'), 'a\nb'); - }); - - test('block code', () => { - assert.deepStrictEqual(fromHtml('
a\nb
'), '```\na\nb\n```'); - }); - - test('inline code', () => { - assert.deepStrictEqual(fromHtml('a'), '`a`'); - }); - - test('quote', () => { - assert.deepStrictEqual(fromHtml('
a\nb
'), '> a\n> b'); - }); - - test('br', () => { - assert.deepStrictEqual(fromHtml('

abc

d

'), 'abc\n\nd'); - }); - - test('link with different text', () => { - assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c](https://example.com/b) d'); - }); - - test('link with different text, but not encoded', () => { - assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c]() d'); - }); - - test('link with same text', () => { - assert.deepStrictEqual(fromHtml('

a https://example.com/b d

'), 'a https://example.com/b d'); - }); - - test('link with same text, but not encoded', () => { - assert.deepStrictEqual(fromHtml('

a https://example.com/ä d

'), 'a d'); - }); - - test('link with no url', () => { - assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c](b) d'); - }); - - test('link without href', () => { - assert.deepStrictEqual(fromHtml('

a c d

'), 'a c d'); - }); - - test('link without text', () => { - assert.deepStrictEqual(fromHtml('

a d

'), 'a https://example.com/b d'); - }); - - test('link without both', () => { - assert.deepStrictEqual(fromHtml('

a d

'), 'a d'); - }); - - test('mention', () => { - assert.deepStrictEqual(fromHtml('

a @user d

'), 'a @user@example.com d'); - }); - - test('hashtag', () => { - assert.deepStrictEqual(fromHtml('

a #a d

', ['#a']), 'a #a d'); - }); -}); diff --git a/packages/backend/test/tests/reaction-lib.ts b/packages/backend/test/tests/reaction-lib.ts deleted file mode 100644 index 2e767f7697..0000000000 --- a/packages/backend/test/tests/reaction-lib.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* -import * as assert from 'assert'; - -import { toDbReaction } from '../src/misc/reaction-lib.js'; - -describe('toDbReaction', async () => { - test('既存の文字列リアクションはそのまま', async () => { - assert.strictEqual(await toDbReaction('like'), 'like'); - }); - - test('Unicodeプリンは寿司化不能とするため文字列化しない', async () => { - assert.strictEqual(await toDbReaction('🍮'), '🍮'); - }); - - test('プリン以外の既存のリアクションは文字列化する like', async () => { - assert.strictEqual(await toDbReaction('👍'), 'like'); - }); - - test('プリン以外の既存のリアクションは文字列化する love', async () => { - assert.strictEqual(await toDbReaction('❤️'), 'love'); - }); - - test('プリン以外の既存のリアクションは文字列化する love 異体字セレクタなし', async () => { - assert.strictEqual(await toDbReaction('❤'), 'love'); - }); - - test('プリン以外の既存のリアクションは文字列化する laugh', async () => { - assert.strictEqual(await toDbReaction('😆'), 'laugh'); - }); - - test('プリン以外の既存のリアクションは文字列化する hmm', async () => { - assert.strictEqual(await toDbReaction('🤔'), 'hmm'); - }); - - test('プリン以外の既存のリアクションは文字列化する surprise', async () => { - assert.strictEqual(await toDbReaction('😮'), 'surprise'); - }); - - test('プリン以外の既存のリアクションは文字列化する congrats', async () => { - assert.strictEqual(await toDbReaction('🎉'), 'congrats'); - }); - - test('プリン以外の既存のリアクションは文字列化する angry', async () => { - assert.strictEqual(await toDbReaction('💢'), 'angry'); - }); - - test('プリン以外の既存のリアクションは文字列化する confused', async () => { - assert.strictEqual(await toDbReaction('😥'), 'confused'); - }); - - test('プリン以外の既存のリアクションは文字列化する rip', async () => { - assert.strictEqual(await toDbReaction('😇'), 'rip'); - }); - - test('それ以外はUnicodeのまま', async () => { - assert.strictEqual(await toDbReaction('🍅'), '🍅'); - }); - - test('異体字セレクタ除去', async () => { - assert.strictEqual(await toDbReaction('㊗️'), '㊗'); - }); - - test('異体字セレクタ除去 必要なし', async () => { - assert.strictEqual(await toDbReaction('㊗'), '㊗'); - }); - - test('fallback - undefined', async () => { - assert.strictEqual(await toDbReaction(undefined), 'like'); - }); - - test('fallback - null', async () => { - assert.strictEqual(await toDbReaction(null), 'like'); - }); - - test('fallback - empty', async () => { - assert.strictEqual(await toDbReaction(''), 'like'); - }); - - test('fallback - unknown', async () => { - assert.strictEqual(await toDbReaction('unknown'), 'like'); - }); -}); -*/ diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index 5d91d0923a..8a024a678b 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -33,10 +33,12 @@ "lib": [ "esnext" ], - "types": ["jest"] + "types": ["jest", "node"] }, "compileOnSave": false, "include": [ - "./**/*.ts" + "./**/*.ts", + "../src/**/*.test.ts", + "../src/@types/**/*.ts", ] } diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts new file mode 100644 index 0000000000..4065665579 --- /dev/null +++ b/packages/backend/test/unit/DriveService.ts @@ -0,0 +1,56 @@ +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { DeleteObjectCommandOutput, DeleteObjectCommand, NoSuchKey, InvalidObjectState, S3Client } from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DriveService } from '@/core/DriveService.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import type { TestingModule } from '@nestjs/testing'; + +describe('DriveService', () => { + let app: TestingModule; + let driveService: DriveService; + const s3Mock = mockClient(S3Client); + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [DriveService], + }).compile(); + app.enableShutdownHooks(); + driveService = app.get(DriveService); + }); + + beforeEach(async () => { + s3Mock.reset(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Object storage', () => { + test('delete a file', async () => { + s3Mock.on(DeleteObjectCommand) + .resolves({} as DeleteObjectCommandOutput); + + await driveService.deleteObjectStorageFile('peace of the world'); + }); + + test('delete a file then unexpected error', async () => { + s3Mock.on(DeleteObjectCommand) + .rejects(new InvalidObjectState({ $metadata: {}, message: '' })); + + await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrowError(Error); + }); + + test('delete a file with no valid key', async () => { + // Some S3 implementations returns 404 Not Found on deleting with a non-existent key + s3Mock.on(DeleteObjectCommand) + .rejects(new NoSuchKey({ $metadata: {}, message: 'allowed error.' })); + + await driveService.deleteObjectStorageFile('lol no way'); + }); + }); +}); diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index d05833560d..f378184c74 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -7,10 +7,10 @@ import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { FileInfoService } from '@/core/FileInfoService.js'; -import { DI } from '@/di-symbols.js'; +//import { DI } from '@/di-symbols.js'; import { AiService } from '@/core/AiService.js'; import type { TestingModule } from '@nestjs/testing'; -import type { jest } from '@jest/globals'; +import { describe, beforeAll, afterAll, test } from '@jest/globals'; import type { MockFunctionMetadata } from 'jest-mock'; const _filename = fileURLToPath(import.meta.url); @@ -74,164 +74,271 @@ describe('FileInfoService', () => { }); }); - test('Generic JPEG', async () => { - const path = `${resources}/Lenna.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 25360, - md5: '091b3f259662aa31e2ffef4519951168', - type: { - mime: 'image/jpeg', - ext: 'jpg', - }, - width: 512, - height: 512, - orientation: undefined, + describe('IMAGE', () => { + test('Generic JPEG', async () => { + const path = `${resources}/Lenna.jpg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 25360, + md5: '091b3f259662aa31e2ffef4519951168', + type: { + mime: 'image/jpeg', + ext: 'jpg', + }, + width: 512, + height: 512, + orientation: undefined, + }); + }); + + test('Generic APNG', async () => { + const path = `${resources}/anime.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 1868, + md5: '08189c607bea3b952704676bb3c979e0', + type: { + mime: 'image/apng', + ext: 'apng', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Generic AGIF', async () => { + const path = `${resources}/anime.gif`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 2248, + md5: '32c47a11555675d9267aee1a86571e7e', + type: { + mime: 'image/gif', + ext: 'gif', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('PNG with alpha', async () => { + const path = `${resources}/with-alpha.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 3772, + md5: 'f73535c3e1e27508885b69b10cf6e991', + type: { + mime: 'image/png', + ext: 'png', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Generic SVG', async () => { + const path = `${resources}/image.svg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 505, + md5: 'b6f52b4b021e7b92cdd04509c7267965', + type: { + mime: 'image/svg+xml', + ext: 'svg', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('SVG with XML definition', async () => { + // https://github.com/misskey-dev/misskey/issues/4413 + const path = `${resources}/with-xml-def.svg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 544, + md5: '4b7a346cde9ccbeb267e812567e33397', + type: { + mime: 'image/svg+xml', + ext: 'svg', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Dimension limit', async () => { + const path = `${resources}/25000x25000.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 75933, + md5: '268c5dde99e17cf8fe09f1ab3f97df56', + type: { + mime: 'application/octet-stream', // do not treat as image + ext: null, + }, + width: 25000, + height: 25000, + orientation: undefined, + }); + }); + + test('Rotate JPEG', async () => { + const path = `${resources}/rotate.jpg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 12624, + md5: '68d5b2d8d1d1acbbce99203e3ec3857e', + type: { + mime: 'image/jpeg', + ext: 'jpg', + }, + width: 512, + height: 256, + orientation: 8, + }); }); }); - test('Generic APNG', async () => { - const path = `${resources}/anime.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 1868, - md5: '08189c607bea3b952704676bb3c979e0', - type: { - mime: 'image/apng', - ext: 'apng', - }, - width: 256, - height: 256, - orientation: undefined, + describe('AUDIO', () => { + test('MP3', async () => { + const path = `${resources}/kick_gaba7.mp3`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 19853, + md5: '4f557df8548bc3cecc794c652f690446', + type: { + mime: 'audio/mpeg', + ext: 'mp3', + }, + }); }); - }); - - test('Generic AGIF', async () => { - const path = `${resources}/anime.gif`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 2248, - md5: '32c47a11555675d9267aee1a86571e7e', - type: { - mime: 'image/gif', - ext: 'gif', - }, - width: 256, - height: 256, - orientation: undefined, + + test('WAV', async () => { + const path = `${resources}/kick_gaba7.wav`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 87630, + md5: '8bc9bb4fe5e77bb1871448209be635c1', + type: { + mime: 'audio/wav', + ext: 'wav', + }, + }); }); - }); - - test('PNG with alpha', async () => { - const path = `${resources}/with-alpha.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 3772, - md5: 'f73535c3e1e27508885b69b10cf6e991', - type: { - mime: 'image/png', - ext: 'png', - }, - width: 256, - height: 256, - orientation: undefined, + + test('AAC', async () => { + const path = `${resources}/kick_gaba7.aac`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 7291, + md5: '2789323f05e3392b648066f50be6a2a6', + type: { + mime: 'audio/aac', + ext: 'aac', + }, + }); }); - }); - - test('Generic SVG', async () => { - const path = `${resources}/image.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 505, - md5: 'b6f52b4b021e7b92cdd04509c7267965', - type: { - mime: 'image/svg+xml', - ext: 'svg', - }, - width: 256, - height: 256, - orientation: undefined, + + test('FLAC', async () => { + const path = `${resources}/kick_gaba7.flac`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 108793, + md5: 'bc0f3adfe0e1ca99ae6c7528c46b3173', + type: { + mime: 'audio/flac', + ext: 'flac', + }, + }); }); - }); - - test('SVG with XML definition', async () => { - // https://github.com/misskey-dev/misskey/issues/4413 - const path = `${resources}/with-xml-def.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 544, - md5: '4b7a346cde9ccbeb267e812567e33397', - type: { - mime: 'image/svg+xml', - ext: 'svg', - }, - width: 256, - height: 256, - orientation: undefined, - }); - }); - - test('Dimension limit', async () => { - const path = `${resources}/25000x25000.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 75933, - md5: '268c5dde99e17cf8fe09f1ab3f97df56', - type: { - mime: 'application/octet-stream', // do not treat as image - ext: null, - }, - width: 25000, - height: 25000, - orientation: undefined, - }); - }); - - test('Rotate JPEG', async () => { - const path = `${resources}/rotate.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 12624, - md5: '68d5b2d8d1d1acbbce99203e3ec3857e', - type: { - mime: 'image/jpeg', - ext: 'jpg', - }, - width: 512, - height: 256, - orientation: 8, + + /* + * video/webmとして検出されてしまう + test('WEBM AUDIO', async () => { + const path = `${resources}/kick_gaba7.webm`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 8879, + md5: '3350083dec312419cfdc06c16413aca7', + type: { + mime: 'audio/webm', + ext: 'webm', + }, + }); }); + */ }); }); diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts new file mode 100644 index 0000000000..5496738778 --- /dev/null +++ b/packages/backend/test/unit/MfmService.ts @@ -0,0 +1,102 @@ +import * as assert from 'assert'; +import * as mfm from 'mfm-js'; +import { Test } from '@nestjs/testing'; + +import { CoreModule } from '@/core/CoreModule.js'; +import { MfmService } from '@/core/MfmService.js'; +import { GlobalModule } from '@/GlobalModule.js'; + +describe('MfmService', () => { + let mfmService: MfmService; + + beforeAll(async () => { + const app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + mfmService = app.get(MfmService); + }); + + describe('toHtml', () => { + test('br', () => { + const input = 'foo\nbar\nbaz'; + 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

'; + assert.equal(mfmService.toHtml(mfm.parse(input)), output); + }); + }); + + describe('fromHtml', () => { + test('p', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a

b

'), 'a\n\nb'); + }); + + test('block element', () => { + assert.deepStrictEqual(mfmService.fromHtml('
a
b
'), 'a\nb'); + }); + + test('inline element', () => { + assert.deepStrictEqual(mfmService.fromHtml('
  • a
  • b
'), 'a\nb'); + }); + + test('block code', () => { + assert.deepStrictEqual(mfmService.fromHtml('
a\nb
'), '```\na\nb\n```'); + }); + + test('inline code', () => { + assert.deepStrictEqual(mfmService.fromHtml('a'), '`a`'); + }); + + test('quote', () => { + assert.deepStrictEqual(mfmService.fromHtml('
a\nb
'), '> a\n> b'); + }); + + test('br', () => { + assert.deepStrictEqual(mfmService.fromHtml('

abc

d

'), 'abc\n\nd'); + }); + + test('link with different text', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c](https://example.com/b) d'); + }); + + test('link with different text, but not encoded', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c]() d'); + }); + + test('link with same text', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a https://example.com/b d

'), 'a https://example.com/b d'); + }); + + test('link with same text, but not encoded', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a https://example.com/ä d

'), 'a d'); + }); + + test('link with no url', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c](b) d'); + }); + + test('link without href', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a c d'); + }); + + test('link without text', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a d

'), 'a https://example.com/b d'); + }); + + test('link without both', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a d

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

a @user d

'), 'a @user@example.com d'); + }); + + test('hashtag', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a #a d

', ['#a']), 'a #a d'); + }); + }); +}); diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts new file mode 100644 index 0000000000..38db081ac0 --- /dev/null +++ b/packages/backend/test/unit/ReactionService.ts @@ -0,0 +1,92 @@ +import * as assert from 'assert'; +import { Test } from '@nestjs/testing'; + +import { CoreModule } from '@/core/CoreModule.js'; +import { ReactionService } from '@/core/ReactionService.js'; +import { GlobalModule } from '@/GlobalModule.js'; + +describe('ReactionService', () => { + let reactionService: ReactionService; + + beforeAll(async () => { + const app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + reactionService = app.get(ReactionService); + }); + + describe('toDbReaction', () => { + test('絵文字リアクションはそのまま', async () => { + assert.strictEqual(await reactionService.toDbReaction('👍'), '👍'); + assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅'); + }); + + test('既存のリアクションは絵文字化する pudding', async () => { + assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮'); + }); + + test('既存のリアクションは絵文字化する like', async () => { + assert.strictEqual(await reactionService.toDbReaction('like'), '👍'); + }); + + test('既存のリアクションは絵文字化する love', async () => { + assert.strictEqual(await reactionService.toDbReaction('love'), '❤'); + }); + + test('既存のリアクションは絵文字化する laugh', async () => { + assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆'); + }); + + test('既存のリアクションは絵文字化する hmm', async () => { + assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔'); + }); + + test('既存のリアクションは絵文字化する surprise', async () => { + assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮'); + }); + + test('既存のリアクションは絵文字化する congrats', async () => { + assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉'); + }); + + test('既存のリアクションは絵文字化する angry', async () => { + assert.strictEqual(await reactionService.toDbReaction('angry'), '💢'); + }); + + test('既存のリアクションは絵文字化する confused', async () => { + assert.strictEqual(await reactionService.toDbReaction('confused'), '😥'); + }); + + test('既存のリアクションは絵文字化する rip', async () => { + assert.strictEqual(await reactionService.toDbReaction('rip'), '😇'); + }); + + test('既存のリアクションは絵文字化する star', async () => { + assert.strictEqual(await reactionService.toDbReaction('star'), '⭐'); + }); + + test('異体字セレクタ除去', async () => { + assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗'); + }); + + test('異体字セレクタ除去 必要なし', async () => { + assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗'); + }); + + test('fallback - undefined', async () => { + assert.strictEqual(await reactionService.toDbReaction(undefined), '❤'); + }); + + test('fallback - null', async () => { + assert.strictEqual(await reactionService.toDbReaction(null), '❤'); + }); + + test('fallback - empty', async () => { + assert.strictEqual(await reactionService.toDbReaction(''), '❤'); + }); + + test('fallback - unknown', async () => { + assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤'); + }); + }); +}); diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 529e923b2c..c2280142a6 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -7,6 +7,7 @@ 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/index.js'; @@ -21,6 +22,7 @@ describe('RelayService', () => { let relayService: RelayService; let queueService: jest.Mocked; let relaysRepository: RelaysRepository; + let userEntityService: UserEntityService; beforeAll(async () => { app = await Test.createTestingModule({ @@ -32,6 +34,7 @@ describe('RelayService', () => { CreateSystemUserService, ApRendererService, RelayService, + UserEntityService, ], }) .useMocker((token) => { @@ -51,6 +54,7 @@ describe('RelayService', () => { relayService = app.get(RelayService); queueService = app.get(QueueService) as jest.Mocked; relaysRepository = app.get(DI.relaysRepository); + userEntityService = app.get(UserEntityService); }); afterAll(async () => { @@ -63,7 +67,7 @@ describe('RelayService', () => { expect(result.inbox).toBe('https://example.com'); expect(result.status).toBe('requesting'); expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1].type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Follow'); expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); }); @@ -80,8 +84,8 @@ describe('RelayService', () => { await relayService.removeRelay('https://example.com'); expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1].type).toBe('Undo'); - expect(queueService.deliver.mock.lastCall![1].object.type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Undo'); + expect(queueService.deliver.mock.lastCall![1]?.object.type).toBe('Follow'); expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 221f743d3a..907f1f2edc 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -3,16 +3,18 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; -import { DataSource } from 'typeorm'; +import * as lolex from '@sinonjs/fake-timers'; import rndstr from 'rndstr'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; -import { CoreModule } from '@/core/CoreModule.js'; import { MetaService } from '@/core/MetaService.js'; import { genAid } from '@/misc/id/aid.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { sleep } from '../utils.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -25,6 +27,7 @@ describe('RoleService', () => { let rolesRepository: RolesRepository; let roleAssignmentsRepository: RoleAssignmentsRepository; let metaService: jest.Mocked; + let clock: lolex.InstalledClock; function createUser(data: Partial = {}) { const un = rndstr('a-z0-9', 16); @@ -50,23 +53,21 @@ describe('RoleService', () => { .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); } - async function assign(roleId: Role['id'], userId: User['id']) { - await roleAssignmentsRepository.insert({ - id: genAid(new Date()), - createdAt: new Date(), - roleId, - userId, - }); - } - beforeEach(async () => { + clock = lolex.install({ + now: new Date(), + shouldClearNativeTimers: true, + }); + app = await Test.createTestingModule({ imports: [ GlobalModule, ], providers: [ RoleService, - UserCacheService, + CacheService, + IdService, + GlobalEventService, ], }) .useMocker((token) => { @@ -92,12 +93,15 @@ describe('RoleService', () => { }); afterEach(async () => { + clock.uninstall(); + await Promise.all([ app.get(DI.metasRepository).delete({}), usersRepository.delete({}), rolesRepository.delete({}), roleAssignmentsRepository.delete({}), ]); + await app.close(); }); @@ -115,7 +119,7 @@ describe('RoleService', () => { expect(result.canManageCustomEmojis).toBe(false); }); - test('instance default policies 2', async () => { + test('instance default policies 2', async () => { const user = await createUser(); metaService.fetch.mockResolvedValue({ policies: { @@ -128,7 +132,7 @@ describe('RoleService', () => { expect(result.canManageCustomEmojis).toBe(true); }); - test('with role', async () => { + test('with role', async () => { const user = await createUser(); const role = await createRole({ name: 'a', @@ -140,7 +144,7 @@ describe('RoleService', () => { }, }, }); - await assign(role.id, user.id); + await roleService.assign(user.id, role.id); metaService.fetch.mockResolvedValue({ policies: { canManageCustomEmojis: false, @@ -152,7 +156,7 @@ describe('RoleService', () => { expect(result.canManageCustomEmojis).toBe(true); }); - test('priority', async () => { + test('priority', async () => { const user = await createUser(); const role1 = await createRole({ name: 'role1', @@ -174,8 +178,8 @@ describe('RoleService', () => { }, }, }); - await assign(role1.id, user.id); - await assign(role2.id, user.id); + await roleService.assign(user.id, role1.id); + await roleService.assign(user.id, role2.id); metaService.fetch.mockResolvedValue({ policies: { driveCapacityMb: 50, @@ -187,7 +191,7 @@ describe('RoleService', () => { expect(result.driveCapacityMb).toBe(100); }); - test('conditional role', async () => { + test('conditional role', async () => { const user1 = await createUser({ createdAt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 365)), }); @@ -228,5 +232,42 @@ describe('RoleService', () => { expect(user1Policies.canManageCustomEmojis).toBe(false); expect(user2Policies.canManageCustomEmojis).toBe(true); }); + + test('expired role', async () => { + const user = await createUser(); + const role = await createRole({ + name: 'a', + policies: { + canManageCustomEmojis: { + useDefault: false, + priority: 0, + value: true, + }, + }, + }); + await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24))); + metaService.fetch.mockResolvedValue({ + policies: { + canManageCustomEmojis: false, + }, + } as any); + + const result = await roleService.getUserPolicies(user.id); + expect(result.canManageCustomEmojis).toBe(true); + + clock.tick('25:00:00'); + + const resultAfter25h = await roleService.getUserPolicies(user.id); + expect(resultAfter25h.canManageCustomEmojis).toBe(false); + + await roleService.assign(user.id, role.id); + + // ストリーミング経由で反映されるまでちょっと待つ + clock.uninstall(); + await sleep(100); + + const resultAfter25hAgain = await roleService.getUserPolicies(user.id); + expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true); + }); }); }); diff --git a/packages/backend/test/unit/S3Service.ts b/packages/backend/test/unit/S3Service.ts new file mode 100644 index 0000000000..1dfa22afd2 --- /dev/null +++ b/packages/backend/test/unit/S3Service.ts @@ -0,0 +1,77 @@ +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { S3Service } from '@/core/S3Service'; +import { Meta } from '@/models'; +import type { TestingModule } from '@nestjs/testing'; + +describe('S3Service', () => { + let app: TestingModule; + let s3Service: S3Service; + const s3Mock = mockClient(S3Client); + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [S3Service], + }).compile(); + app.enableShutdownHooks(); + s3Service = app.get(S3Service); + }); + + beforeEach(async () => { + s3Mock.reset(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('upload', () => { + test('upload a file', async () => { + s3Mock.on(PutObjectCommand).resolves({}); + + await s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x', + }); + }); + + test('upload a large file', async () => { + s3Mock.on(CreateMultipartUploadCommand).resolves({ UploadId: '1' }); + s3Mock.on(UploadPartCommand).resolves({ ETag: '1' }); + s3Mock.on(CompleteMultipartUploadCommand).resolves({ Bucket: 'fake', Key: 'fake' }); + + await s3Service.upload({} as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ + }); + }); + + test('upload a file error', async () => { + s3Mock.on(PutObjectCommand).rejects({ name: 'Fake Error' }); + + await expect(s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x', + })).rejects.toThrowError(Error); + }); + + test('upload a large file error', async () => { + s3Mock.on(UploadPartCommand).rejects(); + + await expect(s3Service.upload({} as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ + })).rejects.toThrowError(Error); + }); + }); +}); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts new file mode 100644 index 0000000000..146998937e --- /dev/null +++ b/packages/backend/test/unit/activitypub.ts @@ -0,0 +1,129 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import rndstr from 'rndstr'; +import { Test } from '@nestjs/testing'; +import { jest } from '@jest/globals'; + +import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type { IActor } from '@/core/activitypub/type.js'; +import { MockResolver } from '../misc/mock-resolver.js'; +import { Note } from '@/models/index.js'; + +const host = 'https://host1.test'; + +function createRandomActor(): IActor & { id: string } { + const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; + const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + id: actorId, + type: 'Person', + preferredUsername, + inbox: `${actorId}/inbox`, + outbox: `${actorId}/outbox`, + }; +} + +describe('ActivityPub', () => { + let noteService: ApNoteService; + let personService: ApPersonService; + let rendererService: ApRendererService; + let resolver: MockResolver; + + beforeEach(async () => { + const app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + + await app.init(); + app.enableShutdownHooks(); + + noteService = app.get(ApNoteService); + personService = app.get(ApPersonService); + rendererService = app.get(ApRendererService); + resolver = new MockResolver(await app.resolve(LoggerService)); + + // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error + const federatedInstanceService = app.get(FederatedInstanceService); + jest.spyOn(federatedInstanceService, 'fetch').mockImplementation(() => new Promise(() => {})); + }); + + describe('Parse minimum object', () => { + const actor = createRandomActor(); + + const post = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${host}/users/${rndstr('0-9a-z', 8)}`, + type: 'Note', + attributedTo: actor.id, + to: 'https://www.w3.org/ns/activitystreams#Public', + content: 'あ', + }; + + test('Minimum Actor', async () => { + resolver._register(actor.id, actor); + + const user = await personService.createPerson(actor.id, resolver); + + assert.deepStrictEqual(user.uri, actor.id); + assert.deepStrictEqual(user.username, actor.preferredUsername); + assert.deepStrictEqual(user.inbox, actor.inbox); + }); + + test('Minimum Note', async () => { + resolver._register(actor.id, actor); + resolver._register(post.id, post); + + const note = await noteService.createNote(post.id, resolver, true); + + assert.deepStrictEqual(note?.uri, post.id); + assert.deepStrictEqual(note.visibility, 'public'); + assert.deepStrictEqual(note.text, post.content); + }); + }); + + describe('Name field', () => { + test('Truncate long name', async () => { + const actor = { + ...createRandomActor(), + name: rndstr('0-9a-z', 129), + }; + + resolver._register(actor.id, actor); + + const user = await personService.createPerson(actor.id, resolver); + + assert.deepStrictEqual(user.name, actor.name.slice(0, 128)); + }); + + test('Normalize empty name', async () => { + const actor = { + ...createRandomActor(), + name: '', + }; + + resolver._register(actor.id, actor); + + const user = await personService.createPerson(actor.id, resolver); + + assert.strictEqual(user.name, null); + }); + }); + + describe('Renderer', () => { + test('Render an announce with visibility: followers', () => { + rendererService.renderAnnounce(null, { + createdAt: new Date(0), + visibility: 'followers', + } as Note); + }); + }); +}); diff --git a/packages/backend/test/tests/ap-request.ts b/packages/backend/test/unit/ap-request.ts similarity index 78% rename from packages/backend/test/tests/ap-request.ts rename to packages/backend/test/unit/ap-request.ts index 8c586861ad..98f352e1c6 100644 --- a/packages/backend/test/tests/ap-request.ts +++ b/packages/backend/test/unit/ap-request.ts @@ -1,7 +1,8 @@ import * as assert from 'assert'; import httpSignature from '@peertube/http-signature'; -import { genRsaKeyPair } from '../../src/misc/gen-key-pair.js'; -import { createSignedPost, createSignedGet } from '../../src/activitypub/ap-request.js'; + +import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; +import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { return { @@ -9,7 +10,7 @@ export const buildParsedSignature = (signingString: string, signature: string, a params: { keyId: 'KeyID', // dummy, not used for verify algorithm: algorithm, - headers: [ '(request-target)', 'date', 'host', 'digest' ], // dummy, not used for verify + headers: ['(request-target)', 'date', 'host', 'digest'], // dummy, not used for verify signature: signature, }, signingString: signingString, @@ -29,7 +30,7 @@ describe('ap-request', () => { 'User-Agent': 'UA', }; - const req = createSignedPost({ key, url, body, additionalHeaders: headers }); + const req = ApRequestCreator.createSignedPost({ key, url, body, additionalHeaders: headers }); const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); @@ -45,7 +46,7 @@ describe('ap-request', () => { 'User-Agent': 'UA', }; - const req = createSignedGet({ key, url, additionalHeaders: headers }); + const req = ApRequestCreator.createSignedGet({ key, url, additionalHeaders: headers }); const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/unit/chart.ts index 1e9a51bc88..5ac4cc18a2 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -19,7 +19,7 @@ import Logger from '@/logger.js'; describe('Chart', () => { const config = loadConfig(); const appLockService = { - getChartInsertLock: jest.fn().mockImplementation(() => Promise.resolve(() => {})), + getChartInsertLock: () => () => Promise.resolve(() => {}), } as unknown as jest.Mocked; let db: DataSource | undefined; diff --git a/packages/backend/test/tests/extract-mentions.ts b/packages/backend/test/unit/extract-mentions.ts similarity index 81% rename from packages/backend/test/tests/extract-mentions.ts rename to packages/backend/test/unit/extract-mentions.ts index e81d04c2db..66d32be1c5 100644 --- a/packages/backend/test/tests/extract-mentions.ts +++ b/packages/backend/test/unit/extract-mentions.ts @@ -1,11 +1,11 @@ import * as assert from 'assert'; import { parse } from 'mfm-js'; -import { extractMentions } from '../../src/misc/extract-mentions.js'; +import { extractMentions } from '@/misc/extract-mentions.js'; describe('Extract mentions', () => { test('simple', () => { - const ast = parse('@foo @bar @baz')!; + const ast = parse('@foo @bar @baz'); const mentions = extractMentions(ast); assert.deepStrictEqual(mentions, [{ username: 'foo', @@ -23,7 +23,7 @@ describe('Extract mentions', () => { }); test('nested', () => { - const ast = parse('@foo **@bar** @baz')!; + const ast = parse('@foo **@bar** @baz'); const mentions = extractMentions(ast); assert.deepStrictEqual(mentions, [{ username: 'foo', diff --git a/packages/backend/test/unit/misc/check-word-mute.ts b/packages/backend/test/unit/misc/check-word-mute.ts new file mode 100644 index 0000000000..7ab838bdee --- /dev/null +++ b/packages/backend/test/unit/misc/check-word-mute.ts @@ -0,0 +1,49 @@ +import { checkWordMute } from '@/misc/check-word-mute.js'; + +describe(checkWordMute, () => { + describe('Slacc boost mode', () => { + it('should return false if mutedWords is empty', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [])).toBe(false); + }); + it('should return true if mutedWords is not empty and text contains muted word', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo']])).toBe(true); + }); + it('should return false if mutedWords is not empty and text does not contain muted word', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['bar']])).toBe(false); + }); + it('should return false when the note is written by me even if mutedWords is not empty and text contains muted word', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, { id: '1' }, [['foo']])).toBe(false); + }); + it('should return true if mutedWords is not empty and text contains muted word in CW', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['bar']])).toBe(true); + }); + it('should return true if mutedWords is not empty and text contains muted word in both CW and text', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['bar']])).toBe(true); + }); + it('should return true if mutedWords is not empty and text does not contain muted word in both CW and text', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['baz']])).toBe(true); + }); + }); + describe('normal mode', () => { + it('should return false if text does not contain muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo', 'bar']])).toBe(false); + }); + it('should return true if text contains muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, [['foo', 'bar']])).toBe(true); + }); + it('should return false when the note is written by me even if text contains muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, [['foo', 'bar']])).toBe(false); + }); + }); + describe('RegExp mode', () => { + it('should return false if text does not contain muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, ['/bar/'])).toBe(false); + }); + it('should return true if text contains muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, ['/bar/'])).toBe(true); + }); + it('should return false when the note is written by me even if text contains muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, ['/bar/'])).toBe(false); + }); + }); +}); diff --git a/packages/backend/test/unit/misc/id.ts b/packages/backend/test/unit/misc/id.ts new file mode 100644 index 0000000000..ecd0e60a31 --- /dev/null +++ b/packages/backend/test/unit/misc/id.ts @@ -0,0 +1,44 @@ +import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js'; +import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js'; +import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js'; +import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js'; +import { ulidRegExp, parseUlid } from '@/misc/id/ulid.js'; +import { ulid } from 'ulid'; +import { describe, test, expect } from '@jest/globals'; + +describe('misc:id', () => { + test('aid', () => { + const date = new Date(); + const gotAid = genAid(date); + expect(gotAid).toMatch(aidRegExp); + expect(parseAid(gotAid).date.getTime()).toBe(date.getTime()); + }); + + test('meid', () => { + const date = new Date(); + const gotMeid = genMeid(date); + expect(gotMeid).toMatch(meidRegExp); + expect(parseMeid(gotMeid).date.getTime()).toBe(date.getTime()); + }); + + test('meidg', () => { + const date = new Date(); + const gotMeidg = genMeidg(date); + expect(gotMeidg).toMatch(meidgRegExp); + expect(parseMeidg(gotMeidg).date.getTime()).toBe(date.getTime()); + }); + + test('objectid', () => { + const date = new Date(); + const gotObjectId = genObjectId(date); + expect(gotObjectId).toMatch(objectIdRegExp); + expect(Math.floor(parseObjectId(gotObjectId).date.getTime() / 1000)).toBe(Math.floor(date.getTime() / 1000)); + }); + + test('ulid', () => { + const date = new Date(); + const gotUlid = ulid(date.getTime()); + expect(gotUlid).toMatch(ulidRegExp); + expect(parseUlid(gotUlid).date.getTime()).toBe(date.getTime()); + }); +}); diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts new file mode 100644 index 0000000000..c476aef33b --- /dev/null +++ b/packages/backend/test/unit/misc/others.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from '@jest/globals'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; + +describe('misc:content-disposition', () => { + test('inline', () => { + expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); + }); + test('attachment', () => { + expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); + }); + test('non ascii', () => { + expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D'); + }); +}); + +describe('misc:correct-filename', () => { + test('simple', () => { + expect(correctFilename('filename', 'jpg')).toBe('filename.jpg'); + }); + test('with same ext', () => { + expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg'); + }); + test('.ext', () => { + expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg'); + }); + test('with different ext', () => { + expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg'); + }); + test('non ascii with space', () => { + expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg'); + }); + test('jpeg', () => { + expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg'); + }); + test('tiff', () => { + expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff'); + }); + test('null ext', () => { + expect(correctFilename('filename', null)).toBe('filename.unknown'); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 50988939aa..809ed2c66c 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -1,87 +1,88 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import * as childProcess from 'child_process'; -import * as http from 'node:http'; -import { SIGKILL } from 'constants'; +import * as assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, basename } from 'node:path'; +import { inspect } from 'node:util'; import WebSocket from 'ws'; -import fetch from 'node-fetch'; -import FormData from 'form-data'; +import fetch, { Blob, File, RequestInit } from 'node-fetch'; import { DataSource } from 'typeorm'; -import got, { RequestError } from 'got'; -import loadConfig from '../src/config/load.js'; -import { entities } from '@/postgres.js'; +import { JSDOM } from 'jsdom'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import { entities } from '../src/postgres.js'; +import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); +export { server as startServer } from '@/boot/common.js'; const config = loadConfig(); export const port = config.port; -export const api = async (endpoint: string, params: any, me?: any) => { - endpoint = endpoint.replace(/^\//, ''); - - const auth = me ? { - i: me.token, - } : {}; - - try { - const res = await got(`http://localhost:${port}/api/${endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(Object.assign(auth, params)), - retry: { - limit: 0, - }, - }); - - const status = res.statusCode; - const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; - - return { - status, - body, - }; - } catch (err: unknown) { - if (err instanceof RequestError && err.response) { - const status = err.response.statusCode; - const body = await JSON.parse(err.response.body as string); - - return { - status, - body, - }; - } else { - throw err; - } - } +export const cookie = (me: any): string => { + return `token=${me.token};`; }; -export const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { +export const api = async (endpoint: string, params: any, me?: any) => { + const normalized = endpoint.replace(/^\//, ''); + return await request(`api/${normalized}`, params, me); +}; + +export type ApiRequest = { + endpoint: string, + parameters: object, + user: object | undefined, +}; + +export const successfulApiCall = async (request: ApiRequest, assertion: { + status?: number, +} = {}): Promise => { + const { endpoint, parameters, user } = request; + const res = await api(endpoint, parameters, user); + const status = assertion.status ?? (res.body == null ? 204 : 200); + assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true })); + return res.body; +}; + +export const failedApiCall = async (request: ApiRequest, assertion: { + status: number, + code: string, + id: string +}): Promise => { + const { endpoint, parameters, user } = request; + const { status, code, id } = assertion; + const res = await api(endpoint, parameters, user); + assert.strictEqual(res.status, status, inspect(res.body)); + assert.strictEqual(res.body.error.code, code, inspect(res.body)); + assert.strictEqual(res.body.error.id, id, inspect(res.body)); + return res.body; +}; + +const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const auth = me ? { i: me.token, } : {}; - const res = await fetch(`http://localhost:${port}/${path}`, { + const res = await relativeFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(Object.assign(auth, params)), + redirect: 'manual', }); const status = res.status; - const body = res.status === 200 ? await res.json().catch() : null; + const body = res.headers.get('content-type') === 'application/json; charset=utf-8' + ? await res.json() + : null; return { body, status, }; }; +const relativeFetch = async (path: string, init?: RequestInit | undefined) => { + return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); +}; + export const signup = async (params?: any): Promise => { const q = Object.assign({ username: 'test', @@ -94,15 +95,28 @@ export const signup = async (params?: any): Promise => { }; export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => { - const q = Object.assign({ - text: 'test', - }, params); + const q = params; const res = await api('notes/create', q, user); return res.body ? res.body.createdNote : null; }; +// 非公開ノートをAPI越しに見たときのノート NoteEntityService.ts +export const hiddenNote = (note: any): any => { + const temp = { + ...note, + fileIds: [], + files: [], + text: null, + cw: null, + isHidden: true, + }; + delete temp.visibleUserIds; + delete temp.poll; + return temp; +}; + export const react = async (user: any, note: any, reaction: string): Promise => { await api('notes/reactions/create', { noteId: note.id, @@ -110,30 +124,141 @@ export const react = async (user: any, note: any, reaction: string): Promise => { + const res = await api('pages/create', { + alignCenter: false, + content: [ + { + id: '2be9a64b-5ada-43a3-85f3-ec3429551ded', + text: 'Hello World!', + type: 'text', + }, + ], + eyeCatchingImageId: null, + font: 'sans-serif', + hideTitleWhenPinned: false, + name: '1678594845072', + script: '', + summary: null, + title: '', + variables: [], + ...page, + }, user); + return res.body; +}; + +export const play = async (user: any, play: any = {}): Promise => { + const res = await api('flash/create', { + permissions: [], + script: 'test', + summary: '', + title: 'test', + ...play, + }, user); + return res.body; +}; + +export const clip = async (user: any, clip: any = {}): Promise => { + const res = await api('clips/create', { + description: null, + isPublic: true, + name: 'test', + ...clip, + }, user); + return res.body; +}; + +export const galleryPost = async (user: any, channel: any = {}): Promise => { + const res = await api('gallery/posts/create', { + description: null, + fileIds: [], + isSensitive: false, + title: 'test', + ...channel, + }, user); + return res.body; +}; + +export const channel = async (user: any, channel: any = {}): Promise => { + const res = await api('channels/create', { + bannerId: null, + description: null, + name: 'test', + ...channel, + }, user); + return res.body; +}; + +export const role = async (user: any, role: any = {}, policies: any = {}): Promise => { + const res = await api('admin/roles/create', { + asBadge: false, + canEditMembersByModerator: false, + color: null, + condFormula: { + id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85', + type: 'isRemote', + }, + description: '', + displayOrder: 0, + iconUrl: null, + isAdministrator: false, + isModerator: false, + isPublic: false, + name: 'New Role', + target: 'manual', + policies: { + ...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, { + priority: 0, + useDefault: true, + value: v, + }]), + ...policies, + }, + ...role, + }, user); + return res.body; +}; + +interface UploadOptions { + /** Optional, absolute path or relative from ./resources/ */ + path?: string | URL; + /** The name to be used for the file upload */ + name?: string; + /** A Blob can be provided instead of path */ + blob?: Blob; +} + /** * Upload file * @param user User - * @param _path Optional, absolute path or relative from ./resources/ */ -export const uploadFile = async (user: any, _path?: string): Promise => { - const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`; +export const uploadFile = async (user: any, { path, name, blob }: UploadOptions = {}): Promise => { + const absPath = path == null + ? new URL('resources/Lenna.jpg', import.meta.url) + : isAbsolute(path.toString()) + ? new URL(path) + : new URL(path, new URL('resources/', import.meta.url)); - const formData = new FormData() as any; + const formData = new FormData(); formData.append('i', user.token); - formData.append('file', fs.createReadStream(absPath)); + formData.append('file', blob ?? + new File([await readFile(absPath)], basename(absPath.toString()))); formData.append('force', 'true'); + if (name) { + formData.append('name', name); + } - const res = await got(`http://localhost:${port}/api/drive/files/create`, { + const res = await relativeFetch('api/drive/files/create', { method: 'POST', body: formData, - retry: { - limit: 0, - }, }); - const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; + const body = res.status !== 204 ? await res.json() : null; - return body; + return { + status: res.status, + body, + }; }; export const uploadUrl = async (user: any, url: string) => { @@ -160,7 +285,7 @@ export const uploadUrl = async (user: any, url: string) => { export function connectStream(user: any, channel: string, listener: (message: Record) => any, params?: any): Promise { return new Promise((res, rej) => { - const ws = new WebSocket(`ws://localhost:${port}/streaming?i=${user.token}`); + const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`); ws.on('open', () => { ws.on('message', data => { @@ -187,7 +312,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record) => boolean, params?: any) => { return new Promise(async (res, rej) => { - let timer: NodeJS.Timeout; + let timer: NodeJS.Timeout | null = null; let ws: WebSocket; try { @@ -219,41 +344,41 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond }); }; -export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => { - // node-fetchだと3xxを取れない - return await new Promise((resolve, reject) => { - const req = http.request(`http://localhost:${port}${path}`, { - headers: { - Accept: accept, - }, - }, res => { - if (res.statusCode! >= 400) { - reject(res); - } else { - resolve({ - status: res.statusCode, - type: res.headers['content-type'], - location: res.headers.location, - }); - } - }); - - req.end(); - }); +export type SimpleGetResponse = { + status: number, + body: any | JSDOM | null, + type: string | null, + location: string | null }; +export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise => { + const res = await relativeFetch(path, { + headers: { + Accept: accept, + Cookie: cookie, + }, + redirect: 'manual', + }); -export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProcess) => void, moreProcess: () => Promise = async () => {}) { - return (done: (err?: Error) => any) => { - const p = childProcess.spawn('node', [_dirname + '/../index.js'], { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], - env: { NODE_ENV: 'test', PATH: process.env.PATH }, - }); - callbackSpawnedProcess(p); - p.on('message', message => { - if (message === 'ok') moreProcess().then(() => done()).catch(e => done(e)); - }); + const jsonTypes = [ + 'application/json; charset=utf-8', + 'application/activity+json; charset=utf-8', + ]; + const htmlTypes = [ + 'text/html; charset=utf-8', + ]; + + const body = + jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : + null; + + return { + status: res.status, + body, + type: res.headers.get('content-type'), + location: res.headers.get('location'), }; -} +}; export async function initTestDb(justBorrow = false, initEntities?: any[]) { if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; @@ -275,46 +400,6 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) { return db; } -export function startServer(timeout = 60 * 1000): Promise { - return new Promise((res, rej) => { - const t = setTimeout(() => { - p.kill(SIGKILL); - rej('timeout to start'); - }, timeout); - - const p = childProcess.spawn('node', [_dirname + '/../built/index.js'], { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], - env: { NODE_ENV: 'test', PATH: process.env.PATH }, - }); - - p.on('error', e => rej(e)); - - p.on('message', message => { - if (message === 'ok') { - clearTimeout(t); - res(p); - } - }); - }); -} - -export function shutdownServer(p: childProcess.ChildProcess | undefined, timeout = 20 * 1000) { - if (p == null) return Promise.resolve('nop'); - return new Promise((res, rej) => { - const t = setTimeout(() => { - p.kill(SIGKILL); - res('force exit'); - }, timeout); - - p.once('exit', () => { - clearTimeout(t); - res('exited'); - }); - - p.kill(); - }); -} - export function sleep(msec: number) { return new Promise(res => { setTimeout(() => { diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 544b529e94..faadbcdfc6 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "allowJs": true, - "noEmitOnError": false, + "noEmitOnError": true, "noImplicitAny": true, "noImplicitReturns": true, "noUnusedParameters": false, @@ -26,9 +26,7 @@ "rootDir": "./src", "baseUrl": "./", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] }, "outDir": "./built", "types": [ @@ -46,4 +44,7 @@ "include": [ "./src/**/*.ts" ], + "exclude": [ + "./src/**/*.test.ts" + ] } diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js index 16349dfadb..c75c0877ab 100644 --- a/packages/frontend/.eslintrc.js +++ b/packages/frontend/.eslintrc.js @@ -55,6 +55,7 @@ module.exports = { 'vue/multi-word-component-names': 'warn', 'vue/require-v-for-key': 'warn', 'vue/no-unused-components': 'warn', + 'vue/no-unused-vars': 'warn', 'vue/valid-v-for': 'warn', 'vue/return-in-computed-property': 'warn', 'vue/no-setup-props-destructure': 'warn', diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 0000000000..1aa0ac14e8 --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1 @@ +/storybook-static diff --git a/packages/frontend/.storybook/.gitignore b/packages/frontend/.storybook/.gitignore new file mode 100644 index 0000000000..e421532a54 --- /dev/null +++ b/packages/frontend/.storybook/.gitignore @@ -0,0 +1,7 @@ +/changes.js +/generate.js +/preload-locale.js +/locale.ts +/main.js +/preload-theme.js +/themes.ts diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts new file mode 100644 index 0000000000..755bec6869 --- /dev/null +++ b/packages/frontend/.storybook/changes.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import micromatch from 'micromatch'; +import main from './main'; + +interface Stats { + readonly modules: readonly { + readonly id: string; + readonly name: string; + readonly reasons: readonly { + readonly moduleName: string; + }[]; + }[]; +} + +fs.readFile( + path.resolve(__dirname, '../storybook-static/preview-stats.json') +).then((buffer) => { + const stats: Stats = JSON.parse(buffer.toString()); + const keys = new Set(stats.modules.map((stat) => stat.id)); + const map = new Map( + Array.from(keys, (key) => [ + key, + new Set( + stats.modules + .filter((stat) => stat.id === key) + .flatMap((stat) => stat.reasons) + .map((reason) => reason.moduleName) + ), + ]) + ); + const modules = new Set( + process.argv + .slice(2) + .map((arg) => + path.relative( + path.resolve(__dirname, '..'), + path.resolve(__dirname, '../../..', arg) + ) + ) + .map((path) => path.replace(/(?:(?<=\.stories)\.(?:impl|meta)|\.msw)(?=\.ts$)/g, '')) + .map((path) => (path.startsWith('.') ? path : `./${path}`)) + ); + if ( + micromatch(Array.from(modules), [ + '../../assets/**', + '../../fluent-emojis/**', + '../../locales/**', + '../../misskey-assets/**', + 'assets/**', + 'public/**', + '../../pnpm-lock.yaml', + ]).length + ) { + return; + } + for (;;) { + const oldSize = modules.size; + for (const module of Array.from(modules)) { + if (map.has(module)) { + for (const dependency of Array.from(map.get(module)!)) { + modules.add(dependency); + } + } + } + if (modules.size === oldSize) { + break; + } + } + const stories = micromatch( + Array.from(modules), + main.stories.map((story) => `./${path.relative('..', story)}`) + ); + if (stories.length) { + for (const story of stories) { + process.stdout.write(` --only-story-files ${story}`); + } + } else { + process.stdout.write(` --skip`); + } +}); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts new file mode 100644 index 0000000000..5fd21cdf0a --- /dev/null +++ b/packages/frontend/.storybook/fakes.ts @@ -0,0 +1,117 @@ +import type { entities } from 'misskey-js' + +export function abuseUserReport() { + return { + id: 'someabusereportid', + createdAt: '2016-12-28T22:49:51.000Z', + comment: 'This user is a spammer!', + resolved: false, + reporterId: 'reporterid', + targetUserId: 'targetuserid', + assigneeId: 'assigneeid', + reporter: userDetailed('reporterid', 'reporter', 'misskey-hub.net', 'Reporter'), + targetUser: userDetailed('targetuserid', 'target', 'misskey-hub.net', 'Target'), + assignee: userDetailed('assigneeid', 'assignee', 'misskey-hub.net', 'Assignee'), + me: null, + forwarded: false, + }; +} + +export function galleryPost(isSensitive = false) { + return { + id: 'somepostid', + createdAt: '2016-12-28T22:49:51.000Z', + updatedAt: '2016-12-28T22:49:51.000Z', + userid: 'someuserid', + user: userDetailed(), + title: 'Some post title', + description: 'Some post description', + fileIds: ['somefileid'], + files: [ + file(isSensitive), + ], + isSensitive, + likedCount: 0, + isLiked: false, + } +} + +export function file(isSensitive = false) { + return { + id: 'somefileid', + createdAt: '2016-12-28T22:49:51.000Z', + name: 'somefile.jpg', + type: 'image/jpeg', + md5: 'f6fc51c73dc21b1fb85ead2cdf57530a', + size: 77752, + isSensitive, + blurhash: 'eQAmoa^-MH8w9ZIvNLSvo^$*MwRPbwtSxutRozjEiwR.RjWBoeozog', + properties: { + width: 1024, + height: 270 + }, + url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + thumbnailUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + comment: null, + folderId: null, + folder: null, + userId: null, + user: null, + }; +} + +export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed { + return { + id, + username, + host, + name, + onlineStatus: 'unknown', + avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', + avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', + emojis: [], + bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', + bannerColor: '#000000', + bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + birthday: '2014-06-20', + createdAt: '2016-12-28T22:49:51.000Z', + description: 'I am a cool user!', + ffVisibility: 'public', + roles: [], + fields: [ + { + name: 'Website', + value: 'https://misskey-hub.net', + }, + ], + followersCount: 1024, + followingCount: 16, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isAdmin: false, + isBlocked: false, + isBlocking: false, + isBot: false, + isCat: false, + isFollowed: false, + isFollowing: false, + isLocked: false, + isModerator: false, + isMuted: false, + isSilenced: false, + isSuspended: false, + lang: 'en', + location: 'Fediverse', + notesCount: 65536, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPage: null, + pinnedPageId: null, + publicReactions: false, + securityKeys: false, + twoFactorEnabled: false, + updatedAt: null, + uri: null, + url: null, + }; +} diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx new file mode 100644 index 0000000000..7c51d4c00c --- /dev/null +++ b/packages/frontend/.storybook/generate.tsx @@ -0,0 +1,410 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { basename, dirname } from 'node:path/posix'; +import { GENERATOR, type State, generate } from 'astring'; +import type * as estree from 'estree'; +import glob from 'fast-glob'; +import { format } from 'prettier'; + +interface SatisfiesExpression extends estree.BaseExpression { + type: 'SatisfiesExpression'; + expression: estree.Expression; + reference: estree.Identifier; +} + +const generator = { + ...GENERATOR, + SatisfiesExpression(node: SatisfiesExpression, state: State) { + switch (node.expression.type) { + case 'ArrowFunctionExpression': { + state.write('('); + this[node.expression.type](node.expression, state); + state.write(')'); + break; + } + default: { + // @ts-ignore + this[node.expression.type](node.expression, state); + break; + } + } + state.write(' satisfies ', node as unknown as estree.Expression); + this[node.reference.type](node.reference, state); + }, +}; + +type SplitCamel< + T extends string, + YC extends string = '', + YN extends readonly string[] = [] +> = T extends `${infer XH}${infer XR}` + ? XR extends '' + ? [...YN, Uncapitalize<`${YC}${XH}`>] + : XH extends Uppercase + ? SplitCamel, [...YN, YC]> + : SplitCamel + : YN; + +// @ts-ignore +type SplitKebab = T extends `${infer XH}-${infer XR}` + ? [XH, ...SplitKebab] + : [T]; + +type ToKebab = T extends readonly [ + infer XO extends string +] + ? XO + : T extends readonly [ + infer XH extends string, + ...infer XR extends readonly string[] + ] + ? `${XH}${XR extends readonly string[] ? `-${ToKebab}` : ''}` + : ''; + +// @ts-ignore +type ToPascal = T extends readonly [ + infer XH extends string, + ...infer XR extends readonly string[] +] + ? `${Capitalize}${ToPascal}` + : ''; + +function h( + component: T['type'], + props: Omit +): T { + const type = component.replace(/(?:^|-)([a-z])/g, (_, c) => c.toUpperCase()); + return Object.assign(props || {}, { type }) as T; +} + +declare global { + namespace JSX { + type Element = estree.Node; + type ElementClass = never; + type ElementAttributesProperty = never; + type ElementChildrenAttribute = never; + type IntrinsicAttributes = never; + type IntrinsicClassAttributes = never; + type IntrinsicElements = { + [T in keyof typeof generator as ToKebab>>]: { + [K in keyof Omit< + Parameters<(typeof generator)[T]>[0], + 'type' + >]?: Parameters<(typeof generator)[T]>[0][K]; + }; + }; + } +} + +function toStories(component: string): string { + const msw = `${component.slice(0, -'.vue'.length)}.msw`; + const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`; + const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`; + const hasMsw = existsSync(`${msw}.ts`); + const hasImplStories = existsSync(`${implStories}.ts`); + const hasMetaStories = existsSync(`${metaStories}.ts`); + const base = basename(component); + const dir = dirname(component); + const literal = + as estree.Literal; + const identifier = + as estree.Identifier; + const parameters = + as estree.Identifier} + value={ as estree.Literal} + kind={'init' as const} + /> as estree.Property, + ...(hasMsw + ? [ + as estree.Identifier} + value={ as estree.Identifier} + kind={'init' as const} + shorthand + /> as estree.Property, + ] + : []), + ]} + /> as estree.ObjectExpression; + const program = + as estree.Literal} + specifiers={[ + as estree.Identifier} + imported={ as estree.Identifier} + /> as estree.ImportSpecifier, + ...(hasImplStories + ? [] + : [ + as estree.Identifier} + imported={ as estree.Identifier} + /> as estree.ImportSpecifier, + ]), + ]} + /> as estree.ImportDeclaration, + ...(hasMsw + ? [ + as estree.Literal} + specifiers={[ + as estree.Identifier} + /> as estree.ImportNamespaceSpecifier, + ]} + /> as estree.ImportDeclaration, + ] + : []), + ...(hasImplStories + ? [] + : [ + as estree.Literal} + specifiers={[ + as estree.ImportDefaultSpecifier, + ]} + /> as estree.ImportDeclaration, + ]), + ...(hasMetaStories + ? [ + as estree.Literal} + specifiers={[ + as estree.Identifier} + /> as estree.ImportNamespaceSpecifier, + ]} + /> as estree.ImportDeclaration, + ] + : []), + as estree.Identifier} + init={ + as estree.Identifier} + value={literal} + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={identifier} + kind={'init' as const} + /> as estree.Property, + ...(hasMetaStories + ? [ + as estree.Identifier} + /> as estree.SpreadElement, + ] + : []) + ]} + /> as estree.ObjectExpression + } + reference={`} /> as estree.Identifier} + /> as estree.Expression + } + /> as estree.VariableDeclarator, + ]} + /> as estree.VariableDeclaration, + ...(hasImplStories + ? [] + : [ + as estree.Identifier} + init={ + as estree.Identifier} + value={ + as estree.Identifier, + ]} + body={ + as estree.Identifier} + value={ + as estree.Property, + ]} + /> as estree.ObjectExpression + } + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={ + as estree.Identifier} + value={ as estree.Identifier} + kind={'init' as const} + shorthand + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={ + as estree.Identifier} + value={ + as estree.ThisExpression} + property={ as estree.Identifier} + /> as estree.MemberExpression + } + /> as estree.SpreadElement, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={`} /> as estree.Literal} + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={parameters} + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + reference={`} /> as estree.Identifier} + /> as estree.Expression + } + /> as estree.VariableDeclarator, + ]} + /> as estree.VariableDeclaration + } + /> as estree.ExportNamedDeclaration, + ]), + ) as estree.Identifier} + /> as estree.ExportDefaultDeclaration, + ]} + /> as estree.Program; + return format( + '/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' + + '/* eslint-disable import/no-default-export */\n' + + '/* eslint-disable import/no-duplicates */\n' + + generate(program, { generator }) + + (hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''), + { + parser: 'babel-ts', + singleQuote: true, + useTabs: true, + } + ); +} + +// glob('src/{components,pages,ui,widgets}/**/*.vue') +Promise.all([ + glob('src/components/global/*.vue'), + glob('src/components/Mk{A,B}*.vue'), + glob('src/components/MkGalleryPostPreview.vue'), + glob('src/components/MkSignupServerRules.vue'), + glob('src/components/MkUserSetupDialog.vue'), + glob('src/components/MkUserSetupDialog.*.vue'), + glob('src/pages/user/home.vue'), +]) + .then((globs) => globs.flat()) + .then((components) => Promise.all(components.map((component) => { + const stories = component.replace(/\.vue$/, '.stories.ts'); + return writeFile(stories, toStories(component)); + }))); diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts new file mode 100644 index 0000000000..1d0ce5ab63 --- /dev/null +++ b/packages/frontend/.storybook/main.ts @@ -0,0 +1,45 @@ +import { resolve } from 'node:path'; +import type { StorybookConfig } from '@storybook/vue3-vite'; +import { type Plugin, mergeConfig } from 'vite'; +import turbosnap from 'vite-plugin-turbosnap'; +const config = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-links', + '@storybook/addon-storysource', + resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'), + ], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: { + autodocs: 'tag', + }, + core: { + disableTelemetry: true, + }, + async viteFinal(config) { + const replacePluginForIsChromatic = config.plugins?.findIndex((plugin) => plugin && (plugin as Partial)?.name === 'replace') ?? -1; + if (~replacePluginForIsChromatic) { + config.plugins?.splice(replacePluginForIsChromatic, 1); + } + return mergeConfig(config, { + plugins: [ + turbosnap({ + rootDir: config.root ?? process.cwd(), + }), + ], + build: { + target: [ + 'chrome108', + 'firefox109', + 'safari16', + ], + }, + }); + }, +} satisfies StorybookConfig; +export default config; diff --git a/packages/frontend/.storybook/manager.ts b/packages/frontend/.storybook/manager.ts new file mode 100644 index 0000000000..5653deee84 --- /dev/null +++ b/packages/frontend/.storybook/manager.ts @@ -0,0 +1,12 @@ +import { addons } from '@storybook/manager-api'; +import { create } from '@storybook/theming/create'; + +addons.setConfig({ + theme: create({ + base: 'dark', + brandTitle: 'Misskey Storybook', + brandUrl: 'https://misskey-hub.net', + brandImage: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAABgCAYAAAEobTsDAAAACXBIWXMAACxKAAAsSgF3enRNAAA4fklEQVR42uydaZBU1RXHX/frjWkG2ZR9HXDYkaCAEI0awSX5lA/G0i/5lLXKUoEwgAYjoIPgCMNioUAYUEipJKYs1mFREWXpASpEU1gMZaosNRY1KUsQFPDknMw7cOs/78573TSmwXerfvX63X057/S9r8+97RCRE9FMyVYs6oxS6gx2CSZu3Ltyz8QkrFQbUSwcs+FLD42npYd+QF4HJBfsSpIwb/d4enbvbeIfU4gdfg5RYH3Yimne33zzzY1YXmtpzp49O7EYnZFYtH8ILTlYyfSlp+qz9PT2LFWtT0hg6r5pgx/XynjSQ3I10UprxfEe/c2wL774oqN3m9Myzp8//wKWgXkY6ShE+aZrUn/fzlh9tIwW7etBixs6M+X02F8yNH29QzV7xghm410vo4Ty7bffbvf8XA0XP71XP72in9lYLYNJsGT8WvwgrXTUr4z4rnLmzJlJmDfeSxzs3BadseJIioTaXBsmTjNedmjGOoelpILmbqsgiaOV9DJJMim5CuqHiP+ePXuu03C9wueEYHbyxx9/fL3kr2ga6SCNB/mkWFLuVj9vMJJYlsTRthidHzM7w11+0CVBe3Lm+ubOmPW39jTr9fak/kbG6dOnT8/Uz0eOHBnM14wgfuovnDt37jW990vrkTI7eePGjV0ljuan8Xw+Z7jjbparpNE6aNiXX375O/O+qanpHu3cFp2hsGvDJIz7jJcowZQxcR0NrYwW7KUta4U2JupP7Ay/jHaKmT+mhTRB5WYVqIe2LXnhMc1Lu9s7QwvQwtt6ZE00vK6urgd5Tv3NzgAy2BHr16/vjg1uhbaKpazCO0MfE8voWSpmqThWUKVCsUtHWR5kFewIlIySnQBF0/GoM0pzOh7r0ieeNQMrRjqD5dq1X7aDhJdqI4reGUsP3cLrkjE8FR9xXu51XdJrUKxbDa9Lnn57HIm/z7S60Vg/OAFQHsoay2kKKkPTXHJnLNo3kqfgQ7kzKnjG2Y5kXSL0GRrv++iqsX+Vr1SoJK4XdJEkrhHWAtV6r1frOsW+/pEpeBXUwbr2wDBbOt/OkHUJwx3SlelAszZ2oGkvOdSzMlYp65Jn3xlDUMmEonN+nv6+KlfBb93gc98IU2LFFTRvLU/j4hrGrwwemOmWcjGPWIu1ia5Lat7Jnq/NJWnqKodkOt6jMj5I1yZaOK5L9N6Pr7766n5zTSLrDQ07ceLEWCN9wkTzV8w8zDiCLhI1XlCdNA8yp+LmdNxYl6SZRNVLzinpjFTGaSfrEgEWaSnbOgGuaduVG3Fc73ExZjQs09raRNciWLauV9DPLE8lxnehxpQZ93G593otyyRh9gnrEibPdQmL8k7LrLBl/oouvLBMOzgLxrIS2hmXOhXP4FTctjYxK0ee40Y9DtPkwHWJOImf79rE0iHaGW5enSHoY4IdUtDaxL5wSlmkowjrkpaLNFMySnYCFBEtTSIiwYi4UvD9Dp27ZczBpYd+xEvYCcw4ncQ7SocuTueaN+OkcHhi4f4fn15y8G5e5d1Ftz/Y+2fWn7osrtjvB8ysLwf6Ux+1dI1Xw7sOvwYnWBhYIG5kbuCBHs4Mpqlru2/lsNS8HRlC+gyN9ete2WbErPqRNGvbSH2FH0NInP0nyXoVkCsBW1tUWIpVjj5Mxax7wYKx5lhbEmreHUCLG/qxYPTma3fmOvrjpnY0Z1szj6xyaMoah3oOig2s3X8bKb+sGb4SKhMXjM50/ZAwfnczz4yP74/0d2h5beIXbn623YeNwwveTsY94tJF13T06NHO0B6skxLDMqUcWx0MwYhj3U+dOnWTcd8U0H51ORDuar0PFIxVH2ToAu9naNGBTiwU1zBZqs2lGZemskDMWO/QdKYnv/p55s1hJMzb1YzZOebA21636BsS+TlZP9uchGMc7sBnxE8x4/JPzGP0M4L5obCiH6RPIma5CtobmO/fWMAbsG0mJ0+evAv8Q/eNJR9XAeGJKVbBePFwkgzEs/yhFxNv1OYcks9Va5vfBerP8z0q3UGzN3Uj4cmNzRhrYtdnsFKIhpn3X3/99QyM99lnn42TKz8tv7XlYcsP49vjtLRfMIG0aQTLYkOQ34BlTFLD2Cxhh372e4UnpgJooqCmDJrGjGurg9hfmG2B8XBROHwnVfwmfMTzB2IkzN8e+5ckRJOFx1gghJkvO+KRmfgL95GZr6ZIcBNOW3zCjA5FJ1piheQBtOGGPkjg8I0XOk0v71X1XpFyNB4L3cpPP/30J5onvCNNCzaB0nB5t2qrA2IL88tjw4YN3aCdodpvvlqFuBmxmfGzgZF+Mt7yxS/LCy6cU5hvBwWUcBQEBd7WhaEsCGJ37NixCWY6MAvBt4FCOgCoO5CnXU3INmbDgu3Ctql2VMEQLqdgKHHUGH6mJyYBgtEWCOiQcPY7SoBgpACbYCNl+RBS6LPh+8UuFOEE4/JrDRQOJQ1YhKQoZJV8BQLnF0qQkF9Wwv9Sh23CdiUEFYrot5KI6JV4RCQYEZFgRESCEREJRkQJCwa7+IBRZSP56trCr+vllKnBtOfnDhzd8UYJK9XGRhQsGLrN+XZq3lIwnsraxTugUJi2GFVr4rvYL7m4YRLHv5tqc5PITzjAUv27sGEQN+1y2mPY7EquSnuMPkOS3dVAR20yZOBVKBbsTJ7XfScK+2cXvHsr1ey7nebvuYU0vmDbK4/+GFYkwXCKSYg659TvUttillMSgtF3WLqCNx/JwQiyAYkZylRKTVMPPZ/a5GOoI2FtHt86nJ6ov4Fmbh56RrQOdiTYISDaActL1TAnbFvEX2woilxOrJgUpjGGpSpeONKDag8ME8stZiDTbKwzd5tsyrrInC1l7FdGIjTpMre8dv9dH6L1VtCBEhZDk5hgU9N6mERr4Xgv5yeQ4dDSSgfUL08ABSBuQ8uxDDa2ETer1Qd8VTViezBPdBimG9rCaYzh8Qq14Ko9UEFygsjihp5MV6Yzzd560YJrSp1Dk1c7JMJgWHDJfcLSmVbrLbFG8rE4atJ7DQ9r+WSzxML4WJ4ONsZBdM8XGt4oKOyKDqjdCs1+j2VjPGyLaeGFgsU2Hfeh4AcKhu5YrN3XmWpz/zPpYzoy19DC/Vl6YnMHmrK62Xpr2tpm4x3ZyajwfVoLs5+60hJTcGzWVNKgICHD9DrBDRknbjera2nJpYMvDuwbrCfEsLYboAMcrh3BfipwGo5xVEPYjuZRWhEMt8I07at5txMLRwc5asc7ZSZJj650qEpOmvmzI1tcJfN2hmmf3GdQKEAwkog8dRKuZmhB5mtBJnGmX1izPikXj+nJx7RPT8yRATfzlbraBtVunmive1DfBJkXKiD0McUyx3ArdFuvwlpCbT154GPnTNM++SyCIdt9jS2/GdseZb+fsMFWMglmaIGYal398LMMmt7b4ki5NntLICnmelJ3RC3HUFgkHWgUBrY8e2779u1d8jU3xHCMg+1AwQg07esz1K3ws/mU86imvhTfJp9nrGspGKbNJwoGSG0KkY7AML+4x48fr0RbSUxj+ywuIL4Khg6CVUAhbdoEy7LvO29ptwrpwf7Tam6YDlMHFDDUIoGCkS6LZWXfu/J8zj2pg+td0zNbCka5ntclSBzUFtoxOri6V138VN1C48TvP2g3aX6W9Gh/aabH/LxzvbDzMnqvxrQWA1oFhS2tdfr888/vNfPBc8iwTn55eF8pG8x7PUtMkX7RuiveHOKgn32p2R+opdFIxyoYkkiMgBVZcUC4O3Wl8281Br62V6xSCp/5mhgCNyOForbQioQ1nvUzdjX3/H/00UfjMdzvgDYTMhzaYUL6tCBC3JoxMIHztM4DUC4Ickv/1gyepc1+YSoc6uRerbdEQDRukKFzaAsu8WRSXiEZy+vthFZCzxDwCst6VzesMXCwQXB48jWFI3ZhzOFQKMLbfOLABNereHai9nah5vjeWonLqSTEzsxTnqrvykpcvx4iK/ECBaOYVuKYB/m4AIvqSxYMKCvwyS+QtkgpC0a0fSDaPtC6YFi0hlVzFP/rxC4cIbVFykK62AJS/C0RwUKBc4xoX0m0ryTaVxIRERHZAkdEREQKIyIiolQp2YpFRERECiMiIuIK5tI2YhVlM5c9ztWy0y8i4mohnwc8Ub1j3D+WHb6Dlh66jbmV+SF17ZfsFmZvu8Tp1t/ptfCtGJnMft05rMfEeiT5mPrzSw/dQ8sO30tynVw3bg37uwUojzuZHNldPbNc4pXqAPkwmmlilpdo/fKlP1PN5GBcppVofb/XOGEedCZlKAlmPHMzM5a5iRmtlrJxv/SxmJN69s0EtYb+Ri1/I8//bUG1uYnMJFp0YCI9t/8O4m30Z82DOGxYNnU2MfWIuX0MN4CW6oBBVV8p0TpaMTYQo2vUcbnCzpXob1bzaifMALt9h8uRCBNQSTCjmJHef5nI1sSBn+iWAY/k7DdSn8zfmaYg5L9OOH72yR2j6am3xlL12zdT9e7xJP/1OnfXGLrvD/2WeXnHUAD9QGELAwqy+kfLo3CEHJfl5DnexVeB4Qhsyehoi/f/apfsa44UBmwdkaMyljTcREsaRjOjWDmMZEYww5ghzCDmemYA05+q1nXcPaUus7d6exlV7wiH/P+xWsbxUJT/dHKXxffP6bH5hnvaPSCKBGcw+QiapAuiQGWjAqPfijk91iIM3kzmFWOmUy9KKyiNbvYPqQDrjbyrzHD9Vsd0XppGLBe+/ZdbHuDij4s9r5wQplzpL509FjQWGDecWx4wPlXm+MCYWsvVesEYW+ssilnj6HL28iqMoamKtY3lpDz33gBeKlTS4oYLSkLP2mF6sX8Phrc2by6nOVvLee97CzgsSw+vcOjhlQxfew9xRWGk+o+8ZujihjsJOKezixDEBRTMfPAGkozBibc2E5H4ODA6OBZBN11O8vMUTzWEVec7c/LJ4xXJ23tgcua3tS0f9Zd0mJfXNzlsf9B44NE12L+FENDP8MBAn17iWEgcwZQV9VOkj4PGXuLZznmypcU8sB7iQPmRhEs88feToaIqDDlap+7DLJms/KCcFu7tS7W5PqaSYLow1zKdmA4c3p6e2NSentzSnmZvbWby6hhNrZOTVppPXfl987/sVcpMYuHeCeQDK6kJpLOMMIIJneLmi7FDeDvkmwujiCQdxAutyOSYHYgD6a1hjZYwy0MbXIbUpZU2vlqAUnYFPR3H5ox/Q3SBOJ65BUcEITFQLEUbCz1qKWwfQLvyjodK2vJlNy/MDBnPT0UuUWG4FX/6ZxvyY8E7XWnR/i6sGFRJdGTaM+2YtkwZh2UYOY0nQZNXsYJY69D0dRf/eVEUh/z7IpdVvmD3KPJj/tuj9MWoazbMJpB45FA+6D8t6l98+ikSEdZ8lQ4KmPFPkW5YWlOE+eTJ/wP386B8JI7Wu4CHxbWQQPDkIp4NvUg+DrZPtlA88remYRRsMcYCj+ASV+i4IVIPjZtnHlifuI2wy+4C32G4FSvfT5ONZawUntvbnmoPtFASTJJxqWpdTA73kxmFKAk5y01AhdGueucgMnl6x0VAYVgE1KowkiFJ6NFZqGx0IPN18k2MDwYfuDNQp4w2d/DgwWtbO4sNHzTwDwTjo7/kWYSHJWEhGZYA5Y/j5SrS70H1K2QsCul3KtAFj729PuEVWPA7o7wVxoq/p6gVJGLHmvcyPNu4oCSYGOOQhE2rY+XAykJP7DL/wldO7urhKYy5W3uTyZwtF1CFkWhNUVg6N+lDCtCDJJ+BmUFSOXHixFjI87Igfz1snkZme3hsD5akt+UN7bTng+cWKoUrKazjfOn3IPToPU0XUqnoLNH+sBc+Fqn/snc+rU0EYRjfNUk3m9RSCmJVsEgQ23pQEfTgP1QQBC+ClyLoB5DSc7EFPXjTi9AiBQ8VpXqIHuo38OTFinjsd8gt4KXrvMJbpi+ZvJvJWLLpE/hRmp1/2Xnn2ZlJ9llCOrN18lNU+k3BrwzRnrJEEQxVOHJuepYaa1vlrAtsKJXY9+Pb7y2+l2IhBaN0hgTj+dcjmc2zzV0UwXAruhaQdsdrAd1LufYAlu9J/0m9Hr1+qseaHcyJ4yKduxwhGPy+FpwVBdl+bmPihq0c9XNuDygyXe0iniM5+yJhRP2JfE66fUwiPUplPHTKIwQyYfTzoAuYY3yUmBCCQZmrb4zhq4vrDw7N8bcYju+tK5fuxo+WPpDl416e0hLlXbTrf7X8pZ794/Nenqyk36QDoI28crCXZq8v9v50wc+El8+iJ09PQh6Tnpz0THXpNU51UuDRXzk1lvU7jok0+kv6f8r80p9UC05FKGwSMrqV7eGBS3R53n+iQWmlV6wLs+cx3akvqB/pr7TI5D6UiM3IH4Qrj7Tc5PooTrT6lLa4BGxE4FrmScFgdMGQsAFwtR5NvNiMf61+jzLDzp3HMQ2GmhQLl2hQ2qv34wUjEjuGbP51vDWSRuNUtjXgq9OXo1uLH+M/i5/ibGEt/l1JojEWC21m0S1QFao5SW1MBz3kIOegZ19aDTNVnjVXm7eUh4PNXB1fNpvNY1o7fNor293JX9ZVjuvKmR/3eadBw+eABy6dBxJXOq6hDyodpS9SDUpnfwbKq+WhNPRZWTyo/o2NjeP5rWr9+03MOBhNOFgwdKQho1VoHKgMmaYs/eOUpUhFoAaqgodnb1BSHyjoKHi18q3fDjTz1OshIL7nP/Wkag9Yj/y1/cXfg9K/v9wzjjyCMdAWfXIqFFowAghFPTC1ftne3r7SwYV/2fzu4h5BYmIfo/+1+pUA9KZfobCvzHIZE0IYAvZjPTR6f+nCoQkGA8H4/4IxqhA0QFzp19fXT7Tb7Vf2epqM9Vut1nyP9aSCgRAMnk2xaJh9kaVQV/x9ZjQPEAzPm5cC7mFUuzHAghEkIHsVjAB7GkGXJAVYKohywsaLr1BAMJSZRr9r6gIsTbyEwyMAg25+FlBAguIbF6GFYmj2MLSlicdMI8i3JgUI1LpO+BlF3q9VQ8z8hoSaRojN6LybnUMjGACAAvlhAAAABAMAAMEAAEAwAAAQDABAkRjYhgEAIBgAgAIzsA0DAEAwAAAFZmAbBgAYAsEwr5jBk9sBOFjkTsg3ek2eKo03LqQzlhlv3KPbVrlxLmocnYoO800undKcvjhx/uTM2CSlgXAAUCDBoEE8NZueXf15O1vZumm4YbiW/WXvzIKjOM4AvDu7O7urAySbw7KEBJIJt4wPQGDHBocAhlRsUqmC4IeEB8AkfsAHGBD3fR8CY05zGhwuGwfCseJGBowk4wBJ4Yp4igJFqOUBqniIw5//X22L5md7ujUWIAFd9ZXXM709PfN3f/3PLDATvuxwogZvVPeP/8LzzaKjXpDJbOlpKcSD+AZNaDfuk+/ehGVn+8ZYWt4HQin+ZCGWGrIVnEsE+biuBsfhnCqQ9Drav5oyFImwt7ivQHLraH8fa4xkEQx7gsvOvoGS6I68hryKdEO6wpTd7cuENJxkMXWXdX7BEQsS4fN77Ko3rDXIKyr9JUqiN7bdJyaLJWW9YfGZnsD+oWHtLQ+4KPQmrDp+KxQTYH1+Wzh7kbJRTJ7cooKnXghDrPoTv3rhSDyrQF6Jy6IA6Yy8DINnZC6VpcFlMWSOtWr+ET+oGLXedwDrhcbt6lKy8PQbMUEUlfYCksci/LzgVHdolBVqQPJyKYyIgkQlWocHZvRREIYq2yMUIllRh4UhZ0aeRx2TyRcQWUWVLLoiXZBOyEvIC8jzUPDr1P7ieYMsm25vWb+ddzgAOrBu0pRI5/NzSn4O80++DgtO94iJYh5+nn3iFWjWLiWT2lMJgsPfLO4Evdk8kTQYdWHApstvE4c6Oqg0camQ3zyuqkdvLwep0BvYnWL4sM6rvgv8fgjDTpBVIC8iHfG2IR/pgLSDjDx/rpAG4svI9ebOPRQEE7B+cpf+jX8/7XAnmHmsAGYd74Z0hRlHu8DUQy/R/lBtCUP3Hf69JylxzTG5xmyfVhpPhFFvhJEwq4jLoj3SFmmN/AyC4aoXG9n439kHQ2AKZRjE6K/bXp1wIB8mRTrCxMjzMGF/B8jvmfYW9QOxeOAMhWHpUA3oJ7igFuNS1+NR1/sneFDCsBB7aXlnWFr2MvIiSuGurAJpg7RCWiJ5SAsYMMaeO7M4CWYdNEZ+XV9ym9dT+g6Ylrm7f2HGajvsTZff2v4AhLFC+u4w3XGofel5SIRub1gd0++WIrMMUm+qV6FrG19j+FT8XCqQKLKVtknHHgZYxDa+qtN+p/Nk+90IY7ZJXHS3kdRfw+NWIFtrIxainrroBUIPc/nxDM4hIsWej9cofTYZo8jQ+yqMojMvwqdnW8Dy77Px7ewdoKi0HSwpq84qkOeQXKQ5kg1LSrNg2r5kmBFJhpnFagq3BWDcDps+C2EEkFCPQTmDF57sdX7c9lcPP/VsOEvOLgwGh0VohGG6qkXFNsUAVBQ2kF0MODGROfzXA0WdKKhLlK2MEacVkyaZm34KNBmDpcd95sHkyK6b+1iAYaGxpxCFsggh6GJPcnA47jAhVVOh1aowVl9oDBsrUmOs+6EBLDrVGqVxd1ZRLYuyLORZWHiqCUzdlwrTD6SiOO7l/TUeGBFn5EafEIa9+Ntf/GdJWU+Q6TW4+UCRYTwwYfBBrR4wpRRAMTC5NHSyoP7R9+m7XEKmwuD7Fe2PlqVhIgwmi4p4G7OAlRoKI8qF7Abqj5G0uARrIRYUV6pH8J+AZVTi4kKhTIb1JarJzobxPlOsuOT4GKW6XE73RRgb/pkCMp+UNYXFp/Mwk8iNyyIHaYaTOxPJQJoijWHusXSURgOYth85cIexW234YK0HPtqArPfA+5956GBJPd7J+kPRt90hESLLMB2YUhDHkGxqgtMqiG/ZKhb78G1bnRSDOar6PguWxZEHIR5rG9/PhKGYRGpR4vaV7A3ixYna101qGoRsRbNM4NdXPlecOL0M4mMR+OrHp/k5KPCyerUQC3cZk+6a0niSrulKvl8XFyYkpUxNZP+ThLH+h2TgLCjB25MzOSiG6qwCeQZpgjRCnkLSYXqkIUzZlwZT91cxZW8D+BAFMWqjB0Z/7oGPN6E01sWEkTzvWNcLi069AonIaZ/aDOv4H7YwVAE1qscnvP67szXCMJYRF4uJMHQZAG/DEB8hpOpQovgqxAE8NjWdrJS68zruY+FeGCRYXT2+IJkdS11H9TyNssT7Kox1F5MgEQtPZqE0MqGotDqrQJ6OyyINaYCkwOS9DWHS3nSYvC8dPliDstiAstjsgbFbqqQxcn1MGKnzjnf614KSzpCIFvmp+QphqAakLAx/TWCB8QmoLbZdiTwpHUTicyszWo3d9o1gk10lS5+LWzeBT4FfBgf1KtAUfEfswESv8sNbg3dFHSEXXR9rJxbq66WCZSpGbXJhmvTXRCokSCYMJa6EsfYfYUjEiu+TURpNURp3ZRVIQyQVSUaZhJEgEoBRm7wkh5gkxmyJC2PzHWHMOdqxct7xFyARzfOTSRiBhyMM88HBBzMPHEvDyy5evNioNoRBK3J9FAaH4sWyDx5Hn2l/MR1/jq22li4W91sYdNvhJjY89nVaGJ/9PQQqFn6TDotOP41CuCurQJJwWwixET/MiFjw0Vq6BYnL4ot7hTH7UPvKOUfag8zsw1U075D0PAlDJwoHYQRM4MJgIjEuqjbKy8sb8/3yAKHV1ERmN2/e7K2WnLkU6ZiadtxNFr0gAjp49iHiI0MrNuuDsURJEO5jYX7dKVbgsuhizzGJCQlHJRWNOMyEseZCEJyYfyINf25tiGK4J6tAfJiBWNXPLcbQrYhCGDOLW1XOOtQaZGYerCKnffiREIZpKk4T4XEXRlwa26UUfaziXFiqbn4bYB4L98IQ2aZJ4cetl8JYfc4GJ5aXB68vOJmCYqjOKoQsEC+MWOH996j1KAvMLgo3I1vibMZtn6NI1lUJY/r+3MoZkTyQmX4gBgojJIShS339BBtkAY7q5bQomDmq77JJFqgtrl271oWOlWDAlPG60tPvPmw7W4kVaM6Ft+NudVULAgfr8JKSkiZ0zU2R+npJcS5R3g9aKDR9cxELdd+oiHHEoVjx2Jihjz1HFxONMLTiMBPG32xwollrK6/g7cDgRaeD4hYEsRAPvDbQGtK0hTd39EbMKIQsFMKYuje7cvr+bJCZtq+KnHbB+ywM/cTj+2of8z7wQUMDuq4Lg08uU0Aqqkku9l+9erWlSd/cxMKtMKh/j5UwVp0NgBPZbawcmvAtOlpdFp/x/YhZBWYbnv+1KvD2oO0ZeVb2WBRDoUYYk3dnVE79awbITNlTRXZbWycMP6EQhunAjEoPyQ7y/XJ6bNIeBTZRG5hS/s7pe5cuXWoljsPbUG2nNsU++r6uX6rzVE8Qvdg02EJqrP9BJ/bs2fOMqq8yTIDqyWUeiyCBzzlai7ai0eibvH/sGgQ5vH98PPL64pj450y68u2sH7YbgfFnKnzB/anC8CL2yu/84ABOZisL64XiJAnEtoznrEyRXTgJY+LXjSon724MMpP+EgOPEch3LwxzWTgJobi4uKlpuzS4RV31NjXmwtBPdkW9ByoMfv2omAhDjsuVK1cKVOck30rSMwllv8xjERTUljAUxwuq2rx161ZhvRTG8jLfjyvKfaCC5MAekPHPSU7CGLnO81+skzJ+Z9qlibvSIMZXd9Osja8VE4bq4RpPuS+pAkQXXqx6cqFVTTWAsf51US++CoQ4N27c+JP0IK5cbMd0ua/Ur+u0TTdJxDaTwYsSGaQbwGIfE0ZINwFMbxU4GjFHpfZDHIqdqj+G57ZK3s9jb9IPOd58H8H6F1Jx7ty5Njz2pu3xfTSO5O2a+NkM/kzFT3BhCNwII9B7sDV8eakFKvi/VcH+VqkPCRVu8sC4zQyUxVjcXtDPO4Sk8qvh9szxXyZDjJ13I6RkLAxXRT8oeVAp+LQSUBBJFPT/TkFn+ylTmUuDl8Cn6avZgN+uEwaHtcGL+LMAf3xYwuDHkMVKfSFM48JJIBhbl73wWFAcCX4dMYPZwWNJ8Hpy/2mfqq7YrzoejaV6JwxpwoeLSrzRT894gdMkx9tc/NzpJJ3GzbytEwnjw5Wey9Q+EkSSCrfbEGPbHQbPCOwUUtLdirDf8E1LVGQLBoQJPvF5of2iLocmB2gKPpia57QC0YplsprxUl5e3pb6wISRsG98QDJsnsVpCMpwMfDC2g6ZQJkhX8V11CAWYRW8Pr+2Mij6dwz+dux74rY+0XF27NiRwduVxge/bkGGeEbGhGEoDgAwyjKQpH5DvSOXnfbcRmDyTm+pHfY0jHfCZyCdoB3ypL1X5C0vRFEgt1/9jXcEtSs9jbeR5CHzvfvH/NkLyO1ub3vfFXVMnl04rWwaQoaEZWgwUXDEIMVVao0IqA5c6cfJE4c+04ASA6A20A1y6gMfeAKdMBjGwuC3UbR6CwGL6yCvsG6EwRYALZpYGMWSzkF89/Lly/14HS4OcTx2zkmMsAa3cQsQ7oWh/7UkgITYidiIz/A1A754/TB7KOqv7tD/2buflyjCOI7js6NTuzteOpWlFz0YFBQUHiozrfwR0tFTkBkJUYfo1KFAiMgOkVAudMsu3YrqECFJ1qFYLD116tgt8Q8IdHoe2Y3pgXHGx8fledb3wOuyu+58Z+fZzzzPM7PO/+sJK/KVxxpSAiOhK6wdEIUExRop6EuvM6ossmFnWa9GgOh+/gUd6pyH9udVe2FGRYXW/kq6nMBYYKg3GVImNf3N/r2QSwiXQHmdT2CsT375ZQ8nrU55NIx9sYr1EhjxIQSBsTWBYe8/GxXFKYwGhoGgCA0rblZ8/JpUp7jK8qDyupT1pweGDv2gSJ8XMhEMBvdjqEs/QNKDIy0wqgiMOg4MOYZWZ/bFWH5IEuPjO+rzcr2uBkZ87K9OUBIYBIZLgdGUwnQDCePEkORllGHZwHoKcTYEhpwcTDo1a2KIUGNNWRAYBIbRwFAtLS1dED2M2fjwQ/xc++H09PQ++bzLgaGeZZDbWS6XD8h6CQwCQ2HstGp+PRYHhpEGqREYmzrNanrS04HJSOV9zLYX3aAgMAgMAoPAIDCyBkbWoYnuEMWByU+t4NBogEYv4HIwQIzSbRemg6KeT6sSGAQGgUFg6E1+agxNjFwq7kBDDdOZH4Jk/fGZiaFinSimMHJBXdYLtggMAoPAsBuBAQAAIFlbGAAAcJe1hQEAAHdZWxgAAHCXtYUBAAB3WVsYAABwl7WFAQAAd1lbGAAAcJe1hQEAAHdZWxgAAHCXtYUBAAB31eJ+AL6UdL+1Gt2XwFdsqIas22LrTgYAwOkORuwg3JAPc8Xz11qu35/pnJ9a6F0tLfZEUws90ZPv3avjrw997h/dfSnY6QWxA3XOdA078l7Qf9Ebuf3C+zj5yVsRojVz3srdV97XoSve1Xzo5eM1KKqdicajg81d42+6308tDP4pLZ6LKlYezJ4u9422DTcG/r9tobMBANjuTHcsGls7Ch2TX7p+lxZ7I0F2KoRTQrdwUugSTgjHhWPRxIfDP5rbgpbYndlyujVUOwN72732e29zPx/N+VEWE+9yv5rbvNZqDTHB8K39Nx5/6xMdo35hQNQ8qBhYe1y+5uazzud+Q64WHY2n0dYsy5X3PmNrg3XMEWFZUJd5YZelNW8XY8JMpL/MC2OWbhtQHx2M2IE9ODuy53LpL3tn09NEEAbgfu7WxWoCHLxZKYnQSgVaKMVaFUu8q5FfYLyZKCEaI4kikdpSisjFxJM3CV68aARbq4hCSZP+gJ692mhM7Ude37fuJhvDst2ykAX2TZ5sM1+dw0zmmZlNVl4qkAAygPiRfqQPxl62v2FstdMEi7A4K5UbljMcGn1hej/z0QyNELpqHBF/Fnjk/smpxNoQPM2EYW5jmCQCufSPLP/ENMqjMon1Ibi36E9TfbUkg+qLAYAF2N1YKBQKzfo1UANsHUsa7fO+Qjx3qtXqDdihwLbviv9Lny+KZS8vmhdejfZTRyGqnVw0HTVy89nQHwVSgfh4enl64PLt1pv1LND/XWEwV26Zx+IpK2yH6aT1N2c3HBa+vz+VHszHVs9C/Os5mFm7UBOI2fWLMJshwvS7lpbAvPi38zC9GoLoShAcHns7CRdiVl0wNo+8IAFKKZfLwwAQQfJypxulUsmn1YGsUSIgEfop0e4gGuNbxfNKpXJNbg5RGbnTQ5IYXTAUEYZNQqN91VGIWqcXjKOLbatPKgSx8ApSgXQjp2E+60G6YC7j+tkZsPWLFmmTxLsRVnfQNBBLMr9iKQbU4LjbeALbbSKuP+t4/Tjth8inADxZOQPRL0FA4eAJ0ZPSanmRz4NAZSeWfd8tjJGrVzCUCoHM7kkVZBbGDal6+g5OkjByh9B3Z/KoOV9ICqTGcaNSTlBdakNKWMRlD/J8qWeDowvG/kVNwXBuQyqQU4gbcSGdSAdMvnXk7M2mVn6xtiBm/mm1txhaxhfZXDTJgoqIBYNjbKYjo69cuQdL3TDxoRceJX0wmeoTQ2mY54WHyz0w/s7z45jT1oZ1WeGqZy8KhgC1CxJRLBadf9s79+AqqjOA3937fgRCQEIgCXkQSAIkVRGw0vqY1rbaTv9prdr+YztTrbY+8AFEFASBAPIIOlMBeQgIFkWtNoAkiA8imATGgm1FScY/yOhYe5mpIuO09ev3wS4uN3v23HPP3svuzZ6Z3wzcu3cfZ/f7zu887s1ATJgezm6wUmFIwHYbZXw1KyY9wfAEY6Aj9WF9egQJVUwgwZiCYjAZuUxjEiIkFcg4ZCxSozEGfrti2HpFPTNtEfEHfdFfLwqtW7w3ClkABUOt0KZIIvpUSXFVuP7WNZX7Z7XWQtPOOnhwZz0QTURrHdy1dcwHtVcUXEPbImHG6AUn4KQFQ7UJxQiJBGvKRO8Beng4lVzEC02LGKYYe2j/Tq0PZ8AXDKeer9PJW8EYPSFU/fihSfB496WwqvsS5GLkW/j/RlwI2aAxEWFKBaFLBVKNVCGVSAV+bjQ07y2EBbtjsHBPDBa1naW53V5G158RjIhxsacmGnFkEFKIDNEo1F5LIFGzkQu3Cwanl7FdcnSkB9IvSenEbWwMxEsb1UMG96sn09EfhuytBrHSnCqCZtuIXgPt0+QZaZO7n/bDOKduW+NFvkHtsfF6k7yOQO7jQn5xOivvOTXncGJGn7pLQnqlh7Zn5QTBuk0iP3efYIwPVbd0jocNxwphc2/BOZ7+cBCs6qqClQfroaWrDqWjDmWhFiGpQNhSgYxGypEypBQ/MwpaOkvg0VcLkAQs2HOWhW1izH0lAnc/5YO71n7DPesVmL8rBuXjUwQDqWosnNDcftUR+qqqCXh91/7ryhvLf0bbpgqGDUlDNSIhGFLgMdaASTl16tRl3IRr/yr+JAWcYMOcBOkiPn8PJkVUVASkgrtIl3FObZlcg2EdgnR9ysUHG4tnLqk/u7nEQtZXZ0mmkg6IC61BtbUkdVF3Ts7hxwzJPEgUPXdQLINkMbseBwtGoHrT8QSwWPveEFjeUQMr3xmLDXINjmwgh9KSCmQkUqIxAimGZR1DYd6uApi/m2QD2VMAC9qsoW3u2+yHu9ehUGzwwfSNGhtIMHxA0lFWp1bpoxHhmD++qG3akce7roE0wOu6+lRV4+Aao2Tki2CwEiMdmyMYejAkMw1a7dhJzjSNaILppvNKs9eT5IiCvGCIJ+XtvARL75s0OqvtEAzG+pzVep1aPAfdNk+5qTys1hIZpzXsEA6Z86Fn1e71UvQMOCsu7J8icUbO4cdMSiwm6RhCx2fHcE+m+6K6cIVgVExUq5/+MA6WfBCHlQdKYcWBKmjprIRV3ZUoCxVIWlKBDEcuQoYhQ5EiaN5XCI/sHAzzdg+G+a+eAUWiP3NejsN0FIt7USbuf9oHD2z2wYwtBP2bXjv7Xuk4ZSytpSDuWdf4Yss73wERlr897Z/BiBplfANGOmEyAnoWHSvbgEn5+uuv27k9SHYSUkVgNbgY8GMs6quHkchtGcHJ8H59X6IRWiw3AiV+D3m91hMnTgwVORe6X4xrW5OFBt1PHDt2bBhdJ2RWevCzz1GcaRLiF4BzPzjPMh/FQv5vdWpccDou3M87OecYYkZ65IzuE28UR3LBc5tI+3RBBYMEIh2efLcQG+JyHM0ow15/GY5mGKQC4UgFMgQpRAYjNAVDIxmDYW4rysauQpSN85nxTADuXe+D+zZ+IxYzt/pglsbMZ+h1ev+MYIyjNRXEsv1TT6x4eyqIsLxjKlQ2FkzU12MMZMGgQBIITpmElGRtz7L2TI9NCZVgJ2N5waB64523fOKVFwx+HYgLVLYEw8inn35aQ6Jm0/RAD8UfCYyIYBAM4ekRlLUiKxF1alzICoaTcw7BEgK7YpZkQbRtYe3LJYLhr974fgzSZf3fY9gYF8PyAyUoGiXQ0lUCq7pHIGlJBVKAJJA4EsPPxXAfUVj8RgIWtA+CRfsSsOi1MExfr49aaGLxzFmpaNp2llkIycaMzbQNCYZKglFALH3z0r5l+yeBCI+9NQkqGxINqd8mYSGaIC0EI5BtLBonvxn0XjZkiJJnutNElPScJmXY67iW3hOoN73B8Gej7uh4vGtgnY/dU27CwsAnkC7YCE3GofXb8NyWCIx2cGKR+3wkGSM5fg2ewHbzhNGJcaFDsQAmhfc5B+Qcy/1y6la6fjKZzsvGdFROBWPDP6IgyhNdg2FZx3BYefAiaOm8CFZ1DUNZSEsqNLGIIhEkjISQIBKAh15QYLo+arHJMGpBYkE8myIYW84JRq0mGIOW7GvoW/pGI4iw5PVGqJgYJ8GI5JNgfPHFFz8QCRpWT8KOpPTVV1/9QqTnl05jgdscoqQrMAwuJRgSn5GCrk1WMPTzyTfBSFdCsKe/ljf6QfHCEwx9NMUqrjiSsFggDmyJCycLxoXIORIxLFs/6sASjAn+6vV/i0AmrD0Sxd7/EFybUQQrO4dAS1chSoKJVBBsqUD8KCsqjlgYRi02p4xaEM+mJxjNe+v6Fu+rByuaX+sHCkas0SgYMkKRgWAEbSZAUFJlDD1OZgpJjotVw3D48OGL6BokV5GvZVwvE1bjIypydjaSDMHI6Boc0LAEBAnaCV7H74BRPvvssyky8k4NpmCDByQsIvWXq7iw+3l3es7hxr18/fhzJCuKCNn7FgkKxrr3wiBDy8E4rs0YhFMmBSgZCRSGOMKVCkRFFJj7sgL3rvPB/RvOjlrM3IICgXLRpMmFKVtpG22KZOP5grFwT03fovYasGJhW39GT4jmlWBQ8gAsIg0TnRPkqNACPEqUosH75Zdf3igxHJ6kevEEY2ALBkEiIRAfIvGSNLtmWu8BJoWkQ7b+7I8L+wXDDTnHE4wsCMZTR0IgAy4Uvbjucv/3lrwR//fKgzFo6YyiaFhKBeKDx/b7TlZMVK4aWaNc8sBGlIVNKA0oF02aXDxIbDNh6zeCMRMFgz47yiAYj+6u6Fu4pxKsWPBqf0aPjxgFI8ATCpEEwEgsTQIJMcQhaKSjo2M4azgYvz0wlnUcTDI/NBtupffcAl079VDpvAFLJnUPJoXqRuIzUlDjYdYQSpxPgENORYozupDs7e0dx48BcbAXvxdMisi9oZEAs4Ys9RrxtV6JadJcxkWIh1meoMLKR4Qbco5dMUyfkYkTOcGQFw5bBWPtX4MgQ1mtWon7KiSuvClw2/IDodMtnUGUjH5SQeDrvv/99C7/DP0zI6qUChQFlAu2WPAFw39OMOa1jup7dFcpWDF/Z3/Kx4fyQjD0nlmmwcIaNnaqUPCgXh2YFIlEw2lo7E+U1DgNBMGgRpDR8+zNoWAkRe8Pe5Eie6qSXruAIzghzD9LwaRkQzDcknM8wbBbMMb7q9e8GwAZyuvVMv0PjWnEkUTRSHVUw9Xq9dNu8N/QcLVy/dDSM9slUrctqVZLtZELTS7kBGPuK8V981qLwYpH/tIPvI6gjGAErOD3FuShXp5V7+STTz6Zms5+6LyykeDp+JS8jY0vnTPn+Pq89u0yx2YlDuHt+cdJmjQkz2ejl02v23bN8okzKAPz/rOvNywLPVOMuFyawX0Kmt17EibG6FMvrz5sjouwGWBSksnkj3h1R9uASRGof9mcE04HHK29PDXn4FRVbQb1ERJAWsBkp1tkBcO2v6ZaWusfufqwH2QYUakOpYY5teJSHwTG+5FhpcpQzugFVzBKqv3lJBjEwy8V9c19uQismPPnfqBgBBrcJhjUQFMDBlqxSswiMBaQJXmSwpcWfoKkY3MaRyFYDYmdQqLT3t5eTPXEqTvh8x5IgmF8BizqMZwpra2tI1giTq9nGItBmno0G6VgiYdAfUjHhYhg0XuZCobeeHORzzlhHqdPn36QlXM8wciNYASR6Jzn/W8+eUiFTJi5yd9K+0BCjF/AZF2kqksOEr1lgbIrU8H4TbPyumFkJHHnmkTHwy8VwHm8yOU/hcVKkeGHtvwyUyKMIe6cFmqAKJlmmIgjn3/++R2MJHySglfbd4QFvc8KcirUu2B9ls6ddWw6L4EeapKRlG+mbeR6dmzwdxmeslho1kv1QtefmrSp90zvm9UVQzAihOw1yCZOCaFgEaZ7BHrh12OEBb1P2+HXNg+DXtgjF2EJuOdMRUaQGHFBJZnScEZYUPxQHFnERSQdzPZBr6Xej6NHj9bp8UD3gJU3LnTOIRjf8LmOWx/iIzwhDkLTLQzBYJILwdCnScKhqK+geZf/6JNdKojw0J/UtxTVF+P/NVL+OfiDvvgdK5XO2SgNs7dyoKkUpGmLD25foXQHQr4CkhSNWEm1Mnb2jugpBM7xvDVTfhK42ShKsoLB6vnxi7xU6L0ISSI6OAf4GNhYKAHo++Y1DFoSs61QD1WXLlnBEOhFJYFTeI0GJemBJhgpopi1Qo2feHzwe86cEbswA8Fhf/nCaPi50DOZYQyeZOwzJznHEwxhwZCXDCRWXqdc3Lxbff+PnQpY8cgL6qERlUqNz8eQC4lzKB6tTLjzCeUISYQVtM3wcqXecA4h47QLEr/uVnVO03OB/yLQtN2cWxb6X4nEfMMNchGwY0qElVApwWhTGkkZiaB9UNAYGoyIzURT2bFjRwme/2zRRp+2p4RBn6f9ZAL1bDIcBaKe6Tp2kmDLjd7jpeMK9Owse1d0HdSgpfZE6Rj0GtUvfo2uPvX6P/744+vNrotVX/o14ELDHbRfwuoaRIa0ccj2Jv1e0H71IWxJwukOzdOzL9uYaqNgkWxB9cIQmYidyMYFPVf0vMjAGBFhjjp99NFH3+bvVz7n4NqXZdu2bRtJbYQJUTPo3Iwxg8/bL9M5T969oX1RPGYSb4T27Z9efXE35yvF8sIBAHZKhj5dEtYqK44UDK9QR1Y2KFMqG9RJw0rVEfQaEte2Caf+YbBsnMPQkWpJWa06GbmsCP/NOQd9PwGDaMSQxKBhvpLScb4pZbW+7xZX+sZo+0kYBCVoFKVsCAaHsBz2C0aeEXEYUREoyUJKoddEr1dGMLKL+PPuVEj8zaTGuI0Xr1knxiEqRe7jLZhCwPGCoZOyLiJgbBAZCzQDxpPK3jkQ/HNgYJSNoDEpGfeVejMkFnUGrXBAQhUO0DzD6QmPCfXCwKTYer25F5CwCC5ocCMEjRKZ9aad/Hy5lLjNxNjkPr5Ef/fI0YJhJPVArBMAAFecg3EfnBW2KuEJRt4mrGguoCHV1J6rNjUUy4TOzs7xZsPD2JD93hMMZwmG2UJcKrRWwRMMTzA8wchjsCgs8lQw7E5QcYcTcwI0f2uxwG+2YU44bsXJkyf/QGLB+AGiX8lfv7xg5JILLRS0LobWEdG6hdR5eRJI1n3X19OI4oIG2ZI8yCeS8WW/cIgLBhtPMDzB8ATDhYJBHD9+/Ao7vv3CWNQZJzzBSCHro1L8whi5inqC4cp84gmGhycYDhaMhAwuSCDxdMBh8x+jGLwgIxX4teNpWbieqBWeYJw/QkHCkO79olGqPJxCcBoJO/EEwxMMTzA8wXCdYDi4x+UJRn6veXDqc+kJhicY+QcWJV0c+jXViAyeYHDIcYLOen3kfookJIMLFnk6VSTcsijS8nxckF9yKhSeYLgMTzA8wfAEwxMMTzA8wfAEwxMMNwiG1JSJC77G5/YhWLcl7ASHrCbAAfjDW56AuIt8X8QpJBTe11Q9wfAEwxMMTzA8wfBwZrx5guEJhqsXfXKEQy7hukBABjpxEfJgCsTWP3bmgilFj/xaVJvDH6bjx1de/VT4QMQTDE8wPMHwBMMTDNfgCUYOBeP/FczIFptfb3AAAAAASUVORK5CYII=', + brandTarget: '_blank', + }), +}); diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts new file mode 100644 index 0000000000..4091e39686 --- /dev/null +++ b/packages/frontend/.storybook/mocks.ts @@ -0,0 +1,26 @@ +import { type SharedOptions, rest } from 'msw'; + +export const onUnhandledRequest = ((req, print) => { + if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) { + return + } + print.warning() +}) satisfies SharedOptions['onUnhandledRequest']; + +export const commonHandlers = [ + rest.get('/fluent-emoji/:codepoints.png', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); + }), + rest.get('/fluent-emojis/:codepoints.png', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); + }), + rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value)); + }), +]; diff --git a/packages/frontend/.storybook/preload-locale.ts b/packages/frontend/.storybook/preload-locale.ts new file mode 100644 index 0000000000..a54164742a --- /dev/null +++ b/packages/frontend/.storybook/preload-locale.ts @@ -0,0 +1,9 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as locales from '../../../locales'; + +writeFile( + resolve(__dirname, 'locale.ts'), + `export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`, + 'utf8', +) diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts new file mode 100644 index 0000000000..1ff8f71ecd --- /dev/null +++ b/packages/frontend/.storybook/preload-theme.ts @@ -0,0 +1,39 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as JSON5 from 'json5'; + +const keys = [ + '_dark', + '_light', + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-botanical', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', +] + +Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => { + writeFile( + resolve(__dirname, './themes.ts'), + `export default ${JSON.stringify( + Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])), + undefined, + 2, + )} as const;`, + 'utf8' + ); +}); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html new file mode 100644 index 0000000000..ab694f64fb --- /dev/null +++ b/packages/frontend/.storybook/preview-head.html @@ -0,0 +1,12 @@ + + + + + + diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts new file mode 100644 index 0000000000..e887acaa2e --- /dev/null +++ b/packages/frontend/.storybook/preview.ts @@ -0,0 +1,145 @@ +import { addons } from '@storybook/addons'; +import { FORCE_REMOUNT } from '@storybook/core-events'; +import { type Preview, setup } from '@storybook/vue3'; +import isChromatic from 'chromatic/isChromatic'; +import { initialize, mswDecorator } from 'msw-storybook-addon'; +import { userDetailed } from './fakes'; +import locale from './locale'; +import { commonHandlers, onUnhandledRequest } from './mocks'; +import themes from './themes'; +import '../src/style.scss'; + +const appInitialized = Symbol(); + +let lastStory = null; +let moduleInitialized = false; +let unobserve = () => {}; +let misskeyOS = null; + +function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) { + unobserve(); + const theme = themes[document.documentElement.dataset.misskeyTheme]; + if (theme) { + applyTheme(themes[document.documentElement.dataset.misskeyTheme]); + } else { + applyTheme(themes['l-light']); + } + const observer = new MutationObserver((entries) => { + for (const entry of entries) { + if (entry.attributeName === 'data-misskey-theme') { + const target = entry.target as HTMLElement; + const theme = themes[target.dataset.misskeyTheme]; + if (theme) { + applyTheme(themes[target.dataset.misskeyTheme]); + } else { + target.removeAttribute('style'); + } + } + } + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-misskey-theme'], + }); + unobserve = () => observer.disconnect(); +} + +function initLocalStorage() { + localStorage.clear(); + localStorage.setItem('account', JSON.stringify({ + ...userDetailed(), + policies: {}, + })); + localStorage.setItem('locale', JSON.stringify(locale)); +} + +initialize({ + onUnhandledRequest, +}); +initLocalStorage(); +queueMicrotask(() => { + Promise.all([ + import('../src/components'), + import('../src/directives'), + import('../src/widgets'), + import('../src/scripts/theme'), + import('../src/store'), + import('../src/os'), + ]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => { + setup((app) => { + moduleInitialized = true; + if (app[appInitialized]) { + return; + } + app[appInitialized] = true; + loadTheme(applyTheme); + components(app); + directives(app); + widgets(app); + misskeyOS = os; + if (isChromatic()) { + defaultStore.set('animation', false); + } + }); + }); +}); + +const preview = { + decorators: [ + (Story, context) => { + if (lastStory === context.id) { + lastStory = null; + } else { + lastStory = context.id; + const channel = addons.getChannel(); + const resetIndexedDBPromise = globalThis.indexedDB?.databases + ? indexedDB.databases().then((r) => { + for (var i = 0; i < r.length; i++) { + indexedDB.deleteDatabase(r[i].name!); + } + }).catch(() => {}) + : Promise.resolve(); + const resetDefaultStorePromise = import('../src/store').then(({ defaultStore }) => { + // @ts-expect-error + defaultStore.init(); + }).catch(() => {}); + Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => { + initLocalStorage(); + channel.emit(FORCE_REMOUNT, { storyId: context.id }); + }); + } + const story = Story(); + if (!moduleInitialized) { + const channel = addons.getChannel(); + (globalThis.requestIdleCallback || setTimeout)(() => { + channel.emit(FORCE_REMOUNT, { storyId: context.id }); + }); + } + return story; + }, + mswDecorator, + (Story, context) => { + return { + setup() { + return { + context, + popups: misskeyOS.popups, + }; + }, + template: + '' + + '', + }; + }, + ], + parameters: { + controls: { + exclude: /^__/, + }, + msw: { + handlers: commonHandlers, + }, + }, +} satisfies Preview; + +export default preview; diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json new file mode 100644 index 0000000000..2db2f1eabe --- /dev/null +++ b/packages/frontend/.storybook/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "strict": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "checkJs": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "jsxFactory": "h" + }, + "files": [ + "./changes.ts", + "./generate.tsx", + "./preload-locale.ts", + "./preload-theme.ts" + ] +} diff --git a/packages/frontend/.vscode/storybook.code-snippets b/packages/frontend/.vscode/storybook.code-snippets new file mode 100644 index 0000000000..785d0a1608 --- /dev/null +++ b/packages/frontend/.vscode/storybook.code-snippets @@ -0,0 +1,84 @@ +{ + "Storybook Story Impl File": { + "scope": "typescript", + "prefix": "storyimpl", + "body": [ + "/* eslint-disable @typescript-eslint/explicit-function-return-type */", + "import { StoryObj } from '@storybook/vue3';", + "import $1 from './$1.vue';", + "export const Default = {", + "\trender(args) {", + "\t\treturn {", + "\t\t\tcomponents: {", + "\t\t\t\t$1,", + "\t\t\t},", + "\t\t\tsetup() {", + "\t\t\t\treturn {", + "\t\t\t\t\targs,", + "\t\t\t\t};", + "\t\t\t},", + "\t\t\tcomputed: {", + "\t\t\t\tprops() {", + "\t\t\t\t\treturn {", + "\t\t\t\t\t\t...this.args,", + "\t\t\t\t\t};", + "\t\t\t\t},", + "\t\t\t},", + "\t\t\ttemplate: '<$1 v-bind=\"props\" />',", + "\t\t};", + "\t},", + "\targs: {", + "\t\t$2", + "\t},", + "\tparameters: {", + "\t\tlayout: 'centered',", + "\t},", + "} satisfies StoryObj;", + "" + ] + }, + "Storybook Story Impl File (w/ events)": { + "scope": "typescript", + "prefix": "storyimplevent", + "body": [ + "/* eslint-disable @typescript-eslint/explicit-function-return-type */", + "import { action } from '@storybook/addon-actions';", + "import { StoryObj } from '@storybook/vue3';", + "import $1 from './$1.vue';", + "export const Default = {", + "\trender(args) {", + "\t\treturn {", + "\t\t\tcomponents: {", + "\t\t\t\t$1,", + "\t\t\t},", + "\t\t\tsetup() {", + "\t\t\t\treturn {", + "\t\t\t\t\targs,", + "\t\t\t\t};", + "\t\t\t},", + "\t\t\tcomputed: {", + "\t\t\t\tprops() {", + "\t\t\t\t\treturn {", + "\t\t\t\t\t\t...this.args,", + "\t\t\t\t\t};", + "\t\t\t\t},", + "\t\t\t\tevents() {", + "\t\t\t\t\treturn {", + "\t\t\t\t\t\t$3", + "\t\t\t\t\t};", + "\t\t\t\t},", + "\t\t\t},", + "\t\t\ttemplate: '<$1 v-bind=\"props\" v-on=\"events\" />',", + "\t\t};", + "\t},", + "\targs: {", + "\t\t$2", + "\t},", + "\tparameters: {", + "\t\tlayout: 'centered',", + "\t},", + "} satisfies StoryObj;", + "" + ] + } +} diff --git a/packages/frontend/@types/vue.d.ts b/packages/frontend/@types/vue.d.ts deleted file mode 100644 index 9c9c34ccc5..0000000000 --- a/packages/frontend/@types/vue.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/// - -import type { $i } from '@/account'; -import type { defaultStore } from '@/store'; -import type { instance } from '@/instance'; -import type { i18n } from '@/i18n'; - -declare module 'vue' { - interface ComponentCustomProperties { - $i: typeof $i; - $store: typeof defaultStore; - $instance: typeof instance; - $t: typeof i18n['t']; - $ts: typeof i18n['ts']; - } -} diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3 new file mode 100644 index 0000000000..2ef7024beb Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 new file mode 100644 index 0000000000..a8ad11287e Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 new file mode 100644 index 0000000000..8cc2ead028 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 new file mode 100644 index 0000000000..59cbcaf917 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-aec.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec.mp3 new file mode 100644 index 0000000000..7aec0516e7 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 new file mode 100644 index 0000000000..98fe354d67 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 new file mode 100644 index 0000000000..7b69b3410d Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 new file mode 100644 index 0000000000..44f2deee3d Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 new file mode 100644 index 0000000000..1342a56f85 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea.mp3 new file mode 100644 index 0000000000..88d641fd64 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 new file mode 100644 index 0000000000..468b82bc2c Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 new file mode 100644 index 0000000000..3869e894d2 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 new file mode 100644 index 0000000000..f268b7ee8b Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 new file mode 100644 index 0000000000..d6e895e67b Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 new file mode 100644 index 0000000000..8e055db91d Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea.mp3 new file mode 100644 index 0000000000..c13d13247b Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 new file mode 100644 index 0000000000..06577d5431 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 new file mode 100644 index 0000000000..1622033d6a Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 new file mode 100644 index 0000000000..a3a9f3a9fb Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 new file mode 100644 index 0000000000..4efa848407 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca.mp3 new file mode 100644 index 0000000000..1a3979cc61 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca.mp3 differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index bf22f7aaad..7646e152f9 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,95 +4,142 @@ "scripts": { "watch": "vite", "build": "vite build", - "lint": "vue-tsc --noEmit && eslint --quiet \"src/**/*.{ts,vue}\"" + "storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'", + "build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build", + "chromatic": "chromatic", + "test": "vitest --run", + "test-and-coverage": "vitest --run --coverage", + "typecheck": "vue-tsc --noEmit", + "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", + "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { - "@discordapp/twemoji": "14.0.2", - "@rollup/plugin-alias": "4.0.3", + "@discordapp/twemoji": "14.1.2", + "@rollup/plugin-alias": "5.0.0", "@rollup/plugin-json": "6.0.0", + "@rollup/plugin-replace": "^5.0.2", "@rollup/pluginutils": "5.0.2", - "@syuilo/aiscript": "0.12.4", - "@tabler/icons-webfont": "2.2.0", - "@vitejs/plugin-vue": "4.0.0", + "@syuilo/aiscript": "0.13.2", + "@tabler/icons-webfont": "2.17.0", + "@vitejs/plugin-vue": "4.2.1", + "@vue-macros/reactivity-transform": "^0.3.5", "@vue/compiler-sfc": "3.2.47", - "autobind-decorator": "2.4.0", "autosize": "5.0.2", - "blurhash": "2.0.4", + "blurhash": "2.0.5", "broadcast-channel": "4.20.2", - "browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3", + "browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3", "canvas-confetti": "1.6.0", - "chart.js": "4.2.0", + "chart.js": "4.3.0", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", - "chartjs-plugin-zoom": "2.0.0", + "chartjs-plugin-zoom": "2.0.1", + "chromatic": "6.17.3", "compare-versions": "5.0.1", "cropperjs": "2.0.0-beta.2", - "date-fns": "2.29.3", + "date-fns": "2.30.0", "escape-regexp": "0.0.1", - "eventemitter3": "5.0.0", - "gsap": "3.11.4", + "eventemitter3": "5.0.1", + "gsap": "3.11.5", "idb-keyval": "6.2.0", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", "json5": "2.2.3", - "matter-js": "0.18.0", + "matter-js": "0.19.0", "mfm-js": "0.23.3", - "misskey-js": "0.0.15", - "photoswipe": "5.3.5", + "misskey-js": "workspace:*", + "photoswipe": "5.3.7", "prismjs": "1.29.0", "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.14.0", + "rollup": "3.21.3", "s-age": "1.1.2", - "sanitize-html": "2.9.0", - "sass": "1.58.0", + "sanitize-html": "2.10.0", + "sass": "1.62.1", "seedrandom": "3.0.5", "strict-event-emitter-types": "2.0.0", - "stringz": "2.1.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.149.0", + "three": "0.151.3", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.2", - "tsconfig-paths": "4.1.2", + "tsc-alias": "1.8.6", + "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "4.9.5", + "typescript": "5.0.4", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vue-plyr": "7.0.0", - "vite": "4.1.1", + "vite": "4.3.4", "vue": "3.2.47", + "vue-plyr": "7.0.0", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { + "@storybook/addon-actions": "7.0.7", + "@storybook/addon-essentials": "7.0.7", + "@storybook/addon-interactions": "7.0.7", + "@storybook/addon-links": "7.0.7", + "@storybook/addon-storysource": "7.0.7", + "@storybook/addons": "7.0.7", + "@storybook/blocks": "7.0.7", + "@storybook/core-events": "7.0.7", + "@storybook/jest": "0.1.0", + "@storybook/manager-api": "7.0.7", + "@storybook/preview-api": "7.0.7", + "@storybook/react": "7.0.7", + "@storybook/react-vite": "7.0.7", + "@storybook/testing-library": "0.1.0", + "@storybook/theming": "7.0.7", + "@storybook/types": "7.0.7", + "@storybook/vue3": "7.0.7", + "@storybook/vue3-vite": "7.0.7", + "@testing-library/jest-dom": "5.16.5", + "@testing-library/vue": "7.0.0", "@types/escape-regexp": "0.0.1", - "@types/glob": "8.0.1", + "@types/estree": "1.0.1", "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", "@types/matter-js": "0.18.2", - "@types/node": "18.13.0", + "@types/micromatch": "4.0.2", + "@types/node": "18.16.3", "@types/punycode": "2.1.0", - "@types/sanitize-html": "2.8.0", - "@types/seedrandom": "3.0.4", + "@types/sanitize-html": "2.9.0", + "@types/seedrandom": "3.0.5", + "@types/testing-library__jest-dom": "^5.14.5", "@types/throttle-debounce": "5.0.0", "@types/tinycolor2": "1.4.3", - "@types/uuid": "9.0.0", + "@types/uuid": "9.0.1", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.51.0", - "@typescript-eslint/parser": "5.51.0", + "@typescript-eslint/eslint-plugin": "5.59.2", + "@typescript-eslint/parser": "5.59.2", + "@vitest/coverage-c8": "0.30.1", "@vue/runtime-core": "3.2.47", + "astring": "1.8.4", + "chokidar-cli": "3.0.0", "cross-env": "7.0.3", - "cypress": "12.5.1", - "eslint": "8.33.0", + "cypress": "12.11.0", + "eslint": "8.39.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.9.0", - "start-server-and-test": "1.15.3", - "vue-eslint-parser": "9.1.0", - "vue-tsc": "1.0.24" + "eslint-plugin-vue": "9.11.0", + "fast-glob": "3.2.12", + "happy-dom": "9.10.2", + "micromatch": "3.1.10", + "msw": "1.2.1", + "msw-storybook-addon": "1.8.0", + "prettier": "2.8.8", + "react": "18.2.0", + "react-dom": "18.2.0", + "start-server-and-test": "2.0.0", + "storybook": "7.0.7", + "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", + "summaly": "github:misskey-dev/summaly", + "vite-plugin-turbosnap": "1.0.2", + "vitest": "0.30.1", + "vitest-fetch-mock": "0.2.2", + "vue-eslint-parser": "9.1.1", + "vue-tsc": "1.6.3" } } diff --git a/packages/frontend/public/mockServiceWorker.js b/packages/frontend/public/mockServiceWorker.js new file mode 100644 index 0000000000..e915a1eb08 --- /dev/null +++ b/packages/frontend/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.1.0). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index ed7b6db872..95b060345d 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -1,4 +1,4 @@ -import { defineAsyncComponent, reactive } from 'vue'; +import { defineAsyncComponent, reactive, ref } from 'vue'; import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; @@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; +import { MenuButton } from './types/menu'; import * as os from '@/os'; // TODO: 他のタブと永続化されたstateを同期 @@ -27,11 +28,11 @@ export function incNotesCount() { } export async function signout() { + if (!$i) return; + waiting(); miLocalStorage.removeItem('account'); - await removeAccount($i.id); - const accounts = await getAccounts(); //#region Remove service worker registration @@ -67,6 +68,8 @@ export async function signout() { } export async function signoutAll() { + if (!$i) return; + waiting(); miLocalStorage.removeItem('account'); miLocalStorage.removeItem('accounts'); @@ -112,15 +115,19 @@ export async function addAccount(id: Account['id'], token: Account['token']) { } } -export async function removeAccount(id: Account['id']) { +export async function removeAccount(idOrToken: Account['id']) { const accounts = await getAccounts(); - accounts.splice(accounts.findIndex(x => x.id === id), 1); + const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); + if (i !== -1) accounts.splice(i, 1); - if (accounts.length > 0) await set('accounts', accounts); - else await del('accounts'); + if (accounts.length > 0) { + await set('accounts', accounts); + } else { + await del('accounts'); + } } -function fetchAccount(token: string): Promise { +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { return new Promise((done, fail) => { // Fetch user window.fetch(`${apiUrl}/i`, { @@ -132,44 +139,94 @@ function fetchAccount(token: string): Promise { 'Content-Type': 'application/json', }, }) - .then(res => res.json()) - .then(res => { - if (res.error) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - showSuspendedDialog().then(() => { - signout(); - }); - } else { - alert({ + .then(res => new Promise }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, }); } } else { - res.token = token; - done(res); + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); } - }) - .catch(fail); + + // rejectかつ理由がtrueの場合、削除対象であることを示す + fail(true); + } else { + (res as Account).token = token; + done(res as Account); + } + }) + .catch(fail); }); } -export function updateAccount(accountData) { +export function updateAccount(accountData: Partial) { + if (!$i) return; for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } miLocalStorage.setItem('account', JSON.stringify($i)); } -export function refreshAccount() { - return fetchAccount($i.token).then(updateAccount); +export async function refreshAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id) + .then(updateAccount, reason => { + if (reason === true) return signout(); + return; + }); } export async function login(token: Account['token'], redirect?: string) { - waiting(); + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, {}, 'closed'); if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token); + const me = await fetchAccount(token, undefined, true) + .catch(reason => { + if (reason === true) { + // 削除対象の場合 + removeAccount(token); + } + + showing.value = false; + throw reason; + }); miLocalStorage.setItem('account', JSON.stringify(me)); document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う await addAccount(me.id, token); @@ -191,6 +248,8 @@ export async function openAccountMenu(opts: { active?: misskey.entities.UserDetailed['id']; onChoose?: (account: misskey.entities.UserDetailed) => void; }, ev: MouseEvent) { + if (!$i) return; + function showSigninDialog() { popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { @@ -211,8 +270,9 @@ export async function openAccountMenu(opts: { async function switchAccount(account: misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); - const token = storedAccounts.find(x => x.id === account.id).token; - switchAccountWithToken(token); + const found = storedAccounts.find(x => x.id === account.id); + if (found == null) return; + switchAccountWithToken(found.token); } function switchAccountWithToken(token: string) { @@ -224,7 +284,7 @@ export async function openAccountMenu(opts: { function createItem(account: misskey.entities.UserDetailed) { return { - type: 'user', + type: 'user' as const, user: account, active: opts.active != null ? opts.active === account.id : false, action: () => { @@ -237,22 +297,29 @@ export async function openAccountMenu(opts: { }; } - const accountItemPromises = storedAccounts.map(a => new Promise(res => { + const accountItemPromises = storedAccounts.map(a => new Promise | MenuButton>(res => { accountsPromise.then(accounts => { const account = accounts.find(x => x.id === a.id); - if (account == null) return res(null); + if (account == null) return res({ + type: 'button' as const, + text: a.id, + action: () => { + switchAccountWithToken(a.token); + }, + }); + res(createItem(account)); }); })); if (opts.withExtraOperation) { popupMenu([...[{ - type: 'link', + type: 'link' as const, text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { - type: 'parent', + type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, children: [{ @@ -263,7 +330,7 @@ export async function openAccountMenu(opts: { action: () => { createAccount(); }, }], }, { - type: 'link', + type: 'link' as const, icon: 'ti ti-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts new file mode 100644 index 0000000000..c95da64bba --- /dev/null +++ b/packages/frontend/src/cache.ts @@ -0,0 +1,6 @@ +import * as misskey from 'misskey-js'; +import { Cache } from '@/scripts/cache'; + +export const clipsCache = new Cache(Infinity); +export const rolesCache = new Cache(Infinity); +export const userListsCache = new Cache(Infinity); diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts new file mode 100644 index 0000000000..7d27adeb04 --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { abuseUserReport } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAbuseReport from './MkAbuseReport.vue'; +export const Default = { + render(args) { + return { + components: { + MkAbuseReport, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + resolved: action('resolved'), + }; + }, + }, + template: '', + }; + }, + args: { + report: abuseUserReport(), + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/admin/resolve-abuse-user-report', async (req, res, ctx) => { + action('POST /api/admin/resolve-abuse-user-report')(await req.json()); + return res(ctx.json({})); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index 0e18a5a83d..dee80378e6 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -39,7 +39,6 @@ import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import { acct, userPage } from '@/filters/user'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { dateString } from '@/filters/date'; diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts new file mode 100644 index 0000000000..d0877ffd3b --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAbuseReportWindow from './MkAbuseReportWindow.vue'; +export const Default = { + render(args) { + return { + components: { + MkAbuseReportWindow, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + 'closed': action('closed'), + }; + }, + }, + template: '', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/report-abuse', async (req, res, ctx) => { + action('POST /api/users/report-abuse')(await req.json()); + return res(ctx.json({})); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index a76a1e0f54..9f2bf99338 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -43,7 +43,7 @@ const emit = defineEmits<{ }>(); const uiWindow = shallowRef>(); -const comment = ref(props.initialComment || ''); +const comment = ref(props.initialComment ?? ''); function send() { os.apiWithDialog('users/report-abuse', { diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts new file mode 100644 index 0000000000..bed9d94311 --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../.storybook/fakes'; +import MkAccountMoved from './MkAccountMoved.vue'; +export const Default = { + render(args) { + return { + components: { + MkAccountMoved, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + username: userDetailed().username, + host: userDetailed().host, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue new file mode 100644 index 0000000000..b02bfdc2b8 --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts new file mode 100644 index 0000000000..477152a47b --- /dev/null +++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAchievements from './MkAchievements.vue'; +import { ACHIEVEMENT_TYPES } from '@/scripts/achievements'; +export const Empty = { + render(args) { + return { + components: { + MkAchievements, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/achievements', (req, res, ctx) => { + return res(ctx.json([])); + }), + ], + }, + }, +} satisfies StoryObj; +export const All = { + ...Empty, + parameters: { + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/achievements', (req, res, ctx) => { + return res(ctx.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 })))); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts new file mode 100644 index 0000000000..e7fbb47284 --- /dev/null +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkAnalogClock from './MkAnalogClock.vue'; +import isChromatic from 'chromatic'; +export const Default = { + render(args) { + return { + components: { + MkAnalogClock, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + now: isChromatic() ? () => new Date('2023-01-01T10:10:30') : undefined, + }, + decorators: [ + () => ({ + template: '
', + }), + ], + parameters: { + layout: 'fullscreen', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index 139e49cc40..f12020f810 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -73,7 +73,7 @@ + + diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 57efda44b1..06d5b9949a 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -14,7 +14,7 @@ id-denylist violation when setting it. This is causing about 60+ lint issues. As this is part of Chart.js's API it makes sense to disable the check here. */ -import { onMounted, ref, shallowRef, watch, PropType, onUnmounted } from 'vue'; +import { onMounted, ref, shallowRef, watch, PropType } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import * as os from '@/os'; diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index b950f2836e..7bc6194b7a 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -8,7 +8,6 @@ + + diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue new file mode 100644 index 0000000000..2471aa958d --- /dev/null +++ b/packages/frontend/src/components/MkColorInput.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 833fa9d382..d03331a6eb 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -1,6 +1,6 @@ - diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index f0ea984c4e..b81c806b0c 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -1,10 +1,10 @@ @@ -26,7 +26,7 @@ diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 8c17c0530a..ab408b5008 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -32,14 +32,14 @@ + diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 13f2697e76..0ed47f8f5b 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -23,11 +23,8 @@ import { } from 'vue'; import MkModal from '@/components/MkModal.vue'; import { navbarItemDef } from '@/navbar'; -import { instanceName } from '@/config'; import { defaultStore } from '@/store'; -import { i18n } from '@/i18n'; import { deviceKind } from '@/scripts/device-kind'; -import * as os from '@/os'; const props = withDefaults(defineProps<{ src?: HTMLElement; diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index c0401a6455..5ca4c50518 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -2,8 +2,8 @@
- {{ $ts.sensitive }} - {{ $ts.clickToShow }} + {{ i18n.ts.sensitive }} + {{ i18n.ts.clickToShow }}
@@ -31,8 +31,9 @@ import { onMounted } from 'vue'; import * as misskey from 'misskey-js'; import VuePlyr from 'vue-plyr'; -import { ColdDeviceStorage } from '@/store'; +import { soundConfigStore } from '@/scripts/sound'; import 'vue-plyr/dist/vue-plyr.css'; +import { i18n } from '@/i18n'; const props = withDefaults(defineProps<{ media: misskey.entities.DriveFile; @@ -43,11 +44,11 @@ const audioEl = $shallowRef(); let hide = $ref(true); function volumechange() { - if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume); + if (audioEl) soundConfigStore.set('mediaVolume', audioEl.volume); } onMounted(() => { - if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume'); + if (audioEl) audioEl.volume = soundConfigStore.state.mediaVolume; }); diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index b777a1329b..42dc9e79ff 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -1,23 +1,29 @@ @@ -25,8 +31,12 @@ import { watch } from 'vue'; import * as misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/media-proxy'; +import bytes from '@/filters/bytes'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import { iAmModerator } from '@/account'; const props = defineProps<{ image: misskey.entities.DriveFile; @@ -34,20 +44,33 @@ const props = defineProps<{ }>(); let hide = $ref(true); +let darkMode: boolean = $ref(defaultStore.state.darkMode); -const url = (props.raw || defaultStore.state.loadRawImages) +const url = $computed(() => (props.raw || defaultStore.state.loadRawImages) ? props.image.url : defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(props.image.thumbnailUrl) - : props.image.thumbnailUrl; + ? getStaticImageUrl(props.image.url) + : props.image.thumbnailUrl, +); // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { - hide = (defaultStore.state.nsfw === 'force') ? true : props.image.isSensitive && (defaultStore.state.nsfw !== 'ignore'); + hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); }, { deep: true, immediate: true, }); + +function showMenu(ev: MouseEvent) { + os.popupMenu([...(iAmModerator ? [{ + text: i18n.ts.markAsSensitive, + icon: 'ti ti-eye-off', + action: () => { + os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true }); + }, + }] : [])], ev.currentTarget ?? ev.target); +} + diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 29d15989b7..e456ff3eec 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -2,10 +2,16 @@
-
+
@@ -13,7 +19,7 @@ diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index eee77a9475..e513a65a32 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -1,5 +1,5 @@ - diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue index 0c7230d783..e7fc73bce3 100644 --- a/packages/frontend/src/components/MkObjectView.value.vue +++ b/packages/frontend/src/components/MkObjectView.value.vue @@ -29,7 +29,7 @@ + + diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 52c8b6d026..f33f68cab7 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -8,14 +8,11 @@ @@ -40,6 +44,11 @@ const props = defineProps<{ margin-right: 8px; } +.badge { + height: 1.3em; + vertical-align: -20%; +} + .name { font-weight: bold; } diff --git a/packages/frontend/src/components/MkSample.vue b/packages/frontend/src/components/MkSample.vue index 4410be3d1c..d4a5f885fc 100644 --- a/packages/frontend/src/components/MkSample.vue +++ b/packages/frontend/src/components/MkSample.vue @@ -37,6 +37,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os'; import * as config from '@/config'; +import { $i } from '@/account'; export default defineComponent({ components: { @@ -52,6 +53,7 @@ export default defineComponent({ text: '', flag: true, radio: 'cherrypick', + $i, mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #cherrypick is open-source software.`, }; }, @@ -86,7 +88,7 @@ export default defineComponent({ }, async openDrive() { - os.selectDriveFile(); + os.selectDriveFile(false); }, async selectUser() { diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 12099153eb..2de890186a 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -27,14 +27,14 @@ diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue deleted file mode 100644 index d8703a0b1b..0000000000 --- a/packages/frontend/src/components/MkSignup.vue +++ /dev/null @@ -1,246 +0,0 @@ - - - - - diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue new file mode 100644 index 0000000000..0e8bdb321e --- /dev/null +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts new file mode 100644 index 0000000000..2d95455730 --- /dev/null +++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import { onBeforeUnmount } from 'vue'; +import MkSignupServerRules from './MkSignupDialog.rules.vue'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +export const Empty = { + render(args) { + return { + components: { + MkSignupServerRules, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const groups = await canvas.findAllByRole('group'); + const buttons = await canvas.findAllByRole('button'); + for (const group of groups) { + if (group.ariaExpanded === 'true') { + continue; + } + const button = await within(group).findByRole('button'); + userEvent.click(button); + await waitFor(() => expect(group).toHaveAttribute('aria-expanded', 'true')); + } + const labels = await canvas.findAllByText(i18n.ts.agree); + for (const label of labels) { + expect(buttons.at(-1)).toBeDisabled(); + await waitFor(() => userEvent.click(label)); + } + expect(buttons.at(-1)).toBeEnabled(); + }, + args: { + serverRules: [], + tosUrl: null, + }, + decorators: [ + (_, context) => ({ + setup() { + instance.serverRules = context.args.serverRules; + instance.tosUrl = context.args.tosUrl; + onBeforeUnmount(() => { + // FIXME: 呼び出されない + instance.serverRules = []; + instance.tosUrl = null; + }); + }, + template: '', + }), + ], + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; +export const ServerRulesOnly = { + ...Empty, + args: { + ...Empty.args, + serverRules: [ + 'ルール', + ], + }, +} satisfies StoryObj; +export const TOSOnly = { + ...Empty, + args: { + ...Empty.args, + tosUrl: 'https://example.com/tos', + }, +} satisfies StoryObj; +export const ServerRulesAndTOS = { + ...Empty, + args: { + ...Empty.args, + serverRules: ServerRulesOnly.args.serverRules, + tosUrl: TOSOnly.args.tosUrl, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue new file mode 100644 index 0000000000..6da81c3bcb --- /dev/null +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 790c1e94df..17f8b86425 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -1,24 +1,40 @@ + + diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 98aa12614f..2b1d5e5652 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -8,7 +8,7 @@ RN: ...
- ({{ $t('withNFiles', { n: note.files.length }) }}) + ({{ i18n.t('withNFiles', { n: note.files.length }) }})
@@ -28,6 +28,7 @@ import * as misskey from 'misskey-js'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import { i18n } from '@/i18n'; +import { $i } from '@/account'; const props = defineProps<{ note: misskey.entities.Note; diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 5d33ad0ad3..2a8e43c570 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -24,7 +24,7 @@ - diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index b7bc200a55..3571ca84d9 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -7,29 +7,30 @@
- - diff --git a/packages/frontend/src/components/MkUserPreview.vue b/packages/frontend/src/components/MkUserPreview.vue deleted file mode 100644 index 1086a2c651..0000000000 --- a/packages/frontend/src/components/MkUserPreview.vue +++ /dev/null @@ -1,199 +0,0 @@ - - - - - diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index c17b97f283..dc78bbf42d 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -16,7 +16,7 @@ - + @@ -52,7 +52,7 @@ + + diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts new file mode 100644 index 0000000000..f4930aa26b --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog_Profile, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue new file mode 100644 index 0000000000..adb8d43349 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts new file mode 100644 index 0000000000..7413f4884b --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../.storybook/fakes'; +import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog_User, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue new file mode 100644 index 0000000000..d66f34f165 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts new file mode 100644 index 0000000000..55790602d5 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks'; +import { userDetailed } from '../../.storybook/fakes'; +import MkUserSetupDialog from './MkUserSetupDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44'), + userDetailed('49'), + ])); + }), + rest.post('/api/pinned-users', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44'), + userDetailed('49'), + ])); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue new file mode 100644 index 0000000000..096b88c309 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 516b88c13d..c181d84bc0 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -1,6 +1,9 @@ diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue new file mode 100644 index 0000000000..fb705786cf --- /dev/null +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue new file mode 100644 index 0000000000..6226768127 --- /dev/null +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index e56ce260e9..95616cb0f7 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -2,12 +2,14 @@
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index d89a68f982..49942c87ce 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -1,20 +1,24 @@ diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index ff8f8a356f..e8dbe1c5f0 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -4,11 +4,14 @@
- {{ i18n.ts._role.new }}
- + + + + + @@ -16,7 +19,7 @@ - + @@ -24,7 +27,7 @@ - + @@ -32,7 +35,7 @@ - + @@ -40,7 +43,7 @@ - + @@ -48,7 +51,7 @@ - + @@ -56,7 +59,15 @@ - + + + + + + + + + @@ -64,21 +75,29 @@ - + + + + + + + + + - + - + @@ -86,42 +105,42 @@ - + - + - + - + - + - + @@ -132,8 +151,20 @@ {{ i18n.ts.save }}
+ {{ i18n.ts._role.new }}
- + + +
+ +
+
+ + +
+ +
+
@@ -142,11 +173,9 @@ + + diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index a01a8478dc..aed025f628 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -1,7 +1,7 @@ @@ -174,10 +174,11 @@ import * as os from '@/os'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkButton from '@/components/MkButton.vue'; +import MkColorInput from '@/components/MkColorInput.vue'; let name: string | null = $ref(null); let description: string | null = $ref(null); -let tosUrl: string | null = $ref(null); let maintainerName: string | null = $ref(null); let maintainerEmail: string | null = $ref(null); let iconUrl: string | null = $ref(null); @@ -188,9 +189,9 @@ let defaultLightTheme: any = $ref(null); let defaultDarkTheme: any = $ref(null); let pinnedUsers: string = $ref(''); let cacheRemoteFiles: boolean = $ref(false); -let enableRegistration: boolean = $ref(false); -let emailRequiredForSignup: boolean = $ref(false); let enableServiceWorker: boolean = $ref(false); +let enableChartsForRemoteUser: boolean = $ref(false); +let enableChartsForFederatedInstances: boolean = $ref(false); let swPublicKey: any = $ref(null); let swPrivateKey: any = $ref(null); let translatorType: string | null = $ref(null); @@ -201,7 +202,6 @@ async function init() { const meta = await os.api('admin/meta'); name = meta.name; description = meta.description; - tosUrl = meta.tosUrl; iconUrl = meta.iconUrl; bannerUrl = meta.bannerUrl; backgroundImageUrl = meta.backgroundImageUrl; @@ -212,9 +212,9 @@ async function init() { maintainerEmail = meta.maintainerEmail; pinnedUsers = meta.pinnedUsers.join('\n'); cacheRemoteFiles = meta.cacheRemoteFiles; - enableRegistration = !meta.disableRegistration; - emailRequiredForSignup = meta.emailRequiredForSignup; enableServiceWorker = meta.enableServiceWorker; + enableChartsForRemoteUser = meta.enableChartsForRemoteUser; + enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances; swPublicKey = meta.swPublickey; swPrivateKey = meta.swPrivateKey; translatorType = meta.translatorType; @@ -226,7 +226,6 @@ function save() { os.apiWithDialog('admin/update-meta', { name, description, - tosUrl, iconUrl, bannerUrl, backgroundImageUrl, @@ -237,9 +236,9 @@ function save() { maintainerEmail, pinnedUsers: pinnedUsers.split('\n'), cacheRemoteFiles, - disableRegistration: !enableRegistration, - emailRequiredForSignup, enableServiceWorker, + enableChartsForRemoteUser, + enableChartsForFederatedInstances, swPublicKey, swPrivateKey, translatorType, @@ -250,13 +249,6 @@ function save() { }); } -const headerActions = $computed(() => [{ - asFullButton: true, - icon: 'ti ti-check', - text: i18n.ts.save, - handler: save, -}]); - const headerTabs = $computed(() => []); definePageMetadata({ @@ -264,3 +256,10 @@ definePageMetadata({ icon: 'ti ti-settings', }); + + diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index fc1c1c1dc5..819ced826d 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -3,48 +3,48 @@ -
-
-
- - - - - - - - - - - - - - - - - - - - - -
-
- - - - - - - - -
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + +
- - + +
+ - -
+
+
@@ -138,33 +138,20 @@ definePageMetadata(computed(() => ({ }))); - diff --git a/packages/frontend/src/pages/ads.vue b/packages/frontend/src/pages/ads.vue new file mode 100644 index 0000000000..728ef3c0b1 --- /dev/null +++ b/packages/frontend/src/pages/ads.vue @@ -0,0 +1,25 @@ + + + + diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 131f6d11ea..16a0ee8373 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -10,7 +10,7 @@
@@ -25,6 +25,7 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; const pagination = { endpoint: 'announcements' as const, diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 5f0168ee3c..62e8178af1 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -2,9 +2,9 @@
-
+
- - diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 0fb33e30f7..af1b4d2056 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -1,47 +1,86 @@ - diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index 9043d06c52..e670cdd864 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -2,17 +2,39 @@ -