Merge branch 'develop' into ed25519

This commit is contained in:
tamaina 2024-07-17 15:10:45 +09:00
commit 3777779aa9
263 changed files with 9833 additions and 6425 deletions

View File

@ -1,5 +1,11 @@
# misskey settings
# MISSKEY_URL=https://example.tld/
# db settings # db settings
POSTGRES_PASSWORD=example-misskey-pass POSTGRES_PASSWORD=example-misskey-pass
# DATABASE_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_USER=example-misskey-user POSTGRES_USER=example-misskey-user
# DATABASE_USER=${POSTGRES_USER}
POSTGRES_DB=misskey POSTGRES_DB=misskey
# DATABASE_DB=${POSTGRES_DB}
DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}" DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"

View File

@ -6,6 +6,7 @@
#───┘ URL └───────────────────────────────────────────────────── #───┘ URL └─────────────────────────────────────────────────────
# Final accessible URL seen by a user. # Final accessible URL seen by a user.
# You can set url from an environment variable instead.
url: https://example.tld/ url: https://example.tld/
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
@ -38,9 +39,11 @@ db:
port: 5432 port: 5432
# Database name # Database name
# You can set db from an environment variable instead.
db: misskey db: misskey
# Auth # Auth
# You can set user and pass from environment variables instead.
user: example-misskey-user user: example-misskey-user
pass: example-misskey-pass pass: example-misskey-pass

View File

@ -4,10 +4,11 @@ on:
push: push:
paths: paths:
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/api-misskey-js.yml
pull_request: pull_request:
paths: paths:
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/api-misskey-js.yml
jobs: jobs:
report: report:
@ -20,7 +21,7 @@ jobs:
- run: corepack enable - run: corepack enable
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View File

@ -14,7 +14,7 @@ jobs:
- name: Checkout head - name: Checkout head
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'

View File

@ -28,7 +28,7 @@ jobs:
- name: setup node - name: setup node
id: setup-node id: setup-node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: pnpm cache: pnpm

View File

@ -6,12 +6,13 @@ on:
paths: paths:
- packages/misskey-js/package.json - packages/misskey-js/package.json
- package.json - package.json
- .github/workflows/check-misskey-js-version.yml
pull_request: pull_request:
branches: [ develop ] branches: [ develop ]
paths: paths:
- packages/misskey-js/package.json - packages/misskey-js/package.json
- package.json - package.json
- .github/workflows/check-misskey-js-version.yml
jobs: jobs:
check-version: check-version:
# ルートの package.json と packages/misskey-js/package.json のバージョンが一致しているかを確認する # ルートの package.json と packages/misskey-js/package.json のバージョンが一致しているかを確認する

View File

@ -9,7 +9,7 @@ on:
paths: paths:
- packages/backend/** - packages/backend/**
- .github/workflows/get-api-diff.yml - .github/workflows/get-api-diff.yml
- .github/workflows/get-api-diff.yml
jobs: jobs:
get-from-misskey: get-from-misskey:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -34,7 +34,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -10,15 +10,16 @@ on:
- packages/frontend/** - packages/frontend/**
- packages/sw/** - packages/sw/**
- packages/misskey-js/** - packages/misskey-js/**
- packages/shared/.eslintrc.js - packages/shared/eslint.config.js
- .github/workflows/lint.yml
pull_request: pull_request:
paths: paths:
- packages/backend/** - packages/backend/**
- packages/frontend/** - packages/frontend/**
- packages/sw/** - packages/sw/**
- packages/misskey-js/** - packages/misskey-js/**
- packages/shared/.eslintrc.js - packages/shared/eslint.config.js
- .github/workflows/lint.yml
jobs: jobs:
pnpm_install: pnpm_install:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -28,7 +29,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.2 - uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
@ -39,6 +40,8 @@ jobs:
needs: [pnpm_install] needs: [pnpm_install]
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
env:
eslint-cache-version: v1
strategy: strategy:
matrix: matrix:
workspace: workspace:
@ -52,13 +55,20 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.2 - uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable - run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- run: pnpm --filter ${{ matrix.workspace }} run eslint - name: Restore eslint cache
uses: actions/cache@v4.0.2
with:
path: node_modules/.cache/eslint
key: eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-
- run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location node_modules/.cache/eslint --cache-strategy content
typecheck: typecheck:
needs: [pnpm_install] needs: [pnpm_install]
@ -75,7 +85,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.2 - uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View File

@ -4,10 +4,11 @@ on:
push: push:
paths: paths:
- locales/** - locales/**
- .github/workflows/locale.yml
pull_request: pull_request:
paths: paths:
- locales/** - locales/**
- .github/workflows/locale.yml
jobs: jobs:
locale_verify: locale_verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -18,7 +19,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.2 - uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View File

@ -26,7 +26,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -3,10 +3,10 @@ name: "Release Manager: sync changelog with PR"
on: on:
push: push:
branches: branches:
- release/** - develop
paths: paths:
- 'CHANGELOG.md' - 'CHANGELOG.md'
# - .github/workflows/release-edit-with-push.yml
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -20,24 +20,29 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# headがrelease/かつopenのPRを1つ取得 # headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
- name: Get PR - name: Get PR
run: | run: |
echo "pr_number=$(gh pr list --limit 1 --head "$GITHUB_REF_NAME" --json number --jq '.[] | .number')" >> $GITHUB_OUTPUT echo "pr_number=$(gh pr list --limit 1 --search "head:$GITHUB_REF_NAME base:$STABLE_BRANCH is:open" --json number --jq '.[] | .number')" >> $GITHUB_OUTPUT
id: get_pr id: get_pr
env:
STABLE_BRANCH: ${{ vars.STABLE_BRANCH }}
- name: Get target version - name: Get target version
uses: misskey-dev/release-manager-actions/.github/actions/get-target-version@v1 if: steps.get_pr.outputs.pr_number != ''
uses: misskey-dev/release-manager-actions/.github/actions/get-target-version@v2
id: v id: v
# CHANGELOG.mdの内容を取得 # CHANGELOG.mdの内容を取得
- name: Get changelog - name: Get changelog
uses: misskey-dev/release-manager-actions/.github/actions/get-changelog@v1 if: steps.get_pr.outputs.pr_number != ''
uses: misskey-dev/release-manager-actions/.github/actions/get-changelog@v2
with: with:
version: ${{ steps.v.outputs.target_version }} version: ${{ steps.v.outputs.target_version }}
id: changelog id: changelog
# PRのnotesを更新 # PRのnotesを更新
- name: Update PR - name: Update PR
if: steps.get_pr.outputs.pr_number != ''
run: | run: |
gh pr edit "$PR_NUMBER" --body "$CHANGELOG" gh pr edit "$PR_NUMBER" --body "$CHANGELOG"
env: env:
CHANGELOG: ${{ steps.changelog.outputs.changelog }}
PR_NUMBER: ${{ steps.get_pr.outputs.pr_number }} PR_NUMBER: ${{ steps.get_pr.outputs.pr_number }}
CHANGELOG: ${{ steps.changelog.outputs.changelog }}

View File

@ -33,18 +33,21 @@ jobs:
pr_number: ${{ steps.get_pr.outputs.pr_number }} pr_number: ${{ steps.get_pr.outputs.pr_number }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# headがrelease/かつopenのPRを1つ取得 # headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
- name: Get PRs - name: Get PRs
run: | run: |
echo "pr_number=$(gh pr list --limit 1 --search "head:release/ is:open" --json number --jq '.[] | .number')" >> $GITHUB_OUTPUT echo "pr_number=$(gh pr list --limit 1 --search "head:$GITHUB_REF_NAME base:$STABLE_BRANCH is:open" --json number --jq '.[] | .number')" >> $GITHUB_OUTPUT
id: get_pr id: get_pr
env:
STABLE_BRANCH: ${{ vars.STABLE_BRANCH }}
merge: merge:
uses: misskey-dev/release-manager-actions/.github/workflows/merge.yml@v1 uses: misskey-dev/release-manager-actions/.github/workflows/merge.yml@v2
needs: get-pr needs: get-pr
if: ${{ needs.get-pr.outputs.pr_number != '' && inputs.merge == true }} if: ${{ needs.get-pr.outputs.pr_number != '' && inputs.merge == true }}
with: with:
pr_number: ${{ needs.get-pr.outputs.pr_number }} pr_number: ${{ needs.get-pr.outputs.pr_number }}
user: 'github-actions[bot]'
package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }}
# Text to prepend to the changelog # Text to prepend to the changelog
# The first line must be `## Unreleased` # The first line must be `## Unreleased`
@ -65,15 +68,14 @@ jobs:
secrets: secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
RULESET_EDIT_APP_ID: ${{ secrets.RULESET_EDIT_APP_ID }}
RULESET_EDIT_APP_PRIVATE_KEY: ${{ secrets.RULESET_EDIT_APP_PRIVATE_KEY }}
create-prerelease: create-prerelease:
uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v1 uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v2
needs: get-pr needs: get-pr
if: ${{ needs.get-pr.outputs.pr_number != '' && inputs.merge != true }} if: ${{ needs.get-pr.outputs.pr_number != '' && inputs.merge != true }}
with: with:
pr_number: ${{ needs.get-pr.outputs.pr_number }} pr_number: ${{ needs.get-pr.outputs.pr_number }}
user: 'github-actions[bot]'
package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }}
use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }}
indent: ${{ vars.INDENT }} indent: ${{ vars.INDENT }}
@ -82,10 +84,11 @@ jobs:
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
create-target: create-target:
uses: misskey-dev/release-manager-actions/.github/workflows/create-target.yml@v1 uses: misskey-dev/release-manager-actions/.github/workflows/create-target.yml@v2
needs: get-pr needs: get-pr
if: ${{ needs.get-pr.outputs.pr_number == '' }} if: ${{ needs.get-pr.outputs.pr_number == '' }}
with: with:
user: 'github-actions[bot]'
# The script for version increment. # The script for version increment.
# process.env.CURRENT_VERSION: The current version. # process.env.CURRENT_VERSION: The current version.
# #
@ -118,8 +121,7 @@ jobs:
package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }}
use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }}
indent: ${{ vars.INDENT }} indent: ${{ vars.INDENT }}
stable_branch: ${{ vars.STABLE_BRANCH }}
secrets: secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
RULESET_EDIT_APP_ID: ${{ secrets.RULESET_EDIT_APP_ID }}
RULESET_EDIT_APP_PRIVATE_KEY: ${{ secrets.RULESET_EDIT_APP_PRIVATE_KEY }}

View File

@ -16,23 +16,26 @@ jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
ref: ${{ steps.get_pr.outputs.ref }} head: ${{ steps.get_pr.outputs.head }}
base: ${{ steps.get_pr.outputs.base }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# PR情報を取得 # PR情報を取得
- name: Get PR - name: Get PR
run: | run: |
pr_json=$(gh pr view "$PR_NUMBER" --json isDraft,headRefName) pr_json=$(gh pr view "$PR_NUMBER" --json isDraft,headRefName,baseRefName)
echo "ref=$(echo $pr_json | jq -r '.headRefName')" >> $GITHUB_OUTPUT echo "head=$(echo $pr_json | jq -r '.headRefName')" >> $GITHUB_OUTPUT
echo "base=$(echo $pr_json | jq -r '.baseRefName')" >> $GITHUB_OUTPUT
id: get_pr id: get_pr
env: env:
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
release: release:
uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v1 uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v2
needs: check needs: check
if: startsWith(needs.check.outputs.ref, 'release/') if: needs.check.outputs.head == github.event.repository.default_branch && needs.check.outputs.base == vars.STABLE_BRANCH
with: with:
pr_number: ${{ github.event.pull_request.number }} pr_number: ${{ github.event.pull_request.number }}
user: 'github-actions[bot]'
package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }}
use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }}
indent: ${{ vars.INDENT }} indent: ${{ vars.INDENT }}

View File

@ -36,7 +36,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View File

@ -9,12 +9,13 @@ on:
- packages/backend/** - packages/backend/**
# for permissions # for permissions
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/test-backend.yml
pull_request: pull_request:
paths: paths:
- packages/backend/** - packages/backend/**
# for permissions # for permissions
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/test-backend.yml
jobs: jobs:
unit: unit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -45,7 +46,7 @@ jobs:
- name: Install FFmpeg - name: Install FFmpeg
uses: FedericoCarboni/setup-ffmpeg@v3 uses: FedericoCarboni/setup-ffmpeg@v3
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
@ -92,7 +93,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -11,7 +11,7 @@ on:
- packages/misskey-js/** - packages/misskey-js/**
# for e2e # for e2e
- packages/backend/** - packages/backend/**
- .github/workflows/test-frontend.yml
pull_request: pull_request:
paths: paths:
- packages/frontend/** - packages/frontend/**
@ -19,7 +19,7 @@ on:
- packages/misskey-js/** - packages/misskey-js/**
# for e2e # for e2e
- packages/backend/** - packages/backend/**
- .github/workflows/test-frontend.yml
jobs: jobs:
vitest: vitest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -35,7 +35,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
@ -90,7 +90,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -8,11 +8,12 @@ on:
branches: [ develop ] branches: [ develop ]
paths: paths:
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/test-misskey-js.yml
pull_request: pull_request:
branches: [ develop ] branches: [ develop ]
paths: paths:
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/test-misskey-js.yml
jobs: jobs:
test: test:
@ -30,7 +31,7 @@ jobs:
- run: corepack enable - run: corepack enable
- name: Setup Node.js ${{ matrix.node-version }} - name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -25,7 +25,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -7,10 +7,11 @@ on:
- develop - develop
paths: paths:
- packages/backend/** - packages/backend/**
- .github/workflows/validate-api-json.yml
pull_request: pull_request:
paths: paths:
- packages/backend/** - packages/backend/**
- .github/workflows/validate-api-json.yml
jobs: jobs:
validate-api-json: validate-api-json:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -26,7 +27,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -1,34 +1,69 @@
## Unreleased ## Unreleased
### Note
- デッキUIの新着ートをサウンドで通知する機能の追加v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。
### General ### General
- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に
- 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題
- Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正
- Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`)
### Client ### Client
- Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善
- Enhance: 非ログイン時に他サーバーに遷移するアクションを追加
- Enhance: 非ログイン時のハイライトTLのデザインを改善
- Enhance: フロントエンドのアクセシビリティ改善
(Based on https://github.com/taiyme/misskey/pull/226)
- Enhance: サーバー情報ページ・お問い合わせページを改善
(Cherry-picked from https://github.com/taiyme/misskey/pull/238)
- Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正 - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正
- Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968) - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968)
- Fix: リバーシの対局を正しく共有できないことがある問題を修正 - Fix: リバーシの対局を正しく共有できないことがある問題を修正
- Fix: コントロールパネルでベースロールのポリシーを編集してもUI上では変更が反映されない問題を修正 - Fix: コントロールパネルでベースロールのポリシーを編集してもUI上では変更が反映されない問題を修正
- Fix: アンテナの編集画面のボタンに隙間を追加 - Fix: アンテナの編集画面のボタンに隙間を追加
- Fix: テーマプレビューが見れない問題を修正 - Fix: テーマプレビューが見れない問題を修正
- Fix: ショートカットキーが連打できる問題を修正
(Cherry-picked from https://github.com/taiyme/misskey/pull/234)
- Fix: MkSignin.vueのcredentialRequestからReactivityを削除ProxyがPasskey認証処理に渡ることを避けるため
### Server ### Server
- チャート生成時にinstance.suspentionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正
- Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949)
- Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006)
- Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036)
- Enhance: エンドポイント`clips/update`の必須項目を`clipId`のみに - Enhance: エンドポイント`clips/update`の必須項目を`clipId`のみに
- Enhance: エンドポイント`admin/roles/update`の必須項目を`roleId`のみに - Enhance: エンドポイント`admin/roles/update`の必須項目を`roleId`のみに
- Enhance: エンドポイント`pages/update`の必須項目を`pageId`のみに - Enhance: エンドポイント`pages/update`の必須項目を`pageId`のみに
- Enhance: エンドポイント`gallery/posts/update`の必須項目を`postId`のみに - Enhance: エンドポイント`gallery/posts/update`の必須項目を`postId`のみに
- Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに - Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに
- Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに - Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに
- Enhance: `default.yml`内の`url`, `db.db`, `db.user`, `db.pass`を環境変数から読み込めるように
- Fix: チャート生成時にinstance.suspensionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正
- Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006)
- Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036)
- Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) - Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059)
- Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正 - Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正
- Fix: 自分以外のクリップ内のノート個数が見えることがあるのを修正 - Fix: 自分以外のクリップ内のノート個数が見えることがあるのを修正
- Fix: 空文字列のリアクションはフォールバックされるように - Fix: 空文字列のリアクションはフォールバックされるように
- Fix: リノートにリアクションできないように - Fix: リノートにリアクションできないように
- Fix: ユーザー名の前後に空白文字列がある場合は省略するように
- Fix: プロフィール編集時に名前を空白文字列のみにできる問題を修正
- Fix: ユーザ名のサジェスト時に表示される内容と順番を調整(以下の順番になります) #14149
1. フォロー中かつアクティブなユーザ
2. フォロー中かつ非アクティブなユーザ
3. フォローしていないアクティブなユーザ
4. フォローしていない非アクティブなユーザ
また、自分自身のアカウントもサジェストされるようになりました。
- Fix: 一般ユーザーから見たユーザーのバッジの一覧に公開されていないものが含まれることがある問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/652)
- Fix: ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正
- Fix: エラーメッセージの誤字を修正 (#14213)
### Misskey.js
- Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応)
- Feat: `/admin/role/create` のロールポリシーの型を修正
## 2024.5.0 ## 2024.5.0

View File

@ -165,7 +165,7 @@ cp .github/misskey/test.yml .config/
``` ```
Prepare DB/Redis for testing. Prepare DB/Redis for testing.
``` ```
docker compose -f packages/backend/test/compose.yaml up docker compose -f packages/backend/test/compose.yml up
``` ```
Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`.

View File

@ -17,6 +17,8 @@ services:
networks: networks:
- internal_network - internal_network
- external_network - external_network
# env_file:
# - .config/docker.env
volumes: volumes:
- ./files:/misskey/files - ./files:/misskey/files
- ./.config:/misskey/.config:ro - ./.config:/misskey/.config:ro

38
locales/index.d.ts vendored
View File

@ -736,6 +736,22 @@ export interface Locale extends ILocale {
* *
*/ */
"showOnRemote": string; "showOnRemote": string;
/**
*
*/
"continueOnRemote": string;
/**
* Misskey Hubからサーバーを選択
*/
"chooseServerOnMisskeyHub": string;
/**
*
*/
"specifyServerHost": string;
/**
*
*/
"inputHostName": string;
/** /**
* *
*/ */
@ -1921,9 +1937,13 @@ export interface Locale extends ILocale {
*/ */
"onlyOneFileCanBeAttached": string; "onlyOneFileCanBeAttached": string;
/** /**
* *
*/ */
"signinRequired": string; "signinRequired": string;
/**
* 使
*/
"signinOrContinueOnRemote": string;
/** /**
* *
*/ */
@ -4984,6 +5004,10 @@ export interface Locale extends ILocale {
* *
*/ */
"inquiry": string; "inquiry": string;
/**
*
*/
"tryAgain": string;
"_delivery": { "_delivery": {
/** /**
* *
@ -6594,6 +6618,10 @@ export interface Locale extends ILocale {
* NSFWを常に付与 * NSFWを常に付与
*/ */
"alwaysMarkNsfw": string; "alwaysMarkNsfw": string;
/**
*
*/
"canUpdateBioMedia": string;
/** /**
* *
*/ */
@ -7515,14 +7543,6 @@ export interface Locale extends ILocale {
* *
*/ */
"notification": string; "notification": string;
/**
*
*/
"antenna": string;
/**
*
*/
"channel": string;
/** /**
* *
*/ */

View File

@ -52,7 +52,11 @@ const primaries = {
const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), '');
export function build() { export function build() {
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {}); // vitestの挙動を調整するため、一度ローカル変数化する必要がある
// https://github.com/vitest-dev/vitest/issues/3988#issuecomment-1686599577
// https://github.com/misskey-dev/misskey/pull/14057#issuecomment-2192833785
const metaUrl = import.meta.url;
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, metaUrl), 'utf-8'))) || {}, a), {});
// 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す // 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す
const removeEmpty = (obj) => { const removeEmpty = (obj) => {

View File

@ -180,6 +180,10 @@ addAccount: "アカウントを追加"
reloadAccountsList: "アカウントリストの情報を更新" reloadAccountsList: "アカウントリストの情報を更新"
loginFailed: "ログインに失敗しました" loginFailed: "ログインに失敗しました"
showOnRemote: "リモートで表示" showOnRemote: "リモートで表示"
continueOnRemote: "リモートで続行"
chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選択"
specifyServerHost: "サーバーのドメインを直接指定"
inputHostName: "ドメインを入力してください"
general: "全般" general: "全般"
wallpaper: "壁紙" wallpaper: "壁紙"
setWallpaper: "壁紙を設定" setWallpaper: "壁紙を設定"
@ -476,7 +480,8 @@ attachAsFileQuestion: "クリップボードのテキストが長いです。テ
noMessagesYet: "まだチャットはありません" noMessagesYet: "まだチャットはありません"
newMessageExists: "新しいメッセージがあります" newMessageExists: "新しいメッセージがあります"
onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです"
signinRequired: "続行する前に、サインアップまたはサインインが必要です" signinRequired: "続行する前に、登録またはログインが必要です"
signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります"
invitations: "招待" invitations: "招待"
invitationCode: "招待コード" invitationCode: "招待コード"
checking: "確認しています" checking: "確認しています"
@ -1242,6 +1247,7 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ
noDescription: "説明文はありません" noDescription: "説明文はありません"
alwaysConfirmFollow: "フォローの際常に確認する" alwaysConfirmFollow: "フォローの際常に確認する"
inquiry: "お問い合わせ" inquiry: "お問い合わせ"
tryAgain: "もう一度お試しください。"
_delivery: _delivery:
status: "配信状態" status: "配信状態"
@ -1705,6 +1711,7 @@ _role:
canManageAvatarDecorations: "アバターデコレーションの管理" canManageAvatarDecorations: "アバターデコレーションの管理"
driveCapacity: "ドライブ容量" driveCapacity: "ドライブ容量"
alwaysMarkNsfw: "ファイルにNSFWを常に付与" alwaysMarkNsfw: "ファイルにNSFWを常に付与"
canUpdateBioMedia: "アイコンとバナーの更新を許可"
pinMax: "ノートのピン留めの最大数" pinMax: "ノートのピン留めの最大数"
antennaMax: "アンテナの作成可能数" antennaMax: "アンテナの作成可能数"
wordMuteMax: "ワードミュートの最大文字数" wordMuteMax: "ワードミュートの最大文字数"
@ -1971,8 +1978,6 @@ _sfx:
note: "ノート" note: "ノート"
noteMy: "ノート(自分)" noteMy: "ノート(自分)"
notification: "通知" notification: "通知"
antenna: "アンテナ受信"
channel: "チャンネル通知"
reaction: "リアクション選択時" reaction: "リアクション選択時"
_soundSettings: _soundSettings:

View File

@ -55,20 +55,22 @@
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"postcss": "8.4.38", "postcss": "8.4.38",
"tar": "6.2.1", "tar": "6.2.1",
"terser": "5.30.3", "terser": "5.31.1",
"typescript": "5.5.2", "typescript": "5.5.3",
"esbuild": "0.20.2", "esbuild": "0.22.0",
"glob": "10.3.12" "glob": "10.3.12"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.12.7", "@misskey-dev/eslint-plugin": "2.0.2",
"@typescript-eslint/eslint-plugin": "7.7.1", "@types/node": "20.14.9",
"@typescript-eslint/parser": "7.7.1", "@typescript-eslint/eslint-plugin": "7.15.0",
"@typescript-eslint/parser": "7.15.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.7.3", "cypress": "13.13.0",
"eslint": "8.57.0", "eslint": "9.6.0",
"globals": "15.7.0",
"ncp": "2.0.0", "ncp": "2.0.0",
"start-server-and-test": "2.0.3" "start-server-and-test": "2.0.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tensorflow/tfjs-core": "4.4.0" "@tensorflow/tfjs-core": "4.4.0"

View File

@ -1,4 +0,0 @@
node_modules
/built
/.eslintrc.js
/@types/**/*

View File

@ -1,32 +0,0 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json', './test/tsconfig.json'],
},
extends: [
'../shared/.eslintrc.js',
],
rules: {
'import/order': ['warn', {
'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
'pathGroups': [
{
'pattern': '@/**',
'group': 'external',
'position': 'after'
}
],
}],
'no-restricted-globals': [
'error',
{
'name': '__dirname',
'message': 'Not in ESModule. Use `import.meta.url` instead.'
},
{
'name': '__filename',
'message': 'Not in ESModule. Use `import.meta.url` instead.'
}
]
},
};

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<title>Misskey API</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<script
id="api-reference"
data-url="/api.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>

View File

@ -1,24 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Misskey API</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url="/api.json" expand-responses="200" expand-single-schema-field="true"></redoc>
<script src="https://cdn.redoc.ly/redoc/v2.1.3/bundles/redoc.standalone.js" integrity="sha256-u4DgqzYXoArvNF/Ymw3puKexfOC6lYfw0sfmeliBJ1I=" crossorigin="anonymous"></script>
</body>
</html>

View File

@ -0,0 +1,46 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js';
export default [
...sharedConfig,
{
ignores: ['**/node_modules', 'built', '@types/**/*'],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json', './test/tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'import/order': ['warn', {
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
'object',
'type',
],
pathGroups: [{
pattern: '@/**',
group: 'external',
position: 'after',
}],
}],
'no-restricted-globals': ['error', {
name: '__dirname',
message: 'Not in ESModule. Use `import.meta.url` instead.',
}, {
name: '__filename',
message: 'Not in ESModule. Use `import.meta.url` instead.',
}],
},
},
];

View File

@ -65,43 +65,43 @@
"utf-8-validate": "6.0.3" "utf-8-validate": "6.0.3"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.412.0", "@aws-sdk/client-s3": "3.600.0",
"@aws-sdk/lib-storage": "3.412.0", "@aws-sdk/lib-storage": "3.600.0",
"@bull-board/api": "5.17.0", "@bull-board/api": "5.20.5",
"@bull-board/fastify": "5.17.0", "@bull-board/fastify": "5.20.5",
"@bull-board/ui": "5.17.0", "@bull-board/ui": "5.20.5",
"@discordapp/twemoji": "15.0.3", "@discordapp/twemoji": "15.0.3",
"@fastify/accepts": "4.3.0", "@fastify/accepts": "4.3.0",
"@fastify/cookie": "9.3.1", "@fastify/cookie": "9.3.1",
"@fastify/cors": "9.0.1", "@fastify/cors": "9.0.1",
"@fastify/express": "3.0.0", "@fastify/express": "3.0.0",
"@fastify/http-proxy": "9.5.0", "@fastify/http-proxy": "9.5.0",
"@fastify/multipart": "8.2.0", "@fastify/multipart": "8.3.0",
"@fastify/static": "7.0.3", "@fastify/static": "7.0.4",
"@fastify/view": "9.1.0", "@fastify/view": "9.1.0",
"@misskey-dev/node-http-message-signatures": "0.0.10", "@misskey-dev/node-http-message-signatures": "0.0.10",
"@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.1.0", "@misskey-dev/summaly": "5.1.0",
"@napi-rs/canvas": "^0.1.52", "@napi-rs/canvas": "^0.1.53",
"@nestjs/common": "10.3.8", "@nestjs/common": "10.3.10",
"@nestjs/core": "10.3.8", "@nestjs/core": "10.3.10",
"@nestjs/testing": "10.3.8", "@nestjs/testing": "10.3.10",
"@sentry/node": "^8.5.0", "@sentry/node": "8.13.0",
"@sentry/profiling-node": "^8.5.0", "@sentry/profiling-node": "8.13.0",
"@simplewebauthn/server": "10.0.0", "@simplewebauthn/server": "10.0.0",
"@sinonjs/fake-timers": "11.2.2", "@sinonjs/fake-timers": "11.2.2",
"@smithy/node-http-handler": "2.5.0", "@smithy/node-http-handler": "2.5.0",
"@swc/cli": "0.3.12", "@swc/cli": "0.3.12",
"@swc/core": "1.4.17", "@swc/core": "1.6.6",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.13.0", "ajv": "8.16.0",
"archiver": "7.0.1", "archiver": "7.0.1",
"async-mutex": "0.5.0", "async-mutex": "0.5.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bullmq": "5.7.8", "bullmq": "5.8.3",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.2", "cbor": "9.0.2",
"chalk": "5.3.0", "chalk": "5.3.0",
@ -112,27 +112,27 @@
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"fastify": "4.26.2", "fastify": "4.28.1",
"fastify-raw-body": "4.3.0", "fastify-raw-body": "4.3.0",
"feed": "4.2.2", "feed": "4.2.2",
"file-type": "19.0.0", "file-type": "19.0.0",
"fluent-ffmpeg": "2.1.2", "fluent-ffmpeg": "2.1.3",
"form-data": "4.0.0", "form-data": "4.0.0",
"got": "14.2.1", "got": "14.4.1",
"happy-dom": "10.0.3", "happy-dom": "10.0.3",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"htmlescape": "1.1.1", "htmlescape": "1.1.1",
"http-link-header": "1.1.3", "http-link-header": "1.1.3",
"ioredis": "5.4.1", "ioredis": "5.4.1",
"ip-cidr": "3.1.0", "ip-cidr": "4.0.1",
"ipaddr.js": "2.2.0", "ipaddr.js": "2.2.0",
"is-svg": "5.0.0", "is-svg": "5.0.1",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsdom": "24.0.0", "jsdom": "24.1.0",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.2", "jsonld": "8.3.2",
"jsrsasign": "11.1.0", "jsrsasign": "11.1.0",
"meilisearch": "0.38.0", "meilisearch": "0.41.0",
"mfm-js": "0.24.0", "mfm-js": "0.24.0",
"microformats-parser": "2.0.2", "microformats-parser": "2.0.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
@ -142,24 +142,24 @@
"nanoid": "5.0.7", "nanoid": "5.0.7",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.13", "nodemailer": "6.9.14",
"nsfwjs": "2.4.2", "nsfwjs": "2.4.2",
"oauth": "0.10.0", "oauth": "0.10.0",
"oauth2orize": "1.12.0", "oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2", "oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14", "os-utils": "0.0.14",
"otpauth": "9.2.3", "otpauth": "9.3.1",
"parse5": "7.1.2", "parse5": "7.1.2",
"pg": "8.11.5", "pg": "8.12.0",
"pkce-challenge": "4.1.0", "pkce-challenge": "4.1.0",
"probe-image-size": "7.2.3", "probe-image-size": "7.2.3",
"promise-limit": "2.7.0", "promise-limit": "2.7.0",
"pug": "3.0.2", "pug": "3.0.3",
"punycode": "2.3.1", "punycode": "2.3.1",
"qrcode": "1.5.3", "qrcode": "1.5.3",
"random-seed": "0.3.0", "random-seed": "0.3.0",
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.21.2", "re2": "1.21.3",
"redis-lock": "0.1.4", "redis-lock": "0.1.4",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rename": "1.0.4", "rename": "1.0.4",
@ -167,27 +167,26 @@
"rxjs": "7.8.1", "rxjs": "7.8.1",
"sanitize-html": "2.13.0", "sanitize-html": "2.13.0",
"secure-json-parse": "2.7.0", "secure-json-parse": "2.7.0",
"sharp": "0.33.3", "sharp": "0.33.4",
"slacc": "0.0.10", "slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"systeminformation": "5.22.7", "systeminformation": "5.22.11",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.3", "tmp": "0.2.3",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.10",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typeorm": "0.3.20", "typeorm": "0.3.20",
"typescript": "5.5.2", "typescript": "5.5.3",
"ulid": "2.3.0", "ulid": "2.3.0",
"vary": "1.1.2", "vary": "1.1.2",
"web-push": "3.6.7", "web-push": "3.6.7",
"ws": "8.17.0", "ws": "8.17.1",
"xev": "3.0.2" "xev": "3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "29.7.0", "@jest/globals": "29.7.0",
"@misskey-dev/eslint-plugin": "1.0.0", "@nestjs/platform-express": "10.3.10",
"@nestjs/platform-express": "10.3.8",
"@simplewebauthn/types": "10.0.0", "@simplewebauthn/types": "10.0.0",
"@swc/jest": "0.2.36", "@swc/jest": "0.2.36",
"@types/accepts": "1.3.7", "@types/accepts": "1.3.7",
@ -197,21 +196,21 @@
"@types/color-convert": "2.0.3", "@types/color-convert": "2.0.3",
"@types/content-disposition": "0.5.8", "@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.24", "@types/fluent-ffmpeg": "2.1.24",
"@types/htmlescape": "^1.1.3", "@types/htmlescape": "1.1.3",
"@types/http-link-header": "1.0.5", "@types/http-link-header": "1.0.7",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.6", "@types/jsdom": "21.1.7",
"@types/jsonld": "1.5.13", "@types/jsonld": "1.5.14",
"@types/jsrsasign": "10.5.14", "@types/jsrsasign": "10.5.14",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/ms": "0.7.34", "@types/ms": "0.7.34",
"@types/node": "20.12.7", "@types/node": "20.14.9",
"@types/nodemailer": "6.4.15", "@types/nodemailer": "6.4.15",
"@types/oauth": "0.9.4", "@types/oauth": "0.9.5",
"@types/oauth2orize": "1.11.5", "@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2", "@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.5", "@types/pg": "8.11.6",
"@types/pug": "2.0.10", "@types/pug": "2.0.10",
"@types/punycode": "2.1.4", "@types/punycode": "2.1.4",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
@ -227,18 +226,17 @@
"@types/vary": "1.1.3", "@types/vary": "1.1.3",
"@types/web-push": "3.6.3", "@types/web-push": "3.6.3",
"@types/ws": "8.5.10", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "7.7.1", "@typescript-eslint/eslint-plugin": "7.15.0",
"@typescript-eslint/parser": "7.7.1", "@typescript-eslint/parser": "7.15.0",
"aws-sdk-client-mock": "3.0.1", "aws-sdk-client-mock": "4.0.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.57.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-import": "2.29.1",
"execa": "8.0.1", "execa": "9.2.0",
"fkill": "^9.0.0", "fkill": "9.0.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-mock": "29.7.0", "jest-mock": "29.7.0",
"nodemon": "3.1.0", "nodemon": "3.1.4",
"pid-port": "1.0.0", "pid-port": "1.0.0",
"simple-oauth2": "5.0.0" "simple-oauth2": "5.0.1"
} }
} }

View File

@ -30,6 +30,7 @@ function execStart() {
async function killProc() { async function killProc() {
if (backendProcess) { if (backendProcess) {
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
backendProcess.kill(); backendProcess.kill();
await new Promise(resolve => backendProcess.on('exit', resolve)); await new Promise(resolve => backendProcess.on('exit', resolve));
backendProcess = undefined; backendProcess = undefined;
@ -46,6 +47,7 @@ async function killProc() {
], ],
{ {
stdio: [process.stdin, process.stdout, process.stderr, 'ipc'], stdio: [process.stdin, process.stdout, process.stderr, 'ipc'],
serialization: "json",
}) })
.on('message', async (message) => { .on('message', async (message) => {
if (message.type === 'exit') { if (message.type === 'exit') {

View File

@ -23,7 +23,7 @@ type RedisOptionsSource = Partial<RedisOptions> & {
* *
*/ */
type Source = { type Source = {
url: string; url?: string;
port?: number; port?: number;
socket?: string; socket?: string;
chmodSocket?: string; chmodSocket?: string;
@ -31,9 +31,9 @@ type Source = {
db: { db: {
host: string; host: string;
port: number; port: number;
db: string; db?: string;
user: string; user?: string;
pass: string; pass?: string;
disableCache?: boolean; disableCache?: boolean;
extra?: { [x: string]: string }; extra?: { [x: string]: string };
}; };
@ -202,13 +202,17 @@ export function loadConfig(): Config {
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const url = tryCreateUrl(config.url); const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
const version = meta.version; const version = meta.version;
const host = url.host; const host = url.host;
const hostname = url.hostname; const hostname = url.hostname;
const scheme = url.protocol.replace(/:$/, ''); const scheme = url.protocol.replace(/:$/, '');
const wsScheme = scheme.replace('http', 'ws'); const wsScheme = scheme.replace('http', 'ws');
const dbDb = config.db.db ?? process.env.DATABASE_DB ?? '';
const dbUser = config.db.user ?? process.env.DATABASE_USER ?? '';
const dbPass = config.db.pass ?? process.env.DATABASE_PASSWORD ?? '';
const externalMediaProxy = config.mediaProxy ? const externalMediaProxy = config.mediaProxy ?
config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy
: null; : null;
@ -231,7 +235,7 @@ export function loadConfig(): Config {
apiUrl: `${scheme}://${host}/api`, apiUrl: `${scheme}://${host}/api`,
authUrl: `${scheme}://${host}/auth`, authUrl: `${scheme}://${host}/auth`,
driveUrl: `${scheme}://${host}/files`, driveUrl: `${scheme}://${host}/files`,
db: config.db, db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass },
dbReplications: config.dbReplications, dbReplications: config.dbReplications,
dbSlaves: config.dbSlaves, dbSlaves: config.dbSlaves,
meilisearch: config.meilisearch, meilisearch: config.meilisearch,
@ -259,7 +263,7 @@ export function loadConfig(): Config {
deliverJobMaxAttempts: config.deliverJobMaxAttempts, deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts, inboxJobMaxAttempts: config.inboxJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles, proxyRemoteFiles: config.proxyRemoteFiles,
signToActivityPubGet: config.signToActivityPubGet, signToActivityPubGet: config.signToActivityPubGet ?? true,
mediaProxy: externalMediaProxy ?? internalMediaProxy, mediaProxy: externalMediaProxy ?? internalMediaProxy,
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
videoThumbnailGenerator: config.videoThumbnailGenerator ? videoThumbnailGenerator: config.videoThumbnailGenerator ?

View File

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
// dummy
export const MAX_NOTE_TEXT_LENGTH = 3000; export const MAX_NOTE_TEXT_LENGTH = 3000;
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min

View File

@ -12,6 +12,7 @@ import {
} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; } from '@/core/entities/AbuseReportNotificationRecipientEntityService.js';
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js';
import { AccountMoveService } from './AccountMoveService.js'; import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js'; import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js'; import { AiService } from './AiService.js';
@ -202,6 +203,7 @@ const $UserFollowingService: Provider = { provide: 'UserFollowingService', useEx
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService };
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
@ -348,6 +350,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserKeypairService, UserKeypairService,
UserListService, UserListService,
UserMutingService, UserMutingService,
UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
VideoProcessingService, VideoProcessingService,
@ -490,6 +493,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserKeypairService, $UserKeypairService,
$UserListService, $UserListService,
$UserMutingService, $UserMutingService,
$UserSearchService,
$UserSuspendService, $UserSuspendService,
$UserAuthService, $UserAuthService,
$VideoProcessingService, $VideoProcessingService,
@ -633,6 +637,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserKeypairService, UserKeypairService,
UserListService, UserListService,
UserMutingService, UserMutingService,
UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
VideoProcessingService, VideoProcessingService,
@ -774,6 +779,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserKeypairService, $UserKeypairService,
$UserListService, $UserListService,
$UserMutingService, $UserMutingService,
$UserSearchService,
$UserSuspendService, $UserSuspendService,
$UserAuthService, $UserAuthService,
$VideoProcessingService, $VideoProcessingService,

View File

@ -40,6 +40,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
firstRetrievedAt: new Date(parsed.firstRetrievedAt), firstRetrievedAt: new Date(parsed.firstRetrievedAt),
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null,
}; };
}, },
}); });

View File

@ -13,10 +13,12 @@ import { intersperse } from '@/misc/prelude/array.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; import type { DefaultTreeAdapterMap } from 'parse5';
import type * as mfm from 'mfm-js'; import type * as mfm from 'mfm-js';
const treeAdapter = TreeAdapter.defaultTreeAdapter; const treeAdapter = parse5.defaultTreeAdapter;
type Node = DefaultTreeAdapterMap['node'];
type ChildNode = DefaultTreeAdapterMap['childNode'];
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
@ -46,7 +48,7 @@ export class MfmService {
return text.trim(); return text.trim();
function getText(node: TreeAdapter.Node): string { function getText(node: Node): string {
if (treeAdapter.isTextNode(node)) return node.value; if (treeAdapter.isTextNode(node)) return node.value;
if (!treeAdapter.isElementNode(node)) return ''; if (!treeAdapter.isElementNode(node)) return '';
if (node.nodeName === 'br') return '\n'; if (node.nodeName === 'br') return '\n';
@ -58,7 +60,7 @@ export class MfmService {
return ''; return '';
} }
function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { function appendChildren(childNodes: ChildNode[]): void {
if (childNodes) { if (childNodes) {
for (const n of childNodes) { for (const n of childNodes) {
analyze(n); analyze(n);
@ -66,14 +68,16 @@ export class MfmService {
} }
} }
function analyze(node: TreeAdapter.Node) { function analyze(node: Node) {
if (treeAdapter.isTextNode(node)) { if (treeAdapter.isTextNode(node)) {
text += node.value; text += node.value;
return; return;
} }
// Skip comment or document type node // Skip comment or document type node
if (!treeAdapter.isElementNode(node)) return; if (!treeAdapter.isElementNode(node)) {
return;
}
switch (node.nodeName) { switch (node.nodeName) {
case 'br': { case 'br': {
@ -81,8 +85,7 @@ export class MfmService {
break; break;
} }
case 'a': case 'a': {
{
const txt = getText(node); const txt = getText(node);
const rel = node.attrs.find(x => x.name === 'rel'); const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href'); const href = node.attrs.find(x => x.name === 'href');
@ -90,7 +93,7 @@ export class MfmService {
// ハッシュタグ // ハッシュタグ
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
text += txt; text += txt;
// メンション // メンション
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
const part = txt.split('@'); const part = txt.split('@');
@ -102,7 +105,7 @@ export class MfmService {
} else if (part.length === 3) { } else if (part.length === 3) {
text += txt; text += txt;
} }
// その他 // その他
} else { } else {
const generateLink = () => { const generateLink = () => {
if (!href && !txt) { if (!href && !txt) {
@ -130,8 +133,7 @@ export class MfmService {
break; break;
} }
case 'h1': case 'h1': {
{
text += '【'; text += '【';
appendChildren(node.childNodes); appendChildren(node.childNodes);
text += '】\n'; text += '】\n';
@ -139,16 +141,14 @@ export class MfmService {
} }
case 'b': case 'b':
case 'strong': case 'strong': {
{
text += '**'; text += '**';
appendChildren(node.childNodes); appendChildren(node.childNodes);
text += '**'; text += '**';
break; break;
} }
case 'small': case 'small': {
{
text += '<small>'; text += '<small>';
appendChildren(node.childNodes); appendChildren(node.childNodes);
text += '</small>'; text += '</small>';
@ -156,8 +156,7 @@ export class MfmService {
} }
case 's': case 's':
case 'del': case 'del': {
{
text += '~~'; text += '~~';
appendChildren(node.childNodes); appendChildren(node.childNodes);
text += '~~'; text += '~~';
@ -165,8 +164,7 @@ export class MfmService {
} }
case 'i': case 'i':
case 'em': case 'em': {
{
text += '<i>'; text += '<i>';
appendChildren(node.childNodes); appendChildren(node.childNodes);
text += '</i>'; text += '</i>';
@ -207,8 +205,7 @@ export class MfmService {
case 'h3': case 'h3':
case 'h4': case 'h4':
case 'h5': case 'h5':
case 'h6': case 'h6': {
{
text += '\n\n'; text += '\n\n';
appendChildren(node.childNodes); appendChildren(node.childNodes);
break; break;
@ -221,8 +218,7 @@ export class MfmService {
case 'article': case 'article':
case 'li': case 'li':
case 'dt': case 'dt':
case 'dd': case 'dd': {
{
text += '\n'; text += '\n';
appendChildren(node.childNodes); appendChildren(node.childNodes);
break; break;

View File

@ -47,6 +47,7 @@ export type RolePolicies = {
canHideAds: boolean; canHideAds: boolean;
driveCapacityMb: number; driveCapacityMb: number;
alwaysMarkNsfw: boolean; alwaysMarkNsfw: boolean;
canUpdateBioMedia: boolean;
pinLimit: number; pinLimit: number;
antennaLimit: number; antennaLimit: number;
wordMuteLimit: number; wordMuteLimit: number;
@ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canHideAds: false, canHideAds: false,
driveCapacityMb: 100, driveCapacityMb: 100,
alwaysMarkNsfw: false, alwaysMarkNsfw: false,
canUpdateBioMedia: true,
pinLimit: 5, pinLimit: 5,
antennaLimit: 5, antennaLimit: 5,
wordMuteLimit: 200, wordMuteLimit: 200,
@ -376,6 +378,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)),
pinLimit: calc('pinLimit', vs => Math.max(...vs)), pinLimit: calc('pinLimit', vs => Math.max(...vs)),
antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),
wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)),

View File

@ -0,0 +1,205 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets, SelectQueryBuilder } from 'typeorm';
import { DI } from '@/di-symbols.js';
import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import type { Config } from '@/config.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { Packed } from '@/misc/json-schema.js';
function defaultActiveThreshold() {
return new Date(Date.now() - 1000 * 60 * 60 * 24 * 30);
}
@Injectable()
export class UserSearchService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
) {
}
/**
* .
*
* - .
* 1.
* 2.
* 3.
* 4.
* - .
* 1.
* 2.
* - .
* - (IDが重複することはないが).
* 12, 3, 4
* - .
* - .
* - .
* - {@link opts.limit} .
*
* {@link params.activeThreshold} .
*
* @param params .
* @param opts .
* @param me . .
* @see {@link UserSearchService#buildSearchUserQueries}
* @see {@link UserSearchService#buildSearchUserNoLoginQueries}
*/
@bindThis
public async search(
params: {
username?: string | null,
host?: string | null,
activeThreshold?: Date,
},
opts?: {
limit?: number,
detail?: boolean,
},
me?: MiUser | null,
): Promise<Packed<'User'>[]> {
const queries = me ? this.buildSearchUserQueries(me, params) : this.buildSearchUserNoLoginQueries(params);
let resultSet = new Set<MiUser['id']>();
const limit = opts?.limit ?? 10;
for (const query of queries) {
const ids = await query
.select('user.id')
.limit(limit - resultSet.size)
.orderBy('user.usernameLower', 'ASC')
.getRawMany<{ user_id: MiUser['id'] }>()
.then(res => res.map(x => x.user_id));
resultSet = new Set([...resultSet, ...ids]);
if (resultSet.size >= limit) {
break;
}
}
return this.userEntityService.packMany<'UserLite' | 'UserDetailed'>(
[...resultSet].slice(0, limit),
me,
{ schema: opts?.detail ? 'UserDetailed' : 'UserLite' },
);
}
/**
* .
* @param me
* @param params
* @private
*/
@bindThis
private buildSearchUserQueries(
me: MiUser,
params: {
username?: string | null,
host?: string | null,
activeThreshold?: Date,
},
) {
// デフォルト30日以内に更新されたユーザーをアクティブユーザーとする
const activeThreshold = params.activeThreshold ?? defaultActiveThreshold();
const followingUserQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
const activeFollowingUsersQuery = this.generateUserQueryBuilder(params)
.andWhere(`user.id IN (${followingUserQuery.getQuery()})`)
.andWhere('user.updatedAt > :activeThreshold', { activeThreshold });
activeFollowingUsersQuery.setParameters(followingUserQuery.getParameters());
const inactiveFollowingUsersQuery = this.generateUserQueryBuilder(params)
.andWhere(`user.id IN (${followingUserQuery.getQuery()})`)
.andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt <= :activeThreshold', { activeThreshold });
}));
inactiveFollowingUsersQuery.setParameters(followingUserQuery.getParameters());
// 自分自身がヒットするとしたらここ
const activeUserQuery = this.generateUserQueryBuilder(params)
.andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`)
.andWhere('user.updatedAt > :activeThreshold', { activeThreshold });
activeUserQuery.setParameters(followingUserQuery.getParameters());
const inactiveUserQuery = this.generateUserQueryBuilder(params)
.andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`)
.andWhere('user.updatedAt <= :activeThreshold', { activeThreshold });
inactiveUserQuery.setParameters(followingUserQuery.getParameters());
return [activeFollowingUsersQuery, inactiveFollowingUsersQuery, activeUserQuery, inactiveUserQuery];
}
/**
* .
* @param params
* @private
*/
@bindThis
private buildSearchUserNoLoginQueries(params: {
username?: string | null,
host?: string | null,
activeThreshold?: Date,
}) {
// デフォルト30日以内に更新されたユーザーをアクティブユーザーとする
const activeThreshold = params.activeThreshold ?? defaultActiveThreshold();
const activeUserQuery = this.generateUserQueryBuilder(params)
.andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold });
}));
const inactiveUserQuery = this.generateUserQueryBuilder(params)
.andWhere('user.updatedAt <= :activeThreshold', { activeThreshold });
return [activeUserQuery, inactiveUserQuery];
}
/**
* .
* @param params
* @private
*/
@bindThis
private generateUserQueryBuilder(params: {
username?: string | null,
host?: string | null,
}): SelectQueryBuilder<MiUser> {
const userQuery = this.usersRepository.createQueryBuilder('user');
if (params.username) {
userQuery.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(params.username.toLowerCase()) + '%' });
}
if (params.host) {
if (params.host === this.config.hostname || params.host === '.') {
userQuery.andWhere('user.host IS NULL');
} else {
userQuery.andWhere('user.host LIKE :host', {
host: sqlLikeEscape(params.host.toLowerCase()) + '%',
});
}
}
userQuery.andWhere('user.isSuspended = FALSE');
return userQuery;
}
}

View File

@ -25,7 +25,7 @@ export class ApMfmService {
} }
@bindThis @bindThis
public getNoteHtml(note: MiNote, apAppend?: string) { public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, apAppend?: string) {
let noMisskeyContent = false; let noMisskeyContent = false;
const srcMfm = (note.text ?? '') + (apAppend ?? ''); const srcMfm = (note.text ?? '') + (apAppend ?? '');

View File

@ -35,6 +35,7 @@ import { StatusError } from '@/misc/status-error.js';
import type { UtilityService } from '@/core/UtilityService.js'; import type { UtilityService } from '@/core/UtilityService.js';
import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js';
@ -102,6 +103,8 @@ export class ApPersonService implements OnModuleInit {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private roleService: RoleService,
) { ) {
} }
@ -292,6 +295,11 @@ export class ApPersonService implements OnModuleInit {
return this.apImageService.resolveImage(user, img).catch(() => null); return this.apImageService.resolveImage(user, img).catch(() => null);
})); }));
if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null))
&& !(await this.roleService.getUserPolicies(user.id)).canUpdateBioMedia) {
return {};
}
/* /*
we don't want to return nulls on errors! if the database fields we don't want to return nulls on errors! if the database fields
are already null, nothing changes; if the database has old are already null, nothing changes; if the database has old

View File

@ -74,10 +74,10 @@ export class ApQuestionService {
//#region このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const note = await this.notesRepository.findOneBy({ uri }); const note = await this.notesRepository.findOneBy({ uri });
if (note == null) throw new Error('Question is not registed'); if (note == null) throw new Error('Question is not registered');
const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
if (poll == null) throw new Error('Question is not registed'); if (poll == null) throw new Error('Question is not registered');
//#endregion //#endregion
// resolve new Question object // resolve new Question object

View File

@ -50,6 +50,22 @@ export class MetaEntityService {
})) }))
.getMany(); .getMany();
// クライアントの手間を減らすためあらかじめJSONに変換しておく
let defaultLightTheme = null;
let defaultDarkTheme = null;
if (instance.defaultLightTheme) {
try {
defaultLightTheme = JSON.stringify(JSON5.parse(instance.defaultLightTheme));
} catch (e) {
}
}
if (instance.defaultDarkTheme) {
try {
defaultDarkTheme = JSON.stringify(JSON5.parse(instance.defaultDarkTheme));
} catch (e) {
}
}
const packed: Packed<'MetaLite'> = { const packed: Packed<'MetaLite'> = {
maintainerName: instance.maintainerName, maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail, maintainerEmail: instance.maintainerEmail,
@ -90,9 +106,8 @@ export class MetaEntityService {
backgroundImageUrl: instance.backgroundImageUrl, backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl, logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
// クライアントの手間を減らすためあらかじめJSONに変換しておく defaultLightTheme,
defaultLightTheme: instance.defaultLightTheme ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) : null, defaultDarkTheme,
defaultDarkTheme: instance.defaultDarkTheme ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) : null,
ads: ads.map(ad => ({ ads: ads.map(ad => ({
id: ad.id, id: ad.id,
url: ad.url, url: ad.url,

View File

@ -501,11 +501,15 @@ export class UserEntityService implements OnModuleInit {
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user), onlineStatus: this.getOnlineStatus(user),
// パフォーマンス上の理由でローカルユーザーのみ // パフォーマンス上の理由でローカルユーザーのみ
badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
name: r.name, .filter((r) => r.isPublic || iAmModerator)
iconUrl: r.iconUrl, .sort((a, b) => b.displayOrder - a.displayOrder)
displayOrder: r.displayOrder, .map((r) => ({
}))) : undefined, name: r.name,
iconUrl: r.iconUrl,
displayOrder: r.displayOrder,
}))
) : undefined,
...(isDetailed ? { ...(isDetailed ? {
url: profile!.url, url: profile!.url,

View File

@ -4,6 +4,10 @@
*/ */
export function isUserRelated(note: any, userIds: Set<string>, ignoreAuthor = false): boolean { export function isUserRelated(note: any, userIds: Set<string>, ignoreAuthor = false): boolean {
if (!note) {
return false;
}
if (userIds.has(note.userId) && !ignoreAuthor) { if (userIds.has(note.userId) && !ignoreAuthor) {
return true; return true;
} }

View File

@ -20,7 +20,7 @@ export const packedDriveFileSchema = {
name: { name: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
example: 'lenna.jpg', example: '192.jpg',
}, },
type: { type: {
type: 'string', type: 'string',

View File

@ -228,6 +228,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
canUpdateBioMedia: {
type: 'boolean',
optional: false, nullable: false,
},
pinLimit: { pinLimit: {
type: 'integer', type: 'integer',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -61,7 +61,7 @@ export const meta = {
name: { name: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
example: 'lenna.jpg', example: '192.jpg',
}, },
type: { type: {
type: 'string', type: 'string',

View File

@ -25,7 +25,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { HashtagService } from '@/core/HashtagService.js'; import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RolePolicies, RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
@ -256,8 +256,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profileUpdates = {} as Partial<MiUserProfile>; const profileUpdates = {} as Partial<MiUserProfile>;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
let policies: RolePolicies | null = null;
if (ps.name !== undefined) updates.name = ps.name; if (ps.name !== undefined) {
if (ps.name === null) {
updates.name = null;
} else {
const trimmedName = ps.name.trim();
updates.name = trimmedName === '' ? null : trimmedName;
}
}
if (ps.description !== undefined) profileUpdates.description = ps.description; if (ps.description !== undefined) profileUpdates.description = ps.description;
if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.lang !== undefined) profileUpdates.lang = ps.lang;
if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.location !== undefined) profileUpdates.location = ps.location;
@ -289,14 +297,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.mutedWords !== undefined) { if (ps.mutedWords !== undefined) {
checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); policies ??= await this.roleService.getUserPolicies(user.id);
checkMuteWordCount(ps.mutedWords, policies.wordMuteLimit);
validateMuteWordRegex(ps.mutedWords); validateMuteWordRegex(ps.mutedWords);
profileUpdates.mutedWords = ps.mutedWords; profileUpdates.mutedWords = ps.mutedWords;
profileUpdates.enableWordMute = ps.mutedWords.length > 0; profileUpdates.enableWordMute = ps.mutedWords.length > 0;
} }
if (ps.hardMutedWords !== undefined) { if (ps.hardMutedWords !== undefined) {
checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); policies ??= await this.roleService.getUserPolicies(user.id);
checkMuteWordCount(ps.hardMutedWords, policies.wordMuteLimit);
validateMuteWordRegex(ps.hardMutedWords); validateMuteWordRegex(ps.hardMutedWords);
profileUpdates.hardMutedWords = ps.hardMutedWords; profileUpdates.hardMutedWords = ps.hardMutedWords;
} }
@ -315,13 +325,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
if (typeof ps.alwaysMarkNsfw === 'boolean') { if (typeof ps.alwaysMarkNsfw === 'boolean') {
if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); policies ??= await this.roleService.getUserPolicies(user.id);
if (policies.alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole);
profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
} }
if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
if (ps.avatarId) { if (ps.avatarId) {
policies ??= await this.roleService.getUserPolicies(user.id);
if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole);
const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId });
if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar);
@ -337,6 +351,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.bannerId) { if (ps.bannerId) {
policies ??= await this.roleService.getUserPolicies(user.id);
if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole);
const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId });
if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner);
@ -352,14 +369,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.avatarDecorations) { if (ps.avatarDecorations) {
policies ??= await this.roleService.getUserPolicies(user.id);
const decorations = await this.avatarDecorationService.getAll(true); const decorations = await this.avatarDecorationService.getAll(true);
const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); const myRoles = await this.roleService.getUserRoles(user.id);
const allRoles = await this.roleService.getRoles(); const allRoles = await this.roleService.getRoles();
const decorationIds = decorations const decorationIds = decorations
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
.map(d => d.id); .map(d => d.id);
if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); if (ps.avatarDecorations.length > policies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole);
updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
id: d.id, id: d.id,

View File

@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -74,6 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService, private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set<string>();
const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users
if (!iAmModerator) { if (!iAmModerator) {
const user = await this.cacheService.findUserById(ps.userId); const user = await this.cacheService.findUserById(ps.userId);
@ -85,8 +87,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { if ((me == null || me.id !== ps.userId) && !profile.publicReactions) {
throw new ApiError(meta.errors.reactionsNotPublic); throw new ApiError(meta.errors.reactionsNotPublic);
} }
// early return if me is blocked by requesting user
if (userIdsWhoBlockingMe.has(ps.userId)) {
return [];
}
} }
const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set<string>();
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('reaction.userId = :userId', { userId: ps.userId }) .andWhere('reaction.userId = :userId', { userId: ps.userId })
@ -94,9 +103,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
const reactions = await query const reactions = (await query
.limit(ps.limit) .limit(ps.limit)
.getMany(); .getMany()).filter(reaction => {
if (reaction.note?.userId === ps.userId) return true; // we can see reactions to note of requesting user
if (me && isUserRelated(reaction.note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(reaction.note, userIdsWhoMeMuting)) return false;
return true;
});
return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true }); return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true });
}); });

View File

@ -3,15 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Brackets } from 'typeorm'; import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserSearchService } from '@/core/UserSearchService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = { export const meta = {
tags: ['users'], tags: ['users'],
@ -49,89 +43,16 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.config) private userSearchService: UserSearchService,
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, (ps, me) => {
const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => { return this.userSearchService.search({
if (ps.username) { username: ps.username,
query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); host: ps.host,
} }, {
limit: ps.limit,
if (ps.host) { detail: ps.detail,
if (ps.host === this.config.hostname || ps.host === '.') { }, me);
query.andWhere('user.host IS NULL');
} else {
query.andWhere('user.host LIKE :host', {
host: sqlLikeEscape(ps.host.toLowerCase()) + '%',
});
}
}
return query;
};
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
let users: MiUser[] = [];
if (me) {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
const query = setUsernameAndHostQuery()
.andWhere(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE')
.andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}));
query.setParameters(followingQuery.getParameters());
users = await query
.orderBy('user.usernameLower', 'ASC')
.limit(ps.limit)
.getMany();
if (users.length < ps.limit) {
const otherQuery = setUsernameAndHostQuery()
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`)
.andWhere('user.isSuspended = FALSE')
.andWhere('user.updatedAt IS NOT NULL');
otherQuery.setParameters(followingQuery.getParameters());
const otherUsers = await otherQuery
.orderBy('user.updatedAt', 'DESC')
.limit(ps.limit - users.length)
.getMany();
users = users.concat(otherUsers);
}
} else {
const query = setUsernameAndHostQuery()
.andWhere('user.isSuspended = FALSE')
.andWhere('user.updatedAt IS NOT NULL');
users = await query
.orderBy('user.updatedAt', 'DESC')
.limit(ps.limit - users.length)
.getMany();
}
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
}); });
} }
} }

View File

@ -25,7 +25,7 @@ export class OpenApiServerService {
public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
fastify.get('/api-doc', async (_request, reply) => { fastify.get('/api-doc', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=86400'); reply.header('Cache-Control', 'public, max-age=86400');
return await reply.sendFile('/redoc.html', staticAssets); return await reply.sendFile('/api-doc.html', staticAssets);
}); });
fastify.get('/api.json', (_request, reply) => { fastify.get('/api.json', (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=600'); reply.header('Cache-Control', 'public, max-age=600');

View File

@ -15,7 +15,6 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
info: { info: {
version: config.version, version: config.version,
title: 'Misskey API', title: 'Misskey API',
'x-logo': { url: '/static-assets/api-doc.png' },
}, },
externalDocs: { externalDocs: {

View File

@ -29,7 +29,8 @@
let forceError = localStorage.getItem('forceError'); let forceError = localStorage.getItem('forceError');
if (forceError != null) { if (forceError != null) {
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.') renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
return;
} }
//#region Detect language & fetch translations //#region Detect language & fetch translations
@ -155,7 +156,12 @@
document.head.appendChild(css); document.head.appendChild(css);
} }
function renderError(code, details) { async function renderError(code, details) {
// Cannot set property 'innerHTML' of null を回避
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
let errorsElement = document.getElementById('errors'); let errorsElement = document.getElementById('errors');
if (!errorsElement) { if (!errorsElement) {
@ -314,6 +320,6 @@
#errorInfo { #errorInfo {
width: 50%; width: 50%;
} }
`) }`)
} }
})(); })();

View File

@ -1,32 +0,0 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
extends: [
'../../shared/.eslintrc.js',
],
rules: {
'import/order': ['warn', {
'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
'pathGroups': [
{
'pattern': '@/**',
'group': 'external',
'position': 'after'
}
],
}],
'no-restricted-globals': [
'error',
{
'name': '__dirname',
'message': 'Not in ESModule. Use `import.meta.url` instead.'
},
{
'name': '__filename',
'message': 'Not in ESModule. Use `import.meta.url` instead.'
}
]
},
};

View File

@ -0,0 +1,43 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../../shared/eslint.config.js';
export default [
...sharedConfig,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'import/order': ['warn', {
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
'object',
'type',
],
pathGroups: [{
pattern: '@/**',
group: 'external',
position: 'after',
}],
}],
'no-restricted-globals': ['error', {
name: '__dirname',
message: 'Not in ESModule. Use `import.meta.url` instead.',
}, {
name: '__filename',
message: 'Not in ESModule. Use `import.meta.url` instead.',
}],
},
},
];

View File

@ -1,11 +0,0 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
extends: ['../.eslintrc.cjs'],
env: {
node: true,
jest: true,
},
};

View File

@ -206,7 +206,7 @@ describe('2要素認証', () => {
username, username,
}, alice); }, alice);
assert.strictEqual(usersShowResponse.status, 200); assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true);
const signinResponse = await api('signin', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
@ -248,7 +248,7 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
creationOptions: registerKeyResponse.body, creationOptions: registerKeyResponse.body,
}) as any, alice); } as any) as any, alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
assert.strictEqual(keyDoneResponse.body.name, keyName); assert.strictEqual(keyDoneResponse.body.name, keyName);
@ -257,22 +257,22 @@ describe('2要素認証', () => {
username, username,
}); });
assert.strictEqual(usersShowResponse.status, 200); assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual(usersShowResponse.body.securityKeys, true); assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true);
const signinResponse = await api('signin', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
}); });
assert.strictEqual(signinResponse.status, 200); assert.strictEqual(signinResponse.status, 200);
assert.strictEqual(signinResponse.body.i, undefined); assert.strictEqual(signinResponse.body.i, undefined);
assert.notEqual(signinResponse.body.challenge, undefined); assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined);
assert.notEqual(signinResponse.body.allowCredentials, undefined); assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined);
assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url')); assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url'));
const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ const signinResponse2 = await api('signin', signinWithSecurityKeyParam({
keyName, keyName,
credentialId, credentialId,
requestOptions: signinResponse.body, requestOptions: signinResponse.body,
})); } as any));
assert.strictEqual(signinResponse2.status, 200); assert.strictEqual(signinResponse2.status, 200);
assert.notEqual(signinResponse2.body.i, undefined); assert.notEqual(signinResponse2.body.i, undefined);
@ -307,7 +307,7 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
creationOptions: registerKeyResponse.body, creationOptions: registerKeyResponse.body,
}) as any, alice); } as any) as any, alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
const passwordLessResponse = await api('i/2fa/password-less', { const passwordLessResponse = await api('i/2fa/password-less', {
@ -319,7 +319,7 @@ describe('2要素認証', () => {
username, username,
}); });
assert.strictEqual(usersShowResponse.status, 200); assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual(usersShowResponse.body.usePasswordLessLogin, true); assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true);
const signinResponse = await api('signin', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
@ -333,7 +333,7 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
requestOptions: signinResponse.body, requestOptions: signinResponse.body,
}), } as any),
password: '', password: '',
}); });
assert.strictEqual(signinResponse2.status, 200); assert.strictEqual(signinResponse2.status, 200);
@ -370,7 +370,7 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
creationOptions: registerKeyResponse.body, creationOptions: registerKeyResponse.body,
}) as any, alice); } as any) as any, alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
const renamedKey = 'other-key'; const renamedKey = 'other-key';
@ -383,6 +383,7 @@ describe('2要素認証', () => {
const iResponse = await api('i', { const iResponse = await api('i', {
}, alice); }, alice);
assert.strictEqual(iResponse.status, 200); assert.strictEqual(iResponse.status, 200);
assert.ok(iResponse.body.securityKeysList);
const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url')); const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url'));
assert.strictEqual(securityKeys.length, 1); assert.strictEqual(securityKeys.length, 1);
assert.strictEqual(securityKeys[0].name, renamedKey); assert.strictEqual(securityKeys[0].name, renamedKey);
@ -419,13 +420,14 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
creationOptions: registerKeyResponse.body, creationOptions: registerKeyResponse.body,
}) as any, alice); } as any) as any, alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
// テストの実行順によっては複数残ってるので全部消す // テストの実行順によっては複数残ってるので全部消す
const iResponse = await api('i', { const iResponse = await api('i', {
}, alice); }, alice);
assert.strictEqual(iResponse.status, 200); assert.strictEqual(iResponse.status, 200);
assert.ok(iResponse.body.securityKeysList);
for (const key of iResponse.body.securityKeysList) { for (const key of iResponse.body.securityKeysList) {
const removeKeyResponse = await api('i/2fa/remove-key', { const removeKeyResponse = await api('i/2fa/remove-key', {
token: otpToken(registerResponse.body.secret), token: otpToken(registerResponse.body.secret),
@ -439,7 +441,7 @@ describe('2要素認証', () => {
username, username,
}); });
assert.strictEqual(usersShowResponse.status, 200); assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual(usersShowResponse.body.securityKeys, false); assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false);
const signinResponse = await api('signin', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
@ -470,7 +472,7 @@ describe('2要素認証', () => {
username, username,
}); });
assert.strictEqual(usersShowResponse.status, 200); assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true);
const unregisterResponse = await api('i/2fa/unregister', { const unregisterResponse = await api('i/2fa/unregister', {
token: otpToken(registerResponse.body.secret), token: otpToken(registerResponse.body.secret),

View File

@ -410,21 +410,21 @@ describe('API visibility', () => {
test('[HTL] public-post が 自分が見れる', async () => { test('[HTL] public-post が 自分が見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, alice); const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === pub.id); const notes = res.body.filter(n => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[HTL] public-post が 非フォロワーから見れない', async () => { test('[HTL] public-post が 非フォロワーから見れない', async () => {
const res = await api('notes/timeline', { limit: 100 }, other); const res = await api('notes/timeline', { limit: 100 }, other);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === pub.id); const notes = res.body.filter(n => n.id === pub.id);
assert.strictEqual(notes.length, 0); assert.strictEqual(notes.length, 0);
}); });
test('[HTL] followers-post が フォロワーから見れる', async () => { test('[HTL] followers-post が フォロワーから見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, follower); const res = await api('notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === fol.id); const notes = res.body.filter(n => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
}); });
//#endregion //#endregion
@ -433,21 +433,21 @@ describe('API visibility', () => {
test('[replies] followers-reply が フォロワーから見れる', async () => { test('[replies] followers-reply が フォロワーから見れる', async () => {
const res = await api('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); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id); const notes = res.body.filter(n => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => {
const res = await api('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); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id); const notes = res.body.filter(n => n.id === folR.id);
assert.strictEqual(notes.length, 0); assert.strictEqual(notes.length, 0);
}); });
test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
const res = await api('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); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id); const notes = res.body.filter(n => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
}); });
//#endregion //#endregion
@ -456,14 +456,14 @@ describe('API visibility', () => {
test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
const res = await api('notes/mentions', { limit: 100 }, target); const res = await api('notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id); const notes = res.body.filter(n => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => {
const res = await api('notes/mentions', { limit: 100 }, target); const res = await api('notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folM.id); const notes = res.body.filter(n => n.id === folM.id);
assert.strictEqual(notes[0].text, '@target x'); assert.strictEqual(notes[0].text, '@target x');
}); });
//#endregion //#endregion

View File

@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { api, post, signup } from '../utils.js'; import { api, castAsError, post, signup } from '../utils.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
describe('Block', () => { describe('Block', () => {
@ -33,7 +33,7 @@ describe('Block', () => {
const res = await api('following/create', { userId: alice.id }, bob); const res = await api('following/create', { userId: alice.id }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); assert.strictEqual(castAsError(res.body).error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0');
}); });
test('ブロックされているユーザーにリアクションできない', async () => { test('ブロックされているユーザーにリアクションできない', async () => {
@ -42,7 +42,8 @@ describe('Block', () => {
const res = await api('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.status, 400);
assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); assert.ok(res.body);
assert.strictEqual(castAsError(res.body).error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec');
}); });
test('ブロックされているユーザーに返信できない', async () => { test('ブロックされているユーザーに返信できない', async () => {
@ -51,7 +52,8 @@ describe('Block', () => {
const res = await api('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.status, 400);
assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); assert.ok(res.body);
assert.strictEqual(castAsError(res.body).error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3');
}); });
test('ブロックされているユーザーのートをRenoteできない', async () => { test('ブロックされているユーザーのートをRenoteできない', async () => {
@ -60,7 +62,7 @@ describe('Block', () => {
const res = await api('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.status, 400);
assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); assert.strictEqual(castAsError(res.body).error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3');
}); });
// TODO: ユーザーリストに入れられないテスト // TODO: ユーザーリストに入れられないテスト

View File

@ -79,14 +79,14 @@ describe('クリップ', () => {
}; };
const deleteClip = async (parameters: Misskey.entities.ClipsDeleteRequest, request: Partial<ApiRequest<'clips/delete'>> = {}): Promise<void> => { const deleteClip = async (parameters: Misskey.entities.ClipsDeleteRequest, request: Partial<ApiRequest<'clips/delete'>> = {}): Promise<void> => {
return await successfulApiCall({ await successfulApiCall({
endpoint: 'clips/delete', endpoint: 'clips/delete',
parameters, parameters,
user: alice, user: alice,
...request, ...request,
}, { }, {
status: 204, status: 204,
}) as any as void; });
}; };
const show = async (parameters: Misskey.entities.ClipsShowRequest, request: Partial<ApiRequest<'clips/show'>> = {}): Promise<Misskey.entities.Clip> => { const show = async (parameters: Misskey.entities.ClipsShowRequest, request: Partial<ApiRequest<'clips/show'>> = {}): Promise<Misskey.entities.Clip> => {
@ -454,25 +454,25 @@ describe('クリップ', () => {
let aliceClip: Misskey.entities.Clip; let aliceClip: Misskey.entities.Clip;
const favorite = async (parameters: Misskey.entities.ClipsFavoriteRequest, request: Partial<ApiRequest<'clips/favorite'>> = {}): Promise<void> => { const favorite = async (parameters: Misskey.entities.ClipsFavoriteRequest, request: Partial<ApiRequest<'clips/favorite'>> = {}): Promise<void> => {
return successfulApiCall({ await successfulApiCall({
endpoint: 'clips/favorite', endpoint: 'clips/favorite',
parameters, parameters,
user: alice, user: alice,
...request, ...request,
}, { }, {
status: 204, status: 204,
}) as any as void; });
}; };
const unfavorite = async (parameters: Misskey.entities.ClipsUnfavoriteRequest, request: Partial<ApiRequest<'clips/unfavorite'>> = {}): Promise<void> => { const unfavorite = async (parameters: Misskey.entities.ClipsUnfavoriteRequest, request: Partial<ApiRequest<'clips/unfavorite'>> = {}): Promise<void> => {
return successfulApiCall({ await successfulApiCall({
endpoint: 'clips/unfavorite', endpoint: 'clips/unfavorite',
parameters, parameters,
user: alice, user: alice,
...request, ...request,
}, { }, {
status: 204, status: 204,
}) as any as void; });
}; };
const myFavorites = async (request: Partial<ApiRequest<'clips/my-favorites'>> = {}): Promise<Misskey.entities.Clip[]> => { const myFavorites = async (request: Partial<ApiRequest<'clips/my-favorites'>> = {}): Promise<Misskey.entities.Clip[]> => {

View File

@ -23,7 +23,7 @@ describe('Drive', () => {
const marker = Math.random().toString(); const marker = Math.random().toString();
const url = 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'; const url = 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg';
const catcher = makeStreamCatcher( const catcher = makeStreamCatcher(
alice, alice,
@ -41,14 +41,14 @@ describe('Drive', () => {
const file = await catcher; const file = await catcher;
assert.strictEqual(res.status, 204); assert.strictEqual(res.status, 204);
assert.strictEqual(file.name, 'Lenna.jpg'); assert.strictEqual(file.name, '192.jpg');
assert.strictEqual(file.type, 'image/jpeg'); assert.strictEqual(file.type, 'image/jpeg');
}); });
test('ローカルからアップロードできる', async () => { test('ローカルからアップロードできる', async () => {
// APIレスポンスを直接使用するので utils.js uploadFile が通過することで成功とする // APIレスポンスを直接使用するので utils.js uploadFile が通過することで成功とする
const res = await uploadFile(alice, { path: 'Lenna.jpg', name: 'テスト画像' }); const res = await uploadFile(alice, { path: '192.jpg', name: 'テスト画像' });
assert.strictEqual(res.body?.name, 'テスト画像.jpg'); assert.strictEqual(res.body?.name, 'テスト画像.jpg');
assert.strictEqual(res.body.type, 'image/jpeg'); assert.strictEqual(res.body.type, 'image/jpeg');

View File

@ -10,7 +10,7 @@ import * as assert from 'assert';
// https://github.com/node-fetch/node-fetch/pull/1664 // https://github.com/node-fetch/node-fetch/pull/1664
import { Blob } from 'node-fetch'; import { Blob } from 'node-fetch';
import { MiUser } from '@/models/_.js'; import { MiUser } from '@/models/_.js';
import { api, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js'; import { api, castAsError, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
describe('Endpoints', () => { describe('Endpoints', () => {
@ -117,12 +117,21 @@ describe('Endpoints', () => {
assert.strictEqual(res.body.birthday, myBirthday); assert.strictEqual(res.body.birthday, myBirthday);
}); });
test('名前を空白にできる', async () => { test('名前を空白のみにした場合nullになる', async () => {
const res = await api('i/update', { const res = await api('i/update', {
name: ' ', name: ' ',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.name, ' '); assert.strictEqual(res.body.name, null);
});
test('名前の前後に空白(ホワイトスペース)を入れてもトリムされる', async () => {
const res = await api('i/update', {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#white_space
name: ' あ い う \u0009\u000b\u000c\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\ufeff',
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.name, 'あ い う');
}); });
test('誕生日の設定を削除できる', async () => { test('誕生日の設定を削除できる', async () => {
@ -155,7 +164,7 @@ describe('Endpoints', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.id, alice.id); assert.strictEqual((res.body as unknown as { id: string }).id, alice.id);
}); });
test('ユーザーが存在しなかったら怒る', async () => { test('ユーザーが存在しなかったら怒る', async () => {
@ -276,7 +285,8 @@ describe('Endpoints', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'CANNOT_REACT_TO_RENOTE'); assert.ok(res.body);
assert.strictEqual(castAsError(res.body).error.code, 'CANNOT_REACT_TO_RENOTE');
}); });
test('引用にリアクションできる', async () => { test('引用にリアクションできる', async () => {
@ -584,7 +594,7 @@ describe('Endpoints', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body!.name, 'Lenna.jpg'); assert.strictEqual(res.body!.name, '192.jpg');
}); });
test('ファイルに名前を付けられる', async () => { test('ファイルに名前を付けられる', async () => {
@ -1054,7 +1064,7 @@ describe('Endpoints', () => {
userId: bob.id, userId: bob.id,
}, alice); }, alice);
assert.strictEqual(res1.status, 204); assert.strictEqual(res1.status, 204);
assert.strictEqual(res2.body?.memo, memo); assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo);
}); });
test('自分に関するメモを更新できる', async () => { test('自分に関するメモを更新できる', async () => {
@ -1069,7 +1079,7 @@ describe('Endpoints', () => {
userId: alice.id, userId: alice.id,
}, alice); }, alice);
assert.strictEqual(res1.status, 204); assert.strictEqual(res1.status, 204);
assert.strictEqual(res2.body?.memo, memo); assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo);
}); });
test('メモを削除できる', async () => { test('メモを削除できる', async () => {
@ -1090,7 +1100,7 @@ describe('Endpoints', () => {
}, alice); }, alice);
// memoには常に文字列かnullが入っている(5cac151) // memoには常に文字列かnullが入っている(5cac151)
assert.strictEqual(res.body.memo, null); assert.strictEqual((res.body as unknown as { memo: string | null }).memo, null);
}); });
test('メモは個人ごとに独立して保存される', async () => { test('メモは個人ごとに独立して保存される', async () => {
@ -1117,8 +1127,8 @@ describe('Endpoints', () => {
}, carol), }, carol),
]); ]);
assert.strictEqual(resAlice.body.memo, memoAliceToBob); assert.strictEqual((resAlice.body as unknown as { memo: string }).memo, memoAliceToBob);
assert.strictEqual(resCarol.body.memo, memoCarolToBob); assert.strictEqual((resCarol.body as unknown as { memo: string }).memo, memoCarolToBob);
}); });
}); });
}); });

View File

@ -61,14 +61,14 @@ describe('export-clips', () => {
}); });
test('basic export', async () => { test('basic export', async () => {
let res = await api('clips/create', { const res1 = await api('clips/create', {
name: 'foo', name: 'foo',
description: 'bar', description: 'bar',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res1.status, 200);
res = await api('i/export-clips', {}, alice); const res2 = await api('i/export-clips', {}, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res2.status, 204);
const exported = await pollFirstDriveFile(); const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].name, 'foo'); assert.strictEqual(exported[0].name, 'foo');
@ -77,7 +77,7 @@ describe('export-clips', () => {
}); });
test('export with notes', async () => { test('export with notes', async () => {
let res = await api('clips/create', { const res = await api('clips/create', {
name: 'foo', name: 'foo',
description: 'bar', description: 'bar',
}, alice); }, alice);
@ -96,15 +96,15 @@ describe('export-clips', () => {
}); });
for (const note of [note1, note2]) { for (const note of [note1, note2]) {
res = await api('clips/add-note', { const res2 = await api('clips/add-note', {
clipId: clip.id, clipId: clip.id,
noteId: note.id, noteId: note.id,
}, alice); }, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res2.status, 204);
} }
res = await api('i/export-clips', {}, alice); const res3 = await api('i/export-clips', {}, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res3.status, 204);
const exported = await pollFirstDriveFile(); const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].name, 'foo'); assert.strictEqual(exported[0].name, 'foo');
@ -116,19 +116,19 @@ describe('export-clips', () => {
}); });
test('multiple clips', async () => { test('multiple clips', async () => {
let res = await api('clips/create', { const res1 = await api('clips/create', {
name: 'kawaii', name: 'kawaii',
description: 'kawaii', description: 'kawaii',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res1.status, 200);
const clip1 = res.body; const clip1 = res1.body;
res = await api('clips/create', { const res2 = await api('clips/create', {
name: 'yuri', name: 'yuri',
description: 'yuri', description: 'yuri',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res2.status, 200);
const clip2 = res.body; const clip2 = res2.body;
const note1 = await post(alice, { const note1 = await post(alice, {
text: 'baz1', text: 'baz1',
@ -138,20 +138,26 @@ describe('export-clips', () => {
text: 'baz2', text: 'baz2',
}); });
res = await api('clips/add-note', { {
clipId: clip1.id, const res = await api('clips/add-note', {
noteId: note1.id, clipId: clip1.id,
}, alice); noteId: note1.id,
assert.strictEqual(res.status, 204); }, alice);
assert.strictEqual(res.status, 204);
}
res = await api('clips/add-note', { {
clipId: clip2.id, const res = await api('clips/add-note', {
noteId: note2.id, clipId: clip2.id,
}, alice); noteId: note2.id,
assert.strictEqual(res.status, 204); }, alice);
assert.strictEqual(res.status, 204);
}
res = await api('i/export-clips', {}, alice); {
assert.strictEqual(res.status, 204); const res = await api('i/export-clips', {}, alice);
assert.strictEqual(res.status, 204);
}
const exported = await pollFirstDriveFile(); const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].name, 'kawaii'); assert.strictEqual(exported[0].name, 'kawaii');
@ -163,7 +169,7 @@ describe('export-clips', () => {
}); });
test('Clipping other user\'s note', async () => { test('Clipping other user\'s note', async () => {
let res = await api('clips/create', { const res = await api('clips/create', {
name: 'kawaii', name: 'kawaii',
description: 'kawaii', description: 'kawaii',
}, alice); }, alice);
@ -175,14 +181,14 @@ describe('export-clips', () => {
visibility: 'followers', visibility: 'followers',
}); });
res = await api('clips/add-note', { const res2 = await api('clips/add-note', {
clipId: clip.id, clipId: clip.id,
noteId: note.id, noteId: note.id,
}, alice); }, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res2.status, 204);
res = await api('i/export-clips', {}, alice); const res3 = await api('i/export-clips', {}, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res3.status, 204);
const exported = await pollFirstDriveFile(); const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].name, 'kawaii'); assert.strictEqual(exported[0].name, 'kawaii');

View File

@ -7,19 +7,20 @@ import { INestApplicationContext } from '@nestjs/common';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import { setTimeout } from 'node:timers/promises';
import * as assert from 'assert'; import * as assert from 'assert';
import { loadConfig } from '@/config.js'; import { loadConfig } from '@/config.js';
import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js'; import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { jobQueue } from '@/boot/common.js'; import { jobQueue } from '@/boot/common.js';
import { api, initTestDb, signup, sleep, successfulApiCall, uploadFile } from '../utils.js'; import { api, castAsError, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
describe('Account Move', () => { describe('Account Move', () => {
let jq: INestApplicationContext; let jq: INestApplicationContext;
let url: URL; let url: URL;
let root: any; let root: misskey.entities.SignupResponse;
let alice: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse;
let carol: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse;
@ -92,8 +93,8 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_USER');
assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); assert.strictEqual(castAsError(res.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
}); });
test('Unable to add duplicated aliases to alsoKnownAs', async () => { test('Unable to add duplicated aliases to alsoKnownAs', async () => {
@ -102,8 +103,8 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); assert.strictEqual(castAsError(res.body).error.code, 'INVALID_PARAM');
assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); assert.strictEqual(castAsError(res.body).error.id, '3d81ceae-475f-4600-b2a8-2bc116157532');
}); });
test('Unable to add itself', async () => { test('Unable to add itself', async () => {
@ -112,8 +113,8 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); assert.strictEqual(castAsError(res.body).error.code, 'FORBIDDEN_TO_SET_YOURSELF');
assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); assert.strictEqual(castAsError(res.body).error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4');
}); });
test('Unable to add a nonexisting local account to alsoKnownAs', async () => { test('Unable to add a nonexisting local account to alsoKnownAs', async () => {
@ -122,16 +123,16 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res1.status, 400); assert.strictEqual(res1.status, 400);
assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); assert.strictEqual(castAsError(res1.body).error.code, 'NO_SUCH_USER');
assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); assert.strictEqual(castAsError(res1.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
const res2 = await api('i/update', { const res2 = await api('i/update', {
alsoKnownAs: ['@alice', 'nonexist'], alsoKnownAs: ['@alice', 'nonexist'],
}, bob); }, bob);
assert.strictEqual(res2.status, 400); assert.strictEqual(res2.status, 400);
assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); assert.strictEqual(castAsError(res2.body).error.code, 'NO_SUCH_USER');
assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); assert.strictEqual(castAsError(res2.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
}); });
test('Able to add two existing local account to alsoKnownAs', async () => { test('Able to add two existing local account to alsoKnownAs', async () => {
@ -240,8 +241,8 @@ describe('Account Move', () => {
}, root); }, root);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); assert.strictEqual(castAsError(res.body).error.code, 'NOT_ROOT_FORBIDDEN');
assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); assert.strictEqual(castAsError(res.body).error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24');
}); });
test('Unable to move to a nonexisting local account', async () => { test('Unable to move to a nonexisting local account', async () => {
@ -250,8 +251,8 @@ describe('Account Move', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_USER');
assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); assert.strictEqual(castAsError(res.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
}); });
test('Unable to move if alsoKnownAs is invalid', async () => { test('Unable to move if alsoKnownAs is invalid', async () => {
@ -260,8 +261,8 @@ describe('Account Move', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); assert.strictEqual(castAsError(res.body).error.code, 'DESTINATION_ACCOUNT_FORBIDS');
assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); assert.strictEqual(castAsError(res.body).error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4');
}); });
test('Relationships have been properly migrated', async () => { test('Relationships have been properly migrated', async () => {
@ -271,43 +272,51 @@ describe('Account Move', () => {
assert.strictEqual(move.status, 200); assert.strictEqual(move.status, 200);
await sleep(1000 * 3); // wait for jobs to finish await setTimeout(1000 * 3); // wait for jobs to finish
// Unfollow delayed? // Unfollow delayed?
const aliceFollowings = await api('users/following', { const aliceFollowings = await api('users/following', {
userId: alice.id, userId: alice.id,
}, alice); }, alice);
assert.strictEqual(aliceFollowings.status, 200); assert.strictEqual(aliceFollowings.status, 200);
assert.ok(aliceFollowings);
assert.strictEqual(aliceFollowings.body.length, 3); assert.strictEqual(aliceFollowings.body.length, 3);
const carolFollowings = await api('users/following', { const carolFollowings = await api('users/following', {
userId: carol.id, userId: carol.id,
}, carol); }, carol);
assert.strictEqual(carolFollowings.status, 200); assert.strictEqual(carolFollowings.status, 200);
assert.ok(carolFollowings);
assert.strictEqual(carolFollowings.body.length, 2); assert.strictEqual(carolFollowings.body.length, 2);
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
const blockings = await api('blocking/list', {}, dave); const blockings = await api('blocking/list', {}, dave);
assert.strictEqual(blockings.status, 200); assert.strictEqual(blockings.status, 200);
assert.ok(blockings);
assert.strictEqual(blockings.body.length, 2); assert.strictEqual(blockings.body.length, 2);
assert.strictEqual(blockings.body[0].blockeeId, bob.id); assert.strictEqual(blockings.body[0].blockeeId, bob.id);
assert.strictEqual(blockings.body[1].blockeeId, alice.id); assert.strictEqual(blockings.body[1].blockeeId, alice.id);
const mutings = await api('mute/list', {}, dave); const mutings = await api('mute/list', {}, dave);
assert.strictEqual(mutings.status, 200); assert.strictEqual(mutings.status, 200);
assert.ok(mutings);
assert.strictEqual(mutings.body.length, 2); assert.strictEqual(mutings.body.length, 2);
assert.strictEqual(mutings.body[0].muteeId, bob.id); assert.strictEqual(mutings.body[0].muteeId, bob.id);
assert.strictEqual(mutings.body[1].muteeId, alice.id); assert.strictEqual(mutings.body[1].muteeId, alice.id);
const rootLists = await api('users/lists/list', {}, root); const rootLists = await api('users/lists/list', {}, root);
assert.strictEqual(rootLists.status, 200); assert.strictEqual(rootLists.status, 200);
assert.ok(rootLists);
assert.ok(rootLists.body[0].userIds);
assert.strictEqual(rootLists.body[0].userIds.length, 2); 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 === bob.id));
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
const eveLists = await api('users/lists/list', {}, eve); const eveLists = await api('users/lists/list', {}, eve);
assert.strictEqual(eveLists.status, 200); assert.strictEqual(eveLists.status, 200);
assert.ok(eveLists);
assert.ok(eveLists.body[0].userIds);
assert.strictEqual(eveLists.body[0].userIds.length, 1); assert.strictEqual(eveLists.body[0].userIds.length, 1);
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
}); });
@ -330,7 +339,7 @@ describe('Account Move', () => {
}); });
test('Unfollowed after 10 sec (24 hours in production).', async () => { test('Unfollowed after 10 sec (24 hours in production).', async () => {
await sleep(1000 * 8); await setTimeout(1000 * 8);
const following = await api('users/following', { const following = await api('users/following', {
userId: alice.id, userId: alice.id,
@ -346,8 +355,8 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); assert.strictEqual(castAsError(res.body).error.code, 'DESTINATION_ACCOUNT_FORBIDS');
assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); assert.strictEqual(castAsError(res.body).error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4');
}); });
test('Follow and follower counts are properly adjusted', async () => { test('Follow and follower counts are properly adjusted', async () => {
@ -418,8 +427,9 @@ describe('Account Move', () => {
] as const)('Prohibit access after moving: %s', async (endpoint) => { ] as const)('Prohibit access after moving: %s', async (endpoint) => {
const res = await api(endpoint, {}, alice); const res = await api(endpoint, {}, alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); assert.ok(res.body);
assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED');
assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
}); });
test('Prohibit access after moving: /antennas/update', async () => { test('Prohibit access after moving: /antennas/update', async () => {
@ -437,16 +447,19 @@ describe('Account Move', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); assert.ok(res.body);
assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED');
assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
}); });
test('Prohibit access after moving: /drive/files/create', async () => { test('Prohibit access after moving: /drive/files/create', async () => {
// FIXME: 一旦逃げておく
const res = await uploadFile(alice); const res = await uploadFile(alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);
assert.strictEqual((res.body! as any as { error: misskey.api.APIError }).error.code, 'YOUR_ACCOUNT_MOVED'); assert.ok(res.body);
assert.strictEqual((res.body! as any as { error: misskey.api.APIError }).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED');
assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
}); });
test('Prohibit updating alsoKnownAs after moving', async () => { test('Prohibit updating alsoKnownAs after moving', async () => {
@ -455,8 +468,8 @@ describe('Account Move', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED');
assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
}); });
}); });
}); });

View File

@ -47,8 +47,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => {
@ -92,9 +92,9 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => {
@ -108,9 +108,9 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
}); });
@ -124,8 +124,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => { test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
@ -138,8 +138,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => { test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
@ -152,8 +152,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
@ -166,8 +166,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリノートが含まれない', async () => { test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
@ -180,8 +180,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
@ -193,8 +193,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, bob);
await api('following/delete', { userId: alice.id }, carol); await api('following/delete', { userId: alice.id }, carol);
@ -210,8 +210,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, bob);
await api('following/delete', { userId: alice.id }, carol); await api('following/delete', { userId: alice.id }, carol);
@ -228,8 +228,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => { test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' }); const aliceNote = await post(alice, { text: 'hi' });
@ -241,8 +241,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => { test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
@ -255,8 +255,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
@ -269,8 +269,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリノートが含まれない', async () => { test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
@ -283,8 +283,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
@ -296,8 +296,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, bob);
await api('following/delete', { userId: alice.id }, carol); await api('following/delete', { userId: alice.id }, carol);
@ -313,8 +313,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false);
}); });
}); });
}); });

View File

@ -3,16 +3,18 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { Repository } from "typeorm";
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { api, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js'; import { api, castAsError, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
describe('Note', () => { describe('Note', () => {
let Notes: any; let Notes: Repository<MiNote>;
let root: misskey.entities.SignupResponse; let root: misskey.entities.SignupResponse;
let alice: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse;
@ -41,7 +43,7 @@ describe('Note', () => {
}); });
test('ファイルを添付できる', async () => { test('ファイルを添付できる', async () => {
const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg');
const res = await api('notes/create', { const res = await api('notes/create', {
fileIds: [file.id], fileIds: [file.id],
@ -53,7 +55,7 @@ describe('Note', () => {
}, 1000 * 10); }, 1000 * 10);
test('他人のファイルで怒られる', async () => { test('他人のファイルで怒られる', async () => {
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg');
const res = await api('notes/create', { const res = await api('notes/create', {
text: 'test', text: 'test',
@ -61,8 +63,8 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE');
assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}, 1000 * 10); }, 1000 * 10);
test('存在しないファイルで怒られる', async () => { test('存在しないファイルで怒られる', async () => {
@ -72,8 +74,8 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE');
assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}); });
test('不正なファイルIDで怒られる', async () => { test('不正なファイルIDで怒られる', async () => {
@ -81,8 +83,8 @@ describe('Note', () => {
fileIds: ['kyoppie'], fileIds: ['kyoppie'],
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE');
assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}); });
test('返信できる', async () => { test('返信できる', async () => {
@ -101,6 +103,7 @@ describe('Note', () => {
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.text, alicePost.text);
assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId);
assert.ok(res.body.createdNote.reply);
assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); assert.strictEqual(res.body.createdNote.reply.text, bobPost.text);
}); });
@ -118,6 +121,7 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId);
assert.ok(res.body.createdNote.renote);
assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text);
}); });
@ -137,6 +141,7 @@ describe('Note', () => {
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.text, alicePost.text);
assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId);
assert.ok(res.body.createdNote.renote);
assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text);
}); });
@ -218,7 +223,7 @@ describe('Note', () => {
}, bob); }, bob);
assert.strictEqual(bobReply.status, 400); assert.strictEqual(bobReply.status, 400);
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE'); assert.strictEqual(castAsError(bobReply.body).error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE');
}); });
test('visibility: specifiedなートに対してvisibility: specifiedで返信できる', async () => { test('visibility: specifiedなートに対してvisibility: specifiedで返信できる', async () => {
@ -256,7 +261,7 @@ describe('Note', () => {
}, bob); }, bob);
assert.strictEqual(bobReply.status, 400); assert.strictEqual(bobReply.status, 400);
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY'); assert.strictEqual(castAsError(bobReply.body).error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY');
}); });
test('文字数ぎりぎりで怒られない', async () => { test('文字数ぎりぎりで怒られない', async () => {
@ -333,6 +338,7 @@ describe('Note', () => {
assert.strictEqual(res.body.createdNote.text, post.text); assert.strictEqual(res.body.createdNote.text, post.text);
const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id });
assert.ok(noteDoc);
assert.deepStrictEqual(noteDoc.mentions, [bob.id]); assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
}); });
@ -345,6 +351,7 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.ok(res.body.createdNote.files);
assert.strictEqual(res.body.createdNote.files.length, 1); assert.strictEqual(res.body.createdNote.files.length, 1);
assert.strictEqual(res.body.createdNote.files[0].id, file.body!.id); assert.strictEqual(res.body.createdNote.files[0].id, file.body!.id);
}); });
@ -363,8 +370,9 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); const myNote = res.body.find(note => note.id === createdNote.body.createdNote.id);
assert.notEqual(myNote, null); assert.ok(myNote);
assert.ok(myNote.files);
assert.strictEqual(myNote.files.length, 1); assert.strictEqual(myNote.files.length, 1);
assert.strictEqual(myNote.files[0].id, file.body!.id); assert.strictEqual(myNote.files[0].id, file.body!.id);
}); });
@ -389,7 +397,9 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
assert.notEqual(myNote, null); assert.ok(myNote);
assert.ok(myNote.renote);
assert.ok(myNote.renote.files);
assert.strictEqual(myNote.renote.files.length, 1); assert.strictEqual(myNote.renote.files.length, 1);
assert.strictEqual(myNote.renote.files[0].id, file.body!.id); assert.strictEqual(myNote.renote.files[0].id, file.body!.id);
}); });
@ -415,7 +425,9 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id);
assert.notEqual(myNote, null); assert.ok(myNote);
assert.ok(myNote.reply);
assert.ok(myNote.reply.files);
assert.strictEqual(myNote.reply.files.length, 1); assert.strictEqual(myNote.reply.files.length, 1);
assert.strictEqual(myNote.reply.files[0].id, file.body!.id); assert.strictEqual(myNote.reply.files[0].id, file.body!.id);
}); });
@ -446,7 +458,10 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
assert.notEqual(myNote, null); assert.ok(myNote);
assert.ok(myNote.renote);
assert.ok(myNote.renote.reply);
assert.ok(myNote.renote.reply.files);
assert.strictEqual(myNote.renote.reply.files.length, 1); assert.strictEqual(myNote.renote.reply.files.length, 1);
assert.strictEqual(myNote.renote.reply.files[0].id, file.body!.id); assert.strictEqual(myNote.renote.reply.files[0].id, file.body!.id);
}); });
@ -474,7 +489,7 @@ describe('Note', () => {
priority: 0, priority: 0,
value: true, value: true,
}, },
} as any, },
}, root); }, root);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -498,7 +513,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(liftnsfw.status, 400); assert.strictEqual(liftnsfw.status, 400);
assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE'); assert.strictEqual(castAsError(liftnsfw.body).error.code, 'RESTRICTED_BY_ROLE');
const oldaddnsfw = await api('drive/files/update', { const oldaddnsfw = await api('drive/files/update', {
fileId: file.body!.id, fileId: file.body!.id,
@ -710,7 +725,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note1.status, 400); assert.strictEqual(note1.status, 400);
assert.strictEqual(note1.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); assert.strictEqual(castAsError(note1.body).error.code, 'CONTAINS_PROHIBITED_WORDS');
}); });
test('禁止ワードを含む投稿はエラーになる (正規表現)', async () => { test('禁止ワードを含む投稿はエラーになる (正規表現)', async () => {
@ -727,7 +742,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note2.status, 400); assert.strictEqual(note2.status, 400);
assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); assert.strictEqual(castAsError(note2.body).error.code, 'CONTAINS_PROHIBITED_WORDS');
}); });
test('禁止ワードを含む投稿はエラーになる (スペースアンド)', async () => { test('禁止ワードを含む投稿はエラーになる (スペースアンド)', async () => {
@ -744,7 +759,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note2.status, 400); assert.strictEqual(note2.status, 400);
assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); assert.strictEqual(castAsError(note2.body).error.code, 'CONTAINS_PROHIBITED_WORDS');
}); });
test('禁止ワードを含んでるリモートノートもエラーになる', async () => { test('禁止ワードを含んでるリモートノートもエラーになる', async () => {
@ -786,7 +801,7 @@ describe('Note', () => {
priority: 1, priority: 1,
value: 0, value: 0,
}, },
} as any, },
}, root); }, root);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -807,7 +822,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note.status, 400); assert.strictEqual(note.status, 400);
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS'); assert.strictEqual(castAsError(note.body).error.code, 'CONTAINS_TOO_MANY_MENTIONS');
await api('admin/roles/unassign', { await api('admin/roles/unassign', {
userId: alice.id, userId: alice.id,
@ -840,7 +855,7 @@ describe('Note', () => {
priority: 1, priority: 1,
value: 0, value: 0,
}, },
} as any, },
}, root); }, root);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -863,7 +878,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note.status, 400); assert.strictEqual(note.status, 400);
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS'); assert.strictEqual(castAsError(note.body).error.code, 'CONTAINS_TOO_MANY_MENTIONS');
await api('admin/roles/unassign', { await api('admin/roles/unassign', {
userId: alice.id, userId: alice.id,
@ -896,7 +911,7 @@ describe('Note', () => {
priority: 1, priority: 1,
value: 1, value: 1,
}, },
} as any, },
}, root); }, root);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -951,6 +966,7 @@ describe('Note', () => {
assert.strictEqual(deleteOneRes.status, 204); assert.strictEqual(deleteOneRes.status, 204);
let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id });
assert.ok(mainNote);
assert.strictEqual(mainNote.repliesCount, 1); assert.strictEqual(mainNote.repliesCount, 1);
const deleteTwoRes = await api('notes/delete', { const deleteTwoRes = await api('notes/delete', {
@ -959,6 +975,7 @@ describe('Note', () => {
assert.strictEqual(deleteTwoRes.status, 204); assert.strictEqual(deleteTwoRes.status, 204);
mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id });
assert.ok(mainNote);
assert.strictEqual(mainNote.repliesCount, 0); assert.strictEqual(mainNote.repliesCount, 0);
}); });
}); });
@ -980,7 +997,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); assert.strictEqual(castAsError(res.body).error.code, 'UNAVAILABLE');
}); });
afterAll(async () => { afterAll(async () => {
@ -992,7 +1009,7 @@ describe('Note', () => {
const res = await api('notes/translate', { noteId: 'foo', targetLang: 'ja' }, alice); const res = await api('notes/translate', { noteId: 'foo', targetLang: 'ja' }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_NOTE'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_NOTE');
}); });
test('不可視なノートは翻訳できない', async () => { test('不可視なノートは翻訳できない', async () => {
@ -1000,7 +1017,7 @@ describe('Note', () => {
const bobTranslateAttempt = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, bob); const bobTranslateAttempt = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, bob);
assert.strictEqual(bobTranslateAttempt.status, 400); assert.strictEqual(bobTranslateAttempt.status, 400);
assert.strictEqual(bobTranslateAttempt.body.error.code, 'CANNOT_TRANSLATE_INVISIBLE_NOTE'); assert.strictEqual(castAsError(bobTranslateAttempt.body).error.code, 'CANNOT_TRANSLATE_INVISIBLE_NOTE');
}); });
test('text: null なノートを翻訳すると空のレスポンスが返ってくる', async () => { test('text: null なノートを翻訳すると空のレスポンスが返ってくる', async () => {
@ -1016,7 +1033,7 @@ describe('Note', () => {
// NOTE: デフォルトでは登録されていないので落ちる // NOTE: デフォルトでは登録されていないので落ちる
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); assert.strictEqual(castAsError(res.body).error.code, 'UNAVAILABLE');
}); });
}); });
}); });

View File

@ -6,7 +6,8 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { api, post, signup, sleep, waitFire } from '../utils.js'; import { setTimeout } from 'node:timers/promises';
import { api, post, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
describe('Renote Mute', () => { describe('Renote Mute', () => {
@ -35,15 +36,15 @@ describe('Renote Mute', () => {
const carolNote = await post(carol, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ // redisに追加されるのを待つ
await sleep(100); await setTimeout(100);
const res = await api('notes/local-timeline', {}, alice); const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}); });
test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => {
@ -52,15 +53,15 @@ describe('Renote Mute', () => {
const carolNote = await post(carol, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ // redisに追加されるのを待つ
await sleep(100); await setTimeout(100);
const res = await api('notes/local-timeline', {}, alice); const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}); });
// #12956 // #12956
@ -69,14 +70,14 @@ describe('Renote Mute', () => {
const bobRenote = await post(bob, { renoteId: carolNote.id }); const bobRenote = await post(bob, { renoteId: carolNote.id });
// redisに追加されるのを待つ // redisに追加されるのを待つ
await sleep(100); await setTimeout(100);
const res = await api('notes/local-timeline', {}, alice); const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
}); });
test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => {

View File

@ -33,9 +33,9 @@ describe('Note thread mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false); assert.strictEqual(res.body.some(note => note.id === carolReply.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolReplyWithoutMention.id), false); assert.strictEqual(res.body.some(note => note.id === carolReplyWithoutMention.id), false);
}); });
test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => {
@ -93,8 +93,8 @@ describe('Note thread mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReply.id), false); assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReply.id), false);
assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReplyWithoutMention.id), false); assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReplyWithoutMention.id), false);
// NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい
}); });

File diff suppressed because it is too large Load Diff

View File

@ -17,8 +17,8 @@ describe('users/notes', () => {
beforeAll(async () => { beforeAll(async () => {
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg');
const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.png');
jpgNote = await post(alice, { jpgNote = await post(alice, {
fileIds: [jpg.id], fileIds: [jpg.id],
}); });

View File

@ -231,7 +231,7 @@ describe('ユーザー', () => {
rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); rolePublic = await role(root, { isPublic: true, name: 'Public Role' });
await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root);
userRoleBadge = await signup({ username: 'userRoleBadge' }); userRoleBadge = await signup({ username: 'userRoleBadge' });
roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); roleBadge = await role(root, { asBadge: true, name: 'Badge Role', isPublic: true });
await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root);
userSilenced = await signup({ username: 'userSilenced' }); userSilenced = await signup({ username: 'userSilenced' });
await post(userSilenced, { text: 'test' }); await post(userSilenced, { text: 'test' });
@ -655,7 +655,16 @@ describe('ユーザー', () => {
iconUrl: roleBadge.iconUrl, iconUrl: roleBadge.iconUrl,
displayOrder: roleBadge.displayOrder, displayOrder: roleBadge.displayOrder,
}]); }]);
assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない assert.deepStrictEqual(response.roles, [{
id: roleBadge.id,
name: roleBadge.name,
color: roleBadge.color,
iconUrl: roleBadge.iconUrl,
description: roleBadge.description,
isModerator: roleBadge.isModerator,
isAdministrator: roleBadge.isAdministrator,
displayOrder: roleBadge.displayOrder,
}]);
}); });
test('をID指定のリスト形式で取得することができる', async () => { test('をID指定のリスト形式で取得することができる', async () => {
const parameters = { userIds: [] }; const parameters = { userIds: [] };

View File

@ -0,0 +1,22 @@
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../../shared/eslint.config.js';
export default [
...sharedConfig,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

View File

@ -23,10 +23,10 @@ describe('ApMfmService', () => {
describe('getNoteHtml', () => { describe('getNoteHtml', () => {
test('Do not provide _misskey_content for simple text', () => { test('Do not provide _misskey_content for simple text', () => {
const note: MiNote = { const note = {
text: 'テキスト #タグ @mention 🍊 :emoji: https://example.com', text: 'テキスト #タグ @mention 🍊 :emoji: https://example.com',
mentionedRemoteUsers: '[]', mentionedRemoteUsers: '[]',
} as any; };
const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); const { content, noMisskeyContent } = apMfmService.getNoteHtml(note);
@ -35,10 +35,10 @@ describe('ApMfmService', () => {
}); });
test('Provide _misskey_content for MFM', () => { test('Provide _misskey_content for MFM', () => {
const note: MiNote = { const note = {
text: '$[tada foo]', text: '$[tada foo]',
mentionedRemoteUsers: '[]', mentionedRemoteUsers: '[]',
} as any; };
const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); const { content, noMisskeyContent } = apMfmService.getNoteHtml(note);

View File

@ -12,7 +12,7 @@ import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { afterAll, beforeAll, describe, test } from '@jest/globals'; import { afterAll, beforeAll, describe, test } from '@jest/globals';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { FileInfoService } from '@/core/FileInfoService.js'; import { FileInfo, FileInfoService } from '@/core/FileInfoService.js';
//import { DI } from '@/di-symbols.js'; //import { DI } from '@/di-symbols.js';
import { AiService } from '@/core/AiService.js'; import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
@ -28,6 +28,15 @@ const moduleMocker = new ModuleMocker(global);
describe('FileInfoService', () => { describe('FileInfoService', () => {
let app: TestingModule; let app: TestingModule;
let fileInfoService: FileInfoService; let fileInfoService: FileInfoService;
const strip = (fileInfo: FileInfo): Omit<Partial<FileInfo>, 'warnings' | 'blurhash' | 'sensitive' | 'porn'> => {
const fi: Partial<FileInfo> = fileInfo;
delete fi.warnings;
delete fi.sensitive;
delete fi.blurhash;
delete fi.porn;
return fi;
}
beforeAll(async () => { beforeAll(async () => {
app = await Test.createTestingModule({ app = await Test.createTestingModule({
@ -63,11 +72,7 @@ describe('FileInfoService', () => {
test('Empty file', async () => { test('Empty file', async () => {
const path = `${resources}/emptyfile`; const path = `${resources}/emptyfile`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 0, size: 0,
md5: 'd41d8cd98f00b204e9800998ecf8427e', md5: 'd41d8cd98f00b204e9800998ecf8427e',
@ -83,32 +88,24 @@ describe('FileInfoService', () => {
describe('IMAGE', () => { describe('IMAGE', () => {
test('Generic JPEG', async () => { test('Generic JPEG', async () => {
const path = `${resources}/Lenna.jpg`; const path = `${resources}/192.jpg`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 25360, size: 5131,
md5: '091b3f259662aa31e2ffef4519951168', md5: '8c9ed0677dd2b8f9f7472c3af247e5e3',
type: { type: {
mime: 'image/jpeg', mime: 'image/jpeg',
ext: 'jpg', ext: 'jpg',
}, },
width: 512, width: 192,
height: 512, height: 192,
orientation: undefined, orientation: undefined,
}); });
}); });
test('Generic APNG', async () => { test('Generic APNG', async () => {
const path = `${resources}/anime.png`; const path = `${resources}/anime.png`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 1868, size: 1868,
md5: '08189c607bea3b952704676bb3c979e0', md5: '08189c607bea3b952704676bb3c979e0',
@ -124,11 +121,7 @@ describe('FileInfoService', () => {
test('Generic AGIF', async () => { test('Generic AGIF', async () => {
const path = `${resources}/anime.gif`; const path = `${resources}/anime.gif`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 2248, size: 2248,
md5: '32c47a11555675d9267aee1a86571e7e', md5: '32c47a11555675d9267aee1a86571e7e',
@ -144,11 +137,7 @@ describe('FileInfoService', () => {
test('PNG with alpha', async () => { test('PNG with alpha', async () => {
const path = `${resources}/with-alpha.png`; const path = `${resources}/with-alpha.png`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 3772, size: 3772,
md5: 'f73535c3e1e27508885b69b10cf6e991', md5: 'f73535c3e1e27508885b69b10cf6e991',
@ -164,11 +153,7 @@ describe('FileInfoService', () => {
test('Generic SVG', async () => { test('Generic SVG', async () => {
const path = `${resources}/image.svg`; const path = `${resources}/image.svg`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 505, size: 505,
md5: 'b6f52b4b021e7b92cdd04509c7267965', md5: 'b6f52b4b021e7b92cdd04509c7267965',
@ -185,11 +170,7 @@ describe('FileInfoService', () => {
test('SVG with XML definition', async () => { test('SVG with XML definition', async () => {
// https://github.com/misskey-dev/misskey/issues/4413 // https://github.com/misskey-dev/misskey/issues/4413
const path = `${resources}/with-xml-def.svg`; const path = `${resources}/with-xml-def.svg`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 544, size: 544,
md5: '4b7a346cde9ccbeb267e812567e33397', md5: '4b7a346cde9ccbeb267e812567e33397',
@ -205,11 +186,7 @@ describe('FileInfoService', () => {
test('Dimension limit', async () => { test('Dimension limit', async () => {
const path = `${resources}/25000x25000.png`; const path = `${resources}/25000x25000.png`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 75933, size: 75933,
md5: '268c5dde99e17cf8fe09f1ab3f97df56', md5: '268c5dde99e17cf8fe09f1ab3f97df56',
@ -225,11 +202,7 @@ describe('FileInfoService', () => {
test('Rotate JPEG', async () => { test('Rotate JPEG', async () => {
const path = `${resources}/rotate.jpg`; const path = `${resources}/rotate.jpg`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 12624, size: 12624,
md5: '68d5b2d8d1d1acbbce99203e3ec3857e', md5: '68d5b2d8d1d1acbbce99203e3ec3857e',
@ -247,11 +220,7 @@ describe('FileInfoService', () => {
describe('AUDIO', () => { describe('AUDIO', () => {
test('MP3', async () => { test('MP3', async () => {
const path = `${resources}/kick_gaba7.mp3`; const path = `${resources}/kick_gaba7.mp3`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -267,11 +236,7 @@ describe('FileInfoService', () => {
test('WAV', async () => { test('WAV', async () => {
const path = `${resources}/kick_gaba7.wav`; const path = `${resources}/kick_gaba7.wav`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -287,11 +252,7 @@ describe('FileInfoService', () => {
test('AAC', async () => { test('AAC', async () => {
const path = `${resources}/kick_gaba7.aac`; const path = `${resources}/kick_gaba7.aac`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -307,11 +268,7 @@ describe('FileInfoService', () => {
test('FLAC', async () => { test('FLAC', async () => {
const path = `${resources}/kick_gaba7.flac`; const path = `${resources}/kick_gaba7.flac`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -327,11 +284,7 @@ describe('FileInfoService', () => {
test('MPEG-4 AUDIO (M4A)', async () => { test('MPEG-4 AUDIO (M4A)', async () => {
const path = `${resources}/kick_gaba7.m4a`; const path = `${resources}/kick_gaba7.m4a`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -347,11 +300,7 @@ describe('FileInfoService', () => {
test('WEBM AUDIO', async () => { test('WEBM AUDIO', async () => {
const path = `${resources}/kick_gaba7.webm`; const path = `${resources}/kick_gaba7.webm`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;

View File

@ -5,6 +5,7 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import { setTimeout } from 'node:timers/promises';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock'; import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
@ -29,7 +30,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { RoleCondFormulaValue } from '@/models/Role.js'; import { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { sleep } from '../utils.js';
import type { TestingModule } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock'; import type { MockFunctionMetadata } from 'jest-mock';
@ -278,7 +278,7 @@ describe('RoleService', () => {
// ストリーミング経由で反映されるまでちょっと待つ // ストリーミング経由で反映されるまでちょっと待つ
clock.uninstall(); clock.uninstall();
await sleep(100); await setTimeout(100);
const resultAfter25hAgain = await roleService.getUserPolicies(user.id); const resultAfter25hAgain = await roleService.getUserPolicies(user.id);
expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true); expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true);
@ -807,7 +807,7 @@ describe('RoleService', () => {
await roleService.assign(user.id, role.id); await roleService.assign(user.id, role.id);
clock.uninstall(); clock.uninstall();
await sleep(100); await setTimeout(100);
const assignments = await roleAssignmentsRepository.find({ const assignments = await roleAssignmentsRepository.find({
where: { where: {
@ -835,7 +835,7 @@ describe('RoleService', () => {
await roleService.assign(user.id, role.id); await roleService.assign(user.id, role.id);
clock.uninstall(); clock.uninstall();
await sleep(100); await setTimeout(100);
const assignments = await roleAssignmentsRepository.find({ const assignments = await roleAssignmentsRepository.find({
where: { where: {

View File

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { setTimeout } from 'node:timers/promises';
import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals'; import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { MiUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js';
@ -16,7 +17,7 @@ import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { randomString, sleep } from '../utils.js'; import { randomString } from '../utils.js';
describe('SystemWebhookService', () => { describe('SystemWebhookService', () => {
let app: TestingModule; let app: TestingModule;
@ -358,7 +359,7 @@ describe('SystemWebhookService', () => {
); );
// redisでの配信経由で更新されるのでちょっと待つ // redisでの配信経由で更新されるのでちょっと待つ
await sleep(500); await setTimeout(500);
const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); const fetchedWebhooks = await service.fetchActiveSystemWebhooks();
expect(fetchedWebhooks).toEqual([webhook]); expect(fetchedWebhooks).toEqual([webhook]);
@ -377,7 +378,7 @@ describe('SystemWebhookService', () => {
); );
// redisでの配信経由で更新されるのでちょっと待つ // redisでの配信経由で更新されるのでちょっと待つ
await sleep(500); await setTimeout(500);
const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); const fetchedWebhooks = await service.fetchActiveSystemWebhooks();
expect(fetchedWebhooks).toEqual([]); expect(fetchedWebhooks).toEqual([]);
@ -407,7 +408,7 @@ describe('SystemWebhookService', () => {
); );
// redisでの配信経由で更新されるのでちょっと待つ // redisでの配信経由で更新されるのでちょっと待つ
await sleep(500); await setTimeout(500);
const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); const fetchedWebhooks = await service.fetchActiveSystemWebhooks();
expect(fetchedWebhooks).toEqual([webhook2]); expect(fetchedWebhooks).toEqual([webhook2]);
@ -434,7 +435,7 @@ describe('SystemWebhookService', () => {
); );
// redisでの配信経由で更新されるのでちょっと待つ // redisでの配信経由で更新されるのでちょっと待つ
await sleep(500); await setTimeout(500);
const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); const fetchedWebhooks = await service.fetchActiveSystemWebhooks();
expect(fetchedWebhooks.length).toEqual(0); expect(fetchedWebhooks.length).toEqual(0);
@ -457,7 +458,7 @@ describe('SystemWebhookService', () => {
); );
// redisでの配信経由で更新されるのでちょっと待つ // redisでの配信経由で更新されるのでちょっと待つ
await sleep(500); await setTimeout(500);
const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); const fetchedWebhooks = await service.fetchActiveSystemWebhooks();
expect(fetchedWebhooks).toEqual([webhook2]); expect(fetchedWebhooks).toEqual([webhook2]);
@ -481,7 +482,7 @@ describe('SystemWebhookService', () => {
); );
// redisでの配信経由で更新されるのでちょっと待つ // redisでの配信経由で更新されるのでちょっと待つ
await sleep(500); await setTimeout(500);
const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); const fetchedWebhooks = await service.fetchActiveSystemWebhooks();
expect(fetchedWebhooks.length).toEqual(0); expect(fetchedWebhooks.length).toEqual(0);
@ -504,7 +505,7 @@ describe('SystemWebhookService', () => {
); );
// redisでの配信経由で更新されるのでちょっと待つ // redisでの配信経由で更新されるのでちょっと待つ
await sleep(500); await setTimeout(500);
const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); const fetchedWebhooks = await service.fetchActiveSystemWebhooks();
expect(fetchedWebhooks.length).toEqual(0); expect(fetchedWebhooks.length).toEqual(0);

View File

@ -0,0 +1,265 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Test, TestingModule } from '@nestjs/testing';
import { describe, jest, test } from '@jest/globals';
import { In } from 'typeorm';
import { UserSearchService } from '@/core/UserSearchService.js';
import { FollowingsRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
describe('UserSearchService', () => {
let app: TestingModule;
let service: UserSearchService;
let usersRepository: UsersRepository;
let followingsRepository: FollowingsRepository;
let idService: IdService;
let userProfilesRepository: UserProfilesRepository;
let root: MiUser;
let alice: MiUser;
let alyce: MiUser;
let alycia: MiUser;
let alysha: MiUser;
let alyson: MiUser;
let alyssa: MiUser;
let bob: MiUser;
let bobbi: MiUser;
let bobbie: MiUser;
let bobby: MiUser;
async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
await userProfilesRepository.insert({
userId: user.id,
});
return user;
}
async function createFollowings(follower: MiUser, followees: MiUser[]) {
for (const followee of followees) {
await followingsRepository.insert({
id: idService.gen(),
followerId: follower.id,
followeeId: followee.id,
});
}
}
async function setActive(users: MiUser[]) {
for (const user of users) {
await usersRepository.update(user.id, {
updatedAt: new Date(),
});
}
}
async function setInactive(users: MiUser[]) {
for (const user of users) {
await usersRepository.update(user.id, {
updatedAt: new Date(0),
});
}
}
async function setSuspended(users: MiUser[]) {
for (const user of users) {
await usersRepository.update(user.id, {
isSuspended: true,
});
}
}
beforeAll(async () => {
app = await Test
.createTestingModule({
imports: [
GlobalModule,
],
providers: [
UserSearchService,
{
provide: UserEntityService, useFactory: jest.fn(() => ({
// とりあえずIDが返れば確認が出来るので
packMany: (value: any) => value,
})),
},
IdService,
],
})
.compile();
await app.init();
usersRepository = app.get(DI.usersRepository);
userProfilesRepository = app.get(DI.userProfilesRepository);
followingsRepository = app.get(DI.followingsRepository);
service = app.get(UserSearchService);
idService = app.get(IdService);
});
beforeEach(async () => {
root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
alice = await createUser({ username: 'Alice', usernameLower: 'alice' });
alyce = await createUser({ username: 'Alyce', usernameLower: 'alyce' });
alycia = await createUser({ username: 'Alycia', usernameLower: 'alycia' });
alysha = await createUser({ username: 'Alysha', usernameLower: 'alysha' });
alyson = await createUser({ username: 'Alyson', usernameLower: 'alyson', host: 'example.com' });
alyssa = await createUser({ username: 'Alyssa', usernameLower: 'alyssa', host: 'example.com' });
bob = await createUser({ username: 'Bob', usernameLower: 'bob' });
bobbi = await createUser({ username: 'Bobbi', usernameLower: 'bobbi' });
bobbie = await createUser({ username: 'Bobbie', usernameLower: 'bobbie', host: 'example.com' });
bobby = await createUser({ username: 'Bobby', usernameLower: 'bobby', host: 'example.com' });
});
afterEach(async () => {
await usersRepository.delete({});
});
afterAll(async () => {
await app.close();
});
describe('search', () => {
test('フォロー中のアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => {
await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
await setActive([alice, alyce, alyssa, bob, bobbi, bobbie, bobby]);
await setInactive([alycia, alysha, alyson]);
const result = await service.search(
{ username: 'al' },
{ limit: 100 },
root,
);
// alycia, alysha, alysonは非アクティブなので後ろに行く
expect(result).toEqual([alice, alyce, alyssa, alycia, alysha, alyson].map(x => x.id));
});
test('フォロー中の非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => {
await createFollowings(root, [alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
const result = await service.search(
{ username: 'al' },
{ limit: 100 },
root,
);
// alice, alyceはフォローしていないので後ろに行く
expect(result).toEqual([alycia, alysha, alyson, alyssa, alice, alyce].map(x => x.id));
});
test('フォローしていないアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => {
await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
await setInactive([alice, alyce, alycia]);
const result = await service.search(
{ username: 'al' },
{ limit: 100 },
root,
);
// alice, alyce, alyciaは非アクティブなので後ろに行く
expect(result).toEqual([alysha, alyson, alyssa, alice, alyce, alycia].map(x => x.id));
});
test('フォローしていない非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => {
await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
const result = await service.search(
{ username: 'al' },
{ limit: 100 },
root,
);
expect(result).toEqual([alice, alyce, alycia, alysha, alyson, alyssa].map(x => x.id));
});
test('フォロー(アクティブ)、フォロー(非アクティブ)、非フォロー(アクティブ)、非フォロー(非アクティブ)混在時の優先順位度確認', async () => {
await createFollowings(root, [alyson, alyssa, bob, bobbi, bobbie]);
await setActive([root, alyssa, bob, bobbi, alyce, alycia]);
await setInactive([alyson, alice, alysha, bobbie, bobby]);
const result = await service.search(
{ },
{ limit: 100 },
root,
);
// 見る用
// const users = await usersRepository.findBy({ id: In(result) }).then(it => new Map(it.map(x => [x.id, x])));
// console.log(result.map(x => users.get(x as any)).map(it => it?.username));
// フォローしててアクティブなので先頭: alyssa, bob, bobbi
// フォローしてて非アクティブなので次: alyson, bobbie
// フォローしてないけどアクティブなので次: alyce, alycia, root(アルファベット順的にここになる)
// フォローしてないし非アクティブなので最後: alice, alysha, bobby
expect(result).toEqual([alyssa, bob, bobbi, alyson, bobbie, alyce, alycia, root, alice, alysha, bobby].map(x => x.id));
});
test('[非ログイン] アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => {
await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
await setInactive([alice, alyce, alycia]);
const result = await service.search(
{ username: 'al' },
{ limit: 100 },
);
// alice, alyce, alyciaは非アクティブなので後ろに行く
expect(result).toEqual([alysha, alyson, alyssa, alice, alyce, alycia].map(x => x.id));
});
test('[非ログイン] 非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => {
await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
const result = await service.search(
{ username: 'al' },
{ limit: 100 },
);
expect(result).toEqual([alice, alyce, alycia, alysha, alyson, alyssa].map(x => x.id));
});
test('フォロー中のアクティブユーザのうち、"al"から始まり"example.com"にいる人が全員ヒットする', async () => {
await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
const result = await service.search(
{ username: 'al', host: 'exam' },
{ limit: 100 },
root,
);
expect(result).toEqual([alyson, alyssa].map(x => x.id));
});
test('サスペンド済みユーザは出ない', async () => {
await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]);
await setSuspended([alice, alyce, alycia]);
const result = await service.search(
{ username: 'al' },
{ limit: 100 },
root,
);
expect(result).toEqual([alysha, alyson, alyssa].map(x => x.id));
});
});
});

View File

@ -18,6 +18,7 @@ import { entities } from '../src/postgres.js';
import { loadConfig } from '../src/config.js'; import { loadConfig } from '../src/config.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
import { type Response } from 'node-fetch'; import { type Response } from 'node-fetch';
import { ApiError } from "@/server/api/error.js";
export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js'; export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
@ -48,27 +49,28 @@ export const successfulApiCall = async <E extends keyof misskey.Endpoints, P ext
const res = await api(endpoint, parameters, user); const res = await api(endpoint, parameters, user);
const status = assertion.status ?? (res.body == null ? 204 : 200); const status = assertion.status ?? (res.body == null ? 204 : 200);
assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true })); assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true }));
return res.body;
return res.body as misskey.api.SwitchCaseResponseType<E, P>;
}; };
export const failedApiCall = async <T, E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(request: ApiRequest<E, P>, assertion: { export const failedApiCall = async <E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(request: ApiRequest<E, P>, assertion: {
status: number, status: number,
code: string, code: string,
id: string id: string
}): Promise<T> => { }): Promise<void> => {
const { endpoint, parameters, user } = request; const { endpoint, parameters, user } = request;
const { status, code, id } = assertion; const { status, code, id } = assertion;
const res = await api(endpoint, parameters, user); const res = await api(endpoint, parameters, user);
assert.strictEqual(res.status, status, inspect(res.body)); assert.strictEqual(res.status, status, inspect(res.body));
assert.strictEqual(res.body.error.code, code, inspect(res.body)); assert.ok(res.body);
assert.strictEqual(res.body.error.id, id, inspect(res.body)); assert.strictEqual(castAsError(res.body as any).error.code, code, inspect(res.body));
return res.body; assert.strictEqual(castAsError(res.body as any).error.id, id, inspect(res.body));
}; };
export const api = async <E extends keyof misskey.Endpoints>(path: E, params: misskey.Endpoints[E]['req'], me?: UserToken): Promise<{ export const api = async <E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(path: E, params: P, me?: UserToken): Promise<{
status: number, status: number,
headers: Headers, headers: Headers,
body: any body: misskey.api.SwitchCaseResponseType<E, P>
}> => { }> => {
const bodyAuth: Record<string, string> = {}; const bodyAuth: Record<string, string> = {};
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -89,13 +91,14 @@ export const api = async <E extends keyof misskey.Endpoints>(path: E, params: mi
}); });
const body = res.headers.get('content-type') === 'application/json; charset=utf-8' const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
? await res.json() ? await res.json() as misskey.api.SwitchCaseResponseType<E, P>
: null; : null;
return { return {
status: res.status, status: res.status,
headers: res.headers, headers: res.headers,
body, // FIXME: removing this non-null assertion: requires better typing around empty response.
body: body!,
}; };
}; };
@ -141,7 +144,8 @@ export const post = async (user: UserToken, params: misskey.Endpoints['notes/cre
const res = await api('notes/create', q, user); const res = await api('notes/create', q, user);
return res.body ? res.body.createdNote : null; // FIXME: the return type should reflect this fact.
return (res.body ? res.body.createdNote : null)!;
}; };
export const createAppToken = async (user: UserToken, permissions: (typeof misskey.permissions)[number][]) => { export const createAppToken = async (user: UserToken, permissions: (typeof misskey.permissions)[number][]) => {
@ -297,7 +301,7 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
body: misskey.entities.DriveFile | null body: misskey.entities.DriveFile | null
}> => { }> => {
const absPath = path == null const absPath = path == null
? new URL('resources/Lenna.jpg', import.meta.url) ? new URL('resources/192.jpg', import.meta.url)
: isAbsolute(path.toString()) : isAbsolute(path.toString())
? new URL(path) ? new URL(path)
: new URL(path, new URL('resources/', import.meta.url)); : new URL(path, new URL('resources/', import.meta.url));
@ -605,14 +609,6 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
return db; return db;
} }
export function sleep(msec: number) {
return new Promise<void>(res => {
setTimeout(() => {
res();
}, msec);
});
}
export async function sendEnvUpdateRequest(params: { key: string, value?: string }) { export async function sendEnvUpdateRequest(params: { key: string, value?: string }) {
const res = await fetch( const res = await fetch(
`http://localhost:${port + 1000}/env`, `http://localhost:${port + 1000}/env`,
@ -643,3 +639,9 @@ export async function sendEnvResetRequest() {
throw new Error('server env update failed.'); throw new Error('server env update failed.');
} }
} }
// 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。
// FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する
export function castAsError(obj: Record<string, unknown>): { error: ApiError } {
return obj as { error: ApiError };
}

View File

@ -1,82 +0,0 @@
module.exports = {
root: true,
env: {
'node': false,
},
parser: 'vue-eslint-parser',
parserOptions: {
'parser': '@typescript-eslint/parser',
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
extraFileExtensions: ['.vue'],
},
extends: [
'../shared/.eslintrc.js',
'plugin:vue/vue3-recommended',
],
rules: {
'@typescript-eslint/no-empty-interface': [
'error',
{
'allowSingleExtends': true,
},
],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'],
'no-shadow': ['warn'],
'vue/attributes-order': ['error', {
'alphabetical': false,
}],
'vue/no-use-v-if-with-v-for': ['error', {
'allowUsingIterationVar': false,
}],
'vue/no-ref-as-operand': 'error',
'vue/no-multi-spaces': ['error', {
'ignoreProperties': false,
}],
'vue/no-v-html': 'warn',
'vue/order-in-components': 'error',
'vue/html-indent': ['warn', 'tab', {
'attribute': 1,
'baseIndent': 0,
'closeBracket': 0,
'alignAttributesVertically': true,
'ignores': [],
}],
'vue/html-closing-bracket-spacing': ['warn', {
'startTag': 'never',
'endTag': 'never',
'selfClosingTag': 'never',
}],
'vue/multi-word-component-names': 'warn',
'vue/require-v-for-key': 'warn',
'vue/no-unused-components': 'warn',
'vue/no-unused-vars': 'warn',
'vue/no-dupe-keys': 'warn',
'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true }],
'vue/attribute-hyphenation': ['error', 'never'],
},
globals: {
// Node.js
'module': false,
'require': false,
'__dirname': false,
// Misskey
'_DEV_': false,
'_LANGS_': false,
'_VERSION_': false,
'_ENV_': false,
'_PERF_PREFIX_': false,
'_DATA_TRANSFER_DRIVE_FILE_': false,
'_DATA_TRANSFER_DRIVE_FOLDER_': false,
'_DATA_TRANSFER_DECK_COLUMN_': false,
},
};

View File

@ -47,7 +47,6 @@ await fs.readFile(
) )
) )
.map((path) => path.replace(/(?:(?<=\.stories)\.(?:impl|meta)|\.msw)(?=\.ts$)/g, '')) .map((path) => path.replace(/(?:(?<=\.stories)\.(?:impl|meta)|\.msw)(?=\.ts$)/g, ''))
.map((path) => (path.startsWith('.') ? path : `./${path}`))
); );
if ( if (
micromatch(Array.from(modules), [ micromatch(Array.from(modules), [

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { FORCE_REMOUNT } from '@storybook/core-events'; import { FORCE_RE_RENDER, FORCE_REMOUNT } from '@storybook/core-events';
import { addons } from '@storybook/preview-api'; import { addons } from '@storybook/preview-api';
import { type Preview, setup } from '@storybook/vue3'; import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic'; import isChromatic from 'chromatic/isChromatic';
@ -16,7 +16,7 @@ import '../src/style.scss';
const appInitialized = Symbol(); const appInitialized = Symbol();
let lastStory = null; let lastStory: string | null = null;
let moduleInitialized = false; let moduleInitialized = false;
let unobserve = () => {}; let unobserve = () => {};
let misskeyOS = null; let misskeyOS = null;
@ -110,7 +110,7 @@ const preview = {
}).catch(() => {}); }).catch(() => {});
Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => { Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
initLocalStorage(); initLocalStorage();
channel.emit(FORCE_REMOUNT, { storyId: context.id }); channel.emit(FORCE_RE_RENDER, { storyId: context.id });
}); });
} }
const story = Story(); const story = Story();

View File

@ -0,0 +1,95 @@
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import parser from 'vue-eslint-parser';
import pluginVue from 'eslint-plugin-vue';
import pluginMisskey from '@misskey-dev/eslint-plugin';
import sharedConfig from '../shared/eslint.config.js';
export default [
...sharedConfig,
{
files: ['src/**/*.vue'],
...pluginMisskey.configs.typescript,
},
...pluginVue.configs['flat/recommended'],
{
files: ['src/**/*.{ts,vue}'],
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
...globals.browser,
// Node.js
module: false,
require: false,
__dirname: false,
// Misskey
_DEV_: false,
_LANGS_: false,
_VERSION_: false,
_ENV_: false,
_PERF_PREFIX_: false,
_DATA_TRANSFER_DRIVE_FILE_: false,
_DATA_TRANSFER_DRIVE_FOLDER_: false,
_DATA_TRANSFER_DECK_COLUMN_: false,
},
parser,
parserOptions: {
extraFileExtensions: ['.vue'],
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-empty-interface': ['error', {
allowSingleExtends: true,
}],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'],
'no-shadow': ['warn'],
'vue/attributes-order': ['error', {
alphabetical: false,
}],
'vue/no-use-v-if-with-v-for': ['error', {
allowUsingIterationVar: false,
}],
'vue/no-ref-as-operand': 'error',
'vue/no-multi-spaces': ['error', {
ignoreProperties: false,
}],
'vue/no-v-html': 'warn',
'vue/order-in-components': 'error',
'vue/html-indent': ['warn', 'tab', {
attribute: 1,
baseIndent: 0,
closeBracket: 0,
alignAttributesVertically: true,
ignores: [],
}],
'vue/html-closing-bracket-spacing': ['warn', {
startTag: 'never',
endTag: 'never',
selfClosingTag: 'never',
}],
'vue/multi-word-component-names': 'warn',
'vue/require-v-for-key': 'warn',
'vue/no-unused-components': 'warn',
'vue/no-unused-vars': 'warn',
'vue/no-dupe-keys': 'warn',
'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/v-on-event-hyphenation': ['error', 'never', {
autofix: true,
}],
'vue/attribute-hyphenation': ['error', 'never'],
},
},
];

View File

@ -22,24 +22,24 @@
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@misskey-dev/browser-image-resizer": "2024.1.0", "@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0", "@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.5", "@rollup/plugin-replace": "5.0.7",
"@rollup/pluginutils": "5.1.0", "@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.18.0", "@syuilo/aiscript": "0.18.0",
"@tabler/icons-webfont": "3.3.0", "@tabler/icons-webfont": "3.3.0",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.0.4", "@vitejs/plugin-vue": "5.0.5",
"@vue/compiler-sfc": "3.4.26", "@vue/compiler-sfc": "3.4.31",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11",
"astring": "1.8.6", "astring": "1.8.6",
"broadcast-channel": "7.0.0", "broadcast-channel": "7.0.0",
"buraha": "0.0.1", "buraha": "0.0.1",
"canvas-confetti": "1.9.3", "canvas-confetti": "1.9.3",
"chart.js": "4.4.2", "chart.js": "4.4.3",
"chartjs-adapter-date-fns": "3.0.0", "chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "11.3.0", "chromatic": "11.5.4",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.5", "cropperjs": "2.0.0-beta.5",
"date-fns": "2.30.0", "date-fns": "2.30.0",
@ -55,89 +55,87 @@
"misskey-bubble-game": "workspace:*", "misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"photoswipe": "5.4.3", "photoswipe": "5.4.4",
"punycode": "2.3.1", "punycode": "2.3.1",
"rollup": "4.17.2", "rollup": "4.18.0",
"sanitize-html": "2.13.0", "sanitize-html": "2.13.0",
"sass": "1.76.0", "sass": "1.77.6",
"shiki": "1.4.0", "shiki": "1.10.0",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.164.1", "three": "0.165.0",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.10",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.5.2", "typescript": "5.5.3",
"uuid": "9.0.1", "uuid": "10.0.0",
"v-code-diff": "1.11.0", "v-code-diff": "1.12.0",
"vite": "5.2.11", "vite": "5.3.2",
"vue": "3.4.26", "vue": "3.4.31",
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@misskey-dev/summaly": "5.1.0", "@misskey-dev/summaly": "5.1.0",
"@storybook/addon-actions": "8.0.9", "@storybook/addon-actions": "8.1.11",
"@storybook/addon-essentials": "8.0.9", "@storybook/addon-essentials": "8.1.11",
"@storybook/addon-interactions": "8.0.9", "@storybook/addon-interactions": "8.1.11",
"@storybook/addon-links": "8.0.9", "@storybook/addon-links": "8.1.11",
"@storybook/addon-mdx-gfm": "8.0.9", "@storybook/addon-mdx-gfm": "8.1.11",
"@storybook/addon-storysource": "8.0.9", "@storybook/addon-storysource": "8.1.11",
"@storybook/blocks": "8.0.9", "@storybook/blocks": "8.1.11",
"@storybook/components": "8.0.9", "@storybook/components": "8.1.11",
"@storybook/core-events": "8.0.9", "@storybook/core-events": "8.1.11",
"@storybook/manager-api": "8.0.9", "@storybook/manager-api": "8.1.11",
"@storybook/preview-api": "8.0.9", "@storybook/preview-api": "8.1.11",
"@storybook/react": "8.0.9", "@storybook/react": "8.1.11",
"@storybook/react-vite": "8.0.9", "@storybook/react-vite": "8.1.11",
"@storybook/test": "8.0.9", "@storybook/test": "8.1.11",
"@storybook/theming": "8.0.9", "@storybook/theming": "8.1.11",
"@storybook/types": "8.0.9", "@storybook/types": "8.1.11",
"@storybook/vue3": "8.0.9", "@storybook/vue3": "8.1.11",
"@storybook/vue3-vite": "8.0.9", "@storybook/vue3-vite": "8.1.11",
"@testing-library/vue": "8.0.3", "@testing-library/vue": "8.1.0",
"@types/escape-regexp": "0.0.3", "@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5", "@types/estree": "1.0.5",
"@types/matter-js": "0.19.6", "@types/matter-js": "0.19.6",
"@types/micromatch": "4.0.7", "@types/micromatch": "4.0.9",
"@types/node": "20.12.7", "@types/node": "20.14.9",
"@types/punycode": "2.1.4", "@types/punycode": "2.1.4",
"@types/sanitize-html": "2.11.0", "@types/sanitize-html": "2.11.0",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2", "@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.8", "@types/uuid": "10.0.0",
"@types/ws": "8.5.10", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "7.7.1", "@typescript-eslint/eslint-plugin": "7.15.0",
"@typescript-eslint/parser": "7.7.1", "@typescript-eslint/parser": "7.15.0",
"@vitest/coverage-v8": "0.34.6", "@vitest/coverage-v8": "1.6.0",
"@vue/runtime-core": "3.4.26", "@vue/runtime-core": "3.4.31",
"acorn": "8.11.3", "acorn": "8.12.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.8.1", "cypress": "13.13.0",
"eslint": "8.57.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.25.0", "eslint-plugin-vue": "9.26.0",
"fast-glob": "3.3.2", "fast-glob": "3.3.2",
"happy-dom": "10.0.3", "happy-dom": "10.0.3",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"micromatch": "4.0.5", "micromatch": "4.0.7",
"msw": "2.2.14", "msw": "2.3.1",
"msw-storybook-addon": "2.0.1", "msw-storybook-addon": "2.0.2",
"nodemon": "3.1.0", "nodemon": "3.1.4",
"prettier": "3.2.5", "prettier": "3.3.2",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"start-server-and-test": "2.0.3", "start-server-and-test": "2.0.4",
"storybook": "8.0.9", "storybook": "8.1.11",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6", "vitest": "1.6.0",
"vitest-fetch-mock": "0.2.2", "vitest-fetch-mock": "0.2.2",
"vue-component-type-helpers": "2.0.16", "vue-component-type-helpers": "2.0.24",
"vue-eslint-parser": "9.4.2", "vue-eslint-parser": "9.4.3",
"vue-tsc": "2.0.16" "vue-tsc": "2.0.24"
} }
} }

View File

@ -184,10 +184,12 @@ export async function refreshAccount() {
export async function login(token: Account['token'], redirect?: string) { export async function login(token: Account['token'], redirect?: string) {
const showing = ref(true); const showing = ref(true);
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
success: false, success: false,
showing: showing, showing: showing,
}, {}, 'closed'); }, {
closed: () => dispose(),
});
if (_DEV_) console.log('logging as token ', token); if (_DEV_) console.log('logging as token ', token);
const me = await fetchAccount(token, undefined, true) const me = await fetchAccount(token, undefined, true)
.catch(reason => { .catch(reason => {
@ -223,21 +225,23 @@ export async function openAccountMenu(opts: {
if (!$i) return; if (!$i) return;
function showSigninDialog() { function showSigninDialog() {
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: res => { done: res => {
addAccount(res.id, res.i); addAccount(res.id, res.i);
success(); success();
}, },
}, 'closed'); closed: () => dispose(),
});
} }
function createAccount() { function createAccount() {
popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: res => { done: res => {
addAccount(res.id, res.i); addAccount(res.id, res.i);
switchAccountWithToken(res.i); switchAccountWithToken(res.i);
}, },
}, 'closed'); closed: () => dispose(),
});
} }
async function switchAccount(account: Misskey.entities.UserDetailed) { async function switchAccount(account: Misskey.entities.UserDetailed) {

View File

@ -13,7 +13,6 @@ import * as sound from '@/scripts/sound.js';
import { $i, signout, updateAccount } from '@/account.js'; import { $i, signout, updateAccount } from '@/account.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js'; import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { makeHotkey } from '@/scripts/hotkey.js';
import { reactionPicker } from '@/scripts/reaction-picker.js'; import { reactionPicker } from '@/scripts/reaction-picker.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
@ -21,6 +20,7 @@ import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js'; import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js'; import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mainRouter } from '@/router/main.js'; import { mainRouter } from '@/router/main.js';
import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
export async function mainBoot() { export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp( const { isClientUpdated } = await common(() => createApp(
@ -35,7 +35,9 @@ export async function mainBoot() {
emojiPicker.init(); emojiPicker.init();
if (isClientUpdated && $i) { if (isClientUpdated && $i) {
popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {
closed: () => dispose(),
});
} }
const stream = useStream(); const stream = useStream();
@ -67,14 +69,6 @@ export async function mainBoot() {
}); });
} }
const hotkeys = {
'd': (): void => {
defaultStore.set('darkMode', !defaultStore.state.darkMode);
},
's': (): void => {
mainRouter.push('/search');
},
};
try { try {
if (defaultStore.state.enableSeasonalScreenEffect) { if (defaultStore.state.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1; const month = new Date().getMonth() + 1;
@ -96,34 +90,37 @@ export async function mainBoot() {
}).render(); }).render();
} }
} }
} }
} catch (error) { } catch (error) {
// console.error(error); // console.error(error);
console.error('Failed to initialise the seasonal screen effect canvas context:', error); console.error('Failed to initialise the seasonal screen effect canvas context:', error);
} }
if ($i) { if ($i) {
// only add post shortcuts if logged in
hotkeys['p|n'] = post;
defaultStore.loaded.then(() => { defaultStore.loaded.then(() => {
if (defaultStore.state.accountSetupWizard !== -1) { if (defaultStore.state.accountSetupWizard !== -1) {
popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed'); const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
closed: () => dispose(),
});
} }
}); });
for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) { for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) {
popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
announcement, announcement,
}, {}, 'closed'); }, {
closed: () => dispose(),
});
} }
stream.on('announcementCreated', (ev) => { stream.on('announcementCreated', (ev) => {
const announcement = ev.announcement; const announcement = ev.announcement;
if (announcement.display === 'dialog') { if (announcement.display === 'dialog') {
popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
announcement, announcement,
}, {}, 'closed'); }, {
closed: () => dispose(),
});
} }
}); });
@ -247,13 +244,17 @@ export async function mainBoot() {
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) { if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed'); const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {
closed: () => dispose(),
});
} }
} }
const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read'); const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') { if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed'); const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {
closed: () => dispose(),
});
} }
if ('Notification' in window) { if ('Notification' in window) {
@ -322,7 +323,19 @@ export async function mainBoot() {
} }
// shortcut // shortcut
document.addEventListener('keydown', makeHotkey(hotkeys)); const keymap = {
'p|n': () => {
if ($i == null) return;
post();
},
'd': () => {
defaultStore.set('darkMode', !defaultStore.state.darkMode);
},
's': () => {
mainRouter.push('/search');
},
} as const satisfies Keymap;
document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
initializeSw(); initializeSw();
} }

View File

@ -153,7 +153,7 @@ onMounted(() => {
background: linear-gradient(0deg, #ffee20, #eb7018); background: linear-gradient(0deg, #ffee20, #eb7018);
} }
&:before { &::before {
content: ""; content: "";
display: block; display: block;
position: absolute; position: absolute;
@ -173,7 +173,7 @@ onMounted(() => {
background: linear-gradient(0deg, #e1e1e1, #7c7c7c); background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
} }
&:before { &::before {
content: ""; content: "";
display: block; display: block;
position: absolute; position: absolute;

View File

@ -250,7 +250,6 @@ function onMousedown(evt: MouseEvent): void {
} }
&:focus-visible { &:focus-visible {
outline: solid 2px var(--focus);
outline-offset: 2px; outline-offset: 2px;
} }

View File

@ -12,14 +12,12 @@ import { expect, userEvent, within } from '@storybook/test';
import { channel } from '../../.storybook/fakes.js'; import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelFollowButton from './MkChannelFollowButton.vue'; import MkChannelFollowButton from './MkChannelFollowButton.vue';
import { semaphore } from '@/scripts/test-utils.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
function sleep(ms: number) { function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
const s = semaphore();
export const Default = { export const Default = {
render(args) { render(args) {
return { return {
@ -46,17 +44,13 @@ export const Default = {
full: true, full: true,
}, },
async play({ canvasElement }) { async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement); const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button'); const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts.follow); await expect(buttonElement).toHaveTextContent(i18n.ts.follow);
await userEvent.click(buttonElement); await userEvent.click(buttonElement);
await sleep(1000); await sleep(1000);
await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow); await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow);
await sleep(100);
await userEvent.click(buttonElement); await userEvent.click(buttonElement);
s.release();
}, },
parameters: { parameters: {
layout: 'centered', layout: 'centered',

View File

@ -87,17 +87,7 @@ async function onClick() {
} }
&:focus-visible { &:focus-visible {
&:after { outline-offset: 2px;
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
} }
&:hover { &:hover {

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div style="position: relative;"> <div style="position: relative;">
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt"> <MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" @click="updateLastReadedAt">
<div class="banner" :style="bannerStyle"> <div class="banner" :style="bannerStyle">
<div class="fade"></div> <div class="fade"></div>
<div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div> <div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div>
@ -80,6 +80,7 @@ const bannerStyle = computed(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.eftoefju { .eftoefju {
display: block; display: block;
position: relative;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
@ -87,6 +88,22 @@ const bannerStyle = computed(() => {
text-decoration: none; text-decoration: none;
} }
&:focus-within {
outline: none;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 0 0 2px var(--focus);
}
}
> .banner { > .banner {
position: relative; position: relative;
width: 100%; width: 100%;

View File

@ -8,7 +8,7 @@
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw'; import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { expect, within } from '@storybook/test'; import { expect, userEvent, within } from '@storybook/test';
import { commonHandlers } from '../../.storybook/mocks.js'; import { commonHandlers } from '../../.storybook/mocks.js';
import MkClickerGame from './MkClickerGame.vue'; import MkClickerGame from './MkClickerGame.vue';
@ -41,12 +41,10 @@ export const Default = {
await sleep(1000); await sleep(1000);
const canvas = within(canvasElement); const canvas = within(canvasElement);
const count = canvas.getByTestId('count'); const count = canvas.getByTestId('count');
// NOTE: flaky なので N/A も通しておく await expect(count).toHaveTextContent('0');
await expect(count).toHaveTextContent(/^(0|N\/A)$/); const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
// FIXME: flaky await userEvent.click(buttonElement);
// const buttonElement = canvas.getByRole<HTMLButtonElement>('button'); await expect(count).toHaveTextContent('1');
// await userEvent.click(buttonElement);
// await expect(count).toHaveTextContent('1');
}, },
parameters: { parameters: {
layout: 'centered', layout: 'centered',

View File

@ -35,7 +35,9 @@ const prevCookies = ref(0);
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
const x = ev.clientX; const x = ev.clientX;
const y = ev.clientY; const y = ev.clientY;
os.popup(MkPlusOneEffect, { x, y }, {}, 'end'); const { dispose } = os.popup(MkPlusOneEffect, { x, y }, {
end: () => dispose(),
});
saveData.value!.cookies++; saveData.value!.cookies++;
saveData.value!.totalCookies++; saveData.value!.totalCookies++;

View File

@ -40,6 +40,14 @@ const remaining = computed(() => {
.link { .link {
display: block; display: block;
&:focus-visible {
outline: none;
.root {
box-shadow: inset 0 0 0 2px var(--focus);
}
}
&:hover { &:hover {
text-decoration: none; text-decoration: none;
color: var(--accent); color: var(--accent);

View File

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
> >
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/> <MkMenu :items="items" :align="'left'" @close="emit('closed')"/>
</div> </div>
</Transition> </Transition>
</template> </template>

Some files were not shown because too many files have changed in this diff Show More