diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index c076a4aa57..658560087c 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -2,6 +2,19 @@ # CherryPick configuration #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# ┌────────────────────────┐ +#───┘ Initial Setup Password └───────────────────────────────────────────────────── + +# Password to initiate setting up admin account. +# It will not be used after the initial setup is complete. +# +# Be sure to change this when you set up Misskey via the Internet. +# +# The provider of the service who sets up Misskey on behalf of the customer should +# set this value to something unique when generating the Misskey config file, +# and provide it to the customer. +setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index 2487bce6bc..a499785ca7 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -59,6 +59,20 @@ # # publishTarballInsteadOfProvideRepositoryUrl: true +# ┌────────────────────────┐ +#───┘ Initial Setup Password └───────────────────────────────────────────────────── + +# Password to initiate setting up admin account. +# It will not be used after the initial setup is complete. +# +# Be sure to change this when you set up CherryPick via the Internet. +# +# The provider of the service who sets up CherryPick on behalf of the customer should +# set this value to something unique when generating the CherryPick config file, +# and provide it to the customer. +# +# setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── diff --git a/.github/cherrypick/test.yml b/.github/cherrypick/test.yml index a353d4d713..c4ce858ad2 100644 --- a/.github/cherrypick/test.yml +++ b/.github/cherrypick/test.yml @@ -1,5 +1,7 @@ url: 'http://cherrypick.local' +setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ローカルでテストするときにポートを被らないようにするためデフォルトのものとは変える(以下同じ) port: 61812 diff --git a/.github/labeler.yml b/.github/labeler.yml index bd234333b2..299f5b85a6 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,7 +6,7 @@ 'packages/backend:test': - any: - changed-files: - - any-glob-to-any-file: ['packages/backend/test/**/*'] + - any-glob-to-any-file: ['packages/backend/test/**/*', 'packages/backend/test-federation/**/*'] 'packages/frontend': - any: diff --git a/.github/workflows/check-cherrypick-js-autogen.yml b/.github/workflows/check-cherrypick-js-autogen.yml index 7083430406..9039cf5e67 100644 --- a/.github/workflows/check-cherrypick-js-autogen.yml +++ b/.github/workflows/check-cherrypick-js-autogen.yml @@ -21,6 +21,7 @@ jobs: uses: actions/checkout@v4.1.1 with: submodules: true + persist-credentials: false ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: setup pnpm @@ -57,7 +58,7 @@ jobs: name: generated-cherrypick-js path: packages/cherrypick-js/generator/built/autogen - # pull_request_target safety: permissions: read-all, and there are no secrets used in this job + # pull_request_target safety: permissions: read-all, and no user codes are executed get-actual-cherrypick-js: runs-on: ubuntu-latest permissions: @@ -68,6 +69,7 @@ jobs: uses: actions/checkout@v4.1.1 with: submodules: true + persist-credentials: false ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: Upload From Merged @@ -131,3 +133,7 @@ jobs: mode: delete message: "Thank you!" create_if_not_exists: false + + - name: Make failure if changes are detected + if: steps.check-changes.outputs.changes == 'true' + run: exit 1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 559e1f952d..4697e07396 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -75,7 +75,7 @@ jobs: - run: corepack enable - run: pnpm i --frozen-lockfile - name: Restore eslint cache - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.1.0 with: path: ${{ env.eslint-cache-path }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml new file mode 100644 index 0000000000..cb7df30662 --- /dev/null +++ b/.github/workflows/test-federation.yml @@ -0,0 +1,59 @@ +name: Test (federation) + +on: + push: + branches: + - master + - develop + paths: + - packages/backend/** + - packages/cherrypick-js/** + - .github/workflows/test-federation.yml + pull_request: + paths: + - packages/backend/** + - packages/cherrypick-js/** + - .github/workflows/test-federation.yml + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.16.0] + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v4 + - name: Install FFmpeg + uses: FedericoCarboni/setup-ffmpeg@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4.0.3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Build CherryPick + run: | + corepack enable && corepack prepare + pnpm i --frozen-lockfile + pnpm build + - name: Setup + run: | + cd packages/backend/test-federation + bash ./setup.sh + sudo chmod 644 ./certificates/*.test.key + - name: Start servers + # https://github.com/docker/compose/issues/1294#issuecomment-374847206 + run: | + cd packages/backend/test-federation + docker compose up -d --scale tester=0 + - name: Test + run: | + cd packages/backend/test-federation + docker compose run --no-deps tester + - name: Stop servers + run: | + cd packages/backend/test-federation + docker compose down diff --git a/.gitignore b/.gitignore index 662127e78a..15ce07fd51 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ coverage !/.config/docker_example.env !/.config/cypress-devcontainer.yml docker-compose.yml -compose.yml +./compose.yml .devcontainer/compose.yml !/.devcontainer/compose.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e2a9215a..ccb182083c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,57 @@ -## Unreleased +## 2024.10.1 + +### Note +- スパム対策として、モデレータ権限を持つユーザのアクティビティが7日以上確認できない場合は自動的に招待制へと切り替え(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)るようになりました。 ( #13437 ) + - 切り替わった際はモデレーターへお知らせとして通知されます。登録をオープンな状態で継続したい場合は、コントロールパネルから再度設定を行ってください。 ### General -- +- Feat: ユーザーの名前に禁止ワードを設定できるように ### Client -- Enhance: フォロワーへのメッセージ欄のデザイン改良 +- Enhance: タイムライン表示時のパフォーマンスを向上 +- Enhance: アーカイブした個人宛のお知らせを表示・編集できるように +- Enhance: l10nの更新 +- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正 ### Server -- +- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと切り替えるように ( #13437 ) +- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように +- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正 +- Fix: RBT有効時、リノートのリアクションが反映されない問題を修正 +- Fix: キューのエラーログを簡略化するように + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649) +## 2024.10.0 + +### Note +- セキュリティ向上のため、サーバー初期設定時に使用する初期パスワードを設定できるようになりました。今後Misskeyサーバーを新たに設置する際には、初回の起動前にコンフィグファイルの`setupPassword`をコメントアウトし、初期パスワードを設定することをおすすめします。(すでに初期設定を完了しているサーバーについては、この変更に伴い対応する必要はありません) + - ホスティングサービスを運営している場合は、コンフィグファイルを構築する際に`setupPassword`をランダムな値に設定し、ユーザーに通知するようにシステムを更新することをおすすめします。 + - なお、初期パスワードが設定されていない場合でも初期設定を行うことが可能です(UI上で初期パスワードの入力欄を空欄にすると続行できます)。 +- ユーザーデータを読み込む際の型が一部変更されました。 + - `twoFactorEnabled`, `usePasswordLessLogin`, `securityKeys`: 自分とモデレーター以外のユーザーからは取得できなくなりました + +### General +- Feat: サーバー初期設定時に初期パスワードを設定できるように +- Feat: 通報にモデレーションノートを残せるように +- Feat: 通報の解決種別を設定できるように +- Enhance: 通報の解決と転送を個別に行えるように +- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました +- Enhance: 依存関係の更新 +- Enhance: l10nの更新 +- Enhance: Playの「人気」タブで10件以上表示可能に #14399 +- Fix: 連合のホワイトリストが正常に登録されない問題を修正 + +### Client +- Enhance: デザインの調整 +- Enhance: ログイン画面の認証フローを改善 +- Fix: クライアント上での時間ベースの実績獲得動作が実績獲得後も発動していた問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/657) + +### Server +- Enhance: セキュリティ向上のため、ログイン時にメール通知を行うように +- Enhance: 自分とモデレーター以外のユーザーから二要素認証関連のデータが取得できないように +- Enhance: 通報および通報解決時に送出されるSystemWebhookにユーザ情報を含めるように ( #14697 ) +- Fix: `admin/abuse-user-reports`エンドポイントのスキーマが間違っていた問題を修正 ## 2024.9.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46dc898b75..7495abd3e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,31 +181,45 @@ CP_DEV_PREFER=backend pnpm dev - HMR may not work in some environments such as Windows. ## Testing -- Test codes are located in [`/packages/backend/test`](/packages/backend/test). - -### Run test -Create a config file. +You can run non-backend tests by executing following commands: +```sh +pnpm --filter frontend test +pnpm --filter cherrypick-js test ``` + +Backend tests require manual preparation of servers. See the next section for more on this. + +### Backend +There are three types of test codes for the backend: +- Unit tests: [`/packages/backend/test/unit`](/packages/backend/test/unit) +- Single-server E2E tests: [`/packages/backend/test/e2e`](/packages/backend/test/e2e) +- Multiple-server E2E tests: [`/packages/backend/test-federation`](/packages/backend/test-federation) + +#### Running Unit Tests or Single-server E2E Tests +1. Create a config file: +```sh cp .github/cherrypick/test.yml .config/ ``` -Prepare DB/Redis for testing. -``` + +2. Start DB and Redis servers for testing: +```sh docker compose -f packages/backend/test/compose.yml up ``` -Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. +Instead, you can prepare an empty (data can be erased) DB and edit `.config/test.yml` appropriately. -Run all test. +3. Run all tests: +```sh +pnpm --filter backend test # unit tests +pnpm --filter backend test:e2e # single-server E2E tests ``` -pnpm test +If you want to run a specific test, run as a following command: +```sh +pnpm --filter backend test -- packages/backend/test/unit/activitypub.ts +pnpm --filter backend test:e2e -- packages/backend/test/e2e/nodeinfo.ts ``` -#### Run specify test -``` -pnpm jest -- foo.ts -``` - -### e2e tests -TODO +#### Running Multiple-server E2E Tests +See [`/packages/backend/test-federation/README.md`](/packages/backend/test-federation/README.md). ## Environment Variable @@ -581,18 +595,18 @@ ESMではディレクトリインポートは廃止されているのと、デ ### Lighten CSS vars ``` css -color: hsl(from var(--accent) h s calc(l + 10)); +color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); ``` ### Darken CSS vars ``` css -color: hsl(from var(--accent) h s calc(l - 10)); +color: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); ``` ### Add alpha to CSS vars ``` css -color: color(from var(--accent) srgb r g b / 0.5); +color: color(from var(--MI_THEME-accent) srgb r g b / 0.5); ``` diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index f0ea7a3d44..5e295ae878 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -23,6 +23,7 @@ describe('Before setup instance', () => { cy.intercept('POST', '/api/admin/accounts/create').as('signup'); + cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked'); cy.get('[data-cy-admin-username] input').type('admin'); cy.get('[data-cy-admin-password] input').type('admin1234'); cy.get('[data-cy-admin-ok]').click(); @@ -119,11 +120,16 @@ describe('After user signup', () => { it('signin', () => { cy.visitHome(); - cy.intercept('POST', '/api/signin').as('signin'); + cy.intercept('POST', '/api/signin-flow').as('signin'); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type('alice'); - // Enterキーでサインインできるかの確認も兼ねる + + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + // Enterキーで続行できるかの確認も兼ねる + cy.get('[data-cy-signin-username] input').type('alice{enter}'); + + cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 }); + // Enterキーで続行できるかの確認も兼ねる cy.get('[data-cy-signin-password] input').type('alice1234{enter}'); cy.wait('@signin'); @@ -138,8 +144,9 @@ describe('After user signup', () => { cy.visitHome(); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type('alice'); - cy.get('[data-cy-signin-password] input').type('alice1234{enter}'); + + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + cy.get('[data-cy-signin-username] input').type('alice{enter}'); // TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 281f2e6ccd..197ff963ac 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -48,16 +48,19 @@ Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => { cy.request('POST', route, { username: username, password: password, + ...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}), }).its('body').as(username); }); Cypress.Commands.add('login', (username, password) => { cy.visitHome(); - cy.intercept('POST', '/api/signin').as('signin'); + cy.intercept('POST', '/api/signin-flow').as('signin'); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type(username); + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + cy.get('[data-cy-signin-username] input').type(`${username}{enter}`); + cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 }); cy.get('[data-cy-signin-password] input').type(`${password}{enter}`); cy.wait('@signin').as('signedIn'); diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/idea/MkAbuseReport.stories.impl.ts similarity index 87% rename from packages/frontend/src/components/MkAbuseReport.stories.impl.ts rename to idea/MkAbuseReport.stories.impl.ts index f662c9b0f8..63b2f6afd4 100644 --- a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts +++ b/idea/MkAbuseReport.stories.impl.ts @@ -6,8 +6,8 @@ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; -import { abuseUserReport } from '../../.storybook/fakes.js'; -import { commonHandlers } from '../../.storybook/mocks.js'; +import { abuseUserReport } from '../packages/frontend/.storybook/fakes.js'; +import { commonHandlers } from '../packages/frontend/.storybook/mocks.js'; import MkAbuseReport from './MkAbuseReport.vue'; export const Default = { render(args) { diff --git a/idea/MkDisableSection.vue b/idea/MkDisableSection.vue index d177886569..360705071b 100644 --- a/idea/MkDisableSection.vue +++ b/idea/MkDisableSection.vue @@ -34,7 +34,7 @@ defineProps<{ width: 100%; height: 100%; cursor: not-allowed; - --color: color(from var(--error) srgb r g b / 0.25); + --color: color(from var(--MI_THEME-error) srgb r g b / 0.25); background-size: auto auto; background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px); } diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index c6e2f46d72..0bd43d4543 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -638,10 +638,7 @@ abuseReported: "أُرسل البلاغ، شكرًا لك" reporter: "المُبلّغ" reporteeOrigin: "أصل البلاغ" reporterOrigin: "أصل المُبلّغ" -forwardReport: "وجّه البلاغ إلى المثيل البعيد" -forwardReportIsAnonymous: "في المثيل البعيد سيظهر المبلّغ كحساب مجهول." send: "أرسل" -abuseMarkAsResolved: "علّم البلاغ كمحلول" openInNewTab: "افتح في لسان جديد" defaultNavigationBehaviour: "سلوك الملاحة الافتراضي" editTheseSettingsMayBreakAccount: "تعديل هذه الإعدادات قد يسبب عطبًا لحسابك" @@ -1269,7 +1266,6 @@ _theme: buttonBg: "خلفية الأزرار" buttonHoverBg: "خلفية الأزرار (عند التمرير فوقها)" inputBorder: "حواف حقل الإدخال" - listItemHoverBg: "خلفية عناصر القائمة (عند التمرير فوقها)" driveFolderBg: "خلفية مجلد قرص التخزين" messageBg: "خلفية المحادثة" _sfx: @@ -1551,6 +1547,7 @@ _notification: receiveFollowRequest: "طلبات المتابعة" followRequestAccepted: "طلبات المتابعة المقبولة" groupInvited: "دعوات الفريق" + login: "لِج" app: "إشعارات التطبيقات المرتبطة" _actions: followBack: "تابعك بالمثل" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 5ecdbd3b39..08e5a1c47c 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -636,10 +636,7 @@ abuseReported: "আপনার অভিযোগটি দাখিল কর reporter: "অভিযোগকারী" reporteeOrigin: "অভিযোগটির উৎস" reporterOrigin: "অভিযোগকারীর উৎস" -forwardReport: "রিমোট ইন্সত্যান্সে অভিযোগটি পাঠান" -forwardReportIsAnonymous: "আপনার তথ্য রিমোট ইন্সত্যান্সে পাঠানো হবে না এবং একটি বেনামী সিস্টেম অ্যাকাউন্ট হিসাবে প্রদর্শিত হবে।" send: "পাঠান" -abuseMarkAsResolved: "অভিযোগটিকে সমাধাকৃত হিসাবে চিহ্নিত করুন" openInNewTab: "নতুন ট্যাবে খুলুন" openInSideView: "সাইড ভিউতে খুলুন" defaultNavigationBehaviour: "ডিফল্ট নেভিগেশন" @@ -1097,7 +1094,6 @@ _theme: buttonBg: "বাটনের পটভূমি" buttonHoverBg: "বাটনের পটভূমি (হভার)" inputBorder: "ইনপুট ফিল্ডের বর্ডার" - listItemHoverBg: "লিস্ট আইটেমের পটভূমি (হোভার)" driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি" wallpaperOverlay: "ওয়ালপেপার ওভারলে" badge: "ব্যাজ" @@ -1395,6 +1391,7 @@ _notification: receiveFollowRequest: "প্রাপ্ত অনুসরণের অনুরোধসমূহ" followRequestAccepted: "গৃহীত অনুসরণের অনুরোধসমূহ" groupInvited: "গ্রুপের আমন্ত্রনসমূহ" + login: "প্রবেশ করুন" app: "লিঙ্ক করা অ্যাপ থেকে বিজ্ঞপ্তি" _actions: followBack: "ফলো ব্যাক করেছে" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 94969f4291..730856f87d 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -8,6 +8,8 @@ search: "Cercar" notifications: "Notificacions" username: "Nom d'usuari" password: "Contrasenya" +initialPasswordForSetup: "Contrasenya inicial per la configuració inicial" +initialPasswordIsIncorrect: "La contrasenya no és correcta." forgotPassword: "Contrasenya oblidada" fetchingAsApObject: "Cercant en el Fediverse..." ok: "OK" @@ -236,6 +238,8 @@ silencedInstances: "Instàncies silenciades" silencedInstancesDescription: "Llista els enllaços d'amfitrió de les instàncies que vols silenciar. Tots els comptes de les instàncies llistades s'establiran com silenciades i només podran fer sol·licitacions de seguiment, i no podran mencionar als comptes locals si no els segueixen. Això no afectarà les instàncies bloquejades." mediaSilencedInstances: "Instàncies amb els arxius silenciats" mediaSilencedInstancesDescription: "Llista els noms dels servidors que vulguis silenciar els arxius, un servidor per línia. Tots els comptes que pertanyin als servidors llistats seran tractats com sensibles i no podran fer servir emojis personalitzats. Això no tindrà efecte sobre els servidors blocats." +federationAllowedHosts: "Llista de servidors federats" +federationAllowedHostsDescription: "Llista dels servidors amb els quals es federa." muteAndBlock: "Silencia i bloca" mutedUsers: "Usuaris silenciats" blockedUsers: "Usuaris bloquejats" @@ -334,6 +338,7 @@ renameFolder: "Canvia el nom de la carpeta" deleteFolder: "Elimina la carpeta" folder: "Carpeta " addFile: "Afegeix un fitxer" +showFile: "Mostrar fitxer" emptyDrive: "La teva unitat és buida" emptyFolder: "La carpeta està buida" unableToDelete: "No es pot eliminar" @@ -448,6 +453,7 @@ totpDescription: "Escriu una contrasenya d'un sol us fent servir l'aplicació d' moderator: "Moderador/a" moderation: "Moderació" moderationNote: "Nota de moderació " +moderationNoteDescription: "Pots escriure notes que es compartiran entre els moderadors." addModerationNote: "Afegir una nota de moderació " moderationLogs: "Registre de moderació " nUsersMentioned: "{n} usuaris mencionats" @@ -509,6 +515,10 @@ uiLanguage: "Idioma de l'interfície" aboutX: "Respecte a {x}" emojiStyle: "Estil d'emoji" native: "Nadiu" +menuStyle: "Estil de menú" +style: "Estil" +drawer: "Calaix" +popup: "Emergent" showNoteActionsOnlyHover: "Només mostra accions de la nota en passar amb el cursor" showReactionsCount: "Mostra el nombre de reaccions a les publicacions" noHistory: "No hi ha un registre previ" @@ -709,10 +719,7 @@ abuseReported: "La teva denúncia s'ha enviat. Moltes gràcies." reporter: "Denunciant " reporteeOrigin: "Origen de la denúncia " reporterOrigin: "Origen del denunciant" -forwardReport: "Transferir la denúncia a una instància remota" -forwardReportIsAnonymous: "En lloc del teu compte, es farà servir un compte anònim com a denunciant al servidor remot." send: "Envia" -abuseMarkAsResolved: "Marca la denúncia com a resolta" openInNewTab: "Obre a una pestanya nova" openInSideView: "Obre a una vista lateral" defaultNavigationBehaviour: "Navegació per defecte" @@ -914,6 +921,7 @@ followersVisibility: "Visibilitat dels seguidors" continueThread: "Veure la continuació del fil" deleteAccountConfirm: "Això eliminarà el teu compte irreversiblement. Procedir?" incorrectPassword: "Contrasenya incorrecta." +incorrectTotp: "La contrasenya no és correcta, o ha caducat." voteConfirm: "Confirma el teu vot \"{choice}\"" hide: "Amagar" useDrawerReactionPickerForMobile: "Mostrar el selector de reaccions com un calaix al mòbil " @@ -1268,6 +1276,24 @@ fromX: "De {x}" genEmbedCode: "Obtenir el codi per incrustar" noteOfThisUser: "Notes d'aquest usuari" clipNoteLimitExceeded: "No es poden afegir més notes a aquest clip." +performance: "Rendiment" +modified: "Modificat" +discard: "Descarta" +thereAreNChanges: "Hi ha(n) {n} canvi(s)" +signinWithPasskey: "Inicia sessió amb Passkey" +unknownWebAuthnKey: "Passkey desconeguda" +passkeyVerificationFailed: "La verificació a fallat" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya." +messageToFollower: "Missatge als meus seguidors" +target: "Assumpte " +testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. No l'utilitzes en l'entorn real." +_abuseUserReport: + forward: "Reenviar " + forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima." + resolve: "Solució " + accept: "Acceptar " + reject: "Rebutjar" + resolveTutorial: "Si l'informe és legítim selecciona \"Acceptar\" per resoldre'l positivament. Però si l'informe no és legítim selecciona \"Rebutjar\" per resoldre'l negativament." _delivery: status: "Estat d'entrega " stop: "Suspés" @@ -1405,6 +1431,7 @@ _serverSettings: reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat." inquiryUrl: "URL de consulta " inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa." _accountMigration: moveFrom: "Migrar un altre compte a aquest" moveFromSub: "Crear un àlies per un altre compte" @@ -1958,7 +1985,6 @@ _theme: buttonBg: "Fons botó " buttonHoverBg: "Fons botó (en passar-hi per sobre)" inputBorder: "Contorn del cap d'introducció " - listItemHoverBg: "Fons dels elements d'una llista" driveFolderBg: "Fons de la carpeta Disc" wallpaperOverlay: "Superposició del fons de pantalla " badge: "Insígnia " @@ -2236,6 +2262,9 @@ _profile: changeBanner: "Canviar el bàner " verifiedLinkDescription: "Escrivint una adreça URL que enllaci a aquest perfil, una icona de propietat verificada es mostrarà al costat del camp." avatarDecorationMax: "Pot afegir un màxim de {max} decoracions." + followedMessage: "Missatge als nous seguidors" + followedMessageDescription: "Es pot configurar un missatge curt que es mostra a l'altra persona quan comença a seguir-te." + followedMessageDescriptionForLockedAccount: "Si comencen a seguir-te es mostra un missatge de quan es permet aquesta sol·licitud. " _exportOrImport: allNotes: "Totes les publicacions" favoritedNotes: "Notes preferides" @@ -2374,6 +2403,7 @@ _notification: renotedBySomeUsers: "L'han impulsat {n} usuaris" followedBySomeUsers: "Et segueixen {n} usuaris" flushNotification: "Netejar notificacions" + exportOfXCompleted: "Completada l'exportació de {n}" _types: all: "Tots" note: "Notes noves" @@ -2388,6 +2418,9 @@ _notification: followRequestAccepted: "Petició de seguiment acceptada" roleAssigned: "Rol donat" achievementEarned: "Assoliment desbloquejat" + exportCompleted: "Exportació completada" + login: "Iniciar sessió" + test: "Prova la notificació" app: "Notificacions d'aplicacions" _actions: followBack: "t'ha seguit també" @@ -2498,6 +2531,8 @@ _moderationLogTypes: markSensitiveDriveFile: "Fitxer marcat com a sensible" unmarkSensitiveDriveFile: "S'ha tret la marca de sensible del fitxer" resolveAbuseReport: "Informe resolt" + forwardAbuseReport: "Informe reenviat" + updateAbuseReportNote: "Nota de moderació d'un informe actualitzat" createInvitation: "Crear codi d'invitació " createAd: "Anunci creat" deleteAd: "Anunci esborrat" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 2f532e0f4c..052755087f 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -657,10 +657,7 @@ abuseReported: "Nahlášení bylo odesláno. Děkujeme převelice." reporter: "Nahlásil" reporteeOrigin: "Původ nahlášení" reporterOrigin: "Původ nahlasovače" -forwardReport: "Přeposlat nahlášení do vzdálené instance" -forwardReportIsAnonymous: "Místo vašeho účtu se ve vzdálené instanci zobrazí anonymní systémový účet jako nahlašovač." send: "Odeslat" -abuseMarkAsResolved: "Označit nahlášení jako vyřešené" openInNewTab: "Otevřít v nové kartě" openInSideView: "Otevřít v bočním panelu" defaultNavigationBehaviour: "Výchozí chování navigace" @@ -1635,7 +1632,6 @@ _theme: buttonBg: "Pozadí tlačítka" buttonHoverBg: "Pozadí tlačítka (Hover)" inputBorder: "Ohraničení vstupního pole" - listItemHoverBg: "Pozadí položky seznamu (Hover)" driveFolderBg: "Pozadí složky disku" wallpaperOverlay: "Překrytí tapety" badge: "Odznak" @@ -1967,6 +1963,7 @@ _notification: receiveFollowRequest: "Obdržené žádosti o sledování" followRequestAccepted: "Přijaté žádosti o sledování" achievementEarned: "Úspěch odemčen" + login: "Přihlásit se" app: "Oznámení z propojených aplikací" _actions: followBack: "vás začal sledovat zpět" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 49e42c1885..2647da6777 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -698,10 +698,7 @@ abuseReported: "Deine Meldung wurde versendet. Vielen Dank." reporter: "Melder" reporteeOrigin: "Herkunft des Gemeldeten" reporterOrigin: "Herkunft des Meldenden" -forwardReport: "Meldung an fremde Instanz weiterleiten" -forwardReportIsAnonymous: "Anstatt deines Benutzerkontos wird bei der fremden Instanz ein anonymes Systemkonto als Melder angezeigt." send: "Senden" -abuseMarkAsResolved: "Meldung als gelöst markieren" openInNewTab: "In neuem Tab öffnen" openInSideView: "In Seitenansicht öffnen" defaultNavigationBehaviour: "Standardnavigationsverhalten" @@ -1871,7 +1868,6 @@ _theme: buttonBg: "Hintergrund von Schaltflächen" buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)" inputBorder: "Rahmen von Eingabefeldern" - listItemHoverBg: "Hintergrund von Listeneinträgen (Mouseover)" driveFolderBg: "Hintergrund von Drive-Ordnern" wallpaperOverlay: "Hintergrundbild-Overlay" badge: "Wappen" @@ -2231,6 +2227,7 @@ _notification: followRequestAccepted: "Akzeptierte Follow-Anfragen" groupInvited: "Erhaltene Gruppeneinladungen" achievementEarned: "Errungenschaft freigeschaltet" + login: "Anmelden" app: "Benachrichtigungen von Apps" _actions: followBack: "folgt dir nun auch" diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 6515453567..8214092794 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -380,6 +380,7 @@ _notification: renote: "Κοινοποίηση σημειώματος" quote: "Παράθεση" reaction: "Αντιδράσεις" + login: "Σύνδεση" _actions: reply: "Απάντηση" renote: "Κοινοποίηση σημειώματος" diff --git a/locales/en-US.yml b/locales/en-US.yml index 6f1fa2e2d1..af50d23fdc 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -41,7 +41,7 @@ copied: "Copied!" welcome: "Welcome!" cherrypickMigrated: "The migration to CherryPick is complete!" cherrypickMigratedCacheClearTitle: "The cache must be cleared." -cherrypickMigratedCacheClear: "This server was migrated from Misskey or CherryPick v4.3.0 or earlier.\nBecause of the different versioning, any leftover cache can cause problems, so you'll need to clear the cache on the first connection after the migration.\n\nThis will only happen the first time." +cherrypickMigratedCacheClear: "This server was migrated from Misskey, CherryPick v4.3.0 or earlier.\nBecause of the different versioning, any leftover cache can cause problems, so you'll need to clear the cache on the first connection after the migration.\n\nThis will only happen the first time." showRenoteVisibilitySelector: "Show renote visibility selector" cannotBeUsedFunc: "This feature is currently unavailable." scale: "Scale" @@ -109,6 +109,9 @@ search: "Search" notifications: "Notifications" username: "Username" password: "Password" +initialPasswordForSetup: "Initial password for setup" +initialPasswordIsIncorrect: "Initial password for setup is incorrect" +initialPasswordForSetupDescription: "Use the password you entered in the configuration file if you installed CherryPick yourself.\n If you are using a CherryPick hosting service, use the password provided.\n If you have not set a password, leave it blank to continue." forgotPassword: "Forgot password" fetchingAsApObject: "Fetching from the Fediverse..." ok: "OK" @@ -215,7 +218,7 @@ enterEmoji: "Enter an emoji" renote: "Renote" unrenote: "Remove renote" renoted: "Renoted." -renotedToX: "Renote to {name}." +renotedToX: "Renoted to {name}." quoted: "Quoted." replied: "Replied." cantRenote: "This post can't be renoted." @@ -345,6 +348,8 @@ silencedInstances: "Silenced instances" silencedInstancesDescription: "List the host names of the servers that you want to silence, separated by a new line. All accounts belonging to the listed servers will be treated as silenced, and can only make follow requests, and cannot mention local accounts if not followed. This will not affect the blocked servers." mediaSilencedInstances: "Media-silenced servers" mediaSilencedInstancesDescription: "List the host names of the servers that you want to media-silence, separated by a new line. All accounts belonging to the listed servers will be treated as sensitive, and can't use custom emojis. This will not affect the blocked servers." +federationAllowedHosts: "Federation allowed servers" +federationAllowedHostsDescription: "Specify the hostnames of the servers you want to allow federation separated by line breaks." muteAndBlock: "Mutes and Blocks" mutedUsers: "Muted users" blockedUsers: "Blocked users" @@ -444,6 +449,7 @@ renameFolder: "Rename this folder" deleteFolder: "Delete this folder" folder: "Folder" addFile: "Add a file" +showFile: "Show files" emptyDrive: "Your Drive is empty" emptyFolder: "This folder is empty" unableToDelete: "Unable to delete" @@ -558,6 +564,7 @@ totpDescription: "Use an authenticator app to enter one-time passwords" moderator: "Moderator" moderation: "Moderation" moderationNote: "Moderation note" +moderationNoteDescription: "You can fill in notes that will be shared only among moderators." addModerationNote: "Add moderation note" moderationLogs: "Moderation logs" nUsersMentioned: "Mentioned by {n} users" @@ -629,6 +636,10 @@ groupInvited: "You've been invited to a group" aboutX: "About {x}" emojiStyle: "Emoji style" native: "Native" +menuStyle: "Menu style" +style: "Style" +drawer: "Drawer" +popup: "Pop up" youHaveNoGroups: "You have no groups" joinOrCreateGroup: "Get invited to a group or create your own." showNoteActionsOnlyHover: "Only show note actions on hover" @@ -720,6 +731,8 @@ ascendingOrder: "Ascending" descendingOrder: "Descending" scratchpad: "Scratchpad" scratchpadDescription: "The Scratchpad provides an environment for AiScript experiments. You can write, execute, and check the results of it interacting with CherryPick in it." +uiInspector: "UI inspector" +uiInspectorDescription: "You can see the UI component server list on memory. UI component will be generated by Ui:C: function." output: "Output" script: "Script" disablePagesScript: "Disable AiScript on Pages" @@ -836,10 +849,7 @@ abuseReported: "Your report has been sent. Thank you very much." reporter: "Reporter" reporteeOrigin: "Reportee Origin" reporterOrigin: "Reporter Origin" -forwardReport: "Forward report to remote instance" -forwardReportIsAnonymous: "Instead of your account, an anonymous system account will be displayed as reporter at the remote instance." send: "Send" -abuseMarkAsResolved: "Mark report as resolved" openInNewTab: "Open in new tab" openInSideView: "Open in side view" defaultNavigationBehaviour: "Default navigation behavior" @@ -1044,6 +1054,7 @@ followersVisibility: "Visibility of followers" continueThread: "View thread continuation" deleteAccountConfirm: "This will irreversibly delete your account. Proceed?" incorrectPassword: "Incorrect password." +incorrectTotp: "The one-time password is incorrect or has expired." voteConfirm: "Confirm your vote for \"{choice}\"?" hide: "Hide" leaveGroup: "Leave group" @@ -1263,7 +1274,7 @@ options: "Options" specifyUser: "Specific user" lookupConfirm: "Do you want to look up?" openTagPageConfirm: "Do you want to open a hashtag page?" -specifyHost: "Specify a host" +specifyHost: "Specific host" failedToPreviewUrl: "Could not preview" update: "Update" rolesThatCanBeUsedThisEmojiAsReaction: "Roles that can use this emoji as reaction" @@ -1407,6 +1418,16 @@ fromX: "From {x}" genEmbedCode: "Generate embed code" noteOfThisUser: "Notes by this user" clipNoteLimitExceeded: "No more notes can be added to this clip." +performance: "Performance" +modified: "Modified" +discard: "Discard" +thereAreNChanges: "There are {n} change(s)" +signinWithPasskey: "Sign in with Passkey" +unknownWebAuthnKey: "Unknown Passkey" +passkeyVerificationFailed: "Passkey verification has failed." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled." +messageToFollower: "Message to followers" +target: "Target" showUnreadNotificationsCount: "Show the number of unread notifications" showCatOnly: "Show only cats" additionalPermissionsForFlash: "Allow to add permission to Play" @@ -1432,6 +1453,13 @@ _showingAnimatedImages: inactive: "Stop after a certain amount of time" _messaging: direct: "Direct Message" +_abuseUserReport: + forward: "Forward" + forwardDescription: "Forward the report to a remote server as an anonymous system account." + resolve: "Resolve" + accept: "Accept" + reject: "Reject" + resolveTutorial: "If the report is legitimate in content, select \"Accept\" to mark the case as resolved in the affirmative.\nIf the content of the report is not legitimate, select \"Reject\" to mark the case as resolved in the negative." _delivery: status: "Delivery status" stop: "Suspended" @@ -1651,6 +1679,7 @@ _serverSettings: fanoutTimelineDescription: "Greatly increases performance of timeline retrieval and reduces load on the database when enabled. In exchange, memory usage of Redis will increase. Consider disabling this in case of low server memory or server instability." fanoutTimelineDbFallback: "Fallback to database" fanoutTimelineDbFallbackDescription: "When enabled, the timeline will fall back to the database for additional queries if the timeline is not cached. Disabling it further reduces the server load by eliminating the fallback process, but limits the range of timelines that can be retrieved." + reactionsBufferingDescription: "When enabled, performance during reaction creation will be greatly improved, reducing the load on the database. However, Redis memory usage will increase." inquiryUrl: "Inquiry URL" inquiryUrlDescription: "Specify a URL for the inquiry form to the server maintainer or a web page for the contact information." _accountMigration: @@ -1974,7 +2003,7 @@ _role: canManageAvatarDecorations: "Manage avatar decorations" driveCapacity: "Drive capacity" alwaysMarkNsfw: "Always mark files as NSFW" - canUpdateBioMedia: "Allow to edit an icon or a banner image" + canUpdateBioMedia: "Can edit an icon or a banner image" pinMax: "Maximum number of pinned notes" antennaMax: "Maximum number of antennas" wordMuteMax: "Maximum number of characters allowed in word mutes" @@ -1991,6 +2020,11 @@ _role: canUseAutoTranslate: "Automatic translation usage" canUseAutoTranslateDescription: "Users who enable the automatic translation feature will have all notes in their timeline automatically translated, which can very quickly reach the API limits set by the translation service provider, potentially temporarily disabling the translation feature.\nThis means that all users on the server may be temporarily unable to use the API.\nAdditionally, depending on the translation service provider, you may be charged excessive fees for API usage.\n\nDo you still want to enable it?" avatarDecorationLimit: "Maximum number of avatar decorations that can be applied" + canImportAntennas: "Allow importing antennas" + canImportBlocking: "Allow importing blocking" + canImportFollowing: "Allow importing following" + canImportMuting: "Allow importing muting" + canImportUserLists: "Allow importing lists" _condition: roleAssignedTo: "Assigned to manual roles" isLocal: "Local user" @@ -2299,7 +2333,6 @@ _theme: buttonBg: "Button background" buttonHoverBg: "Button background (Hover)" inputBorder: "Input field border" - listItemHoverBg: "List item background (Hover)" driveFolderBg: "Drive folder background" wallpaperOverlay: "Wallpaper overlay" badge: "Badge" @@ -2585,6 +2618,9 @@ _profile: changeBanner: "Change banner" verifiedLinkDescription: "By entering an URL that contains a link to your profile here, an ownership verification icon can be displayed next to the field." avatarDecorationMax: "You can add up to {max} decorations." + followedMessage: "Message when you are followed" + followedMessageDescription: "You can set a short message to be displayed to the recipient when they follow you." + followedMessageDescriptionForLockedAccount: "If you have set up that follow requests require approval, this will be displayed when you grant a follow request." _exportOrImport: allNotes: "All notes" favoritedNotes: "Favorite notes" @@ -2726,6 +2762,8 @@ _notification: notedBySomeUsers: "There are {n} new notes" followedBySomeUsers: "Followed by {n} users" flushNotification: "Clear notifications" + exportOfXCompleted: "Export of {x} has been completed" + login: "Someone logged in" _types: all: "All" note: "New notes" @@ -2741,6 +2779,9 @@ _notification: groupInvited: "Group invitations" roleAssigned: "Role given" achievementEarned: "Achievement unlocked" + exportCompleted: "The export has been completed" + login: "Sign In" + test: "Notification test" app: "Notifications from linked apps" _actions: followBack: "followed you back" @@ -2803,21 +2844,22 @@ _webhookSettings: reaction: "When receiving a reaction" mention: "When being mentioned" _systemEvents: - abuseReport: "When received a new abuse report" - abuseReportResolved: "When resolved abuse report" + abuseReport: "When received a new report" + abuseReportResolved: "When resolved report" userCreated: "When user is created" deleteConfirm: "Are you sure you want to delete the Webhook?" + testRemarks: "Click the button to the right of the switch to send a test Webhook with dummy data." _abuseReport: _notificationRecipient: - createRecipient: "Add a recipient for abuse reports" - modifyRecipient: "Edit a recipient for abuse reports" + createRecipient: "Add a recipient for reports" + modifyRecipient: "Edit a recipient for reports" recipientType: "Notification type" _recipientType: mail: "Email" webhook: "Webhook" _captions: - mail: "Send the email to moderators' email addresses when you receive abuse." - webhook: "Send a notification to SystemWebhook when you receive or resolve abuse." + mail: "Send the email to moderators' email addresses when you receive reports." + webhook: "Send a notification to System Webhook when you receive or resolve reports." keywords: "Keywords" notifiedUser: "Users to notify" notifiedWebhook: "Webhook to use" @@ -2850,6 +2892,8 @@ _moderationLogTypes: markSensitiveDriveFile: "File marked as sensitive" unmarkSensitiveDriveFile: "File unmarked as sensitive" resolveAbuseReport: "Report resolved" + forwardAbuseReport: "Report forwarded" + updateAbuseReportNote: "Moderation note of a report updated" createInvitation: "Invite generated" createAd: "Ad created" deleteAd: "Ad deleted" @@ -2857,18 +2901,18 @@ _moderationLogTypes: createAvatarDecoration: "Avatar decoration created" updateAvatarDecoration: "Avatar decoration updated" deleteAvatarDecoration: "Avatar decoration deleted" - unsetUserAvatar: "Unset this user's avatar" - unsetUserBanner: "Unset this user's banner" - createSystemWebhook: "Create SystemWebhook" - updateSystemWebhook: "Update SystemWebhook" - deleteSystemWebhook: "Delete SystemWebhook" - createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" - updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" - deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" - deleteAccount: "Delete the account" - deletePage: "Delete the page" - deleteFlash: "Delete Play" - deleteGalleryPost: "Delete the gallery post" + unsetUserAvatar: "User avatar unset" + unsetUserBanner: "User banner unset" + createSystemWebhook: "System Webhook created" + updateSystemWebhook: "System Webhook updated" + deleteSystemWebhook: "System Webhook deleted" + createAbuseReportNotificationRecipient: "Recipient for reports created" + updateAbuseReportNotificationRecipient: "Recipient for reports updated" + deleteAbuseReportNotificationRecipient: "Recipient for reports deleted" + deleteAccount: "Account deleted" + deletePage: "Page deleted" + deleteFlash: "Play deleted" + deleteGalleryPost: "Gallery post deleted" _fileViewer: title: "File details" type: "File type" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 3adce64031..29035dbcbd 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -712,10 +712,7 @@ abuseReported: "Se ha enviado el reporte. Muchas gracias." reporter: "Reportador" reporteeOrigin: "Reportar a" reporterOrigin: "Origen del reporte" -forwardReport: "Transferir un informe a una instancia remota" -forwardReportIsAnonymous: "No puede ver su información de la instancia remota y aparecerá como una cuenta anónima del sistema" send: "Enviar" -abuseMarkAsResolved: "Marcar reporte como resuelto" openInNewTab: "Abrir en una Nueva Pestaña" openInSideView: "Abrir en una vista al costado" defaultNavigationBehaviour: "Navegación por defecto" @@ -2001,7 +1998,6 @@ _theme: buttonBg: "Fondo de botón" buttonHoverBg: "Fondo de botón (hover)" inputBorder: "Borde de los campos de entrada" - listItemHoverBg: "Fondo de elemento de listas (hover)" driveFolderBg: "Fondo de capeta del drive" wallpaperOverlay: "Transparencia del fondo de pantalla" badge: "Medalla" @@ -2432,6 +2428,7 @@ _notification: groupInvited: "Invitado al grupo" roleAssigned: "Rol asignado" achievementEarned: "Logro desbloqueado" + login: "Iniciar sesión" app: "Notificaciones desde aplicaciones" _actions: followBack: "Te sigue de vuelta" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 1cfabcd867..ed27c3adb1 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -703,10 +703,7 @@ abuseReported: "Le rapport est envoyé. Merci." reporter: "Signalé par" reporteeOrigin: "Origine du signalement" reporterOrigin: "Signalé par" -forwardReport: "Transférer le signalement à l’instance distante" -forwardReportIsAnonymous: "L'instance distante ne sera pas en mesure de voir vos informations et apparaîtra comme un compte anonyme du système." send: "Envoyer" -abuseMarkAsResolved: "Marquer le signalement comme résolu" openInNewTab: "Ouvrir dans un nouvel onglet" openInSideView: "Ouvrir en vue latérale" defaultNavigationBehaviour: "Navigation par défaut" @@ -1786,7 +1783,6 @@ _theme: buttonBg: "Arrière-plan du bouton" buttonHoverBg: "Arrière-plan du bouton (survolé)" inputBorder: "Cadre de la zone de texte" - listItemHoverBg: "Arrière-plan d'item de liste (survolé)" driveFolderBg: "Arrière-plan du dossier de disque" wallpaperOverlay: "Superposition de fond d'écran" badge: "Badge" @@ -2125,6 +2121,7 @@ _notification: groupInvited: "Invitation à un groupe" roleAssigned: "Rôle reçu" achievementEarned: "Déverrouillage d'accomplissement" + login: "Se connecter" app: "Notifications provenant des apps" _actions: followBack: "Suivre" diff --git a/locales/hu-HU.yml b/locales/hu-HU.yml index 023a91494d..acc27ed092 100644 --- a/locales/hu-HU.yml +++ b/locales/hu-HU.yml @@ -96,6 +96,7 @@ _notification: renote: "Renote" quote: "Idézet" reaction: "Reakciók" + login: "Bejelentkezés" _actions: renote: "Renote" _deck: diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 5de0443e92..f9253296c9 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -714,10 +714,7 @@ abuseReported: "Laporan kamu telah dikirimkan. Terima kasih." reporter: "Pelapor" reporteeOrigin: "Yang dilaporkan" reporterOrigin: "Pelapor" -forwardReport: "Teruskan laporan ke instansi luar" -forwardReportIsAnonymous: "Untuk melindungi privasi akun kamu, akun anonim dari sistem akan digunakan sebagai pelapor pada instansi luar." send: "Kirim" -abuseMarkAsResolved: "Tandai laporan sebagai selesai" openInNewTab: "Buka di tab baru" openInSideView: "Buka di tampilan samping" defaultNavigationBehaviour: "Navigasi bawaan" @@ -2008,7 +2005,6 @@ _theme: buttonBg: "Latar belakang tombol" buttonHoverBg: "Latar belakang tombol (Mengambang)" inputBorder: "Batas bidang masukan" - listItemHoverBg: "Latar belakang daftar item (Mengambang)" driveFolderBg: "Latar belakang folder drive" wallpaperOverlay: "Lapisan wallpaper" badge: "Lencana" @@ -2441,6 +2437,7 @@ _notification: roleAssigned: "Peran Diberikan" achievementEarned: "Pencapaian didapatkan" groupInvited: "Diundang ke grup" + login: "Masuk" app: "Notifikasi dari aplikasi tertaut" _actions: followBack: "Ikuti Kembali" diff --git a/locales/index.d.ts b/locales/index.d.ts index c90549b1d0..f039250163 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -463,6 +463,20 @@ export interface Locale extends ILocale { * パスワード */ "password": string; + /** + * 初期設定開始用パスワード + */ + "initialPasswordForSetup": string; + /** + * 初期設定開始用のパスワードが違います。 + */ + "initialPasswordIsIncorrect": string; + /** + * CherryPickを自分でインストールした場合は、設定ファイルに入力したパスワードを使用してください。 + * CherryPickのホスティングサービスなどを使用している場合は、提供されたパスワードを使用してください。 + * パスワードを設定していない場合は、空欄にしたまま続行してください。 + */ + "initialPasswordForSetupDescription": string; /** * パスワードを忘れた */ @@ -2271,6 +2285,10 @@ export interface Locale extends ILocale { * モデレーションノート */ "moderationNote": string; + /** + * モデレーター間でだけ共有されるメモを記入することができます。 + */ + "moderationNoteDescription": string; /** * モデレーションノートを追加する */ @@ -3407,22 +3425,10 @@ export interface Locale extends ILocale { * 通報元 */ "reporterOrigin": string; - /** - * リモートサーバーに通報を転送する - */ - "forwardReport": string; - /** - * リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。 - */ - "forwardReportIsAnonymous": string; /** * 送信 */ "send": string; - /** - * 対応済みにする - */ - "abuseMarkAsResolved": string; /** * 新しいタブで開く */ @@ -4240,6 +4246,10 @@ export interface Locale extends ILocale { * パスワードが間違っています。 */ "incorrectPassword": string; + /** + * ワンタイムパスワードが間違っているか、期限切れになっています。 + */ + "incorrectTotp": string; /** * 「{choice}」に投票しますか? */ @@ -4908,6 +4918,10 @@ export interface Locale extends ILocale { * リモートサーバーのチャートを生成 */ "enableChartsForFederatedInstances": string; + /** + * リモートサーバーの情報を取得 + */ + "enableStatsForFederatedInstances": string; /** * ノートのアクションにクリップを追加 */ @@ -5728,6 +5742,30 @@ export interface Locale extends ILocale { * フォロワーへのメッセージ */ "messageToFollower": string; + /** + * 対象 + */ + "target": string; + /** + * CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。 + */ + "testCaptchaWarning": string; + /** + * 禁止ワード(ユーザーの名前) + */ + "prohibitedWordsForNameOfUser": string; + /** + * このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。 + */ + "prohibitedWordsForNameOfUserDescription": string; + /** + * 変更しようとした名前に禁止された文字列が含まれています + */ + "yourNameContainsProhibitedWords": string; + /** + * 名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。 + */ + "yourNameContainsProhibitedWordsDescription": string; /** * 未読の通知の数を表示する */ @@ -5820,6 +5858,33 @@ export interface Locale extends ILocale { */ "direct": string; }; + "_abuseUserReport": { + /** + * 転送 + */ + "forward": string; + /** + * 匿名のシステムアカウントとして、リモートサーバーに通報を転送します。 + */ + "forwardDescription": string; + /** + * 解決 + */ + "resolve": string; + /** + * 是認 + */ + "accept": string; + /** + * 否認 + */ + "reject": string; + /** + * 内容が正当である通報に対応した場合は「是認」を選択し、肯定的にケースが解決されたことをマークします。 + * 内容が正当でない通報の場合は「否認」を選択し、否定的にケースが解決されたことをマークします。 + */ + "resolveTutorial": string; + }; "_delivery": { /** * 配信状態 @@ -6650,6 +6715,10 @@ export interface Locale extends ILocale { * サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。 */ "inquiryUrlDescription": string; + /** + * 一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。 + */ + "thisSettingWillAutomaticallyOffWhenModeratorsInactive": string; }; "_accountMigration": { /** @@ -9047,10 +9116,6 @@ export interface Locale extends ILocale { * 入力ボックスの縁取り */ "inputBorder": string; - /** - * リスト項目の背景 (ホバー) - */ - "listItemHoverBg": string; /** * ドライブフォルダーの背景 */ @@ -10716,6 +10781,10 @@ export interface Locale extends ILocale { * {x}のエクスポートが完了しました */ "exportOfXCompleted": ParameterizedString<"x">; + /** + * ログインがありました + */ + "login": string; "_types": { /** * すべて @@ -10777,6 +10846,10 @@ export interface Locale extends ILocale { * エクスポートが完了した */ "exportCompleted": string; + /** + * ログイン + */ + "login": string; /** * 通知のテスト */ @@ -11023,6 +11096,14 @@ export interface Locale extends ILocale { * ユーザーが作成されたとき */ "userCreated": string; + /** + * モデレーターが一定期間非アクティブになったとき + */ + "inactiveModeratorsWarning": string; + /** + * モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき + */ + "inactiveModeratorsInvitationOnlyChanged": string; }; /** * Webhookを削除しますか? @@ -11194,6 +11275,14 @@ export interface Locale extends ILocale { * 通報を解決 */ "resolveAbuseReport": string; + /** + * 通報を転送 + */ + "forwardAbuseReport": string; + /** + * 通報のモデレーションノート更新 + */ + "updateAbuseReportNote": string; /** * 招待コードを作成 */ diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 3004187a2d..0eefa699f1 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -8,6 +8,9 @@ search: "Cerca" notifications: "Notifiche" username: "Nome utente" password: "Password" +initialPasswordForSetup: "Password iniziale, per avviare le impostazioni" +initialPasswordIsIncorrect: "Password iniziale, sbagliata." +initialPasswordForSetupDescription: "Se hai installato CherryPick di persona, usa la password che hai indicato nel file di configurazione.\nSe stai utilizzando un servizio di hosting CherryPick, usa la password fornita dal gestore.\nSe non hai una password preimpostata, lascia il campo vuoto e continua." forgotPassword: "Hai dimenticato la password?" fetchingAsApObject: "Recuperando dal Fediverso..." ok: "OK" @@ -236,6 +239,8 @@ silencedInstances: "Istanze silenziate" silencedInstancesDescription: "Elenca i nomi host delle istanze che vuoi silenziare. Tutti i profili nelle istanze silenziate vengono trattati come tali. Possono solo inviare richieste di follow e menzionare soltanto i profili locali che seguono. Le istanze bloccate non sono interessate." mediaSilencedInstances: "Istanze coi media silenziati" mediaSilencedInstancesDescription: "Elenca i nomi host delle istanze di cui vuoi silenziare i media, uno per riga. Tutti gli allegati dei profili nelle istanze silenziate per via degli allegati espliciti, verranno impostati come tali, le emoji personalizzate non saranno disponibili. Le istanze bloccate sono escluse." +federationAllowedHosts: "Server a cui consentire la federazione" +federationAllowedHostsDescription: "Indica gli host dei server a cui è consentita la federazione, uno per ogni linea." muteAndBlock: "Silenziare e bloccare" mutedUsers: "Profili silenziati" blockedUsers: "Profili bloccati" @@ -449,6 +454,7 @@ totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App d moderator: "Moderatore" moderation: "moderazione" moderationNote: "Promemoria di moderazione" +moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori." addModerationNote: "Aggiungi promemoria di moderazione" moderationLogs: "Cronologia di moderazione" nUsersMentioned: "{n} profili ne parlano" @@ -726,10 +732,7 @@ abuseReported: "La segnalazione è stata inviata. Grazie." reporter: "il corrispondente" reporteeOrigin: "Segnalazione a" reporterOrigin: "Segnalazione da" -forwardReport: "Inoltro di un report a un'istanza remota." -forwardReportIsAnonymous: "L'istanza remota non vedrà le tue informazioni, apparirai come profilo di sistema, anonimo." send: "Inviare" -abuseMarkAsResolved: "Risolvi segnalazione" openInNewTab: "Apri in una nuova scheda" openInSideView: "Apri in vista laterale" defaultNavigationBehaviour: "Navigazione preimpostata" @@ -851,7 +854,7 @@ onlineStatus: "Stato di connessione" hideOnlineStatus: "Modalità invisibile" hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca." online: "Online" -active: "Attività" +active: "Attivo" offline: "Offline" notRecommended: "Sconsigliato" botProtection: "Protezione contro i bot" @@ -931,6 +934,7 @@ followersVisibility: "Visibilità dei profili che ti seguono" continueThread: "Altre conversazioni" deleteAccountConfirm: "Così verrà eliminato il profilo. Vuoi procedere?" incorrectPassword: "La password è errata." +incorrectTotp: "Il codice OTP è sbagliato, oppure scaduto." voteConfirm: "Votare per「{choice}」?" hide: "Nascondere" leaveGroup: "Esci dal gruppo" @@ -1097,6 +1101,7 @@ retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?" retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente." enableChartsForRemoteUser: "Abilita i grafici per i profili remoti" enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate" +enableStatsForFederatedInstances: "Informazioni statistiche sui server federati" showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note" reactionsDisplaySize: "Grandezza delle reazioni" limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale" @@ -1295,6 +1300,20 @@ signinWithPasskey: "Accedi con passkey" unknownWebAuthnKey: "Questa è una passkey sconosciuta." passkeyVerificationFailed: "La verifica della passkey non è riuscita." passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato." +messageToFollower: "Messaggio ai follower" +target: "Riferimento" +testCaptchaWarning: "Questa funzione è destinata al test CAPTCHA. Da non utilizzare in ambiente di produzione." +prohibitedWordsForNameOfUser: "Parole proibite (nome utente)" +prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione." +yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate" +yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione." +_abuseUserReport: + forward: "Inoltra" + forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo." + resolve: "Risolvi" + accept: "Approva" + reject: "Rifiuta" + resolveTutorial: "Se moderi una segnalazione legittima, scegli \"Approva\" per risolvere positivamente.\nSe la segnalazione non è legittima, seleziona \"Rifiuta\" per risolvere negativamente." _delivery: status: "Stato della consegna" stop: "Sospensione" @@ -1322,16 +1341,16 @@ _bubbleGame: _announcement: forExistingUsers: "Solo ai profili attuali" forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio." - needConfirmationToRead: "Richiede la conferma di lettura" - needConfirmationToReadDescription: "Sarà visualizzata una finestra di dialogo che richiede la conferma di lettura. Inoltre, non è soggetto a conferme di lettura massicce." + needConfirmationToRead: "Conferma di lettura obbligatoria" + needConfirmationToReadDescription: "I profili riceveranno una finestra di dialogo che richiede di accettare obbligatoriamente per procedere. Tale richiesta è esente da \"conferma tutte\"." end: "Archivia l'annuncio" tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi." readConfirmTitle: "Segnare come già letto?" readConfirmText: "Hai già letto \"{title}˝?" shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte." dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte." - silence: "Silenziare gli annunci" - silenceDescription: "Se attivi questa opzione, non riceverai notifiche sugli annunci, evitando di contrassegnarle come già lette." + silence: "Annuncio silenzioso" + silenceDescription: "Attivando questa opzione, non invierai la notifica, evitando che debba essere contrassegnata come già letta." _initialAccountSetting: accountCreated: "Il tuo profilo è stato creato!" letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo." @@ -1432,6 +1451,7 @@ _serverSettings: reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis." inquiryUrl: "URL di contatto" inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo." _accountMigration: moveFrom: "Migra un altro profilo dentro a questo" moveFromSub: "Crea un alias verso un altro profilo remoto" @@ -2054,7 +2074,6 @@ _theme: buttonBg: "Sfondo del pulsante" buttonHoverBg: "Sfondo del pulsante (sorvolato)" inputBorder: "Inquadra casella di testo" - listItemHoverBg: "Sfondo della voce di elenco (sorvolato)" driveFolderBg: "Sfondo della cartella di disco" wallpaperOverlay: "Sovrapposizione dello sfondo" badge: "Distintivo" @@ -2270,7 +2289,7 @@ _widgets: _userList: chooseList: "Seleziona una lista" clicker: "Cliccaggio" - birthdayFollowings: "Chi nacque oggi" + birthdayFollowings: "Compleanni del giorno" _cw: hide: "Nascondere" show: "Continua la lettura..." @@ -2334,6 +2353,9 @@ _profile: changeBanner: "Cambia intestazione" verifiedLinkDescription: "Puoi verificare il tuo profilo mostrando una icona. Devi inserire la URL alla pagina che contiene un link al tuo profilo." avatarDecorationMax: "Puoi aggiungere fino a {max} decorazioni." + followedMessage: "Messaggio, quando qualcuno ti segue" + followedMessageDescription: "Puoi impostare un breve messaggio da mostrare agli altri profili quando ti seguono." + followedMessageDescriptionForLockedAccount: "Quando approvi una richiesta di follow, verrà visualizzato questo testo." _exportOrImport: allNotes: "Tutte le note" favoritedNotes: "Note preferite" @@ -2475,6 +2497,7 @@ _notification: followedBySomeUsers: "{n} follower" flushNotification: "Azzera le notifiche" exportOfXCompleted: "Abbiamo completato l'esportazione di {x}" + login: "Autenticazione avvenuta" _types: all: "Tutto" note: "Nuove Note" @@ -2491,6 +2514,7 @@ _notification: roleAssigned: "Ruolo concesso" achievementEarned: "Risultato raggiunto" exportCompleted: "Esportazione completata" + login: "Accedi" test: "Prova la notifica" app: "Notifiche da applicazioni" _actions: @@ -2557,6 +2581,8 @@ _webhookSettings: abuseReport: "Quando arriva una segnalazione" abuseReportResolved: "Quando una segnalazione è risolta" userCreated: "Quando viene creato un profilo" + inactiveModeratorsWarning: "Quando un profilo moderatore rimane inattivo per un determinato periodo" + inactiveModeratorsInvitationOnlyChanged: "Quando la moderazione è rimasta inattiva per un determinato periodo e il sistema è cambiato in modalità \"solo inviti\"" deleteConfirm: "Vuoi davvero eliminare il Webhook?" testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi." _abuseReport: @@ -2602,6 +2628,8 @@ _moderationLogTypes: markSensitiveDriveFile: "File nel Drive segnato come esplicito" unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito" resolveAbuseReport: "Segnalazione risolta" + forwardAbuseReport: "Segnalazione inoltrata" + updateAbuseReportNote: "Ha aggiornato la segnalazione" createInvitation: "Genera codice di invito" createAd: "Banner creato" deleteAd: "Banner eliminato" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6c80c55e74..6c1c1ae5da 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -109,6 +109,9 @@ search: "検索" notifications: "通知" username: "ユーザー名" password: "パスワード" +initialPasswordForSetup: "初期設定開始用パスワード" +initialPasswordIsIncorrect: "初期設定開始用のパスワードが違います。" +initialPasswordForSetupDescription: "CherryPickを自分でインストールした場合は、設定ファイルに入力したパスワードを使用してください。\nCherryPickのホスティングサービスなどを使用している場合は、提供されたパスワードを使用してください。\nパスワードを設定していない場合は、空欄にしたまま続行してください。" forgotPassword: "パスワードを忘れた" fetchingAsApObject: "連合に照会中" ok: "OK" @@ -561,6 +564,7 @@ totpDescription: "認証アプリを使ってワンタイムパスワードを moderator: "モデレーター" moderation: "モデレーション" moderationNote: "モデレーションノート" +moderationNoteDescription: "モデレーター間でだけ共有されるメモを記入することができます。" addModerationNote: "モデレーションノートを追加する" moderationLogs: "モデログ" nUsersMentioned: "{n}人が投稿" @@ -845,10 +849,7 @@ abuseReported: "内容が送信されました。ご報告ありがとうござ reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートサーバーに通報を転送する" -forwardReportIsAnonymous: "リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。" send: "送信" -abuseMarkAsResolved: "対応済みにする" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" @@ -1053,6 +1054,7 @@ followersVisibility: "フォロワーの公開範囲" continueThread: "さらにスレッドを見る" deleteAccountConfirm: "アカウントが削除されます。よろしいですか?" incorrectPassword: "パスワードが間違っています。" +incorrectTotp: "ワンタイムパスワードが間違っているか、期限切れになっています。" voteConfirm: "「{choice}」に投票しますか?" hide: "隠す" leaveGroup: "グループから抜ける" @@ -1220,6 +1222,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?" retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" enableChartsForRemoteUser: "リモートユーザーのチャートを生成" enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" +enableStatsForFederatedInstances: "リモートサーバーの情報を取得" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" reactionsDisplaySize: "リアクションの表示サイズ" limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する" @@ -1425,6 +1428,12 @@ unknownWebAuthnKey: "登録されていないパスキーです。" passkeyVerificationFailed: "パスキーの検証に失敗しました。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" messageToFollower: "フォロワーへのメッセージ" +target: "対象" +testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。" +prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)" +prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。" +yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています" +yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。" showUnreadNotificationsCount: "未読の通知の数を表示する" showCatOnly: "キャット付きのみ" additionalPermissionsForFlash: "Playへの追加許可" @@ -1455,6 +1464,14 @@ _showingAnimatedImages: _messaging: direct: "ダイレクトメッセージ" +_abuseUserReport: + forward: "転送" + forwardDescription: "匿名のシステムアカウントとして、リモートサーバーに通報を転送します。" + resolve: "解決" + accept: "是認" + reject: "否認" + resolveTutorial: "内容が正当である通報に対応した場合は「是認」を選択し、肯定的にケースが解決されたことをマークします。\n内容が正当でない通報の場合は「否認」を選択し、否定的にケースが解決されたことをマークします。" + _delivery: status: "配信状態" stop: "配信停止" @@ -1689,6 +1706,7 @@ _serverSettings: reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" inquiryUrl: "問い合わせ先URL" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" @@ -2367,7 +2385,6 @@ _theme: buttonBg: "ボタンの背景" buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" - listItemHoverBg: "リスト項目の背景 (ホバー)" driveFolderBg: "ドライブフォルダーの背景" wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" @@ -2823,6 +2840,7 @@ _notification: followedBySomeUsers: "{n}人にフォローされました" flushNotification: "通知の履歴をリセットする" exportOfXCompleted: "{x}のエクスポートが完了しました" + login: "ログインがありました" _types: all: "すべて" @@ -2840,6 +2858,7 @@ _notification: roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" exportCompleted: "エクスポートが完了した" + login: "ログイン" test: "通知のテスト" app: "連携アプリからの通知" @@ -2913,6 +2932,8 @@ _webhookSettings: abuseReport: "ユーザーから通報があったとき" abuseReportResolved: "ユーザーからの通報を処理したとき" userCreated: "ユーザーが作成されたとき" + inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき" + inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき" deleteConfirm: "Webhookを削除しますか?" testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。" @@ -2960,6 +2981,8 @@ _moderationLogTypes: markSensitiveDriveFile: "ファイルをセンシティブ付与" unmarkSensitiveDriveFile: "ファイルをセンシティブ解除" resolveAbuseReport: "通報を解決" + forwardAbuseReport: "通報を転送" + updateAbuseReportNote: "通報のモデレーションノート更新" createInvitation: "招待コードを作成" createAd: "広告を作成" deleteAd: "広告を削除" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 3db81019d2..45e69b8e8b 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -719,10 +719,7 @@ abuseReported: "無事内容が送信されたみたいやで。おおきに〜 reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートサーバーに通報を転送するで" -forwardReportIsAnonymous: "リモートサーバーからはあんたの情報は見えんなって、匿名のシステムアカウントとして表示されるで。" send: "送信" -abuseMarkAsResolved: "対応したで" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" @@ -2029,7 +2026,6 @@ _theme: buttonBg: "ボタンの背景" buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" - listItemHoverBg: "リスト項目の背景 (ホバー)" driveFolderBg: "ドライブフォルダーの背景" wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" @@ -2463,6 +2459,7 @@ _notification: groupInvited: "グループに招待されたで" roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" + login: "ログイン" app: "連携アプリからの通知や" _actions: followBack: "フォローバック" diff --git a/locales/kn-IN.yml b/locales/kn-IN.yml index 756c41452d..f50ab8ebf4 100644 --- a/locales/kn-IN.yml +++ b/locales/kn-IN.yml @@ -77,6 +77,8 @@ _profile: username: "ಬಳಕೆಹೆಸರು" _notification: youWereFollowed: "ಹಿಂಬಾಲಿಸಿದರು" + _types: + login: "ಪ್ರವೇಶ" _actions: reply: "ಉತ್ತರಿಸು" _deck: diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index e3adaa6b06..dc1ca1a2a6 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -601,8 +601,6 @@ reportAbuseOf: "{name}님얼 신고하기" reporter: "신고한 사람" reporteeOrigin: "신고덴 사람" reporterOrigin: "신고한 곳" -forwardReport: "웬겍 서버에 신고 보내기" -forwardReportIsAnonymous: "웬겍 서버서는 나으 정보럴 몬 보고 익멩으 시스템 게정어로 보입니다." waitingFor: "{x}(얼)럴 지달리고 잇십니다" random: "무작이" system: "시스템" @@ -813,6 +811,7 @@ _notification: mention: "멘션" quote: "따오기" reaction: "반엉" + login: "로그인" _actions: reply: "답하기" _deck: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index ae20b9804f..8afe910d79 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -109,6 +109,9 @@ search: "검색" notifications: "알림" username: "사용자 이름" password: "비밀번호" +initialPasswordForSetup: "초기 설정용 비밀번호" +initialPasswordIsIncorrect: "초기 설정용 비밀번호가 올바르지 않아요." +initialPasswordForSetupDescription: "CherryPick을 직접 설치하는 경우에는 설정 파일에 입력했던 비밀번호를 사용해 주세요.\nCherryPick 설치를 돕는 호스팅 서비스 등을 사용하는 경우에는 서비스 제공자로부터 받은 비밀번호를 사용해 주세요.\n비밀번호를 따로 설정하지 않은 경우에는 아무것도 입력하지 않아도 돼요." forgotPassword: "비밀번호를 잊었어요!" fetchingAsApObject: "연합에서 조회 중" ok: "확인" @@ -561,6 +564,7 @@ totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" moderator: "모더레이터" moderation: "모더레이션" moderationNote: "모더레이션 노트" +moderationNoteDescription: "모더레이터 역할을 가진 유저에게만 보이는 메모를 적을 수 있어요." addModerationNote: "모더레이션 노트 추가하기" moderationLogs: "모더레이션 로그" nUsersMentioned: "{n}명이 언급함" @@ -845,10 +849,7 @@ abuseReported: "신고를 보냈어요! 신고해 주셔서 감사합니다." reporter: "신고자" reporteeOrigin: "피신고자" reporterOrigin: "신고자" -forwardReport: "원격 서버에도 신고 내용 보내기" -forwardReportIsAnonymous: "원격 서버에서는 내 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시돼요." send: "전송" -abuseMarkAsResolved: "해결됨으로 표시" openInNewTab: "새 탭에서 열기" openInSideView: "사이드뷰로 열기" defaultNavigationBehaviour: "기본 탐색 동작" @@ -1053,6 +1054,7 @@ followersVisibility: "팔로워의 공개 범위" continueThread: "이 글타래 이어서 보기" deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 돼요. 그래도 계속할까요? " incorrectPassword: "비밀번호가 다른 것 같아요!" +incorrectTotp: "OTP 번호가 다르거나 유효기간이 만료된 것 같아요." voteConfirm: "\"{choice}\"에 투표할까요?" hide: "숨기기" leaveGroup: "그룹 나가기" @@ -1220,6 +1222,7 @@ retryAllQueuesConfirmTitle: "지금 다시 시도할까요?" retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있어요!" enableChartsForRemoteUser: "원격 서버 사용자 차트 생성" enableChartsForFederatedInstances: "원격 서버 차트 생성" +enableStatsForFederatedInstances: "원격 서버 정보 가져오기" showClipButtonInNoteFooter: "노트 동작에 클립 버튼 추가" reactionsDisplaySize: "리액션 표시 크기" limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시" @@ -1424,6 +1427,13 @@ signinWithPasskey: "패스키로 로그인" unknownWebAuthnKey: "등록되지 않은 패스키에요." passkeyVerificationFailed: "패스키 검증을 실패했어요." passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했지만 '비밀번호 없이 로그인하기'가 꺼져 있어요." +messageToFollower: "팔로워에게 보낼 메시지" +target: "대상" +testCaptchaWarning: "CAPTCHA 테스트 전용 기능이에요. 실제 환경에서는 사용하지 마세요!" +prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)" +prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있으면 일반 사용자는 이름을 바꿀 수 없어요. 모더레이터 권한을 가진 사용자는 이 제한 대상에서 제외돼요." +yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있어요." +yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있어요. 이 이름을 사용해야 하는 경우에는 서버 관리자에게 문의해 주세요." showUnreadNotificationsCount: "읽지 않은 알림 수 표시" showCatOnly: "고양이만 보기" additionalPermissionsForFlash: "Play에 대한 추가 권한" @@ -1449,6 +1459,13 @@ _showingAnimatedImages: inactive: "일정 시간이 지나면 멈춤" _messaging: direct: "다이렉트 메시지" +_abuseUserReport: + forward: "전달" + forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있어요." + resolve: "해결됨" + accept: "인용" + reject: "기각" + resolveTutorial: "적절한 신고 내용에 대응한 경우, \"인용\"을 선택하여 \"해결됨\"으로 기록할 수 있어요.\n적절하지 않은 신고를 받은 경우, \"기각\"을 선택하여 \"기각\"으로 기록할 수 있어요." _delivery: status: "전송 상태" stop: "정지됨" @@ -1671,6 +1688,7 @@ _serverSettings: reactionsBufferingDescription: "활성화하면 노트에 리액션할 때의 성능이 대폭 향상되어 DB의 부하를 줄일 수 있지만, Redis의 메모리 사용량이 많아져요." inquiryUrl: "문의처 URL" inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정할 수 있어요." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터가 활동이 없으면 스팸 방지를 위해 이 설정은 자동으로 비활성화 돼요." _accountMigration: moveFrom: "다른 계정에서 이 계정으로 이사" moveFromSub: "다른 계정에 대한 별칭을 생성" @@ -2322,7 +2340,6 @@ _theme: buttonBg: "버튼 배경" buttonHoverBg: "버튼 배경 (호버)" inputBorder: "입력 필드 테두리" - listItemHoverBg: "리스트 항목 배경 (호버)" driveFolderBg: "드라이브 폴더 배경" wallpaperOverlay: "배경화면 오버레이" badge: "배지" @@ -2752,7 +2769,8 @@ _notification: notedBySomeUsers: "{n}개의 새 노트가 있어요" followedBySomeUsers: "{n}명에게 팔로우됨" flushNotification: "모든 알림 지우기" - exportOfXCompleted: "{x} 내보내기에 성공했어요." + exportOfXCompleted: "{x} 내보내기에 성공했어요" + login: "새 기기에서 로그인을 시도했어요" _types: all: "전부" note: "사용자의 새 게시물" @@ -2769,6 +2787,7 @@ _notification: roleAssigned: "역할이 할당됨" achievementEarned: "도전 과제 획득" exportCompleted: "내보내기를 완료함" + login: "로그인" test: "알림 테스트" app: "연동된 앱을 통한 알림" _actions: @@ -2835,6 +2854,8 @@ _webhookSettings: abuseReport: "사용자로부터 신고를 받았을 때" abuseReportResolved: "받은 신고를 처리했을 때" userCreated: "사용자가 생성되었을 때" + inactiveModeratorsWarning: "모더레이터가 일정 기간동안 활동하지 않은 경우" + inactiveModeratorsInvitationOnlyChanged: "모더레이터가 일정 기간 활동하지 않아 시스템에 의해 초대제로 바뀐 경우" deleteConfirm: "이 Webhook을 삭제할까요?" testRemarks: "스위치 오른쪽에 있는 버튼을 클릭해 더미 데이터를 사용한 테스트용 Webhook을 보낼 수 있어요." _abuseReport: @@ -2880,6 +2901,8 @@ _moderationLogTypes: markSensitiveDriveFile: "파일을 열람 주의로 설정" unmarkSensitiveDriveFile: "파일의 열람 주의를 해제" resolveAbuseReport: "신고 처리" + forwardAbuseReport: "신고 전달" + updateAbuseReportNote: "신고 조정 노트 갱신" createInvitation: "초대 코드 생성" createAd: "광고 생성" deleteAd: "광고 삭제" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 275cdaa6b2..956ddea3db 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -457,6 +457,7 @@ _notification: renote: "Renote" quote: "ອ້າງອີງ" reaction: "Reaction" + login: "ເຂົ້າ​ສູ່​ລະ​ບົບ" _actions: reply: "ຕອບ​ກັບ" renote: "Renote" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 1ccdb970e6..467416a37b 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -487,6 +487,7 @@ _notification: renote: "Herdelen" quote: "Quote" reaction: "Reacties" + login: "Inloggen" _actions: reply: "Antwoord" renote: "Herdelen" diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 0893523a9f..cfdf79135d 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -703,6 +703,7 @@ _notification: renote: "Renotes" quote: "Sitater" reaction: "Reaksjoner" + login: "Logg inn" _actions: reply: "Svar" renote: "Renote" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 53b2076092..deec150945 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -701,10 +701,7 @@ abuseReported: "Twoje zgłoszenie zostało wysłane. Dziękujemy." reporter: "Zgłaszający" reporteeOrigin: "Pochodzenie zgłoszonego" reporterOrigin: "Pochodzenie zgłaszającego" -forwardReport: "Przekaż zgłoszenie do innej instancji" -forwardReportIsAnonymous: "Zamiast twojego konta, anonimowe konto systemowe będzie wyświetlone jako zgłaszający na instancji zdalnej." send: "Wyślij" -abuseMarkAsResolved: "Oznacz zgłoszenie jako rozwiązane" openInNewTab: "Otwórz w nowej karcie" openInSideView: "Otwórz w bocznym widoku" defaultNavigationBehaviour: "Domyślne zachowanie nawigacji" @@ -1284,7 +1281,6 @@ _theme: buttonBg: "Tło przycisku" buttonHoverBg: "Tło przycisku (po najechaniu)" inputBorder: "Obramowanie pola wejścia" - listItemHoverBg: "Tło elementu listy (po najechaniu)" driveFolderBg: "Tło folderu na dysku" wallpaperOverlay: "Nakładka tapety" badge: "Odznaka" @@ -1589,6 +1585,7 @@ _notification: receiveFollowRequest: "Otrzymano prośbę o możliwość obserwacji" followRequestAccepted: "Przyjęto prośbę o możliwość obserwacji" groupInvited: "Zaproszono do grup" + login: "Zaloguj się" app: "Powiadomienia z aplikacji" _actions: followBack: "zaobserwował cię z powrotem" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 75b97f2bef..5f07f06fce 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -25,7 +25,7 @@ basicSettings: "Configurações básicas" otherSettings: "Outras configurações" openInWindow: "Abrir em um janela" profile: "Perfil" -timeline: "Cronologia" +timeline: "Linha do tempo" noAccountDescription: "Este usuário não tem uma descrição." login: "Iniciar sessão" loggingIn: "Iniciando sessão…" @@ -707,10 +707,7 @@ abuseReported: "Denúncia enviada. Obrigado por sua ajuda." reporter: "Denunciante" reporteeOrigin: "Origem da denúncia" reporterOrigin: "Origem do denunciante" -forwardReport: "Encaminhar a denúncia para o servidor remoto" -forwardReportIsAnonymous: "No servidor remoto, suas informações não serão visíveis, e você será apresentado como uma conta do sistema anônima." send: "Enviar" -abuseMarkAsResolved: "Marcar denúncia como resolvida" openInNewTab: "Abrir em nova aba" openInSideView: "Abrir em visão lateral" defaultNavigationBehaviour: "Navegação padrão" @@ -1061,7 +1058,7 @@ resetPasswordConfirm: "Deseja realmente mudar a sua senha?" sensitiveWords: "Palavras sensíveis" sensitiveWordsDescription: "A visibilidade de todas as notas contendo as palavras configuradas será colocadas como \"Início\" automaticamente. Você pode listar várias delas separando-as por linha." sensitiveWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)" -prohibitedWords: "Palavras proibídas" +prohibitedWords: "Palavras proibidas" prohibitedWordsDescription: "Habilita um erro ao tentar publicar uma nota contendo as palavras escolhidas. Várias palavras podem ser escolhidas, separando-as por linha." prohibitedWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)" hiddenTags: "Hashtags escondidas" @@ -1419,7 +1416,7 @@ _achievements: _types: _notes1: title: "Configurando o meu cherrypick" - description: "Post uma nota pela primeira vez" + description: "Poste uma nota pela primeira vez" flavor: "Divirta-se com o CherryPick!" _notes10: title: "Algumas notas" @@ -1947,7 +1944,6 @@ _theme: buttonBg: "Plano de fundo de botão" buttonHoverBg: "Plano de fundo de botão (Selecionado)" inputBorder: "Borda de campo digitável" - listItemHoverBg: "Plano de fundo do item de uma lista (Selecionado)" driveFolderBg: "Plano de fundo da pasta no Drive" wallpaperOverlay: "Sobreposição do papel de parede." badge: "Emblema" @@ -2377,6 +2373,7 @@ _notification: followRequestAccepted: "Aceitou pedidos de seguidor" roleAssigned: "Cargo dado" achievementEarned: "Conquista desbloqueada" + login: "Iniciar sessão" app: "Notificações de aplicativos conectados" _actions: followBack: "te seguiu de volta" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 4f62211194..ad10679b60 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -625,10 +625,7 @@ abuseReported: "Raportul tău a fost trimis. Mulțumim." reporter: "Raportorul" reporteeOrigin: "Originea raportatului" reporterOrigin: "Originea raportorului" -forwardReport: "Redirecționează raportul către instanța externă" -forwardReportIsAnonymous: "În locul contului tău, va fi afișat un cont anonim, de sistem, ca raportor către instanța externă." send: "Trimite" -abuseMarkAsResolved: "Marchează raportul ca rezolvat" openInNewTab: "Deschide în tab nou" openInSideView: "Deschide în vedere laterală" defaultNavigationBehaviour: "Comportament de navigare implicit" @@ -715,6 +712,7 @@ _notification: renote: "Re-notează" quote: "Citează" reaction: "Reacție" + login: "Autentifică-te" _actions: reply: "Răspunde" renote: "Re-notează" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 0c45806b0c..0b1e253792 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -712,10 +712,7 @@ abuseReported: "Жалоба отправлена. Большое спасибо reporter: "Сообщивший" reporteeOrigin: "О ком сообщено" reporterOrigin: "Кто сообщил" -forwardReport: "Отправить жалобу на инстанс автора." -forwardReportIsAnonymous: "Жалоба на удалённый инстанс будет отправлена анонимно. Вместо ваших данных у получателя будет отображена системная учётная запись." send: "Отправить" -abuseMarkAsResolved: "Отметить жалобу как решённую" openInNewTab: "Открыть в новой вкладке" openInSideView: "Открывать в боковой колонке" defaultNavigationBehaviour: "Поведение навигации по умолчанию" @@ -1780,7 +1777,6 @@ _theme: buttonBg: "Фон кнопки" buttonHoverBg: "Текст кнопки" inputBorder: "Рамка поля ввода" - listItemHoverBg: "Фон пункта списка (под указателем)" driveFolderBg: "Фон папки «Диска»" wallpaperOverlay: "Слой обоев" badge: "Значок" @@ -2135,6 +2131,7 @@ _notification: followRequestAccepted: "Запрос на подписку одобрен" groupInvited: "Приглашение в группы" achievementEarned: "Получение достижений" + login: "Войти" app: "Уведомления из приложений" _actions: followBack: "отвечает взаимной подпиской" diff --git a/locales/si-LK.yml b/locales/si-LK.yml index e130d68ed8..c43f3d860d 100644 --- a/locales/si-LK.yml +++ b/locales/si-LK.yml @@ -17,3 +17,6 @@ _sfx: note: "නෝට්" _profile: username: "පරිශීලක නාමය" +_notification: + _types: + login: "පිවිසෙන්න" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 35bd11fcac..fe800cf194 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -643,10 +643,7 @@ abuseReported: "Vaše nahlásenie je odoslané. Veľmi pekne ďakujeme." reporter: "Nahlásil" reporteeOrigin: "Pôvod nahláseného" reporterOrigin: "Pôvod nahlasovača" -forwardReport: "Preposlať nahlásenie na server" -forwardReportIsAnonymous: "Namiesto vášho účtu bude zobrazený anonymný systémový účet na vzdialenom serveri ako autor nahlásenia." send: "Poslať" -abuseMarkAsResolved: "Označiť nahlásenia ako vyriešené" openInNewTab: "Otvoriť v novom tabe" openInSideView: "Otvoriť v bočnom paneli" defaultNavigationBehaviour: "Predvolené správanie navigácie" @@ -1191,7 +1188,6 @@ _theme: buttonBg: "Pozadie tlačidla" buttonHoverBg: "Pozadie tlačidla (pod kurzorom)" inputBorder: "Okraj vstupného poľa" - listItemHoverBg: "Pozadie položky zoznamu (pod kurzorom)" driveFolderBg: "Pozadie priečinu disku" wallpaperOverlay: "Vrstvenie pozadia" badge: "Odznak" @@ -1495,6 +1491,7 @@ _notification: receiveFollowRequest: "Doručené žiadosti o sledovanie" followRequestAccepted: "Schválené žiadosti o sledovanie" groupInvited: "Pozvánky do skupín" + login: "Prihlásiť sa" app: "Oznámenia z prepojených aplikácií" _actions: followBack: "Sledovať späť\n" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index fe941dae4d..31c906c32a 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -563,6 +563,7 @@ _notification: renote: "Omnotera" quote: "Citat" reaction: "Reaktioner" + login: "Logga in" _actions: reply: "Svara" renote: "Omnotera" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index e95bcfa41b..630b76f7c6 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -719,10 +719,7 @@ abuseReported: "เราได้ส่งรายงานของคุณ reporter: "ผู้รายงาน" reporteeOrigin: "ปลายทางรายงาน" reporterOrigin: "แหล่งผู้รายงาน" -forwardReport: "ส่งต่อรายงานไปยังเซิร์ฟเวอร์ระยะไกล" -forwardReportIsAnonymous: "ข้อมูลของคุณจะไม่ปรากฏบนเซิร์ฟเวอร์ระยะไกลและปรากฏเป็นบัญชีระบบที่ไม่ระบุชื่อ" send: "ส่ง" -abuseMarkAsResolved: "ทำเครื่องหมายรายงานว่าแก้ไขแล้ว" openInNewTab: "เปิดในแท็บใหม่" openInSideView: "เปิดในมุมมองด้านข้าง" defaultNavigationBehaviour: "พฤติกรรมการนำทางที่เป็นค่าเริ่มต้น" @@ -2030,7 +2027,6 @@ _theme: buttonBg: "ปุ่มพื้นหลัง" buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)" inputBorder: "เส้นขอบของช่องป้อนข้อมูล" - listItemHoverBg: "รายการไอเทมพื้นหลัง (โฮเวอร์)" driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์" wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ" badge: "ตรา" @@ -2463,6 +2459,7 @@ _notification: groupInvited: "ได้รับคำเชิญเข้ากลุ่ม" roleAssigned: "ให้บทบาท" achievementEarned: "ปลดล็อกความสำเร็จแล้ว" + login: "เข้าสู่ระบบ" app: "การแจ้งเตือนจากแอปที่มีลิงก์" _actions: followBack: "ติดตามกลับด้วย" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index d659833313..4faaa05371 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -447,6 +447,7 @@ _notification: reaction: "Tepkiler" receiveFollowRequest: "Takip isteği alındı" followRequestAccepted: "Takip isteği kabul edildi" + login: "Giriş Yap " _actions: reply: "yanıt" renote: "vazgeçme" diff --git a/locales/ug-CN.yml b/locales/ug-CN.yml index e48f64511c..fef26040a5 100644 --- a/locales/ug-CN.yml +++ b/locales/ug-CN.yml @@ -17,3 +17,6 @@ _2fa: renewTOTPCancel: "ئۇنى توختىتىڭ" _widgets: profile: "profile" +_notification: + _types: + login: "كىرىش" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 3c6b4a0b48..2755f60ff2 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -642,10 +642,7 @@ abuseReported: "Дякуємо, вашу скаргу було відправл reporter: "Репортер" reporteeOrigin: "Про кого повідомлено" reporterOrigin: "Хто повідомив" -forwardReport: "Переслати звіт на віддалений інстанс" -forwardReportIsAnonymous: "Замість вашого облікового запису анонімний системний обліковий запис буде відображатися як доповідач на віддаленому інстансі" send: "Відправити" -abuseMarkAsResolved: "Позначити скаргу як вирішену" openInNewTab: "Відкрити в новій вкладці" openInSideView: "Відкрити збоку" defaultNavigationBehaviour: "Поведінка навігації за замовчуванням" @@ -1381,7 +1378,6 @@ _theme: buttonBg: "Фон кнопки" buttonHoverBg: "Фон кнопки (при наведенні)" inputBorder: "Край поля вводу" - listItemHoverBg: "Фон елементу в списку (при наведенні)" driveFolderBg: "Фон папки на диску" wallpaperOverlay: "Накладання шпалер" badge: "Значок" @@ -1668,6 +1664,7 @@ _notification: receiveFollowRequest: "Запити на підписку" followRequestAccepted: "Прийняті підписки" groupInvited: "Запрошення до груп" + login: "Увійти" app: "Сповіщення від додатків" _actions: reply: "Відповісти" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index e1c522d745..5886c5367e 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -629,10 +629,7 @@ abuseReported: "Shikoyatingiz yetkazildi. Ma'lumot uchun rahmat." reporter: "Shikoyat qiluvchi" reporteeOrigin: "Xabarning kelib chiqishi" reporterOrigin: "Xabarchining joylashuvi" -forwardReport: "Xabarni masofadagi serverga yuborish" -forwardReportIsAnonymous: "Sizning yuborayotgan xabaringiz o'z akkountingiz emas balki anonim tarzda qoladi" send: "Yuborish" -abuseMarkAsResolved: "Yuborilgan xabarni hal qilingan deb belgilash" openInNewTab: "Yangi tab da ochish" openInSideView: "Yon panelda ochish" defaultNavigationBehaviour: "Standart navigatsiya harakati" @@ -1058,6 +1055,7 @@ _notification: quote: "Iqtibos keltirish" reaction: "Reaktsiyalar" receiveFollowRequest: "Qabul qilingan kuzatuv so'rovlari" + login: "Kirish" _actions: reply: "Javob berish" renote: "Qayta qayd qilish" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 802f177241..695b7edaec 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -687,10 +687,7 @@ abuseReported: "Báo cáo đã được gửi. Cảm ơn bạn nhiều." reporter: "Người báo cáo" reporteeOrigin: "Bị báo cáo" reporterOrigin: "Máy chủ người báo cáo" -forwardReport: "Chuyển tiếp báo cáo cho máy chủ từ xa" -forwardReportIsAnonymous: "Thay vì tài khoản của bạn, một tài khoản hệ thống ẩn danh sẽ được hiển thị dưới dạng người báo cáo ở máy chủ từ xa." send: "Gửi" -abuseMarkAsResolved: "Đánh dấu đã xử lý" openInNewTab: "Mở trong tab mới" openInSideView: "Mở trong thanh bên" defaultNavigationBehaviour: "Thao tác điều hướng mặc định" @@ -1632,7 +1629,6 @@ _theme: buttonBg: "Nền nút" buttonHoverBg: "Nền nút (Chạm)" inputBorder: "Đường viền khung soạn thảo" - listItemHoverBg: "Nền mục liệt kê (Chạm)" driveFolderBg: "Nền thư mục Ổ đĩa" wallpaperOverlay: "Lớp phủ hình nền" badge: "Huy hiệu" @@ -1967,6 +1963,7 @@ _notification: followRequestAccepted: "Yêu cầu theo dõi được chấp nhận" groupInvited: "Mời vào nhóm" achievementEarned: "Hoàn thành Achievement" + login: "Đăng nhập" app: "Từ app liên kết" _actions: followBack: "đã theo dõi lại bạn" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index d1728d8c11..c8ebf6e237 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -8,6 +8,9 @@ search: "搜索" notifications: "通知" username: "用户名" password: "密码" +initialPasswordForSetup: "初始化密码" +initialPasswordIsIncorrect: "初始化密码不正确" +initialPasswordForSetupDescription: "如果是自己安装的 CherryPick,请输入配置文件里设好的密码。\n如果使用的是 CherryPick 的托管服务等,请输入服务商提供的密码。\n如果没有设置密码,请留空并继续。" forgotPassword: "忘记密码" fetchingAsApObject: "在联邦宇宙查询中..." ok: "OK" @@ -90,7 +93,7 @@ followsYou: "正在关注你" createList: "创建列表" manageLists: "管理列表" error: "错误" -somethingHappened: "出现了一些问题!" +somethingHappened: "出错了" retry: "重试" pageLoadError: "页面加载失败。" pageLoadErrorDescription: "这通常是由于网络或浏览器缓存的原因。请清除缓存或等待片刻后重试。" @@ -167,7 +170,7 @@ emojiUrl: "emoji 地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" cacheRemoteFiles: "缓存远程文件" -cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 default.yml 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。" +cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 default.yml 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。" youCanCleanRemoteFilesCache: "可以使用文件管理的🗑️按钮来删除所有的缓存。" cacheRemoteSensitiveFiles: "缓存远程敏感媒体文件" cacheRemoteSensitiveFilesDescription: "如果禁用这项设定,远程服务器的敏感媒体将不会被缓存,而是直接链接。" @@ -236,6 +239,8 @@ silencedInstances: "被静音的服务器" silencedInstancesDescription: "设置要静音的服务器,以换行分隔。被静音的服务器内所有的账户将默认处于「静音」状态,仅能发送关注请求,并且在未关注状态下无法提及本地账户。被阻止的实例不受影响。" mediaSilencedInstances: "已隐藏媒体文件的服务器" mediaSilencedInstancesDescription: "设置要隐藏媒体文件的服务器,以换行分隔。被设置为隐藏媒体文件服务器内所有账号的文件均按照「敏感内容」处理,且将无法使用自定义表情符号。被阻止的实例不受影响。" +federationAllowedHosts: "允许联合的服务器" +federationAllowedHostsDescription: "设定允许联合的服务器,以换行分隔。" muteAndBlock: "静音/拉黑" mutedUsers: "已静音用户" blockedUsers: "已拉黑的用户" @@ -449,6 +454,7 @@ totpDescription: "使用验证器输入一次性密码" moderator: "监察员" moderation: "管理" moderationNote: "管理笔记" +moderationNoteDescription: "可以用来记录仅在管理员之间共享的笔记。" addModerationNote: "添加管理笔记" moderationLogs: "管理日志" nUsersMentioned: "{n} 被提到" @@ -522,6 +528,7 @@ emojiStyle: "表情符号的样式" native: "原生" menuStyle: "菜单样式" style: "样式" +drawer: "抽屉" popup: "弹窗" youHaveNoGroups: "没有群组" joinOrCreateGroup: "请加入一个现有的群组,或者创建新群组。" @@ -725,10 +732,7 @@ abuseReported: "内容已发送。感谢您提交信息。" reporter: "举报者" reporteeOrigin: "举报来源" reporterOrigin: "举报者来源" -forwardReport: "将该举报信息转发给远程服务器" -forwardReportIsAnonymous: "在远程实例上显示的报告者是匿名的系统账号,而不是您的账号。" send: "发送" -abuseMarkAsResolved: "处理完毕" openInNewTab: "在新标签页中打开" openInSideView: "在侧边栏中打开" defaultNavigationBehaviour: "默认导航" @@ -930,6 +934,7 @@ followersVisibility: "关注者的公开范围" continueThread: "查看更多帖子" deleteAccountConfirm: "将要删除账户。是否确认?" incorrectPassword: "密码错误" +incorrectTotp: "一次性密码不正确或已过期" voteConfirm: "确定投给 “{choice}” ?" hide: "隐藏" leaveGroup: "离开群组" @@ -1096,6 +1101,7 @@ retryAllQueuesConfirmTitle: "要再尝试一次吗?" retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" enableChartsForRemoteUser: "生成远程用户的图表" enableChartsForFederatedInstances: "生成远程服务器的图表" +enableStatsForFederatedInstances: "获取远程服务器的信息" showClipButtonInNoteFooter: "在贴文下方显示便签按钮" reactionsDisplaySize: "回应显示大小" limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示" @@ -1208,10 +1214,10 @@ followingOrFollower: "关注中或关注者" fileAttachedOnly: "仅限媒体" showRepliesToOthersInTimeline: "在时间线中包含给别人的回复" hideRepliesToOthersInTimeline: "在时间线中隐藏给别人的回复" -showRepliesToOthersInTimelineAll: "在时间线中包含现在关注的所有人的回复" -hideRepliesToOthersInTimelineAll: "在时间线中隐藏现在关注的所有人的回复" -confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中包含现在关注的所有人的回复吗?" -confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏现在关注的所有人的回复吗?" +showRepliesToOthersInTimelineAll: "在时间线中显示所有现在关注的人的回复" +hideRepliesToOthersInTimelineAll: "在时间线中隐藏所有现在关注的人的回复" +confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中显示所有现在关注的人的回复吗?" +confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏所有现在关注的人的回复吗?" externalServices: "外部服务" sourceCode: "源代码" sourceCodeIsNotYetProvided: "还未提供源代码。要解决此问题请联系管理员。" @@ -1287,10 +1293,27 @@ genEmbedCode: "生成嵌入代码" noteOfThisUser: "此用户的帖子" clipNoteLimitExceeded: "无法再往此便签内添加更多帖子" performance: "性能" +modified: "有变更" +discard: "取消" +thereAreNChanges: "有 {n} 处更改" signinWithPasskey: "使用通行密钥登录" unknownWebAuthnKey: "此通行密钥未注册。" passkeyVerificationFailed: "验证通行密钥失败。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。" +messageToFollower: "给关注者的消息" +target: "对象" +testCaptchaWarning: "此功能为测试 CAPTCHA 用。请勿在正式环境中使用。" +prohibitedWordsForNameOfUser: "用户名中禁止的词" +prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。" +yourNameContainsProhibitedWords: "目标用户名包含违禁词" +yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。" +_abuseUserReport: + forward: "转发" + forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。" + resolve: "解决" + accept: "确认" + reject: "拒绝" + resolveTutorial: "如果举报内容有理且已解决,选择「确认」将案件以肯定的态度标记为已解决。\n如果举报内容站不住脚,选择「拒绝」将案件以否定的态度标记为已解决。" _delivery: status: "投递状态" stop: "停止投递" @@ -1428,6 +1451,7 @@ _serverSettings: reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。" inquiryUrl: "联络地址" inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。" _accountMigration: moveFrom: "从别的账号迁移到此账户" moveFromSub: "为另一个账户建立别名" @@ -1627,7 +1651,7 @@ _achievements: _postedAt0min0sec: title: "报时" description: "在 0 点发布一篇帖子" - flavor: "报时信号最后一响,零点整" + flavor: "嘟 · 嘟 · 嘟 · 哔——" _selfQuote: title: "自我引用" description: "引用了自己的帖子" @@ -2051,7 +2075,6 @@ _theme: buttonBg: "按钮背景" buttonHoverBg: "按钮背景(悬停)" inputBorder: "输入框边框" - listItemHoverBg: "下拉列表项目背景(悬停)" driveFolderBg: "网盘的文件夹背景" wallpaperOverlay: "壁纸叠加层" badge: "徽章" @@ -2331,6 +2354,9 @@ _profile: changeBanner: "修改横幅" verifiedLinkDescription: "如果将内容设置为 URL,当链接所指向的网页内包含自己的个人资料链接时,可以显示一个已验证图标。" avatarDecorationMax: "最多可添加 {max} 个挂件" + followedMessage: "被关注时显示的消息" + followedMessageDescription: "可以设置被关注时向对方显示的短消息。" + followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。" _exportOrImport: allNotes: "所有帖子" favoritedNotes: "收藏的帖子" @@ -2472,6 +2498,7 @@ _notification: followedBySomeUsers: "被 {n} 人关注" flushNotification: "重置通知历史" exportOfXCompleted: "已完成 {x} 个导出" + login: "有新的登录" _types: all: "全部" note: "用户的新帖子" @@ -2488,6 +2515,7 @@ _notification: roleAssigned: "授予的角色" achievementEarned: "取得的成就" exportCompleted: "已完成导出" + login: "登录" test: "测试通知" app: "关联应用的通知" _actions: @@ -2554,6 +2582,8 @@ _webhookSettings: abuseReport: "当收到举报时" abuseReportResolved: "当举报被处理时" userCreated: "当用户被创建时" + inactiveModeratorsWarning: "当管理员在一段时间内不活跃时" + inactiveModeratorsInvitationOnlyChanged: "当因为管理员在一段时间内不活跃,导致服务器变为邀请制时" deleteConfirm: "要删除 webhook 吗?" testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。" _abuseReport: @@ -2599,6 +2629,8 @@ _moderationLogTypes: markSensitiveDriveFile: "标记网盘文件为敏感媒体" unmarkSensitiveDriveFile: "取消标记网盘文件为敏感媒体" resolveAbuseReport: "处理举报" + forwardAbuseReport: "转发举报" + updateAbuseReportNote: "更新举报用管理笔记" createInvitation: "生成邀请码" createAd: "创建了广告" deleteAd: "删除了广告" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 8e45b79991..363e0d7b65 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -8,6 +8,9 @@ search: "搜尋" notifications: "通知" username: "使用者名稱" password: "密碼" +initialPasswordForSetup: "初始設定用的密碼" +initialPasswordIsIncorrect: "初始設定用的密碼錯誤。" +initialPasswordForSetupDescription: "如果您自己安裝了 CherryPick,請使用您在設定檔中輸入的密碼。\n如果您使用 CherryPick 的託管服務之類的服務,請使用提供的密碼。\n如果您尚未設定密碼,請將其留空並繼續。" forgotPassword: "忘記密碼" fetchingAsApObject: "從聯邦宇宙取得中..." ok: "OK" @@ -451,6 +454,7 @@ totpDescription: "以驗證應用程式輸入一次性密碼" moderator: "審查員" moderation: "審查" moderationNote: "管理筆記" +moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解。" addModerationNote: "新增管理筆記" moderationLogs: "管理日誌" nUsersMentioned: "被 {n} 個人提及" @@ -528,7 +532,7 @@ drawer: "側邊欄" popup: "彈出式視窗" youHaveNoGroups: "找不到群組" joinOrCreateGroup: "請加入現有群組,或創建新群組。" -showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項" +showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的" showReactionsCount: "顯示貼文的反應數目" noHistory: "沒有歷史紀錄" signinHistory: "登入歷史" @@ -728,10 +732,7 @@ abuseReported: "檢舉完成。感謝您的報告。" reporter: "檢舉者" reporteeOrigin: "檢舉來源" reporterOrigin: "檢舉者來源" -forwardReport: "將報告轉送給遠端伺服器" -forwardReportIsAnonymous: "在遠端實例上看不到您的資訊,顯示的報告者是匿名的系统帳戶。" send: "發送" -abuseMarkAsResolved: "處理完畢" openInNewTab: "在新分頁中開啟" openInSideView: "在側欄中開啟" defaultNavigationBehaviour: "預設導航" @@ -933,6 +934,7 @@ followersVisibility: "追隨者的可見性" continueThread: "查看更多貼文" deleteAccountConfirm: "將要刪除帳戶。是否確定?" incorrectPassword: "密碼錯誤。" +incorrectTotp: "一次性密碼錯誤,或者已過期。" voteConfirm: "確定投給「{choice}」?" hide: "隱藏" leaveGroup: "離開群組" @@ -1031,7 +1033,7 @@ show: "檢視" neverShow: "不再顯示" remindMeLater: "以後再說" didYouLikeMisskey: "您喜歡 CherryPick 嗎?" -pleaseDonate: "CherryPick 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!" +pleaseDonate: "CherryPick是由{host}使用的免費軟體。請贊助我們,讓開發的工作能夠持續!" correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。" roles: "角色" role: "角色" @@ -1099,6 +1101,7 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?" retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" enableChartsForRemoteUser: "生成遠端使用者的圖表" enableChartsForFederatedInstances: "生成遠端伺服器的圖表" +enableStatsForFederatedInstances: "取得遠端伺服器資訊" showClipButtonInNoteFooter: "新增摘錄按鈕至貼文" reactionsDisplaySize: "反應的顯示尺寸" limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。" @@ -1207,7 +1210,7 @@ showRenotes: "顯示其他人的轉發貼文" edited: "已編輯" notificationRecieveConfig: "接受通知的設定" mutualFollow: "互相追隨" -followingOrFollower: "追隨中或追隨者" +followingOrFollower: "追隨中或者追隨者" fileAttachedOnly: "只顯示包含附件的貼文" showRepliesToOthersInTimeline: "顯示給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" @@ -1278,7 +1281,7 @@ useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊" keepOriginalFilename: "保留原始檔名" keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。" noDescription: "沒有說明文字" -alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息" +alwaysConfirmFollow: "跟隨時總是確認" inquiry: "聯絡我們" tryAgain: "請再試一次。" confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認" @@ -1297,6 +1300,20 @@ signinWithPasskey: "使用密碼金鑰登入" unknownWebAuthnKey: "未註冊的金鑰。" passkeyVerificationFailed: "驗證金鑰失敗。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。" +messageToFollower: "給追隨者的訊息" +target: "目標 " +testCaptchaWarning: "此功能用於 CAPTCHA 的測試。請勿在正式環境中使用。" +prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)" +prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。" +yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串" +yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。" +_abuseUserReport: + forward: "轉發" + forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。" + resolve: "解決" + accept: "接受" + reject: "拒絕" + resolveTutorial: "如果您已回覆正當的檢舉,請選擇「接受」以將案件標記為已解決。\n 如果檢舉的內容不正當,請選擇「拒絕」將案件標記為已解決。" _delivery: status: "傳送狀態" stop: "停止發送" @@ -1434,6 +1451,7 @@ _serverSettings: reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。" inquiryUrl: "聯絡表單網址" inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam,如果一段期間內沒有偵測到審查員的活動,此設定將自動關閉。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" @@ -1447,7 +1465,7 @@ _accountMigration: startMigration: "遷移" migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。" - postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。" + postMigrationNote: "取消追蹤此帳戶將在遷移操作後 24 小時執行。\n 此帳戶有 0 個關注者/關注者。 您的關注者仍然可以看到此帳戶的關注者帖子,因為您不會被取消關注。" movedTo: "要遷移到的帳戶:" _achievements: earnedAt: "獲得日期" @@ -1567,7 +1585,7 @@ _achievements: _markedAsCat: title: "我是貓" description: "已將帳戶設定為貓" - flavor: "還沒有名字。" + flavor: "沒有名字。" _following1: title: "首次追隨" description: "首次追隨了" @@ -1581,7 +1599,7 @@ _achievements: title: "一百位朋友" description: "追隨超過100人了" _following300: - title: "朋友過多" + title: "朋友太多" description: "追隨超過300人了" _followers1: title: "第一個追隨者" @@ -1977,7 +1995,7 @@ _channel: following: "追隨中" usersCount: "有 {n} 人參與" notesCount: "有 {n} 篇貼文" - nameAndDescription: "名稱與說明" + nameAndDescription: "名稱" nameOnly: "僅名稱" allowRenoteToExternal: "允許在頻道外轉發和引用" _menuDisplay: @@ -2057,7 +2075,6 @@ _theme: buttonBg: "按鈕背景" buttonHoverBg: "按鈕背景 (漂浮)" inputBorder: "輸入框邊框" - listItemHoverBg: "列表物品背景 (漂浮)" driveFolderBg: "雲端硬碟文件夾背景" wallpaperOverlay: "壁紙覆蓋層" badge: "徽章" @@ -2481,6 +2498,7 @@ _notification: followedBySomeUsers: "被{n}人追隨了" flushNotification: "重置通知歷史紀錄" exportOfXCompleted: "{x} 的匯出已完成。" + login: "已登入" _types: all: "全部 " note: "使用者的最新貼文" @@ -2497,6 +2515,7 @@ _notification: roleAssigned: "已授予角色" achievementEarned: "獲得成就" exportCompleted: "已完成匯出。" + login: "登入" test: "通知測試" app: "應用程式通知" _actions: @@ -2563,6 +2582,8 @@ _webhookSettings: abuseReport: "當使用者檢舉時" abuseReportResolved: "當處理了使用者的檢舉時" userCreated: "使用者被新增時" + inactiveModeratorsWarning: "當審查員在一段時間內沒有活動時" + inactiveModeratorsInvitationOnlyChanged: "當審查員在一段時間內不活動時,系統會將模式變更為邀請制" deleteConfirm: "請問是否要刪除 Webhook?" testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。" _abuseReport: @@ -2577,7 +2598,7 @@ _abuseReport: mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)" webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)" keywords: "關鍵字" - notifiedUser: "被通知的使用者" + notifiedUser: "通知的使用者" notifiedWebhook: "使用的 Webhook" deleteConfirm: "確定要刪除通知對象嗎?" _moderationLogTypes: @@ -2608,6 +2629,8 @@ _moderationLogTypes: markSensitiveDriveFile: "標記為敏感檔案" unmarkSensitiveDriveFile: "撤銷標記為敏感檔案" resolveAbuseReport: "解決檢舉" + forwardAbuseReport: "轉發檢舉" + updateAbuseReportNote: "更新檢舉的審查備註" createInvitation: "建立邀請碼" createAd: "建立廣告" deleteAd: "刪除廣告" diff --git a/package.json b/package.json index ccc82c0c73..db7cb3c066 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cherrypick", "version": "4.12.1", - "basedMisskeyVersion": "2024.9.0", + "basedMisskeyVersion": "2024.10.1", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/assets/tabler-badges/login-2.png b/packages/backend/assets/tabler-badges/login-2.png new file mode 100644 index 0000000000..f3ca8de3dd Binary files /dev/null and b/packages/backend/assets/tabler-badges/login-2.png differ diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index 4fd9f0cd51..ae7b2baf49 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -11,7 +11,7 @@ export default [ languageOptions: { parserOptions: { parser: tsParser, - project: ['./tsconfig.json', './test/tsconfig.json'], + project: ['./tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'], sourceType: 'module', tsconfigRootDir: import.meta.dirname, }, diff --git a/packages/backend/jest.config.fed.cjs b/packages/backend/jest.config.fed.cjs new file mode 100644 index 0000000000..fae187bc23 --- /dev/null +++ b/packages/backend/jest.config.fed.cjs @@ -0,0 +1,13 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/en/configuration.html + */ + +const base = require('./jest.config.cjs'); + +module.exports = { + ...base, + testMatch: [ + '/test-federation/test/**/*.test.ts', + ], +}; diff --git a/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js b/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js new file mode 100644 index 0000000000..4ff520172b --- /dev/null +++ b/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class EnableStatsForFederatedInstances1727318020265 { + name = 'EnableStatsForFederatedInstances1727318020265' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`); + } +} diff --git a/packages/backend/migration/1728085812127-refine-abuse-user-report.js b/packages/backend/migration/1728085812127-refine-abuse-user-report.js new file mode 100644 index 0000000000..57cbfdcf6d --- /dev/null +++ b/packages/backend/migration/1728085812127-refine-abuse-user-report.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RefineAbuseUserReport1728085812127 { + name = 'RefineAbuseUserReport1728085812127' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "moderationNote" character varying(8192) NOT NULL DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "resolvedAs" character varying(128)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "resolvedAs"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "moderationNote"`); + } +} diff --git a/packages/backend/migration/1728550878802-testcaptcha.js b/packages/backend/migration/1728550878802-testcaptcha.js new file mode 100644 index 0000000000..d8d987c0c1 --- /dev/null +++ b/packages/backend/migration/1728550878802-testcaptcha.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Testcaptcha1728550878802 { + name = 'Testcaptcha1728550878802' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`); + } +} diff --git a/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js b/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js new file mode 100644 index 0000000000..36e698d120 --- /dev/null +++ b/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ProhibitedWordsForNameOfUser1728634286056 { + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 326b0aaade..92fbeaf605 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -19,19 +19,21 @@ "watch": "node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", "dev": "node ./scripts/dev.mjs", - "typecheck": "tsc --noEmit", - "eslint": "eslint --quiet \"src/**/*.ts\"", + "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", + "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", "biome-lint": "pnpm typecheck && pnpm biome lint", "format": "pnpm biome format", "format:write": "pnpm biome format --write", "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs", "jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs", + "jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs", "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs", "jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs", "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", "test": "pnpm jest", "test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e", + "test:fed": "pnpm jest:fed", "test-and-coverage": "pnpm jest-and-coverage", "test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e", "generate-api-json": "node ./scripts/generate_api_json.js", @@ -75,22 +77,22 @@ "@bull-board/fastify": "6.0.0", "@bull-board/ui": "6.0.0", "@discordapp/twemoji": "15.1.0", - "@fastify/accepts": "5.0.0", - "@fastify/cookie": "10.0.0", - "@fastify/cors": "10.0.0", - "@fastify/express": "4.0.0", + "@fastify/accepts": "5.0.1", + "@fastify/cookie": "10.0.1", + "@fastify/cors": "10.0.1", + "@fastify/express": "4.0.1", "@fastify/http-proxy": "10.0.0", - "@fastify/multipart": "9.0.0", - "@fastify/static": "8.0.0", - "@fastify/view": "10.0.0", + "@fastify/multipart": "9.0.1", + "@fastify/static": "8.0.1", + "@fastify/view": "10.0.1", "@google-cloud/logging": "^10.5.0", "@google-cloud/translate": "^7.2.1", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.1.0", "@napi-rs/canvas": "0.1.56", - "@nestjs/common": "10.4.3", - "@nestjs/core": "10.4.3", - "@nestjs/testing": "10.4.3", + "@nestjs/common": "10.4.4", + "@nestjs/core": "10.4.4", + "@nestjs/testing": "10.4.4", "@peertube/http-signature": "1.7.0", "@sentry/node": "8.20.0", "@sentry/profiling-node": "8.20.0", @@ -109,7 +111,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.3", - "bullmq": "5.13.2", + "bullmq": "5.15.0", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", "mfc-js": "0.24.0-cherrypick.9", @@ -157,7 +159,7 @@ "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.3.2", + "otpauth": "9.3.4", "parse5": "7.1.2", "pg": "8.13.0", "pkce-challenge": "4.1.0", @@ -174,7 +176,7 @@ "rename": "1.0.4", "rss-parser": "3.13.0", "rxjs": "7.8.1", - "sanitize-html": "2.13.0", + "sanitize-html": "2.13.1", "secure-json-parse": "2.7.0", "sharp": "0.33.5", "slacc": "0.0.10", @@ -197,14 +199,14 @@ "devDependencies": { "@biomejs/biome": "1.9.3", "@jest/globals": "29.7.0", - "@nestjs/platform-express": "10.4.3", + "@nestjs/platform-express": "10.4.4", "@simplewebauthn/types": "10.0.0", "@swc/jest": "0.2.36", "@types/accepts": "1.3.7", "@types/archiver": "6.0.2", "@types/bcryptjs": "2.4.6", "@types/body-parser": "1.19.5", - "@types/color-convert": "2.0.3", + "@types/color-convert": "2.0.4", "@types/content-disposition": "0.5.8", "@types/fluent-ffmpeg": "2.1.26", "@types/htmlescape": "1.1.3", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c3e0446457..3c8b5abfb2 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -63,6 +63,8 @@ type Source = { publishTarballInsteadOfProvideRepositoryUrl?: boolean; + setupPassword?: string; + proxy?: string; proxySmtp?: string; proxyBypassHosts?: string[]; @@ -169,6 +171,7 @@ export type Config = { version: string; basedMisskeyVersion: string; publishTarballInsteadOfProvideRepositoryUrl: boolean; + setupPassword: string | undefined; host: string; hostname: string; scheme: string; @@ -251,6 +254,7 @@ export function loadConfig(): Config { version, basedMisskeyVersion, publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl, + setupPassword: config.setupPassword, url: url.origin, port: config.port ?? parseInt(process.env.PORT ?? '', 10), socket: config.socket, diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index fe2c63e7d6..25e265f2b1 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -22,6 +22,7 @@ import { RoleService } from '@/core/RoleService.js'; import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { IdService } from './IdService.js'; @Injectable() @@ -42,6 +43,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { private emailService: EmailService, private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, + private userEntityService: UserEntityService, ) { this.redisForSub.on('message', this.onMessage); } @@ -59,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { return; } - const moderatorIds = await this.roleService.getModeratorIds(true, true); + const moderatorIds = await this.roleService.getModeratorIds({ + includeAdmins: true, + excludeExpire: true, + }); for (const moderatorId of moderatorIds) { for (const abuseReport of abuseReports) { @@ -135,6 +140,26 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { return; } + const usersMap = await this.userEntityService.packMany( + [ + ...new Set([ + ...abuseReports.map(it => it.reporter ?? it.reporterId), + ...abuseReports.map(it => it.targetUser ?? it.targetUserId), + ...abuseReports.map(it => it.assignee ?? it.assigneeId), + ].filter(x => x != null)), + ], + null, + { schema: 'UserLite' }, + ).then(it => new Map(it.map(it => [it.id, it]))); + const convertedReports = abuseReports.map(it => { + return { + ...it, + reporter: usersMap.get(it.reporterId), + targetUser: usersMap.get(it.targetUserId), + assignee: it.assigneeId ? usersMap.get(it.assigneeId) : null, + }; + }); + const recipientWebhookIds = await this.fetchWebhookRecipients() .then(it => it .filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook') @@ -142,7 +167,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .filter(x => x != null)); for (const webhookId of recipientWebhookIds) { await Promise.all( - abuseReports.map(it => { + convertedReports.map(it => { return this.systemWebhookService.enqueueSystemWebhook( webhookId, type, @@ -263,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .log(updater, 'createAbuseReportNotificationRecipient', { recipientId: id, recipient: created, - }) - .then(); + }); return created; } @@ -302,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { recipientId: params.id, before: beforeEntity, after: afterEntity, - }) - .then(); + }); return afterEntity; } @@ -324,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .log(updater, 'deleteAbuseReportNotificationRecipient', { recipientId: id, recipient: entity, - }) - .then(); + }); } /** @@ -348,7 +370,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { } // モデレータ権限の有無で通知先設定を振り分ける - const authorizedUserIds = await this.roleService.getModeratorIds(true, true); + const authorizedUserIds = await this.roleService.getModeratorIds({ + includeAdmins: true, + excludeExpire: true, + }); const authorizedUserRecipients = Array.of(); const unauthorizedUserRecipients = Array.of(); for (const recipient of userRecipients) { diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts index 69c51509ba..0b022d3b08 100644 --- a/packages/backend/src/core/AbuseReportService.ts +++ b/packages/backend/src/core/AbuseReportService.ts @@ -20,8 +20,10 @@ export class AbuseReportService { constructor( @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, + private idService: IdService, private abuseReportNotificationService: AbuseReportNotificationService, private queueService: QueueService, @@ -77,16 +79,16 @@ export class AbuseReportService { * - SystemWebhook * * @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える - * @param operator 通報を処理したユーザ + * @param moderator 通報を処理したユーザ * @see AbuseReportNotificationService.notify */ @bindThis public async resolve( params: { reportId: string; - forward: boolean; + resolvedAs: MiAbuseUserReport['resolvedAs']; }[], - operator: MiUser, + moderator: MiUser, ) { const paramsMap = new Map(params.map(it => [it.reportId, it])); const reports = await this.abuseUserReportsRepository.findBy({ @@ -99,30 +101,76 @@ export class AbuseReportService { await this.abuseUserReportsRepository.update(report.id, { resolved: true, - assigneeId: operator.id, - forwarded: ps.forward && report.targetUserHost !== null, + assigneeId: moderator.id, + resolvedAs: ps.resolvedAs, }); - if (ps.forward && report.targetUserHost != null) { - const actor = await this.instanceActorService.getInstanceActor(); - const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); - - // eslint-disable-next-line - const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment); - const contextAssignedFlag = this.apRendererService.addContext(flag); - this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false); - } - this.moderationLogService - .log(operator, 'resolveAbuseReport', { + .log(moderator, 'resolveAbuseReport', { reportId: report.id, report: report, - forwarded: ps.forward && report.targetUserHost !== null, - }) - .then(); + resolvedAs: ps.resolvedAs, + }); } return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) }) .then(reports => this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReportResolved')); } + + @bindThis + public async forward( + reportId: MiAbuseUserReport['id'], + moderator: MiUser, + ) { + const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId }); + + if (report.targetUserHost == null) { + throw new Error('The target user host is null.'); + } + + if (report.forwarded) { + throw new Error('The report has already been forwarded.'); + } + + await this.abuseUserReportsRepository.update(report.id, { + forwarded: true, + }); + + const actor = await this.instanceActorService.getInstanceActor(); + const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); + + const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment); + const contextAssignedFlag = this.apRendererService.addContext(flag); + this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false); + + this.moderationLogService + .log(moderator, 'forwardAbuseReport', { + reportId: report.id, + report: report, + }); + } + + @bindThis + public async update( + reportId: MiAbuseUserReport['id'], + params: { + moderationNote?: MiAbuseUserReport['moderationNote']; + }, + moderator: MiUser, + ) { + const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId }); + + await this.abuseUserReportsRepository.update(report.id, { + moderationNote: params.moderationNote, + }); + + if (params.moderationNote != null && report.moderationNote !== params.moderationNote) { + this.moderationLogService.log(moderator, 'updateAbuseReportNote', { + reportId: report.id, + report: report, + before: report.moderationNote, + after: params.moderationNote, + }); + } + } } diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 6e3125044c..24d11f29ff 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -274,13 +274,15 @@ export class AccountMoveService { } // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. - if (this.userEntityService.isRemoteUser(oldAccount)) { - this.federatedInstanceService.fetch(oldAccount.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(oldAccount)) { + this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } } // FIXME: expensive? diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index 4d8f11e595..59adbda205 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -211,6 +211,13 @@ export class AnnouncementService { return; } + const announcement = await this.announcementsRepository.findOneBy({ id: announcementId }); + if (announcement != null && announcement.userId === user.id) { + await this.announcementsRepository.update(announcementId, { + isActive: false, + }); + } + if ((await this.getUnreadAnnouncements(user)).length === 0) { this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); } diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index f6b7955cd2..206d0dbe0a 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -119,5 +119,18 @@ export class CaptchaService { throw new Error(`turnstile-failed: ${errorCodes}`); } } + + @bindThis + public async verifyTestcaptcha(response: string | null | undefined): Promise { + if (response == null) { + throw new Error('testcaptcha-failed: no response provided'); + } + + const success = response === 'testcaptcha-passed'; + + if (!success) { + throw new Error('testcaptcha-failed'); + } + } } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index a0351739f5..6d09100e6f 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -14,6 +14,7 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { UserSearchService } from '@/core/UserSearchService.js'; import { WebhookTestService } from '@/core/WebhookTestService.js'; +import { FlashService } from '@/core/FlashService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -225,6 +226,7 @@ const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useEx const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; +const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; @@ -381,6 +383,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv WebhookTestService, UtilityService, FileInfoService, + FlashService, SearchService, ClipService, FeaturedService, @@ -533,6 +536,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $WebhookTestService, $UtilityService, $FileInfoService, + $FlashService, $SearchService, $ClipService, $FeaturedService, @@ -686,6 +690,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv WebhookTestService, UtilityService, FileInfoService, + FlashService, SearchService, ClipService, FeaturedService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index cc68c9b203..165e8b6936 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -117,19 +117,33 @@ export class CustomEmojiService implements OnApplicationShutdown { } @bindThis - public async update(id: MiEmoji['id'], data: { + public async update(data: ( + { id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], } + ) & { driveFile?: MiDriveFile; - name?: string; category?: string | null; aliases?: string[]; license?: string | null; isSensitive?: boolean; localOnly?: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; - }, moderator?: MiUser): Promise { - const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); - const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); - if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); + }, moderator?: MiUser): Promise< + null + | 'NO_SUCH_EMOJI' + | 'SAME_NAME_EMOJI_EXISTS' + > { + const emoji = data.id + ? await this.getEmojiById(data.id) + : await this.getEmojiByName(data.name!); + if (emoji === null) return 'NO_SUCH_EMOJI'; + const id = emoji.id; + + // IDと絵文字名が両方指定されている場合は絵文字名の変更を行うため重複チェックが必要 + const doNameUpdate = data.id && data.name && (data.name !== emoji.name); + if (doNameUpdate) { + const isDuplicate = await this.checkDuplicate(data.name!); + if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS'; + } await this.emojisRepository.update(emoji.id, { updatedAt: new Date(), @@ -149,7 +163,7 @@ export class CustomEmojiService implements OnApplicationShutdown { const packed = await this.emojiEntityService.packDetailed(emoji.id); - if (emoji.name === data.name) { + if (!doNameUpdate) { this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: [packed], }); @@ -171,6 +185,7 @@ export class CustomEmojiService implements OnApplicationShutdown { after: updated, }); } + return null; } @bindThis diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 7aeeb78178..73bbf03b26 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown { } @bindThis - public async fetch(host: string): Promise { + public async fetchOrRegister(host: string): Promise { host = this.utilityService.toPuny(host); const cached = await this.federatedInstanceCache.get(host); @@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown { } } + @bindThis + public async fetch(host: string): Promise { + host = this.utilityService.toPuny(host); + + const cached = await this.federatedInstanceCache.get(host); + if (cached !== undefined) return cached; + + const index = await this.instancesRepository.findOneBy({ host }); + + if (index == null) { + this.federatedInstanceCache.set(host, null); + return null; + } else { + this.federatedInstanceCache.set(host, index); + return index; + } + } + @bindThis public async update(id: MiInstance['id'], data: Partial): Promise { const result = await this.instancesRepository.createQueryBuilder().update() diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 3f3171327e..b3e46f01d2 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -83,7 +83,7 @@ export class FetchInstanceMetadataService { try { if (!force) { - const _instance = await this.federatedInstanceService.fetch(host); + const _instance = await this.federatedInstanceService.fetchOrRegister(host); const now = Date.now(); if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { // unlock at the finally caluse diff --git a/packages/backend/src/core/FlashService.ts b/packages/backend/src/core/FlashService.ts new file mode 100644 index 0000000000..2a98225382 --- /dev/null +++ b/packages/backend/src/core/FlashService.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { type FlashsRepository } from '@/models/_.js'; + +/** + * MisskeyPlay関係のService + */ +@Injectable() +export class FlashService { + constructor( + @Inject(DI.flashsRepository) + private flashRepository: FlashsRepository, + ) { + } + + /** + * 人気のあるPlay一覧を取得する. + */ + public async featured(opts?: { offset?: number, limit: number }) { + const builder = this.flashRepository.createQueryBuilder('flash') + .andWhere('flash.likedCount > 0') + .andWhere('flash.visibility = :visibility', { visibility: 'public' }) + .addOrderBy('flash.likedCount', 'DESC') + .addOrderBy('flash.updatedAt', 'DESC') + .addOrderBy('flash.id', 'DESC'); + + if (opts?.offset) { + builder.skip(opts.offset); + } + + builder.take(opts?.limit ?? 10); + + return await builder.getMany(); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index bf305f587a..c421aedd81 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -224,7 +224,7 @@ export class NoteCreateService implements OnApplicationShutdown { private utilityService: UtilityService, private userBlockingService: UserBlockingService, ) { - this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); + this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); } @bindThis @@ -538,13 +538,15 @@ export class NoteCreateService implements OnApplicationShutdown { } // Register host - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - this.updateNotesCountQueue.enqueue(i.id, 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, true); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { + this.updateNotesCountQueue.enqueue(i.id, 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } + }); + } } // ハッシュタグ更新 diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 7596e965c6..1852f786a9 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -108,13 +108,15 @@ export class NoteDeleteService { this.perUserNotesChart.update(user, note, false); } - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, false); + } + }); + } } } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 6fce806f19..a04d4a0595 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -98,6 +98,13 @@ export class QueueService { repeat: { pattern: '0 0 * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('checkModeratorsActivity', { + }, { + // 毎時30分に起動 + repeat: { pattern: '30 * * * *' }, + removeOnComplete: true, + }); } @bindThis diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 860f7a142d..7e58ce60e2 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -107,6 +107,7 @@ export const DEFAULT_POLICIES: RolePolicies = { @Injectable() export class RoleService implements OnApplicationShutdown, OnModuleInit { + private rootUserIdCache: MemorySingleCache; private rolesCache: MemorySingleCache; private roleAssignmentByUserIdCache: MemoryKVCache; private notificationService: NotificationService; @@ -144,6 +145,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { ) { //this.onMessage = this.onMessage.bind(this); + this.rootUserIdCache = new MemorySingleCache(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに this.rolesCache = new MemorySingleCache(1000 * 60 * 60); // 1h this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m @@ -427,49 +429,78 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async isExplorable(role: { id: MiRole['id']} | null): Promise { + public async isExplorable(role: { id: MiRole['id'] } | null): Promise { if (role == null) return false; const check = await this.rolesRepository.findOneBy({ id: role.id }); if (check == null) return false; return check.isExplorable; } + /** + * モデレーター権限のロールが割り当てられているユーザID一覧を取得する. + * + * @param opts.includeAdmins 管理者権限も含めるか(デフォルト: true) + * @param opts.includeRoot rootユーザも含めるか(デフォルト: false) + * @param opts.excludeExpire 期限切れのロールを除外するか(デフォルト: false) + */ @bindThis - public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise { + public async getModeratorIds(opts?: { + includeAdmins?: boolean, + includeRoot?: boolean, + excludeExpire?: boolean, + }): Promise { + const includeAdmins = opts?.includeAdmins ?? true; + const includeRoot = opts?.includeRoot ?? false; + const excludeExpire = opts?.excludeExpire ?? false; + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); - // TODO: isRootなアカウントも含める const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) }) : []; + // Setを経由して重複を除去(ユーザIDは重複する可能性があるので) const now = Date.now(); - const result = [ - // Setを経由して重複を除去(ユーザIDは重複する可能性があるので) - ...new Set( - assigns - .filter(it => - (excludeExpire) - ? (it.expiresAt == null || it.expiresAt.getTime() > now) - : true, - ) - .map(a => a.userId), - ), - ]; + const resultSet = new Set( + assigns + .filter(it => + (excludeExpire) + ? (it.expiresAt == null || it.expiresAt.getTime() > now) + : true, + ) + .map(a => a.userId), + ); - return result.sort((x, y) => x.localeCompare(y)); + if (includeRoot) { + const rootUserId = await this.rootUserIdCache.fetch(async () => { + const it = await this.usersRepository.createQueryBuilder('users') + .select('id') + .where({ isRoot: true }) + .getRawOne<{ id: string }>(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return it!.id; + }); + resultSet.add(rootUserId); + } + + return [...resultSet].sort((x, y) => x.localeCompare(y)); } @bindThis - public async getModerators(includeAdmins = true): Promise { - const ids = await this.getModeratorIds(includeAdmins); - const users = ids.length > 0 ? await this.usersRepository.findBy({ - id: In(ids), - }) : []; - return users; + public async getModerators(opts?: { + includeAdmins?: boolean, + includeRoot?: boolean, + excludeExpire?: boolean, + }): Promise { + const ids = await this.getModeratorIds(opts); + return ids.length > 0 + ? await this.usersRepository.findBy({ + id: In(ids), + }) + : []; } @bindThis diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 5374ec09f6..45fe001993 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -151,8 +151,8 @@ export class SignupService { })); }); - this.usersChart.update(account, true).then(); - this.userService.notifySystemWebhook(account, 'userCreated').then(); + this.usersChart.update(account, true); + this.userService.notifySystemWebhook(account, 'userCreated'); return { account, secret }; } diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts index bb7c6b8c0e..db6407dcb3 100644 --- a/packages/backend/src/core/SystemWebhookService.ts +++ b/packages/backend/src/core/SystemWebhookService.ts @@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown { .log(updater, 'createSystemWebhook', { systemWebhookId: webhook.id, webhook: webhook, - }) - .then(); + }); return webhook; } @@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown { systemWebhookId: beforeEntity.id, before: beforeEntity, after: afterEntity, - }) - .then(); + }); return afterEntity; } @@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown { .log(updater, 'deleteSystemWebhook', { systemWebhookId: webhook.id, webhook, - }) - .then(); + }); } /** diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 77e7b60bea..8963003057 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -305,20 +305,22 @@ export class UserFollowingService implements OnModuleInit { //#endregion //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, true); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, true); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, true); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, true); + } + }); + } } //#endregion @@ -437,20 +439,22 @@ export class UserFollowingService implements OnModuleInit { //#endregion //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, false); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, false); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } } //#endregion diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index c2764f30e8..55c8a52705 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -12,11 +12,18 @@ import { Packed } from '@/misc/json-schema.js'; import { type WebhookEventTypes } from '@/models/Webhook.js'; import { UserWebhookService } from '@/core/UserWebhookService.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; const oneDayMillis = 24 * 60 * 60 * 1000; -function generateAbuseReport(override?: Partial): MiAbuseUserReport { - return { +type AbuseUserReportDto = Omit & { + targetUser: Packed<'UserLite'> | null, + reporter: Packed<'UserLite'> | null, + assignee: Packed<'UserLite'> | null, +}; + +function generateAbuseReport(override?: Partial): AbuseUserReportDto { + const result: MiAbuseUserReport = { id: 'dummy-abuse-report1', targetUserId: 'dummy-target-user', targetUser: null, @@ -29,8 +36,17 @@ function generateAbuseReport(override?: Partial): MiAbuseUser comment: 'This is a dummy report for testing purposes.', targetUserHost: null, reporterHost: null, + resolvedAs: null, + moderationNote: 'foo', ...override, }; + + return { + ...result, + targetUser: result.targetUser ? toPackedUserLite(result.targetUser) : null, + reporter: result.reporter ? toPackedUserLite(result.reporter) : null, + assignee: result.assignee ? toPackedUserLite(result.assignee) : null, + }; } function generateDummyUser(override?: Partial): MiUser { @@ -268,7 +284,8 @@ const dummyUser3 = generateDummyUser({ @Injectable() export class WebhookTestService { - public static NoSuchWebhookError = class extends Error {}; + public static NoSuchWebhookError = class extends Error { + }; constructor( private userWebhookService: UserWebhookService, @@ -430,6 +447,22 @@ export class WebhookTestService { send(toPackedUserLite(dummyUser1)); break; } + case 'inactiveModeratorsWarning': { + const dummyTime: ModeratorInactivityRemainingTime = { + time: 100000, + asDays: 1, + asHours: 24, + }; + + send({ + remainingTime: dummyTime, + }); + break; + } + case 'inactiveModeratorsInvitationOnlyChanged': { + send({}); + break; + } } } } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index f4fbdbd649..3c38c9e5e2 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -494,13 +494,15 @@ export class ApPersonService implements OnModuleInit { this.cacheService.uriPersonCache.set(user.uri, user); // Register host - this.federatedInstanceService.fetch(host).then(i => { - this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.newUser(i.host); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + this.federatedInstanceService.fetchOrRegister(host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.newUser(i.host); + } + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + }); + } this.usersChart.update(user, true); diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index a13c244c19..70ead890ab 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -53,6 +53,8 @@ export class AbuseUserReportEntityService { schema: 'UserDetailedNotMe', }) : null, forwarded: report.forwarded, + resolvedAs: report.resolvedAs, + moderationNote: report.moderationNote, }); } diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index 4aa7104c1e..7b0150f5b6 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -5,10 +5,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { FlashLikesRepository, FlashsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; import type { MiUser } from '@/models/User.js'; import type { MiFlash } from '@/models/Flash.js'; import { bindThis } from '@/decorators.js'; @@ -20,10 +18,8 @@ export class FlashEntityService { constructor( @Inject(DI.flashsRepository) private flashsRepository: FlashsRepository, - @Inject(DI.flashLikesRepository) private flashLikesRepository: FlashLikesRepository, - private userEntityService: UserEntityService, private idService: IdService, ) { @@ -34,25 +30,36 @@ export class FlashEntityService { src: MiFlash['id'] | MiFlash, me?: { id: MiUser['id'] } | null | undefined, hint?: { - packedUser?: Packed<'UserLite'> + packedUser?: Packed<'UserLite'>, + likedFlashIds?: MiFlash['id'][], }, ): Promise> { const meId = me ? me.id : null; const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src }); - return await awaitAll({ + // { schema: 'UserDetailed' } すると無限ループするので注意 + const user = hint?.packedUser ?? await this.userEntityService.pack(flash.user ?? flash.userId, me); + + let isLiked = undefined; + if (meId) { + isLiked = hint?.likedFlashIds + ? hint.likedFlashIds.includes(flash.id) + : await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }); + } + + return { id: flash.id, createdAt: this.idService.parse(flash.id).date.toISOString(), updatedAt: flash.updatedAt.toISOString(), userId: flash.userId, - user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 + user: user, title: flash.title, summary: flash.summary, script: flash.script, visibility: flash.visibility, likedCount: flash.likedCount, - isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined, - }); + isLiked: isLiked, + }; } @bindThis @@ -63,7 +70,19 @@ export class FlashEntityService { const _users = flashes.map(({ user, userId }) => user ?? userId); const _userMap = await this.userEntityService.packMany(_users, me) .then(users => new Map(users.map(u => [u.id, u]))); - return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) }))); + const _likedFlashIds = me + ? await this.flashLikesRepository.createQueryBuilder('flashLike') + .select('flashLike.flashId') + .where('flashLike.userId = :userId', { userId: me.id }) + .getRawMany<{ flashLike_flashId: string }>() + .then(likes => [...new Set(likes.map(like => like.flashLike_flashId))]) + : []; + return Promise.all( + flashes.map(flash => this.pack(flash, me, { + packedUser: _userMap.get(flash.userId), + likedFlashIds: _likedFlashIds, + })), + ); } } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 6b2e4164a1..976780e29a 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -97,6 +97,7 @@ export class MetaEntityService { recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 53c67f278c..6eff6b376b 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -22,6 +22,30 @@ import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; +// is-renote.tsとよしなにリンク +function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } { + return ( + note.renote != null && + note.reply == null && + note.text == null && + note.cw == null && + (note.fileIds == null || note.fileIds.length === 0) && + !note.hasPoll + ); +} + +function getAppearNoteIds(notes: MiNote[]): Set { + const appearNoteIds = new Set(); + for (const note of notes) { + if (isPureRenote(note)) { + appearNoteIds.add(note.renoteId); + } else { + appearNoteIds.add(note.id); + } + } + return appearNoteIds; +} + @Injectable() export class NoteEntityService implements OnModuleInit { private userEntityService: UserEntityService; @@ -93,7 +117,7 @@ export class NoteEntityService implements OnModuleInit { hide = false; } else { // 指定されているかどうか - const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); + const specified = packedNote.visibleUserIds!.some(id => meId === id); if (specified) { hide = false; @@ -241,7 +265,7 @@ export class NoteEntityService implements OnModuleInit { return true; } else { // 指定されているかどうか - return note.visibleUserIds.some((id: any) => meId === id); + return note.visibleUserIds.some(id => meId === id); } } @@ -442,7 +466,7 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; - const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null; + const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; const meId = me ? me.id : null; const myReactionsMap = new Map(); @@ -453,7 +477,7 @@ export class NoteEntityService implements OnModuleInit { const oldId = this.idService.gen(Date.now() - 2000); for (const note of notes) { - if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote + if (isPureRenote(note)) { const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { myReactionsMap.set(note.renote.id, null); diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 3edbe6a6c9..df6abfb465 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -585,11 +585,6 @@ export class UserEntityService implements OnModuleInit { publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964 followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, - twoFactorEnabled: profile!.twoFactorEnabled, - usePasswordLessLogin: profile!.usePasswordLessLogin, - securityKeys: profile!.twoFactorEnabled - ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) - : false, roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, @@ -604,6 +599,14 @@ export class UserEntityService implements OnModuleInit { moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), + ...(isDetailed && (isMe || iAmModerator) ? { + twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, + securityKeys: profile!.twoFactorEnabled + ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) + : false, + } : {}), + ...(isDetailed && isMe ? { avatarId: user.avatarId, bannerId: user.bannerId, diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index ba44cfa2e6..0b7083d743 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -14,7 +14,7 @@ const envOption = { }; for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) { - if (process.env['MK_' + key.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()]) envOption[key] = true; + if (process.env['CP_' + key.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()]) envOption[key] = true; } if (process.env.NODE_ENV === 'test') envOption.disableClustering = true; diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts index 48f821806c..f4bb329d80 100644 --- a/packages/backend/src/misc/is-renote.ts +++ b/packages/backend/src/misc/is-renote.ts @@ -6,6 +6,8 @@ import type { MiNote } from '@/models/Note.js'; import type { Packed } from '@/misc/json-schema.js'; +// NoteEntityService.isPureRenote とよしなにリンク + type Renote = MiNote & { renoteId: NonNullable diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts index 0615fd7eb5..cb5672e4ac 100644 --- a/packages/backend/src/models/AbuseUserReport.ts +++ b/packages/backend/src/models/AbuseUserReport.ts @@ -50,6 +50,9 @@ export class MiAbuseUserReport { }) public resolved: boolean; + /** + * リモートサーバーに転送したかどうか + */ @Column('boolean', { default: false, }) @@ -60,6 +63,21 @@ export class MiAbuseUserReport { }) public comment: string; + @Column('varchar', { + length: 8192, default: '', + }) + public moderationNote: string; + + /** + * accept 是認 ... 通報内容が正当であり、肯定的に対応された + * reject 否認 ... 通報内容が正当でなく、否定的に対応された + * null ... その他 + */ + @Column('varchar', { + length: 128, nullable: true, + }) + public resolvedAs: 'accept' | 'reject' | null; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/Flash.ts b/packages/backend/src/models/Flash.ts index a1469a0d94..5db7dca992 100644 --- a/packages/backend/src/models/Flash.ts +++ b/packages/backend/src/models/Flash.ts @@ -7,6 +7,9 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typ import { id } from './util/id.js'; import { MiUser } from './User.js'; +export const flashVisibility = ['public', 'private'] as const; +export type FlashVisibility = typeof flashVisibility[number]; + @Entity('flash') export class MiFlash { @PrimaryColumn(id()) @@ -63,5 +66,5 @@ export class MiFlash { @Column('varchar', { length: 512, default: 'public', }) - public visibility: 'public' | 'private'; + public visibility: FlashVisibility; } diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 30565a7b3a..c222852eda 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -81,6 +81,11 @@ export class MiMeta { }) public prohibitedWords: string[]; + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public prohibitedWordsForNameOfUser: string[]; + @Column('varchar', { length: 1024, array: true, default: '{}', }) @@ -264,6 +269,11 @@ export class MiMeta { }) public turnstileSecretKey: string | null; + @Column('boolean', { + default: false, + }) + public enableTestcaptcha: boolean; + // chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること @Column('enum', { @@ -633,6 +643,11 @@ export class MiMeta { }) public enableChartsForFederatedInstances: boolean; + @Column('boolean', { + default: true, + }) + public enableStatsForFederatedInstances: boolean; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 5b846cdfb2..1a07a8cf2e 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -93,6 +93,10 @@ export type MiNotification = { createdAt: string; exportedEntity: typeof userExportableEntities[number]; fileId: MiDriveFile['id']; +} | { + type: 'login'; + id: string; + createdAt: string; } | { type: 'app'; id: string; diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts index d6c27eae51..1a7ce4962b 100644 --- a/packages/backend/src/models/SystemWebhook.ts +++ b/packages/backend/src/models/SystemWebhook.ts @@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [ 'abuseReportResolved', // ユーザが作成された時 'userCreated', + // モデレータが一定期間不在である警告 + 'inactiveModeratorsWarning', + // モデレータが一定期間不在のためシステムにより招待制へと変更された + 'inactiveModeratorsInvitationOnlyChanged', ] as const; export type SystemWebhookEventType = typeof systemWebhookEventTypes[number]; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 0942279b52..7bc032fdc7 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -119,6 +119,10 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index ef7a8c76f5..2549cfc8d6 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -322,6 +322,16 @@ export const packedNotificationSchema = { format: 'id', }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['login'], + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 2f61a6a354..89d8943439 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -354,21 +354,6 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: false, enum: ['public', 'followers', 'private'], }, - twoFactorEnabled: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, - usePasswordLessLogin: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, - securityKeys: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, roles: { type: 'array', nullable: false, optional: false, @@ -390,6 +375,18 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'string', nullable: false, optional: true, }, + twoFactorEnabled: { + type: 'boolean', + nullable: false, optional: true, + }, + usePasswordLessLogin: { + type: 'boolean', + nullable: false, optional: true, + }, + securityKeys: { + type: 'boolean', + nullable: false, optional: true, + }, //#region relations isFollowing: { type: 'boolean', @@ -643,6 +640,21 @@ export const packedMeDetailedOnlySchema = { nullable: false, optional: false, ref: 'RolePolicies', }, + twoFactorEnabled: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + usePasswordLessLogin: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + securityKeys: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, //#region secrets email: { type: 'string', diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 7ca8d3a779..4c567685e3 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -6,6 +6,7 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; import { GlobalModule } from '@/GlobalModule.js'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; @@ -84,6 +85,8 @@ import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostP DeliverProcessorService, InboxProcessorService, AggregateRetentionProcessorService, + CheckExpiredMutingsProcessorService, + CheckModeratorsActivityProcessorService, QueueProcessorService, ScheduledNoteDeleteProcessorService, ScheduleNotePostProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 126723557b..0c72f96cb0 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -11,6 +11,7 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -70,7 +71,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string { // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする const currentAttempts = job.attemptsMade + (increment ? 1 : 0); - const maxAttempts = job.opts ? job.opts.attempts : 0; + const maxAttempts = job.opts.attempts ?? 0; return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; } @@ -130,26 +131,37 @@ export class QueueProcessorService implements OnApplicationShutdown { private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, + private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService, private cleanProcessorService: CleanProcessorService, private scheduledNoteDeleteProcessorService: ScheduledNoteDeleteProcessorService, private scheduleNotePostProcessorService: ScheduleNotePostProcessorService, ) { this.logger = this.queueLoggerService.logger; - function renderError(e: Error): any { - if (e) { // 何故かeがundefinedで来ることがある - return { - stack: e.stack, - message: e.message, - name: e.name, - }; - } else { - return { - stack: '?', - message: '?', - name: '?', - }; + function renderError(e?: Error) { + // 何故かeがundefinedで来ることがある + if (!e) return '?'; + + if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') { + return `${e.name}: ${e.message}`; } + + return { + stack: e.stack, + message: e.message, + name: e.name, + }; + } + + function renderJob(job?: Bull.Job) { + if (!job) return '?'; + + return { + name: job.name || undefined, + info: getJobInfo(job), + failedReason: job.failedReason || undefined, + data: job.data, + }; } //#region system @@ -162,6 +174,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); + case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); default: throw new Error(`unrecognized job type ${job.name} for system`); } @@ -184,15 +197,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -242,15 +255,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -282,15 +295,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: Deliver: ${err.message}`, { + Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -322,15 +335,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: Inbox: ${err.message}`, { + Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -362,15 +375,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, { + Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -402,15 +415,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, { + Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -449,15 +462,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -490,15 +503,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts new file mode 100644 index 0000000000..d3dd354c7f --- /dev/null +++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -0,0 +1,307 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { MiUser, type UserProfilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; + +// モデレーターが不在と判断する日付の閾値 +const MODERATOR_INACTIVITY_LIMIT_DAYS = 7; +// 警告通知やログ出力を行う残日数の閾値 +const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2; +// 期限から6時間ごとに通知を行う +const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6; +const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60; +const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24; + +export type ModeratorInactivityEvaluationResult = { + isModeratorsInactive: boolean; + inactiveModerators: MiUser[]; + remainingTime: ModeratorInactivityRemainingTime; +} + +export type ModeratorInactivityRemainingTime = { + time: number; + asHours: number; + asDays: number; +}; + +function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) { + const subject = 'Moderator Inactivity Warning / モデレーター不在の通知'; + + const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`; + const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`; + const timeVariantKo = remainingTime.asDays === 0 ? `${remainingTime.asHours} 시간` : `${remainingTime.asDays} 일간`; + const message = [ + 'To Moderators,', + '', + `A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`, + 'If you do not wish to move to invitation only, you must log into CherryPick and update your last active date and time.', + '', + '---------------', + '', + 'To モデレーター各位', + '', + `モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`, + '招待制に切り替わることを望まない場合は、CherryPickにログインして最終アクティブ日時を更新してください。', + '', + '---------------', + '', + 'To 모더레이터 여러분께', + '', + `モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。모더레이터가 일정 기간 동안 활동이 없는 것으로 추정돼요. 앞으로 ${timeVariantKo} 비활성 상태가 지속되면 자동으로 초대제로 전환돼요.`, + '초대제로 전환을 원하지 않는 경우, CherryPick에 로그인하여 마지막 활성 날짜를 업데이트해 주세요.', + '', + ]; + + const html = message.join('
'); + const text = message.join('\n'); + + return { + subject, + html, + text, + }; +} + +function generateInvitationOnlyChangedMail() { + const subject = 'Change to Invitation-Only / 招待制に変更されました'; + + const message = [ + 'To Moderators,', + '', + `Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`, + 'To cancel the invitation only, you need to access the control panel.', + '', + '---------------', + '', + 'To モデレーター各位', + '', + `モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`, + '招待制を解除するには、コントロールパネルにアクセスする必要があります。', + '', + '---------------', + '', + 'To 모더레이터 여러분께', + '', + `모더레이터가 ${MODERATOR_INACTIVITY_LIMIT_DAYS}일간 활동이 확인되지 않아 초대제로 변경되었어요.`, + '초대제를 해제하려면 `제어판 - 모더레이션`에 접속해서 변경해야 해요.', + '', + ]; + + const html = message.join('
'); + const text = message.join('\n'); + + return { + subject, + html, + text, + }; +} + +@Injectable() +export class CheckModeratorsActivityProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private metaService: MetaService, + private roleService: RoleService, + private emailService: EmailService, + private announcementService: AnnouncementService, + private systemWebhookService: SystemWebhookService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity'); + } + + @bindThis + public async process(): Promise { + this.logger.info('start.'); + + const meta = await this.metaService.fetch(false); + if (!meta.disableRegistration) { + await this.processImpl(); + } else { + this.logger.info('is already invitation only.'); + } + + this.logger.succ('finish.'); + } + + @bindThis + private async processImpl() { + const evaluateResult = await this.evaluateModeratorsInactiveDays(); + if (evaluateResult.isModeratorsInactive) { + this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`); + + await this.changeToInvitationOnly(); + await this.notifyChangeToInvitationOnly(); + } else { + const remainingTime = evaluateResult.remainingTime; + if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) { + const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`; + this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`); + + if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) { + // ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する + // つまり、のこり2日を切ったら6時間ごとに通知が送られる + await this.notifyInactiveModeratorsWarning(remainingTime); + } + } + } + } + + /** + * モデレーターが不在であるかどうかを確認する。trueの場合はモデレーターが不在である。 + * isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に、 + * {@link MiUser.lastActiveDate}の値が実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前よりも古いユーザがいるかどうかを確認する。 + * {@link MiUser.lastActiveDate}がnullの場合は、そのユーザは確認の対象外とする。 + * + * ----- + * + * ### サンプルパターン + * - 実行日時: 2022-01-30 12:00:00 + * - 判定基準: 2022-01-23 12:00:00(実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前) + * + * #### パターン① + * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト + * - モデレータB: lastActiveDate = 2022-01-23 12:00:00 ※セーフ(判定基準と同値なのでギリギリ残り0日) + * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日) + * - モデレータD: lastActiveDate = null + * + * この場合、モデレータBのアクティビティのみ判定基準日よりも古くないため、モデレーターが在席と判断される。 + * + * #### パターン② + * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト + * - モデレータB: lastActiveDate = 2022-01-22 12:00:00 ※アウト(残り-1日) + * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日) + * - モデレータD: lastActiveDate = null + * + * この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。 + */ + @bindThis + public async evaluateModeratorsInactiveDays(): Promise { + const today = new Date(); + const inactivePeriod = new Date(today); + inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS); + + const moderators = await this.fetchModerators() + .then(it => it.filter(it => it.lastActiveDate != null)); + const inactiveModerators = moderators + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .filter(it => it.lastActiveDate!.getTime() < inactivePeriod.getTime()); + + // 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime()))); + const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime(); + const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC); + const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC)); + + return { + isModeratorsInactive: inactiveModerators.length === moderators.length, + inactiveModerators, + remainingTime: { + time: remainingTime, + asHours: remainingTimeAsHours, + asDays: remainingTimeAsDays, + }, + }; + } + + @bindThis + private async changeToInvitationOnly() { + await this.metaService.update({ disableRegistration: true }); + } + + @bindThis + public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) { + // -- モデレータへのメール送信 + + const moderators = await this.fetchModerators(); + const moderatorProfiles = await this.userProfilesRepository + .findBy({ userId: In(moderators.map(it => it.id)) }) + .then(it => new Map(it.map(it => [it.userId, it]))); + + const mail = generateModeratorInactivityMail(remainingTime); + for (const moderator of moderators) { + const profile = moderatorProfiles.get(moderator.id); + if (profile && profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text); + } + } + + // -- SystemWebhook + + const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks() + .then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning'))); + for (const systemWebhook of systemWebhooks) { + this.systemWebhookService.enqueueSystemWebhook( + systemWebhook, + 'inactiveModeratorsWarning', + { remainingTime: remainingTime }, + ); + } + } + + @bindThis + public async notifyChangeToInvitationOnly() { + // -- モデレータへのメールとお知らせ(個人向け)送信 + + const moderators = await this.fetchModerators(); + const moderatorProfiles = await this.userProfilesRepository + .findBy({ userId: In(moderators.map(it => it.id)) }) + .then(it => new Map(it.map(it => [it.userId, it]))); + + const mail = generateInvitationOnlyChangedMail(); + for (const moderator of moderators) { + this.announcementService.create({ + title: mail.subject, + text: mail.text, + forExistingUsers: true, + needConfirmationToRead: true, + userId: moderator.id, + }); + + const profile = moderatorProfiles.get(moderator.id); + if (profile && profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text); + } + } + + // -- SystemWebhook + + const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks() + .then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged'))); + for (const systemWebhook of systemWebhooks) { + this.systemWebhookService.enqueueSystemWebhook( + systemWebhook, + 'inactiveModeratorsInvitationOnlyChanged', + {}, + ); + } + } + + @bindThis + private async fetchModerators() { + // TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する + return this.roleService.getModerators({ + includeAdmins: true, + includeRoot: true, + excludeExpire: true, + }); + } +} diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 9590a4fe71..5a16496011 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -74,8 +74,17 @@ export class DeliverProcessorService { try { await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest); - // Update stats - this.federatedInstanceService.fetch(host).then(i => { + this.apRequestChart.deliverSucc(); + this.federationChart.deliverd(host, true); + + // Update instance stats + process.nextTick(async () => { + const i = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(host) + : this.federatedInstanceService.fetch(host)); + + if (i == null) return; + if (i.isNotResponding) { this.federatedInstanceService.update(i.id, { isNotResponding: false, @@ -83,9 +92,9 @@ export class DeliverProcessorService { }); } - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - this.apRequestChart.deliverSucc(); - this.federationChart.deliverd(i.host, true); + if (this.meta.enableStatsForFederatedInstances) { + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + } if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestSent(i.host, true); @@ -94,8 +103,11 @@ export class DeliverProcessorService { return 'Success'; } catch (res) { - // Update stats - this.federatedInstanceService.fetch(host).then(i => { + this.apRequestChart.deliverFail(); + this.federationChart.deliverd(host, false); + + // Update instance stats + this.federatedInstanceService.fetchOrRegister(host).then(i => { if (!i.isNotResponding) { this.federatedInstanceService.update(i.id, { isNotResponding: true, @@ -116,9 +128,6 @@ export class DeliverProcessorService { }); } - this.apRequestChart.deliverFail(); - this.federationChart.deliverd(i.host, false); - if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestSent(i.host, false); } @@ -129,7 +138,7 @@ export class DeliverProcessorService { if (!res.isRetryable) { // 相手が閉鎖していることを明示しているため、配送停止する if (job.data.isSharedInbox && res.statusCode === 410) { - this.federatedInstanceService.fetch(host).then(i => { + this.federatedInstanceService.fetchOrRegister(host).then(i => { this.federatedInstanceService.update(i.id, { suspensionState: 'goneSuspended', }); diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 09d51bec72..95d764e4d8 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -59,7 +59,7 @@ export class InboxProcessorService implements OnApplicationShutdown { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); - this.updateInstanceQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseUpdateInstanceJobs, this.performUpdateInstance); + this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance); } @bindThis @@ -192,21 +192,27 @@ export class InboxProcessorService implements OnApplicationShutdown { } } - // Update stats - this.federatedInstanceService.fetch(authUser.user.host).then(i => { + this.apRequestChart.inbox(); + this.federationChart.inbox(authUser.user.host); + + // Update instance stats + process.nextTick(async () => { + const i = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(authUser.user.host) + : this.federatedInstanceService.fetch(authUser.user.host)); + + if (i == null) return; + this.updateInstanceQueue.enqueue(i.id, { latestRequestReceivedAt: new Date(), shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding', }); - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - - this.apRequestChart.inbox(); - this.federationChart.inbox(i.host); - if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestReceived(i.host); } + + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); }); // アクティビティを処理 diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 709a044601..3a8cb19f01 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -118,25 +118,29 @@ export class ApiServerService { 'hcaptcha-response'?: string; 'g-recaptcha-response'?: string; 'turnstile-response'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); fastify.post<{ Body: { username: string; - password: string; + password?: string; token?: string; - signature?: string; - authenticatorData?: string; - clientDataJSON?: string; - credentialId?: string; - challengeId?: string; + credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; - }>('/signin', (request, reply) => this.signinApiService.signin(request, reply)); + }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); fastify.post<{ Body: { credential?: AuthenticationResponseJSON; + context?: string; }; }>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply)); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 4643153d39..a87acc267f 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -76,6 +76,8 @@ import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; +import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js'; +import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js'; import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; @@ -495,6 +497,8 @@ const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default }; const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default }; const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default }; +const $admin_forwardAbuseUserReport: Provider = { provide: 'ep:admin/forward-abuse-user-report', useClass: ep___admin_forwardAbuseUserReport.default }; +const $admin_updateAbuseUserReport: Provider = { provide: 'ep:admin/update-abuse-user-report', useClass: ep___admin_updateAbuseUserReport.default }; const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default }; const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default }; const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default }; @@ -919,6 +923,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_relays_remove, $admin_resetPassword, $admin_resolveAbuseUserReport, + $admin_forwardAbuseUserReport, + $admin_updateAbuseUserReport, $admin_sendEmail, $admin_serverInfo, $admin_showModerationLogs, @@ -1336,6 +1342,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_relays_remove, $admin_resetPassword, $admin_resolveAbuseUserReport, + $admin_forwardAbuseUserReport, + $admin_updateAbuseUserReport, $admin_sendEmail, $admin_serverInfo, $admin_showModerationLogs, diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 1554510697..cb766eef4e 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -6,12 +6,14 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; -import * as OTPAuth from 'otpauth'; import { IsNull } from 'typeorm'; +import * as Misskey from 'cherrypick-js'; import { DI } from '@/di-symbols.js'; import type { + MiMeta, SigninsRepository, UserProfilesRepository, + UserSecurityKeysRepository, UsersRepository, } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -21,6 +23,8 @@ import { IdService } from '@/core/IdService.js'; import { bindThis } from '@/decorators.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { UserAuthService } from '@/core/UserAuthService.js'; +import { CaptchaService } from '@/core/CaptchaService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; @@ -32,12 +36,18 @@ export class SigninApiService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, @@ -46,6 +56,7 @@ export class SigninApiService { private signinService: SigninService, private userAuthService: UserAuthService, private webAuthnService: WebAuthnService, + private captchaService: CaptchaService, ) { } @@ -54,9 +65,14 @@ export class SigninApiService { request: FastifyRequest<{ Body: { username: string; - password: string; + password?: string; token?: string; credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>, reply: FastifyReply, @@ -93,11 +109,6 @@ export class SigninApiService { return; } - if (typeof password !== 'string') { - reply.code(400); - return; - } - if (token != null && typeof token !== 'string') { reply.code(400); return; @@ -122,11 +133,32 @@ export class SigninApiService { } const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1); + + if (password == null) { + reply.code(200); + if (profile.twoFactorEnabled) { + return { + finished: false, + next: 'password', + } satisfies Misskey.entities.SigninFlowResponse; + } else { + return { + finished: false, + next: 'captcha', + } satisfies Misskey.entities.SigninFlowResponse; + } + } + + if (typeof password !== 'string') { + reply.code(400); + return; + } // Compare password const same = await argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!); - const fail = async (status?: number, failure?: { id: string }) => { + const fail = async (status?: number, failure?: { id: string; }) => { // Append signin history await this.signinsRepository.insert({ id: this.idService.gen(), @@ -140,6 +172,38 @@ export class SigninApiService { }; if (!profile.twoFactorEnabled) { + if (process.env.NODE_ENV !== 'test') { + if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { + await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { + await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { + await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { + await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + } + if (same) { if (profile.password!.startsWith('$2')) { const newHash = await argon2.hash(password); @@ -193,7 +257,7 @@ export class SigninApiService { id: '93b86c4b-72f9-40eb-9815-798928603d1e', }); } - } else { + } else if (securityKeysAvailable) { if (!same && !profile.usePasswordLessLogin) { return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', @@ -203,7 +267,23 @@ export class SigninApiService { const authRequest = await this.webAuthnService.initiateAuthentication(user.id); reply.code(200); - return authRequest; + return { + finished: false, + next: 'passkey', + authRequest, + } satisfies Misskey.entities.SigninFlowResponse; + } else { + if (!same || !profile.twoFactorEnabled) { + return await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + } else { + reply.code(200); + return { + finished: false, + next: 'totp', + } satisfies Misskey.entities.SigninFlowResponse; + } } // never get here } diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index 70306c3113..c2e5e20092 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -4,13 +4,16 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import * as Misskey from 'cherrypick-js'; import { DI } from '@/di-symbols.js'; -import type { SigninsRepository } from '@/models/_.js'; +import type { SigninsRepository, UserProfilesRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import type { MiLocalUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; import { bindThis } from '@/decorators.js'; +import { EmailService } from '@/core/EmailService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() @@ -19,7 +22,12 @@ export class SigninService { @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private signinEntityService: SigninEntityService, + private emailService: EmailService, + private notificationService: NotificationService, private idService: IdService, private globalEventService: GlobalEventService, ) { @@ -28,7 +36,8 @@ export class SigninService { @bindThis public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) { setImmediate(async () => { - // Append signin history + this.notificationService.createNotification(user.id, 'login', {}); + const record = await this.signinsRepository.insertOne({ id: this.idService.gen(), userId: user.id, @@ -37,15 +46,22 @@ export class SigninService { success: true, }); - // Publish signin event this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + if (profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, 'New login / ログインがありました / 로그인 알림', + 'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。/ 새 기기에서 로그인을 시도했어요. 이 로그인이 신뢰할 수 있는 기기에서 한 것이 아니라면, 즉시 비밀번호를 변경하는 등의 조치를 통해 계정의 보안 상태를 업데이트해야 해요.', + 'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。/ 새 기기에서 로그인을 시도했어요. 이 로그인이 신뢰할 수 있는 기기에서 한 것이 아니라면, 즉시 비밀번호를 변경하는 등의 조치를 통해 계정의 보안 상태를 업데이트해야 해요.'); + } }); reply.code(200); return { + finished: true, id: user.id, - i: user.token, - }; + i: user.token!, + } satisfies Misskey.entities.SigninFlowResponse; } } diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index f99e2761a3..250daf9ed9 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -68,6 +68,7 @@ export class SignupApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>, reply: FastifyReply, @@ -100,6 +101,12 @@ export class SignupApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } } const username = body['username']; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index f02da4145b..1d28596cd5 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -81,6 +81,8 @@ import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; +import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js'; +import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js'; import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; @@ -498,6 +500,8 @@ const eps = [ ['admin/relays/remove', ep___admin_relays_remove], ['admin/reset-password', ep___admin_resetPassword], ['admin/resolve-abuse-user-report', ep___admin_resolveAbuseUserReport], + ['admin/forward-abuse-user-report', ep___admin_forwardAbuseUserReport], + ['admin/update-abuse-user-report', ep___admin_updateAbuseUserReport], ['admin/send-email', ep___admin_sendEmail], ['admin/server-info', ep___admin_serverInfo], ['admin/show-moderation-logs', ep___admin_showModerationLogs], diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts index cf3f257ca6..0dbfaae054 100644 --- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -71,9 +71,22 @@ export const meta = { }, assignee: { type: 'object', - nullable: true, optional: true, + nullable: true, optional: false, ref: 'UserDetailedNotMe', }, + forwarded: { + type: 'boolean', + nullable: false, optional: false, + }, + resolvedAs: { + type: 'string', + nullable: true, optional: false, + enum: ['accept', 'reject', null], + }, + moderationNote: { + type: 'string', + nullable: false, optional: false, + }, }, }, }, @@ -88,7 +101,6 @@ export const paramDef = { state: { type: 'string', nullable: true, default: null }, reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, - forwarded: { type: 'boolean', default: false }, }, required: [], } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index a7e8a3b018..d30131a62f 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -12,11 +12,27 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; import { localUsernameSchema, passwordSchema } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { ApiError } from '@/server/api/error.js'; import { Packed } from '@/misc/json-schema.js'; export const meta = { tags: ['admin'], + errors: { + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '1fb7cb09-d46a-4fff-b8df-057708cce513', + }, + + wrongInitialPassword: { + message: 'Initial password is incorrect.', + code: 'INCORRECT_INITIAL_PASSWORD', + id: '97147c55-1ae1-4f6f-91d6-e1c3e0e76d62', + }, + }, + res: { type: 'object', optional: false, nullable: false, @@ -35,6 +51,7 @@ export const paramDef = { properties: { username: localUsernameSchema, password: passwordSchema, + setupPassword: { type: 'string', nullable: true }, }, required: ['username', 'password'], } as const; @@ -42,6 +59,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -52,7 +72,23 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, _me, token) => { const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; const realUsers = await this.instanceActorService.realLocalUsersPresent(); - if ((realUsers && !me?.isRoot) || token !== null) throw new Error('access denied'); + + if (!realUsers && me == null && token == null) { + // 初回セットアップの場合 + if (this.config.setupPassword != null) { + // 初期パスワードが設定されている場合 + if (ps.setupPassword !== this.config.setupPassword) { + // 初期パスワードが違う場合 + throw new ApiError(meta.errors.wrongInitialPassword); + } + } else if (ps.setupPassword != null && ps.setupPassword.trim() !== '') { + // 初期パスワードが設定されていないのに初期パスワードが入力された場合 + throw new ApiError(meta.errors.wrongInitialPassword); + } + } else if ((realUsers && !me?.isRoot) || token !== null) { + // 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合 + throw new ApiError(meta.errors.accessDenied); + } const { account, secret } = await this.signupService.signup({ username: ps.username, diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 22609a16a3..212cba5c5d 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, MiEmoji } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -78,25 +78,14 @@ export default class extends Endpoint { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); } - let emojiId; - if (ps.id) { - emojiId = ps.id; - const emoji = await this.customEmojiService.getEmojiById(ps.id); - if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); - if (ps.name && (ps.name !== emoji.name)) { - const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); - if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); - } - } else { - if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.'); - const emoji = await this.customEmojiService.getEmojiByName(ps.name); - if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); - emojiId = emoji.id; - } + // JSON schemeのanyOfの型変換がうまくいっていないらしい + const required = { id: ps.id, name: ps.name } as + | { id: MiEmoji['id']; name?: string } + | { id?: MiEmoji['id']; name: string }; - await this.customEmojiService.update(emojiId, { + const error = await this.customEmojiService.update({ + ...required, driveFile, - name: ps.name, category: ps.category, aliases: ps.aliases, license: ps.license, @@ -104,6 +93,14 @@ export default class extends Endpoint { // eslint- localOnly: ps.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, }, me); + + switch (error) { + case null: return; + case 'NO_SUCH_EMOJI': throw new ApiError(meta.errors.noSuchEmoji); + case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists); + } + // 網羅性チェック + const mustBeNever: never = error; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts new file mode 100644 index 0000000000..3e42c91fed --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AbuseUserReportsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:resolve-abuse-user-report', + + errors: { + noSuchAbuseReport: { + message: 'No such abuse report.', + code: 'NO_SUCH_ABUSE_REPORT', + id: '8763e21b-d9bc-40be-acf6-54c1a6986493', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + reportId: { type: 'string', format: 'misskey:id' }, + }, + required: ['reportId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + private abuseReportService: AbuseReportService, + ) { + super(meta, paramDef, async (ps, me) => { + const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); + if (!report) { + throw new ApiError(meta.errors.noSuchAbuseReport); + } + + await this.abuseReportService.forward(report.id, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 0fd911917b..1de2ecdf22 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -69,6 +69,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -181,6 +185,13 @@ export const meta = { type: 'string', }, }, + prohibitedWordsForNameOfUser: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, bannedEmailDomains: { type: 'array', optional: true, nullable: false, @@ -393,6 +404,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + enableStatsForFederatedInstances: { + type: 'boolean', + optional: false, nullable: false, + }, enableServerMachineStats: { type: 'boolean', optional: false, nullable: false, @@ -651,6 +666,7 @@ export default class extends Endpoint { // eslint- recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, @@ -682,6 +698,7 @@ export default class extends Endpoint { // eslint- mediaSilencedHosts: instance.mediaSilencedHosts, sensitiveWords: instance.sensitiveWords, prohibitedWords: instance.prohibitedWords, + prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser, preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, mcaptchaSecretKey: instance.mcaptchaSecretKey, @@ -741,6 +758,7 @@ export default class extends Endpoint { // eslint- truemailAuthKey: instance.truemailAuthKey, enableChartsForRemoteUser: instance.enableChartsForRemoteUser, enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, + enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances, enableServerMachineStats: instance.enableServerMachineStats, enableIdenticonGeneration: instance.enableIdenticonGeneration, bannedEmailDomains: instance.bannedEmailDomains, diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts index 9b79100fcf..554d324ff2 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -32,7 +32,7 @@ export const paramDef = { type: 'object', properties: { reportId: { type: 'string', format: 'misskey:id' }, - forward: { type: 'boolean', default: false }, + resolvedAs: { type: 'string', enum: ['accept', 'reject', null], nullable: true }, }, required: ['reportId'], } as const; @@ -50,7 +50,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchAbuseReport); } - await this.abuseReportService.resolve([{ reportId: report.id, forward: ps.forward }], me); + await this.abuseReportService.resolve([{ reportId: report.id, resolvedAs: ps.resolvedAs ?? null }], me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 2fef9abbf9..2b2c8c60ab 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -71,13 +71,13 @@ export default class extends Endpoint { // eslint- break; } case 'moderator': { - const moderatorIds = await this.roleService.getModeratorIds(false); + const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false }); if (moderatorIds.length === 0) return []; query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds }); break; } case 'adminOrModerator': { - const adminOrModeratorIds = await this.roleService.getModeratorIds(); + const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true }); if (adminOrModeratorIds.length === 0) return []; query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds }); break; diff --git a/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts new file mode 100644 index 0000000000..73d4b843f0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AbuseUserReportsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:resolve-abuse-user-report', + + errors: { + noSuchAbuseReport: { + message: 'No such abuse report.', + code: 'NO_SUCH_ABUSE_REPORT', + id: '15f51cf5-46d1-4b1d-a618-b35bcbed0662', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + reportId: { type: 'string', format: 'misskey:id' }, + moderationNote: { type: 'string' }, + }, + required: ['reportId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + private abuseReportService: AbuseReportService, + ) { + super(meta, paramDef, async (ps, me) => { + const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); + if (!report) { + throw new ApiError(meta.errors.noSuchAbuseReport); + } + + await this.abuseReportService.update(report.id, { + moderationNote: ps.moderationNote, + }, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 264551bdd3..da27d27f11 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -48,6 +48,11 @@ export const paramDef = { type: 'string', }, }, + prohibitedWordsForNameOfUser: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, @@ -81,6 +86,7 @@ export const paramDef = { enableTurnstile: { type: 'boolean' }, turnstileSiteKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true }, + enableTestcaptcha: { type: 'boolean' }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, @@ -152,6 +158,7 @@ export const paramDef = { truemailAuthKey: { type: 'string', nullable: true }, enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' }, + enableStatsForFederatedInstances: { type: 'boolean' }, enableServerMachineStats: { type: 'boolean' }, enableIdenticonGeneration: { type: 'boolean' }, serverRules: { type: 'array', items: { type: 'string' } }, @@ -251,6 +258,9 @@ export default class extends Endpoint { // eslint- if (Array.isArray(ps.prohibitedWords)) { set.prohibitedWords = ps.prohibitedWords.filter(Boolean); } + if (Array.isArray(ps.prohibitedWordsForNameOfUser)) { + set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean); + } if (Array.isArray(ps.silencedHosts)) { let lastValue = ''; set.silencedHosts = ps.silencedHosts.sort().filter((h) => { @@ -399,6 +409,10 @@ export default class extends Endpoint { // eslint- set.turnstileSecretKey = ps.turnstileSecretKey; } + if (ps.enableTestcaptcha !== undefined) { + set.enableTestcaptcha = ps.enableTestcaptcha; + } + if (ps.sensitiveMediaDetection !== undefined) { set.sensitiveMediaDetection = ps.sensitiveMediaDetection; } @@ -687,6 +701,10 @@ export default class extends Endpoint { // eslint- set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; } + if (ps.enableStatsForFederatedInstances !== undefined) { + set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances; + } + if (ps.enableServerMachineStats !== undefined) { set.enableServerMachineStats = ps.enableServerMachineStats; } @@ -774,7 +792,7 @@ export default class extends Endpoint { // eslint- } if (Array.isArray(ps.federationHosts)) { - set.blockedHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); + set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } if (ps.doNotSendNotificationEmailsForAbuseReport !== undefined) { diff --git a/packages/backend/src/server/api/endpoints/flash/featured.ts b/packages/backend/src/server/api/endpoints/flash/featured.ts index c2d6ab5085..9a0cb461f2 100644 --- a/packages/backend/src/server/api/endpoints/flash/featured.ts +++ b/packages/backend/src/server/api/endpoints/flash/featured.ts @@ -8,6 +8,7 @@ import type { FlashsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { DI } from '@/di-symbols.js'; +import { FlashService } from '@/core/FlashService.js'; export const meta = { tags: ['flash'], @@ -27,26 +28,25 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, + properties: { + offset: { type: 'integer', minimum: 0, default: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, required: [], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - + private flashService: FlashService, private flashEntityService: FlashEntityService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.flashsRepository.createQueryBuilder('flash') - .andWhere('flash.likedCount > 0') - .orderBy('flash.likedCount', 'DESC'); - - const flashs = await query.limit(10).getMany(); - - return await this.flashEntityService.packMany(flashs, me); + const result = await this.flashService.featured({ + offset: ps.offset, + limit: ps.limit, + }); + return await this.flashEntityService.packMany(result, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 6703fcdfba..17c8d40cb2 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; -import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js'; +import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; @@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RolePolicies, RoleService } from '@/core/RoleService.js'; @@ -114,6 +115,13 @@ export const meta = { code: 'RESTRICTED_BY_ROLE', id: '8feff0ba-5ab5-585b-31f4-4df816663fad', }, + + nameContainsProhibitedWords: { + message: 'Your new name contains prohibited words.', + code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS', + id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191', + httpStatusCode: 422, + }, }, res: { @@ -226,6 +234,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private instanceMeta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -250,6 +261,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private httpRequestService: HttpRequestService, private avatarDecorationService: AvatarDecorationService, + private utilityService: UtilityService, ) { super(meta, paramDef, async (ps, _user, token, flashToken) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; @@ -454,6 +466,14 @@ export default class extends Endpoint { // eslint- const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields; if (newName != null) { + let hasProhibitedWords = false; + if (!await this.roleService.isModerator(user)) { + hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser); + } + if (hasProhibitedWords) { + throw new ApiError(meta.errors.nameContainsProhibitedWords); + } + const tokens = mfm.parseSimple(newName); emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); } diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js index e008080e7d..c5bc2c0020 100644 --- a/packages/backend/src/server/web/boot.embed.js +++ b/packages/backend/src/server/web/boot.embed.js @@ -156,7 +156,7 @@ box-sizing: border-box; overflow: hidden; - border-radius: var(--radius, 12px); + border-radius: var(--MI-radius, 12px); border: 1px solid rgba(231, 255, 251, 0.14); } @@ -168,7 +168,7 @@ width: 100%; height: 100%; background: #192320; - border-radius: var(--radius, 12px); + border-radius: var(--MI-radius, 12px); z-index: -1; } diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 3fcf28ce58..07ac89ced8 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -103,7 +103,7 @@ const theme = localStorage.getItem('theme'); if (theme) { for (const [k, v] of Object.entries(JSON.parse(theme))) { - document.documentElement.style.setProperty(`--${k}`, v.toString()); + document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); // HTMLの theme-color 適用 if (k === 'htmlThemeColor') { diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index 179578c8a0..3e6b6473b4 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -5,8 +5,8 @@ */ html { - background-color: var(--bg); - color: var(--fg); + background-color: var(--MI_THEME-bg); + color: var(--MI_THEME-fg); } #splash { @@ -17,7 +17,7 @@ html { width: 100vw; height: 100vh; cursor: wait; - background-color: var(--bg); + background-color: var(--MI_THEME-bg); opacity: 1; transition: opacity 0.5s ease; } @@ -57,7 +57,7 @@ html { width: 28px; height: 28px; transform: translateY(80px); - color: var(--accent); + color: var(--MI_THEME-accent); } #splashSpinner > .spinner { @@ -69,7 +69,7 @@ html { animation: splashSpinner 2s linear infinite; } #splashSpinner > .spinner > .path { - stroke: var(--accent); + stroke: var(--MI_THEME-accent); stroke-linecap: round; animation: dash 1.2s ease-in-out infinite; } diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css index 8e9726998b..0dabc0324e 100644 --- a/packages/backend/src/server/web/style.embed.css +++ b/packages/backend/src/server/web/style.embed.css @@ -5,8 +5,8 @@ */ html { - background-color: var(--bg); - color: var(--fg); + background-color: var(--MI_THEME-bg); + color: var(--MI_THEME-fg); } html.embed { @@ -24,7 +24,7 @@ html.embed { width: 100vw; height: 100vh; cursor: wait; - background-color: var(--bg); + background-color: var(--MI_THEME-bg); opacity: 1; transition: opacity 0.5s ease; } @@ -32,8 +32,8 @@ html.embed { html.embed #splash { box-sizing: border-box; min-height: 300px; - border-radius: var(--radius, 12px); - border: 1px solid var(--divider, #e8e8e8); + border-radius: var(--MI-radius, 12px); + border: 1px solid var(--MI_THEME-divider, #e8e8e8); } html.embed.norounded #splash { @@ -68,7 +68,7 @@ html.embed.noborder #splash { width: 28px; height: 28px; transform: translateY(70px); - color: var(--accent); + color: var(--MI_THEME-accent); } #splashSpinner > .spinner { @@ -80,7 +80,7 @@ html.embed.noborder #splash { animation: splashSpinner 2s linear infinite; } #splashSpinner > .spinner > .path { - stroke: var(--accent); + stroke: var(--MI_THEME-accent); stroke-linecap: round; animation: dash 1.2s ease-in-out infinite; } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index e7c9e3b3ad..65d50bcf6d 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -18,6 +18,7 @@ * roleAssigned - ロールが付与された * achievementEarned - 実績を獲得 * exportCompleted - エクスポートが完了 + * login - ログイン * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -36,6 +37,7 @@ export const notificationTypes = [ 'roleAssigned', 'achievementEarned', 'exportCompleted', + 'login', 'app', 'test', ] as const; @@ -100,6 +102,8 @@ export const moderationLogTypes = [ 'markSensitiveDriveFile', 'unmarkSensitiveDriveFile', 'resolveAbuseReport', + 'forwardAbuseReport', + 'updateAbuseReportNote', 'createInvitation', 'createAd', 'updateAd', @@ -268,7 +272,18 @@ export type ModerationLogPayloads = { resolveAbuseReport: { reportId: string; report: any; - forwarded: boolean; + forwarded?: boolean; + resolvedAs?: string | null; + }; + forwardAbuseReport: { + reportId: string; + report: any; + }; + updateAbuseReportNote: { + reportId: string; + report: any; + before: string; + after: string; }; createInvitation: { invitations: any[]; diff --git a/packages/backend/test-federation/.config/example.conf b/packages/backend/test-federation/.config/example.conf new file mode 100644 index 0000000000..4dc9a7a41d --- /dev/null +++ b/packages/backend/test-federation/.config/example.conf @@ -0,0 +1,70 @@ +# based on https://github.com/misskey-dev/misskey-hub/blob/7071f63a1c80ee35c71f0fd8a6d8722c118c7574/src/docs/admin/nginx.md + +# For WebSocket +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off; + +server { + listen 80; + listen [::]:80; + server_name ${HOST}; + + # For SSL domain validation + root /var/www/html; + location /.well-known/acme-challenge/ { allow all; } + location /.well-known/pki-validation/ { allow all; } + location / { return 301 https://$server_name$request_uri; } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name ${HOST}; + + ssl_session_timeout 1d; + ssl_session_cache shared:ssl_session_cache:10m; + ssl_session_tickets off; + + ssl_trusted_certificate /etc/nginx/certificates/rootCA.crt; + ssl_certificate /etc/nginx/certificates/$server_name.crt; + ssl_certificate_key /etc/nginx/certificates/$server_name.key; + + # SSL protocol settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_stapling on; + ssl_stapling_verify on; + + # Change to your upload limit + client_max_body_size 80m; + + # Proxy to Node + location / { + proxy_pass http://cherrypick.${HOST}:3000; + proxy_set_header Host $host; + proxy_http_version 1.1; + proxy_redirect off; + + # If it's behind another reverse proxy or CDN, remove the following. + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # For WebSocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Cache settings + proxy_cache cache1; + proxy_cache_lock on; + proxy_cache_use_stale updating; + proxy_force_ranges on; + add_header X-Cache $upstream_cache_status; + } +} diff --git a/packages/backend/test-federation/.config/example.default.yml b/packages/backend/test-federation/.config/example.default.yml new file mode 100644 index 0000000000..b0a091b39a --- /dev/null +++ b/packages/backend/test-federation/.config/example.default.yml @@ -0,0 +1,25 @@ +url: https://${HOST}/ +port: 3000 +db: + host: db.${HOST} + port: 5432 + db: cherrypick + user: postgres + pass: postgres +dbReplications: false +redis: + host: redis.test + port: 6379 +id: 'aidx' +proxyBypassHosts: + - api.deepl.com + - api-free.deepl.com + - www.recaptcha.net + - hcaptcha.com + - challenges.cloudflare.com +proxyRemoteFiles: true +signToActivityPubGet: true +allowedPrivateNetworks: [ + '127.0.0.1/32', + '172.20.0.0/16' +] diff --git a/packages/backend/test-federation/.config/example.docker.env b/packages/backend/test-federation/.config/example.docker.env new file mode 100644 index 0000000000..11fb7f2d03 --- /dev/null +++ b/packages/backend/test-federation/.config/example.docker.env @@ -0,0 +1,5 @@ +NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt +POSTGRES_DB=cherrypick +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +CP_VERBOSE=true diff --git a/packages/backend/test-federation/.gitignore b/packages/backend/test-federation/.gitignore new file mode 100644 index 0000000000..e00f952cb5 --- /dev/null +++ b/packages/backend/test-federation/.gitignore @@ -0,0 +1,6 @@ +certificates +volumes +.env +docker.env +*.test.conf +*.test.default.yml diff --git a/packages/backend/test-federation/README.md b/packages/backend/test-federation/README.md new file mode 100644 index 0000000000..0fab0b2572 --- /dev/null +++ b/packages/backend/test-federation/README.md @@ -0,0 +1,24 @@ +## test-federation +Test federation between two CherryPick servers: `a.test` and `b.test`. + +Before testing, you need to build the entire project, and change working directory to here: +```sh +pnpm build +cd packages/backend/test-federation +``` + +First, you need to start servers by executing following commands: +```sh +bash ./setup.sh +docker compose up --scale tester=0 +``` + +Then you can run all tests by a following command: +```sh +docker compose run --no-deps --rm tester +``` + +For testing a specific file, run a following command: +```sh +docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts +``` diff --git a/packages/backend/test-federation/compose.a.yml b/packages/backend/test-federation/compose.a.yml new file mode 100644 index 0000000000..e351ad527b --- /dev/null +++ b/packages/backend/test-federation/compose.a.yml @@ -0,0 +1,64 @@ +services: + a.test: + extends: + file: ./compose.tpl.yml + service: nginx + depends_on: + cherrypick.a.test: + condition: service_healthy + networks: + - internal_network_a + volumes: + - type: bind + source: ./.config/a.test.conf + target: /etc/nginx/conf.d/a.test.conf + read_only: true + - type: bind + source: ./certificates/a.test.crt + target: /etc/nginx/certificates/a.test.crt + read_only: true + - type: bind + source: ./certificates/a.test.key + target: /etc/nginx/certificates/a.test.key + read_only: true + + cherrypick.a.test: + extends: + file: ./compose.tpl.yml + service: cherrypick + depends_on: + db.a.test: + condition: service_healthy + redis.test: + condition: service_healthy + setup: + condition: service_completed_successfully + networks: + - internal_network_a + volumes: + - type: bind + source: ./.config/a.test.default.yml + target: /cherrypick/.config/default.yml + read_only: true + + db.a.test: + extends: + file: ./compose.tpl.yml + service: db + networks: + - internal_network_a + volumes: + - type: bind + source: ./volumes/db.a + target: /var/lib/postgresql/data + bind: + create_host_path: true + +networks: + internal_network_a: + internal: true + driver: bridge + ipam: + config: + - subnet: 172.21.0.0/16 + ip_range: 172.21.0.0/24 diff --git a/packages/backend/test-federation/compose.b.yml b/packages/backend/test-federation/compose.b.yml new file mode 100644 index 0000000000..342f7592c3 --- /dev/null +++ b/packages/backend/test-federation/compose.b.yml @@ -0,0 +1,64 @@ +services: + b.test: + extends: + file: ./compose.tpl.yml + service: nginx + depends_on: + cherrypick.b.test: + condition: service_healthy + networks: + - internal_network_b + volumes: + - type: bind + source: ./.config/b.test.conf + target: /etc/nginx/conf.d/b.test.conf + read_only: true + - type: bind + source: ./certificates/b.test.crt + target: /etc/nginx/certificates/b.test.crt + read_only: true + - type: bind + source: ./certificates/b.test.key + target: /etc/nginx/certificates/b.test.key + read_only: true + + cherrypick.b.test: + extends: + file: ./compose.tpl.yml + service: cherrypick + depends_on: + db.b.test: + condition: service_healthy + redis.test: + condition: service_healthy + setup: + condition: service_completed_successfully + networks: + - internal_network_b + volumes: + - type: bind + source: ./.config/b.test.default.yml + target: /cherrypick/.config/default.yml + read_only: true + + db.b.test: + extends: + file: ./compose.tpl.yml + service: db + networks: + - internal_network_b + volumes: + - type: bind + source: ./volumes/db.b + target: /var/lib/postgresql/data + bind: + create_host_path: true + +networks: + internal_network_b: + internal: true + driver: bridge + ipam: + config: + - subnet: 172.22.0.0/16 + ip_range: 172.22.0.0/24 diff --git a/packages/backend/test-federation/compose.override.yaml b/packages/backend/test-federation/compose.override.yaml new file mode 100644 index 0000000000..2973a25c1e --- /dev/null +++ b/packages/backend/test-federation/compose.override.yaml @@ -0,0 +1,117 @@ +services: + setup: + volumes: + - type: volume + source: node_modules + target: /cherrypick/node_modules + - type: volume + source: node_modules_backend + target: /cherrypick/packages/backend/node_modules + - type: volume + source: node_modules_cherrypick-js + target: /cherrypick/packages/cherrypick-js/node_modules + - type: volume + source: node_modules_misskey-reversi + target: /cherrypick/packages/misskey-reversi/node_modules + + tester: + networks: + external_network: + internal_network: + ipv4_address: 172.20.1.1 + volumes: + - type: volume + source: node_modules_dev + target: /cherrypick/node_modules + - type: volume + source: node_modules_backend_dev + target: /cherrypick/packages/backend/node_modules + - type: volume + source: node_modules_cherrypick-js_dev + target: /cherrypick/packages/cherrypick-js/node_modules + + daemon: + networks: + - external_network + - internal_network_a + - internal_network_b + volumes: + - type: volume + source: node_modules_dev + target: /cherrypick/node_modules + - type: volume + source: node_modules_backend_dev + target: /cherrypick/packages/backend/node_modules + + redis.test: + networks: + - internal_network_a + - internal_network_b + + a.test: + networks: + - internal_network + + cherrypick.a.test: + networks: + - external_network + - internal_network + volumes: + - type: volume + source: node_modules + target: /cherrypick/node_modules + - type: volume + source: node_modules_backend + target: /cherrypick/packages/backend/node_modules + - type: volume + source: node_modules_cherrypick-js + target: /cherrypick/packages/cherrypick-js/node_modules + - type: volume + source: node_modules_misskey-reversi + target: /cherrypick/packages/misskey-reversi/node_modules + + b.test: + networks: + - internal_network + + cherrypick.b.test: + networks: + - external_network + - internal_network + volumes: + - type: volume + source: node_modules + target: /cherrypick/node_modules + - type: volume + source: node_modules_backend + target: /cherrypick/packages/backend/node_modules + - type: volume + source: node_modules_cherrypick-js + target: /cherrypick/packages/cherrypick-js/node_modules + - type: volume + source: node_modules_misskey-reversi + target: /cherrypick/packages/misskey-reversi/node_modules + +networks: + external_network: + driver: bridge + ipam: + config: + - subnet: 172.23.0.0/16 + ip_range: 172.23.0.0/24 + internal_network: + internal: true + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + ip_range: 172.20.0.0/24 + +volumes: + node_modules: + node_modules_dev: + node_modules_backend: + node_modules_backend_dev: + node_modules_cherrypick-js: + node_modules_cherrypick-js_dev: + node_modules_misskey-reversi: diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml new file mode 100644 index 0000000000..24159166b7 --- /dev/null +++ b/packages/backend/test-federation/compose.tpl.yml @@ -0,0 +1,101 @@ +services: + nginx: + image: nginx:1.27 + volumes: + - type: bind + source: ./certificates/rootCA.crt + target: /etc/nginx/certificates/rootCA.crt + read_only: true + healthcheck: + test: service nginx status + interval: 5s + retries: 20 + + cherrypick: + image: node:20 + env_file: + - ./.config/docker.env + environment: + - NODE_ENV=production + volumes: + - type: bind + source: ../../../built + target: /cherrypick/built + read_only: true + - type: bind + source: ../assets + target: /cherrypick/packages/backend/assets + read_only: true + - type: bind + source: ../built + target: /cherrypick/packages/backend/built + read_only: true + - type: bind + source: ../migration + target: /cherrypick/packages/backend/migration + read_only: true + - type: bind + source: ../ormconfig.js + target: /cherrypick/packages/backend/ormconfig.js + read_only: true + - type: bind + source: ../package.json + target: /cherrypick/packages/backend/package.json + read_only: true + - type: bind + source: ../../cherrypick-js/built + target: /cherrypick/packages/cherrypick-js/built + read_only: true + - type: bind + source: ../../cherrypick-js/package.json + target: /cherrypick/packages/cherrypick-js/package.json + read_only: true + - type: bind + source: ../../misskey-reversi/built + target: /cherrypick/packages/misskey-reversi/built + read_only: true + - type: bind + source: ../../misskey-reversi/package.json + target: /cherrypick/packages/misskey-reversi/package.json + read_only: true + - type: bind + source: ../../../healthcheck.sh + target: /cherrypick/healthcheck.sh + read_only: true + - type: bind + source: ../../../package.json + target: /cherrypick/package.json + read_only: true + - type: bind + source: ../../../pnpm-lock.yaml + target: /cherrypick/pnpm-lock.yaml + read_only: true + - type: bind + source: ../../../pnpm-workspace.yaml + target: /cherrypick/pnpm-workspace.yaml + read_only: true + - type: bind + source: ./certificates/rootCA.crt + target: /usr/local/share/ca-certificates/rootCA.crt + read_only: true + working_dir: /cherrypick + command: > + bash -c " + corepack enable && corepack prepare + pnpm -F backend migrate + pnpm -F backend start + " + healthcheck: + test: bash /cherrypick/healthcheck.sh + interval: 5s + retries: 20 + + db: + image: postgres:15-alpine + env_file: + - ./.config/docker.env + volumes: + healthcheck: + test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB + interval: 5s + retries: 20 diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml new file mode 100644 index 0000000000..3a2efb9ce7 --- /dev/null +++ b/packages/backend/test-federation/compose.yml @@ -0,0 +1,133 @@ +include: + - ./compose.a.yml + - ./compose.b.yml + +services: + setup: + extends: + file: ./compose.tpl.yml + service: cherrypick + command: > + bash -c " + corepack enable && corepack prepare + pnpm -F backend i + pnpm -F cherrypick-js i + pnpm -F misskey-reversi i + " + + tester: + image: node:20 + depends_on: + a.test: + condition: service_healthy + b.test: + condition: service_healthy + environment: + - NODE_ENV=development + - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt + volumes: + - type: bind + source: ../package.json + target: /cherrypick/packages/backend/package.json + read_only: true + - type: bind + source: ../test/resources + target: /cherrypick/packages/backend/test/resources + read_only: true + - type: bind + source: ./test + target: /cherrypick/packages/backend/test-federation/test + read_only: true + - type: bind + source: ../jest.config.cjs + target: /cherrypick/packages/backend/jest.config.cjs + read_only: true + - type: bind + source: ../jest.config.fed.cjs + target: /cherrypick/packages/backend/jest.config.fed.cjs + read_only: true + - type: bind + source: ../../cherrypick-js/built + target: /cherrypick/packages/cherrypick-js/built + read_only: true + - type: bind + source: ../../cherrypick-js/package.json + target: /cherrypick/packages/cherrypick-js/package.json + read_only: true + - type: bind + source: ../../../package.json + target: /cherrypick/package.json + read_only: true + - type: bind + source: ../../../pnpm-lock.yaml + target: /cherrypick/pnpm-lock.yaml + read_only: true + - type: bind + source: ../../../pnpm-workspace.yaml + target: /cherrypick/pnpm-workspace.yaml + read_only: true + - type: bind + source: ./certificates/rootCA.crt + target: /usr/local/share/ca-certificates/rootCA.crt + read_only: true + working_dir: /cherrypick + entrypoint: > + bash -c ' + corepack enable && corepack prepare + pnpm -F cherrypick-js i --frozen-lockfile + pnpm -F backend i --frozen-lockfile + exec "$0" "$@" + ' + command: pnpm -F backend test:fed + + daemon: + image: node:20 + depends_on: + redis.test: + condition: service_healthy + volumes: + - type: bind + source: ../package.json + target: /cherrypick/packages/backend/package.json + read_only: true + - type: bind + source: ./daemon.ts + target: /cherrypick/packages/backend/test-federation/daemon.ts + read_only: true + - type: bind + source: ./tsconfig.json + target: /cherrypick/packages/backend/test-federation/tsconfig.json + read_only: true + - type: bind + source: ../../../package.json + target: /cherrypick/package.json + read_only: true + - type: bind + source: ../../../pnpm-lock.yaml + target: /cherrypick/pnpm-lock.yaml + read_only: true + - type: bind + source: ../../../pnpm-workspace.yaml + target: /cherrypick/pnpm-workspace.yaml + read_only: true + working_dir: /cherrypick + command: > + bash -c " + corepack enable && corepack prepare + pnpm -F backend i --frozen-lockfile + pnpm exec tsc -p ./packages/backend/test-federation + node ./packages/backend/test-federation/built/daemon.js + " + + redis.test: + image: redis:7-alpine + volumes: + - type: bind + source: ./volumes/redis + target: /data + bind: + create_host_path: true + healthcheck: + test: redis-cli ping + interval: 5s + retries: 20 diff --git a/packages/backend/test-federation/daemon.ts b/packages/backend/test-federation/daemon.ts new file mode 100644 index 0000000000..46b6963c79 --- /dev/null +++ b/packages/backend/test-federation/daemon.ts @@ -0,0 +1,38 @@ +import IPCIDR from 'ip-cidr'; +import { Redis } from 'ioredis'; + +const TESTER_IP_ADDRESS = '172.20.1.1'; + +/** + * This should be same as {@link file://./../src/misc/get-ip-hash.ts}. + */ +function getIpHash(ip: string) { + const prefix = IPCIDR.createAddress(ip).mask(64); + return `ip-${BigInt('0b' + prefix).toString(36)}`; +} + +/** + * This prevents hitting rate limit when login. + */ +export async function purgeLimit(host: string, client: Redis) { + const ipHash = getIpHash(TESTER_IP_ADDRESS); + const key = `${host}:limit:${ipHash}:signin`; + const res = await client.zrange(key, 0, -1); + if (res.length !== 0) { + console.log(`${key} - ${JSON.stringify(res)}`); + await client.del(key); + } +} + +console.log('Daemon started running'); + +{ + const redisClient = new Redis({ + host: 'redis.test', + }); + + setInterval(() => { + purgeLimit('a.test', redisClient); + purgeLimit('b.test', redisClient); + }, 200); +} diff --git a/packages/backend/test-federation/eslint.config.js b/packages/backend/test-federation/eslint.config.js new file mode 100644 index 0000000000..e3bcf4c0fe --- /dev/null +++ b/packages/backend/test-federation/eslint.config.js @@ -0,0 +1,21 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + globals: { + ...globals.node, + }, + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/backend/test-federation/setup.sh b/packages/backend/test-federation/setup.sh new file mode 100644 index 0000000000..d5b7decb35 --- /dev/null +++ b/packages/backend/test-federation/setup.sh @@ -0,0 +1,35 @@ +#!/bin/bash +mkdir certificates + +# rootCA +openssl genrsa -des3 \ + -passout pass:rootCA \ + -out certificates/rootCA.key 4096 +openssl req -x509 -new -nodes -batch \ + -key certificates/rootCA.key \ + -sha256 \ + -days 1024 \ + -passin pass:rootCA \ + -out certificates/rootCA.crt + +# domain +function generate { + openssl req -new -newkey rsa:2048 -sha256 -nodes \ + -keyout certificates/$1.key \ + -subj "/CN=$1/emailAddress=admin@$1/C=JP/ST=/L=/O=CherryPick Tester/OU=Some Unit" \ + -out certificates/$1.csr + openssl x509 -req -sha256 \ + -in certificates/$1.csr \ + -CA certificates/rootCA.crt \ + -CAkey certificates/rootCA.key \ + -CAcreateserial \ + -passin pass:rootCA \ + -out certificates/$1.crt \ + -days 500 + if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi + if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi + if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.default.yml > .config/$1.default.yml; fi +} + +generate a.test +generate b.test diff --git a/packages/backend/test-federation/test/abuse-report.test.ts b/packages/backend/test-federation/test/abuse-report.test.ts new file mode 100644 index 0000000000..f5ec8305d1 --- /dev/null +++ b/packages/backend/test-federation/test/abuse-report.test.ts @@ -0,0 +1,52 @@ +import { rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'cherrypick-js'; +import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js'; + +describe('Abuse report', () => { + describe('Forwarding report', () => { + let alice: LoginUser, bob: LoginUser, aModerator: LoginUser, bModerator: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [aModerator, bModerator] = await Promise.all([ + createModerator('a.test'), + createModerator('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Alice reports Bob, moderator in A forwards it, and B moderator receives it', async () => { + const comment = crypto.randomUUID(); + await alice.client.request('users/report-abuse', { userId: bobInA.id, comment }); + const reports = await aModerator.client.request('admin/abuse-user-reports', {}); + const report = reports.filter(report => report.comment === comment)[0]; + await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }); + await sleep(); + + const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {}); + const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0]; + // NOTE: reporter is not Alice, and is not moderator in A + strictEqual(reportInB.reporter.url, 'https://a.test/@instance.actor'); + strictEqual(reportInB.targetUserId, bob.id); + + // NOTE: cannot forward multiple times + await rejects( + async () => await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }), + (err: any) => { + strictEqual(err.code, 'INTERNAL_ERROR'); + strictEqual(err.info.e.message, 'The report has already been forwarded.'); + return true; + }, + ); + }); + }); +}); diff --git a/packages/backend/test-federation/test/block.test.ts b/packages/backend/test-federation/test/block.test.ts new file mode 100644 index 0000000000..0fddb341a1 --- /dev/null +++ b/packages/backend/test-federation/test/block.test.ts @@ -0,0 +1,224 @@ +import { deepStrictEqual, rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'cherrypick-js'; +import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; + +describe('Block', () => { + describe('Check follow', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Cannot follow if blocked', async () => { + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'BLOCKED'); + return true; + }, + ); + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 0); + }); + + // FIXME: this is invalid case + test('Cannot follow even if unblocked', async () => { + // unblock here + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + // TODO: why still being blocked? + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'BLOCKED'); + return true; + }, + ); + }); + + test.skip('Can follow if unblocked', async () => { + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 1); + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); + }); + + test.skip('Remove follower when block them', async () => { + test('before block', async () => { + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 1); + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); + }); + + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + + test('after block', async () => { + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 0); + }); + }); + }); + + describe('Check reply', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Cannot reply if blocked', async () => { + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + await rejects( + async () => await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id }), + (err: any) => { + strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED'); + return true; + }, + ); + }); + + test('Can reply if unblocked', async () => { + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + const reply = (await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id })).createdNote; + + await resolveRemoteNote('b.test', reply.id, alice); + }); + }); + + describe('Check reaction', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Cannot reaction if blocked', async () => { + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + await rejects( + async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }), + (err: any) => { + strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED'); + return true; + }, + ); + }); + + // FIXME: this is invalid case + test('Cannot reaction even if unblocked', async () => { + // unblock here + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + + // TODO: why still being blocked? + await rejects( + async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }), + (err: any) => { + strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED'); + return true; + }, + ); + }); + + test.skip('Can reaction if unblocked', async () => { + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }); + + const _note = await alice.client.request('notes/show', { noteId: note.id }); + deepStrictEqual(_note.reactions, { '😅': 1 }); + }); + }); + + describe('Check mention', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + /** NOTE: You should mute the target to stop receiving notifications */ + test('Can mention and notified even if blocked', async () => { + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + + const text = `@${alice.username}@a.test plz unblock me!`; + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { text }), + notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text, + true, + ); + }); + }); +}); diff --git a/packages/backend/test-federation/test/drive.test.ts b/packages/backend/test-federation/test/drive.test.ts new file mode 100644 index 0000000000..b9329594aa --- /dev/null +++ b/packages/backend/test-federation/test/drive.test.ts @@ -0,0 +1,175 @@ +import assert, { strictEqual } from 'node:assert'; +import * as Misskey from 'cherrypick-js'; +import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js'; + +const bAdmin = await fetchAdmin('b.test'); + +describe('Drive', () => { + describe('Upload image in a.test and resolve from b.test', () => { + let uploader: LoginUser; + + beforeAll(async () => { + uploader = await createAccount('a.test'); + }); + + let image: Misskey.entities.DriveFile, imageInB: Misskey.entities.DriveFile; + + describe('Upload', () => { + beforeAll(async () => { + image = await uploadFile('a.test', uploader); + const noteWithImage = (await uploader.client.request('notes/create', { fileIds: [image.id] })).createdNote; + const noteInB = await resolveRemoteNote('a.test', noteWithImage.id, bAdmin); + assert(noteInB.files != null); + strictEqual(noteInB.files.length, 1); + imageInB = noteInB.files[0]; + }); + + test('Check consistency of DriveFile', () => { + // console.log(`a.test: ${JSON.stringify(image, null, '\t')}`); + // console.log(`b.test: ${JSON.stringify(imageInB, null, '\t')}`); + + deepStrictEqualWithExcludedFields(image, imageInB, [ + 'id', + 'createdAt', + 'size', + 'url', + 'thumbnailUrl', + 'userId', + ]); + }); + }); + + let updatedImage: Misskey.entities.DriveFile, updatedImageInB: Misskey.entities.DriveFile; + + describe('Update', () => { + beforeAll(async () => { + updatedImage = await uploader.client.request('drive/files/update', { + fileId: image.id, + name: 'updated_192.jpg', + isSensitive: true, + }); + + updatedImageInB = await bAdmin.client.request('drive/files/show', { + fileId: imageInB.id, + }); + }); + + test('Check consistency', () => { + // console.log(`a.test: ${JSON.stringify(updatedImage, null, '\t')}`); + // console.log(`b.test: ${JSON.stringify(updatedImageInB, null, '\t')}`); + + // FIXME: not updated with `drive/files/update` + strictEqual(updatedImage.isSensitive, true); + strictEqual(updatedImage.name, 'updated_192.jpg'); + strictEqual(updatedImageInB.isSensitive, false); + strictEqual(updatedImageInB.name, '192.jpg'); + }); + }); + + let reupdatedImageInB: Misskey.entities.DriveFile; + + describe('Re-update with attaching to Note', () => { + beforeAll(async () => { + const noteWithUpdatedImage = (await uploader.client.request('notes/create', { fileIds: [updatedImage.id] })).createdNote; + const noteWithUpdatedImageInB = await resolveRemoteNote('a.test', noteWithUpdatedImage.id, bAdmin); + assert(noteWithUpdatedImageInB.files != null); + strictEqual(noteWithUpdatedImageInB.files.length, 1); + reupdatedImageInB = noteWithUpdatedImageInB.files[0]; + }); + + test('Check consistency', () => { + // console.log(`b.test: ${JSON.stringify(reupdatedImageInB, null, '\t')}`); + + // `isSensitive` is updated + strictEqual(reupdatedImageInB.isSensitive, true); + // FIXME: but `name` is not updated + strictEqual(reupdatedImageInB.name, '192.jpg'); + }); + }); + }); + + describe('Sensitive flag', () => { + describe('isSensitive is federated in delivering to followers', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + }); + + test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => { + const file = await uploadFile('a.test', alice); + await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true }); + await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] }); + await sleep(); + + const notes = await bob.client.request('notes/timeline', {}); + strictEqual(notes.length, 1); + const noteInB = notes[0]; + assert(noteInB.files != null); + strictEqual(noteInB.files.length, 1); + strictEqual(noteInB.files[0].isSensitive, true); + }); + }); + + describe('isSensitive is federated in resolving', () => { + let alice: LoginUser, bob: LoginUser; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + }); + + test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => { + const file = await uploadFile('a.test', alice); + await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true }); + const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] })).createdNote; + + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + assert(noteInB.files != null); + strictEqual(noteInB.files.length, 1); + strictEqual(noteInB.files[0].isSensitive, true); + }); + }); + + /** @see https://github.com/misskey-dev/misskey/issues/12208 */ + describe('isSensitive is federated in replying', () => { + let alice: LoginUser, bob: LoginUser; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + }); + + test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => { + const bobNote = (await bob.client.request('notes/create', { text: 'I\'m Bob' })).createdNote; + + const file = await uploadFile('a.test', alice); + await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true }); + const bobNoteInA = await resolveRemoteNote('b.test', bobNote.id, alice); + const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id], replyId: bobNoteInA.id })).createdNote; + await sleep(); + + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + assert(noteInB.files != null); + strictEqual(noteInB.files.length, 1); + strictEqual(noteInB.files[0].isSensitive, true); + }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/emoji.test.ts b/packages/backend/test-federation/test/emoji.test.ts new file mode 100644 index 0000000000..64d08e324f --- /dev/null +++ b/packages/backend/test-federation/test/emoji.test.ts @@ -0,0 +1,97 @@ +import assert, { deepStrictEqual, strictEqual } from 'assert'; +import * as Misskey from 'cherrypick-js'; +import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js'; + +describe('Emoji', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + }); + + test('Custom emoji are delivered with Note delivery', async () => { + const emoji = await addCustomEmoji('a.test'); + await alice.client.request('notes/create', { text: `I love :${emoji.name}:` }); + await sleep(); + + const notes = await bob.client.request('notes/timeline', {}); + const noteInB = notes[0]; + + strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`); + assert(noteInB.emojis != null); + assert(emoji.name in noteInB.emojis); + strictEqual(noteInB.emojis[emoji.name], emoji.url); + }); + + test('Custom emoji are delivered with Reaction delivery', async () => { + const emoji = await addCustomEmoji('a.test'); + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + await sleep(); + + await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const noteInB = (await bob.client.request('notes/timeline', {}))[0]; + deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1); + deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url); + }); + + test('Custom emoji are delivered with Profile delivery', async () => { + const emoji = await addCustomEmoji('a.test'); + const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` }); + await sleep(); + + const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(renewedaliceInB.name, renewedAlice.name); + assert(emoji.name in renewedaliceInB.emojis); + strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url); + }); + + test('Local-only custom emoji aren\'t delivered with Note delivery', async () => { + const emoji = await addCustomEmoji('a.test', { localOnly: true }); + await alice.client.request('notes/create', { text: `I love :${emoji.name}:` }); + await sleep(); + + const notes = await bob.client.request('notes/timeline', {}); + const noteInB = notes[0]; + + strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`); + // deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?) + deepStrictEqual({ ...noteInB.emojis }, {}); + }); + + test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => { + const emoji = await addCustomEmoji('a.test', { localOnly: true }); + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + await sleep(); + + await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const noteInB = (await bob.client.request('notes/timeline', {}))[0]; + deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 }); + deepStrictEqual({ ...noteInB.reactionEmojis }, {}); + }); + + test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => { + const emoji = await addCustomEmoji('a.test', { localOnly: true }); + const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` }); + await sleep(); + + const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(renewedaliceInB.name, renewedAlice.name); + deepStrictEqual({ ...renewedaliceInB.emojis }, {}); + }); +}); diff --git a/packages/backend/test-federation/test/move.test.ts b/packages/backend/test-federation/test/move.test.ts new file mode 100644 index 0000000000..56a57de8a4 --- /dev/null +++ b/packages/backend/test-federation/test/move.test.ts @@ -0,0 +1,52 @@ +import assert, { strictEqual } from 'node:assert'; +import { createAccount, type LoginUser, sleep } from './utils.js'; + +describe('Move', () => { + test('Minimum move', async () => { + const [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] }); + await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` }); + }); + + /** @see https://github.com/misskey-dev/misskey/issues/11320 */ + describe('Following relation is transferred after move', () => { + let alice: LoginUser, bob: LoginUser, carol: LoginUser; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + carol = await createAccount('a.test'); + + // Follow @carol@a.test ==> @alice@a.test + await carol.client.request('following/create', { userId: alice.id }); + + // Move @alice@a.test ==> @bob@b.test + await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] }); + await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` }); + await sleep(); + }); + + test('Check from follower', async () => { + const following = await carol.client.request('users/following', { userId: carol.id }); + strictEqual(following.length, 2); + const followees = following.map(({ followee }) => followee); + assert(followees.every(followee => followee != null)); + assert(followees.some(({ id, url }) => id === alice.id && url === null)); + assert(followees.some(({ url }) => url === `https://b.test/@${bob.username}`)); + }); + + test('Check from followee', async () => { + const followers = await bob.client.request('users/followers', { userId: bob.id }); + strictEqual(followers.length, 1); + const follower = followers[0].follower; + assert(follower != null); + strictEqual(follower.url, `https://a.test/@${carol.username}`); + }); + }); +}); diff --git a/packages/backend/test-federation/test/note.test.ts b/packages/backend/test-federation/test/note.test.ts new file mode 100644 index 0000000000..bd25fc8ea6 --- /dev/null +++ b/packages/backend/test-federation/test/note.test.ts @@ -0,0 +1,317 @@ +import assert, { rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'cherrypick-js'; +import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js'; + +describe('Note', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + describe('Note content', () => { + test('Consistency of Public Note', async () => { + const image = await uploadFile('a.test', alice); + const note = (await alice.client.request('notes/create', { + text: 'I am Alice!', + fileIds: [image.id], + poll: { + choices: ['neko', 'inu'], + multiple: false, + expiredAfter: 60 * 60 * 1000, + }, + })).createdNote; + + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + deepStrictEqualWithExcludedFields(note, resolvedNote, [ + 'id', + 'emojis', + /** Consistency of files is checked at {@link file://./drive.test.ts}, so let's skip. */ + 'fileIds', + 'files', + /** @see https://github.com/misskey-dev/misskey/issues/12409 */ + 'reactionAcceptance', + 'userId', + 'user', + 'uri', + ]); + strictEqual(aliceInB.id, resolvedNote.userId); + }); + + test('Consistency of reply', async () => { + const _replyedNote = (await alice.client.request('notes/create', { + text: 'a', + })).createdNote; + const note = (await alice.client.request('notes/create', { + text: 'b', + replyId: _replyedNote.id, + })).createdNote; + // NOTE: the repliedCount is incremented, so fetch again + const replyedNote = await alice.client.request('notes/show', { noteId: _replyedNote.id }); + strictEqual(replyedNote.repliesCount, 1); + + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + deepStrictEqualWithExcludedFields(note, resolvedNote, [ + 'id', + 'emojis', + 'reactionAcceptance', + 'replyId', + 'reply', + 'userId', + 'user', + 'uri', + ]); + assert(resolvedNote.replyId != null); + assert(resolvedNote.reply != null); + deepStrictEqualWithExcludedFields(replyedNote, resolvedNote.reply, [ + 'id', + // TODO: why clippedCount loses consistency? + 'clippedCount', + 'emojis', + 'userId', + 'user', + 'uri', + // flaky because this is parallelly incremented, so let's check it below + 'repliesCount', + ]); + strictEqual(aliceInB.id, resolvedNote.userId); + + await sleep(); + + const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId }); + strictEqual(resolvedReplyedNote.repliesCount, 1); + }); + + test('Consistency of Renote', async () => { + // NOTE: the renoteCount is not incremented, so no need to fetch again + const renotedNote = (await alice.client.request('notes/create', { + text: 'a', + })).createdNote; + const note = (await alice.client.request('notes/create', { + text: 'b', + renoteId: renotedNote.id, + })).createdNote; + + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + deepStrictEqualWithExcludedFields(note, resolvedNote, [ + 'id', + 'emojis', + 'reactionAcceptance', + 'renoteId', + 'renote', + 'userId', + 'user', + 'uri', + ]); + assert(resolvedNote.renoteId != null); + assert(resolvedNote.renote != null); + deepStrictEqualWithExcludedFields(renotedNote, resolvedNote.renote, [ + 'id', + 'emojis', + 'userId', + 'user', + 'uri', + ]); + strictEqual(aliceInB.id, resolvedNote.userId); + }); + }); + + describe('Other props', () => { + test('localOnly', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote; + rejects( + async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }), + (err: any) => { + /** + * FIXME: this error is not handled + * @see https://github.com/misskey-dev/misskey/issues/12736 + */ + strictEqual(err.code, 'INTERNAL_ERROR'); + return true; + }, + ); + }); + }); + + describe('Deletion', () => { + describe('Check Delete consistency', () => { + let carol: LoginUser; + + beforeAll(async () => { + carol = await createAccount('a.test'); + + await carol.client.request('following/create', { userId: bobInA.id }); + await sleep(); + }); + + test('Delete is derivered to followers', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, carol); + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await carol.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + }); + + describe('Deletion of remote user\'s note for moderation', () => { + let note: Misskey.entities.Note; + + test('Alice post is deleted in B', async () => { + note = (await alice.client.request('notes/create', { text: 'Hello' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const bMod = await createModerator('b.test'); + await bMod.client.request('notes/delete', { noteId: noteInB.id }); + await rejects( + async () => await bob.client.request('notes/show', { noteId: noteInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + + /** + * FIXME: implement soft deletion as well as user? + * @see https://github.com/misskey-dev/misskey/issues/11437 + */ + test.failing('Not found even if resolve again', async () => { + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + await rejects( + async () => await bob.client.request('notes/show', { noteId: noteInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + }); + }); + + describe('Reaction', () => { + describe('Consistency', () => { + test('Unicode reaction', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + const reaction = '😅'; + await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction }); + await sleep(); + + const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); + strictEqual(reactions.length, 1); + strictEqual(reactions[0].type, reaction); + strictEqual(reactions[0].user.id, bobInA.id); + }); + + test('Custom emoji reaction', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + const emoji = await addCustomEmoji('b.test'); + await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); + strictEqual(reactions.length, 1); + strictEqual(reactions[0].type, `:${emoji.name}@b.test:`); + strictEqual(reactions[0].user.id, bobInA.id); + }); + }); + + describe('Acceptance', () => { + test('Even if likeOnly, remote users can react with custom emoji, but it is converted to like', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'likeOnly' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const emoji = await addCustomEmoji('b.test'); + await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); + strictEqual(reactions.length, 1); + strictEqual(reactions[0].type, '❤'); + }); + + /** + * TODO: this may be unexpected behavior? + * @see https://github.com/misskey-dev/misskey/issues/12409 + */ + test('Even if nonSensitiveOnly, remote users can react with sensitive emoji, and it is not converted', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'nonSensitiveOnly' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const emoji = await addCustomEmoji('b.test', { isSensitive: true }); + await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); + strictEqual(reactions.length, 1); + strictEqual(reactions[0].type, `:${emoji.name}@b.test:`); + }); + }); + }); + + describe('Poll', () => { + describe('Any remote user\'s vote is delivered to the author', () => { + let carol: LoginUser; + + beforeAll(async () => { + carol = await createAccount('a.test'); + }); + + test('Bob creates poll and receives a vote from Carol', async () => { + const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, carol); + await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 }); + await sleep(); + + const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id }); + assert(noteAfterVote.poll != null); + strictEqual(noteAfterVote.poll.choices[0].votes, 1); + strictEqual(noteAfterVote.poll.choices[1].votes, 0); + }); + }); + + describe('Local user\'s vote is delivered to the author\'s remote followers', () => { + let bobRemoteFollower: LoginUser, localVoter: LoginUser; + + beforeAll(async () => { + [ + bobRemoteFollower, + localVoter, + ] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + await bobRemoteFollower.client.request('following/create', { userId: bobInA.id }); + await sleep(); + }); + + test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => { + const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote; + // NOTE: resolve before voting + const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower); + await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 }); + await sleep(); + + const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id }); + assert(noteAfterVote.poll != null); + strictEqual(noteAfterVote.poll.choices[0].votes, 1); + strictEqual(noteAfterVote.poll.choices[1].votes, 0); + }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/notification.test.ts b/packages/backend/test-federation/test/notification.test.ts new file mode 100644 index 0000000000..c71c653f64 --- /dev/null +++ b/packages/backend/test-federation/test/notification.test.ts @@ -0,0 +1,107 @@ +import * as Misskey from 'cherrypick-js'; +import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; + +describe('Notification', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + describe('Follow', () => { + test('Get notification when follow', async () => { + await assertNotificationReceived( + 'b.test', bob, + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + notification => notification.type === 'followRequestAccepted' && notification.userId === aliceInB.id, + true, + ); + + await bob.client.request('following/delete', { userId: aliceInB.id }); + await sleep(); + }); + + test('Get notification when get followed', async () => { + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + notification => notification.type === 'follow' && notification.userId === bobInA.id, + true, + ); + }); + + afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id })); + }); + + describe('Note', () => { + test('Get notification when get a reaction', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const reaction = '😅'; + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction }), + notification => + notification.type === 'reaction' && notification.note.id === note.id && notification.userId === bobInA.id && notification.reaction === reaction, + true, + ); + }); + + test('Get notification when replied', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const text = crypto.randomUUID(); + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { text, replyId: noteInB.id }), + notification => + notification.type === 'reply' && notification.note.reply!.id === note.id && notification.userId === bobInA.id && notification.note.text === text, + true, + ); + }); + + test('Get notification when renoted', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { renoteId: noteInB.id }), + notification => + notification.type === 'renote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id, + true, + ); + }); + + test('Get notification when quoted', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const text = crypto.randomUUID(); + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { text, renoteId: noteInB.id }), + notification => + notification.type === 'quote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id && notification.note.text === text, + true, + ); + }); + + test('Get notification when mentioned', async () => { + const text = `@${alice.username}@a.test`; + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { text }), + notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text, + true, + ); + }); + }); +}); diff --git a/packages/backend/test-federation/test/timeline.test.ts b/packages/backend/test-federation/test/timeline.test.ts new file mode 100644 index 0000000000..4b9761c241 --- /dev/null +++ b/packages/backend/test-federation/test/timeline.test.ts @@ -0,0 +1,328 @@ +import { strictEqual } from 'assert'; +import * as Misskey from 'cherrypick-js'; +import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js'; + +const bAdmin = await fetchAdmin('b.test'); + +describe('Timeline', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + }); + + type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag'); + type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag'); + const timelineMap = new Map([ + ['antenna', 'antennas/notes'], + ['globalTimeline', 'notes/global-timeline'], + ['homeTimeline', 'notes/timeline'], + ['hybridTimeline', 'notes/hybrid-timeline'], + ['localTimeline', 'notes/local-timeline'], + ['roleTimeline', 'roles/notes'], + ['hashtag', 'notes/search-by-tag'], + ['userList', 'notes/user-list-timeline'], + ]); + + async function postAndCheckReception( + timelineChannel: C, + expect: boolean, + noteParams: Misskey.entities.NotesCreateRequest = {}, + channelParams: Misskey.Channels[C]['params'] = {}, + ) { + let note: Misskey.entities.Note | undefined; + const text = noteParams.text ?? crypto.randomUUID(); + const streamingFired = await isFired( + 'b.test', bob, timelineChannel, + async () => { + note = (await alice.client.request('notes/create', { text, ...noteParams })).createdNote; + }, + 'note', msg => msg.text === text, + channelParams, + ); + strictEqual(streamingFired, expect); + + const endpoint = timelineMap.get(timelineChannel)!; + const params: Misskey.Endpoints[typeof endpoint]['req'] = + endpoint === 'antennas/notes' ? { antennaId: (channelParams as Misskey.Channels['antenna']['params']).antennaId } : + endpoint === 'notes/user-list-timeline' ? { listId: (channelParams as Misskey.Channels['userList']['params']).listId } : + endpoint === 'notes/search-by-tag' ? { query: (channelParams as Misskey.Channels['hashtag']['params']).q } : + endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } : + {}; + + await sleep(); + const notes = await (bob.client.request as Request)(endpoint, params); + const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop(); + const endpointFired = noteInB != null; + strictEqual(endpointFired, expect); + + // Let's check Delete reception + if (expect) { + const streamingFired = await isNoteUpdatedEventFired( + 'b.test', bob, noteInB!.id, + async () => await alice.client.request('notes/delete', { noteId: note!.id }), + msg => msg.type === 'deleted' && msg.id === noteInB!.id, + ); + strictEqual(streamingFired, true); + + await sleep(); + const notes = await (bob.client.request as Request)(endpoint, params); + const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`); + strictEqual(endpointFired, true); + } + } + + describe('homeTimeline', () => { + // NOTE: narrowing scope intentionally to prevent mistakes by copy-and-paste + const homeTimeline = 'homeTimeline'; + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(homeTimeline, true); + }); + + test('Receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(homeTimeline, true, { visibility: 'home' }); + }); + + test('Receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(homeTimeline, true, { visibility: 'followers' }); + }); + + test('Receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(homeTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }); + }); + + test('Don\'t receive remote followee\'s localOnly Note', async () => { + await postAndCheckReception(homeTimeline, false, { localOnly: true }); + }); + + test('Don\'t receive remote followee\'s invisible specified-only Note', async () => { + await postAndCheckReception(homeTimeline, false, { visibility: 'specified' }); + }); + + /** + * FIXME: can receive this + * @see https://github.com/misskey-dev/misskey/issues/14083 + */ + test.failing('Don\'t receive remote followee\'s invisible and mentioned specified-only Note', async () => { + await postAndCheckReception(homeTimeline, false, { text: `@${bob.username}@b.test Hello`, visibility: 'specified' }); + }); + + /** + * FIXME: cannot receive this + * @see https://github.com/misskey-dev/misskey/issues/14084 + */ + test.failing('Receive remote followee\'s visible specified-only reply to invisible specified-only Note', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'specified' })).createdNote; + await postAndCheckReception(homeTimeline, true, { replyId: note.id, visibility: 'specified', visibleUserIds: [bobInA.id] }); + }); + }); + }); + + describe('localTimeline', () => { + const localTimeline = 'localTimeline'; + + describe('Check reception of remote followee\'s Note', () => { + test('Don\'t receive remote followee\'s Note', async () => { + await postAndCheckReception(localTimeline, false); + }); + }); + }); + + describe('hybridTimeline', () => { + const hybridTimeline = 'hybridTimeline'; + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(hybridTimeline, true); + }); + + test('Receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(hybridTimeline, true, { visibility: 'home' }); + }); + + test('Receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(hybridTimeline, true, { visibility: 'followers' }); + }); + + test('Receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(hybridTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }); + }); + }); + }); + + describe('globalTimeline', () => { + const globalTimeline = 'globalTimeline'; + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(globalTimeline, true); + }); + + test('Don\'t receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(globalTimeline, false, { visibility: 'home' }); + }); + + test('Don\'t receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(globalTimeline, false, { visibility: 'followers' }); + }); + + test('Don\'t receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(globalTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }); + }); + }); + }); + + describe('userList', () => { + const userList = 'userList'; + + let list: Misskey.entities.UserList; + + beforeAll(async () => { + list = await bob.client.request('users/lists/create', { name: 'Bob\'s List' }); + await bob.client.request('users/lists/push', { listId: list.id, userId: aliceInB.id }); + await sleep(); + }); + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(userList, true, {}, { listId: list.id }); + }); + + test('Receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(userList, true, { visibility: 'home' }, { listId: list.id }); + }); + + test('Receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(userList, true, { visibility: 'followers' }, { listId: list.id }); + }); + + test('Receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(userList, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { listId: list.id }); + }); + }); + }); + + describe('hashtag', () => { + const hashtag = 'hashtag'; + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + const tag = crypto.randomUUID(); + await postAndCheckReception(hashtag, true, { text: `#${tag}` }, { q: [[tag]] }); + }); + + test('Receive remote followee\'s home-only Note', async () => { + const tag = crypto.randomUUID(); + await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'home' }, { q: [[tag]] }); + }); + + test('Receive remote followee\'s followers-only Note', async () => { + const tag = crypto.randomUUID(); + await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'followers' }, { q: [[tag]] }); + }); + + test('Receive remote followee\'s visible specified-only Note', async () => { + const tag = crypto.randomUUID(); + await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'specified', visibleUserIds: [bobInA.id] }, { q: [[tag]] }); + }); + }); + }); + + describe('roleTimeline', () => { + const roleTimeline = 'roleTimeline'; + + let role: Misskey.entities.Role; + + beforeAll(async () => { + role = await createRole('b.test', { + name: 'Remote Users', + description: 'Remote users are assigned to this role.', + condFormula: { + /** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */ + type: 'isRemote' as never, + }, + }); + await sleep(); + }); + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(roleTimeline, true, {}, { roleId: role.id }); + }); + + test('Don\'t receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(roleTimeline, false, { visibility: 'home' }, { roleId: role.id }); + }); + + test('Don\'t receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(roleTimeline, false, { visibility: 'followers' }, { roleId: role.id }); + }); + + test('Don\'t receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(roleTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { roleId: role.id }); + }); + }); + + afterAll(async () => { + await bAdmin.client.request('admin/roles/delete', { roleId: role.id }); + }); + }); + + // TODO: Cannot test + describe.skip('antenna', () => { + const antenna = 'antenna'; + + let bobAntenna: Misskey.entities.Antenna; + + beforeAll(async () => { + bobAntenna = await bob.client.request('antennas/create', { + name: 'Bob\'s Egosurfing Antenna', + src: 'all', + keywords: [['Bob']], + excludeKeywords: [], + users: [], + caseSensitive: false, + localOnly: false, + withReplies: true, + withFile: true, + }); + await sleep(); + }); + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(antenna, true, { text: 'I love Bob (1)' }, { antennaId: bobAntenna.id }); + }); + + test('Don\'t receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(antenna, false, { text: 'I love Bob (2)', visibility: 'home' }, { antennaId: bobAntenna.id }); + }); + + test('Don\'t receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(antenna, false, { text: 'I love Bob (3)', visibility: 'followers' }, { antennaId: bobAntenna.id }); + }); + + test('Don\'t receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(antenna, false, { text: 'I love Bob (4)', visibility: 'specified', visibleUserIds: [bobInA.id] }, { antennaId: bobAntenna.id }); + }); + }); + + afterAll(async () => { + await bob.client.request('antennas/delete', { antennaId: bobAntenna.id }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts new file mode 100644 index 0000000000..e876432dee --- /dev/null +++ b/packages/backend/test-federation/test/user.test.ts @@ -0,0 +1,560 @@ +import assert, { rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'cherrypick-js'; +import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; + +const [aAdmin, bAdmin] = await Promise.all([ + fetchAdmin('a.test'), + fetchAdmin('b.test'), +]); + +describe('User', () => { + describe('Profile', () => { + describe('Consistency of profile', () => { + let alice: LoginUser; + let aliceWatcher: LoginUser; + let aliceWatcherInB: LoginUser; + + beforeAll(async () => { + alice = await createAccount('a.test'); + [ + aliceWatcher, + aliceWatcherInB, + ] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + }); + + test('Check consistency', async () => { + const aliceInA = await aliceWatcher.client.request('users/show', { userId: alice.id }); + const resolved = await resolveRemoteUser('a.test', aliceInA.id, aliceWatcherInB); + const aliceInB = await aliceWatcherInB.client.request('users/show', { userId: resolved.id }); + + // console.log(`a.test: ${JSON.stringify(aliceInA, null, '\t')}`); + // console.log(`b.test: ${JSON.stringify(aliceInB, null, '\t')}`); + + deepStrictEqualWithExcludedFields(aliceInA, aliceInB, [ + 'id', + 'host', + 'avatarUrl', + 'instance', + 'badgeRoles', + 'url', + 'uri', + 'createdAt', + 'lastFetchedAt', + 'publicReactions', + ]); + }); + }); + + describe('ffVisibility is federated', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + // NOTE: follow each other + await Promise.all([ + alice.client.request('following/create', { userId: bobInA.id }), + bob.client.request('following/create', { userId: aliceInB.id }), + ]); + await sleep(); + }); + + test('Visibility set public by default', async () => { + for (const user of await Promise.all([ + alice.client.request('users/show', { userId: bobInA.id }), + bob.client.request('users/show', { userId: aliceInB.id }), + ])) { + strictEqual(user.followersVisibility, 'public'); + strictEqual(user.followingVisibility, 'public'); + } + }); + + /** FIXME: not working */ + test.skip('Setting private for followersVisibility is federated', async () => { + await Promise.all([ + alice.client.request('i/update', { followersVisibility: 'private' }), + bob.client.request('i/update', { followersVisibility: 'private' }), + ]); + await sleep(); + + for (const user of await Promise.all([ + alice.client.request('users/show', { userId: bobInA.id }), + bob.client.request('users/show', { userId: aliceInB.id }), + ])) { + strictEqual(user.followersVisibility, 'private'); + strictEqual(user.followingVisibility, 'public'); + } + }); + + test.skip('Setting private for followingVisibility is federated', async () => { + await Promise.all([ + alice.client.request('i/update', { followingVisibility: 'private' }), + bob.client.request('i/update', { followingVisibility: 'private' }), + ]); + await sleep(); + + for (const user of await Promise.all([ + alice.client.request('users/show', { userId: bobInA.id }), + bob.client.request('users/show', { userId: aliceInB.id }), + ])) { + strictEqual(user.followersVisibility, 'private'); + strictEqual(user.followingVisibility, 'private'); + } + }); + }); + + describe('isCat is federated', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Not isCat for default', () => { + strictEqual(aliceInB.isCat, false); + }); + + test('Becoming a cat is sent to their followers', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + await alice.client.request('i/update', { isCat: true }); + await sleep(); + + const res = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(res.isCat, true); + }); + }); + + describe('Pinning Notes', () => { + let alice: LoginUser, bob: LoginUser; + let aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + aliceInB = await resolveRemoteUser('a.test', alice.id, bob); + + await bob.client.request('following/create', { userId: aliceInB.id }); + }); + + test('Pinning localOnly Note is not delivered', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote; + await alice.client.request('i/pin', { noteId: note.id }); + await sleep(); + + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 0); + }); + + test('Pinning followers-only Note is not delivered', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'followers' })).createdNote; + await alice.client.request('i/pin', { noteId: note.id }); + await sleep(); + + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 0); + }); + + let pinnedNote: Misskey.entities.Note; + + test('Pinning normal Note is delivered', async () => { + pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + await alice.client.request('i/pin', { noteId: pinnedNote.id }); + await sleep(); + + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 1); + const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob); + strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id); + }); + + test('Unpinning normal Note is delivered', async () => { + await alice.client.request('i/unpin', { noteId: pinnedNote.id }); + await sleep(); + + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 0); + }); + }); + }); + + describe('Follow / Unfollow', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + describe('Follow a.test ==> b.test', () => { + beforeAll(async () => { + await alice.client.request('following/create', { userId: bobInA.id }); + + await sleep(); + }); + + test('Check consistency with `users/following` and `users/followers` endpoints', async () => { + await Promise.all([ + strictEqual( + (await alice.client.request('users/following', { userId: alice.id })) + .some(v => v.followeeId === bobInA.id), + true, + ), + strictEqual( + (await bob.client.request('users/followers', { userId: bob.id })) + .some(v => v.followerId === aliceInB.id), + true, + ), + ]); + }); + }); + + describe('Unfollow a.test ==> b.test', () => { + beforeAll(async () => { + await alice.client.request('following/delete', { userId: bobInA.id }); + + await sleep(); + }); + + test('Check consistency with `users/following` and `users/followers` endpoints', async () => { + await Promise.all([ + strictEqual( + (await alice.client.request('users/following', { userId: alice.id })) + .some(v => v.followeeId === bobInA.id), + false, + ), + strictEqual( + (await bob.client.request('users/followers', { userId: bob.id })) + .some(v => v.followerId === aliceInB.id), + false, + ), + ]); + }); + }); + }); + + describe('Follow requests', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + await alice.client.request('i/update', { isLocked: true }); + }); + + describe('Send follow request from Bob to Alice and cancel', () => { + describe('Bob sends follow request to Alice', () => { + beforeAll(async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + }); + + test('Alice should have a request', async () => { + const requests = await alice.client.request('following/requests/list', {}); + strictEqual(requests.length, 1); + strictEqual(requests[0].followee.id, alice.id); + strictEqual(requests[0].follower.id, bobInA.id); + }); + }); + + describe('Alice cancels it', () => { + beforeAll(async () => { + await bob.client.request('following/requests/cancel', { userId: aliceInB.id }); + await sleep(); + }); + + test('Alice should have no requests', async () => { + const requests = await alice.client.request('following/requests/list', {}); + strictEqual(requests.length, 0); + }); + }); + }); + + describe('Send follow request from Bob to Alice and reject', () => { + beforeAll(async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + await alice.client.request('following/requests/reject', { userId: bobInA.id }); + await sleep(); + }); + + test('Bob should have no requests', async () => { + await rejects( + async () => await bob.client.request('following/requests/cancel', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'FOLLOW_REQUEST_NOT_FOUND'); + return true; + }, + ); + }); + + test('Bob doesn\'t follow Alice', async () => { + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); + }); + }); + + describe('Send follow request from Bob to Alice and accept', () => { + beforeAll(async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + await alice.client.request('following/requests/accept', { userId: bobInA.id }); + await sleep(); + }); + + test('Bob follows Alice', async () => { + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 1); + strictEqual(following[0].followeeId, aliceInB.id); + strictEqual(following[0].followerId, bob.id); + }); + }); + }); + + describe('Deletion', () => { + describe('Check Delete consistency', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Bob follows Alice, and Alice deleted themself', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // followed by Bob + + await alice.client.request('i/delete-account', { password: alice.password }); + await sleep(); + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); // no following relation + + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_USER'); + return true; + }, + ); + }); + }); + + describe('Deletion of remote user for moderation', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Bob follows Alice, then Alice gets deleted in B server', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // followed by Bob + + await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id }); + await sleep(); + + /** + * FIXME: remote account is not deleted! + * @see https://github.com/misskey-dev/misskey/issues/14728 + */ + const deletedAlice = await bob.client.request('users/show', { userId: aliceInB.id }); + assert(deletedAlice.id, aliceInB.id); + + // TODO: why still following relation? + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 1); + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'ALREADY_FOLLOWING'); + return true; + }, + ); + }); + + test('Alice tries to follow Bob, but it is not processed', async () => { + await alice.client.request('following/create', { userId: bobInA.id }); + await sleep(); + + const following = await alice.client.request('users/following', { userId: alice.id }); + strictEqual(following.length, 0); // Not following Bob because B server doesn't return Accept + + const followers = await bob.client.request('users/followers', { userId: bob.id }); + strictEqual(followers.length, 0); // Alice's Follow is not processed + }); + }); + }); + + describe('Suspension', () => { + describe('Check suspend/unsuspend consistency', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // followed by Bob + + await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); + await sleep(); + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); // no following relation + + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_USER'); + return true; + }, + ); + }); + + test('Alice gets unsuspended, Bob succeeds in following Alice', async () => { + await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // FIXME: followers are not deleted?? + + /** + * FIXME: still rejected! + * seems to can't process Undo Delete activity because it is not implemented + * related @see https://github.com/misskey-dev/misskey/issues/13273 + */ + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_USER'); + return true; + }, + ); + + // FIXME: resolving also fails + await rejects( + async () => await resolveRemoteUser('a.test', alice.id, bob), + (err: any) => { + strictEqual(err.code, 'INTERNAL_ERROR'); + return true; + }, + ); + }); + + /** + * instead of simple unsuspension, let's tell existence by following from Alice + */ + test('Alice can follow Bob', async () => { + await alice.client.request('following/create', { userId: bobInA.id }); + await sleep(); + + const bobFollowers = await bob.client.request('users/followers', { userId: bob.id }); + strictEqual(bobFollowers.length, 1); // followed by Alice + assert(bobFollowers[0].follower != null); + const renewedaliceInB = bobFollowers[0].follower; + assert(aliceInB.username === renewedaliceInB.username); + assert(aliceInB.host === renewedaliceInB.host); + assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK? + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); // following are deleted + + // Bob tries to follow Alice + await bob.client.request('following/create', { userId: renewedaliceInB.id }); + await sleep(); + + const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(aliceFollowers.length, 1); + + // FIXME: but resolving still fails ... + await rejects( + async () => await resolveRemoteUser('a.test', alice.id, bob), + (err: any) => { + strictEqual(err.code, 'INTERNAL_ERROR'); + return true; + }, + ); + }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts new file mode 100644 index 0000000000..df041b1ddf --- /dev/null +++ b/packages/backend/test-federation/test/utils.ts @@ -0,0 +1,309 @@ +import { deepStrictEqual, strictEqual } from 'assert'; +import { readFile } from 'fs/promises'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import * as Misskey from 'cherrypick-js'; +import { WebSocket } from 'ws'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export const ADMIN_PARAMS = { username: 'admin', password: 'admin' }; +const ADMIN_CACHE = new Map(); + +await Promise.all([ + fetchAdmin('a.test'), + fetchAdmin('b.test'), +]); + +type SigninResponse = Omit; + +export type LoginUser = SigninResponse & { + client: Misskey.api.APIClient; + username: string; + password: string; +} + +/** used for avoiding overload and some endpoints */ +export type Request = < + E extends keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'], +>( + endpoint: E, + params: P, + credential?: string | null, +) => Promise>; + +type Host = 'a.test' | 'b.test'; + +export async function sleep(ms = 200): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function signin( + host: Host, + params: Misskey.entities.SigninFlowRequest, +): Promise { + // wait for a second to prevent hit rate limit + await sleep(1000); + + return await (new Misskey.api.APIClient({ origin: `https://${host}` }).request as Request)('signin-flow', params) + .then(res => { + strictEqual(res.finished, true); + if (params.username === ADMIN_PARAMS.username) ADMIN_CACHE.set(host, res); + return res; + }) + .then(({ id, i }) => ({ id, i })) + .catch(async err => { + if (err.code === 'TOO_MANY_AUTHENTICATION_FAILURES') { + await sleep(Math.random() * 2000); + return await signin(host, params); + } + throw err; + }); +} + +async function createAdmin(host: Host): Promise { + const client = new Misskey.api.APIClient({ origin: `https://${host}` }); + return await client.request('admin/accounts/create', ADMIN_PARAMS).then(res => { + ADMIN_CACHE.set(host, { + id: res.id, + // @ts-expect-error FIXME: openapi-typescript generates incorrect response type for this endpoint, so ignore this + i: res.token, + }); + return res as Misskey.entities.SignupResponse; + }).then(async res => { + await client.request('admin/roles/update-default-policies', { + policies: { + /** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */ + rateLimitFactor: 0 as never, + }, + }, res.token); + return res; + }).catch(err => { + if (err.info.e.message === 'access denied') return undefined; + throw err; + }); +} + +export async function fetchAdmin(host: Host): Promise { + const admin = ADMIN_CACHE.get(host) ?? await signin(host, ADMIN_PARAMS) + .catch(async err => { + if (err.id === '6cc579cc-885d-43d8-95c2-b8c7fc963280') { + await createAdmin(host); + return await signin(host, ADMIN_PARAMS); + } + throw err; + }); + + return { + ...admin, + client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: admin.i }), + ...ADMIN_PARAMS, + }; +} + +export async function createAccount(host: Host): Promise { + const username = crypto.randomUUID().replaceAll('-', '').substring(0, 20); + const password = crypto.randomUUID().replaceAll('-', ''); + const admin = await fetchAdmin(host); + await admin.client.request('admin/accounts/create', { username, password }); + const signinRes = await signin(host, { username, password }); + + return { + ...signinRes, + client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: signinRes.i }), + username, + password, + }; +} + +export async function createModerator(host: Host): Promise { + const user = await createAccount(host); + const role = await createRole(host, { + name: 'Moderator', + isModerator: true, + }); + const admin = await fetchAdmin(host); + await admin.client.request('admin/roles/assign', { roleId: role.id, userId: user.id }); + return user; +} + +export async function createRole( + host: Host, + params: Partial = {}, +): Promise { + const admin = await fetchAdmin(host); + return await admin.client.request('admin/roles/create', { + name: 'Some role', + description: 'Role for testing', + color: null, + iconUrl: null, + target: 'conditional', + condFormula: {}, + isPublic: true, + isModerator: false, + isAdministrator: false, + isExplorable: true, + asBadge: false, + canEditMembersByModerator: false, + displayOrder: 0, + policies: {}, + ...params, + }); +} + +export async function resolveRemoteUser( + host: Host, + id: string, + from: LoginUser, +): Promise { + const uri = `https://${host}/users/${id}`; + return await from.client.request('ap/show', { uri }) + .then(res => { + strictEqual(res.type, 'User'); + strictEqual(res.object.uri, uri); + return res.object; + }); +} + +export async function resolveRemoteNote( + host: Host, + id: string, + from: LoginUser, +): Promise { + const uri = `https://${host}/notes/${id}`; + return await from.client.request('ap/show', { uri }) + .then(res => { + strictEqual(res.type, 'Note'); + strictEqual(res.object.uri, uri); + return res.object; + }); +} + +export async function uploadFile( + host: Host, + user: { i: string }, + path = '../../test/resources/192.jpg', +): Promise { + const filename = path.split('/').pop() ?? 'untitled'; + const blob = new Blob([await readFile(join(__dirname, path))]); + + const body = new FormData(); + body.append('i', user.i); + body.append('force', 'true'); + body.append('file', blob); + body.append('name', filename); + + return await fetch(`https://${host}/api/drive/files/create`, { method: 'POST', body }) + .then(async res => await res.json()); +} + +export async function addCustomEmoji( + host: Host, + param?: Partial, + path?: string, +): Promise { + const admin = await fetchAdmin(host); + const name = crypto.randomUUID().replaceAll('-', ''); + const file = await uploadFile(host, admin, path); + return await admin.client.request('admin/emoji/add', { name, fileId: file.id, ...param }); +} + +export function deepStrictEqualWithExcludedFields(actual: T, expected: T, excludedFields: (keyof T)[]) { + const _actual = structuredClone(actual); + const _expected = structuredClone(expected); + for (const obj of [_actual, _expected]) { + for (const field of excludedFields) { + delete obj[field]; + } + } + deepStrictEqual(_actual, _expected); +} + +export async function isFired( + host: Host, + user: { i: string }, + channel: C, + trigger: () => Promise, + type: T, + // @ts-expect-error TODO: why getting error here? + cond: (msg: Parameters[0]) => boolean, + params?: Misskey.Channels[C]['params'], +): Promise { + return new Promise(async (resolve, reject) => { + // @ts-expect-error TODO: why? + const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + const connection = stream.useChannel(channel, params); + connection.on(type as any, ((msg: any) => { + if (cond(msg)) { + stream.close(); + clearTimeout(timer); + resolve(true); + } + }) as any); + + let timer: NodeJS.Timeout | undefined; + + await trigger().then(() => { + timer = setTimeout(() => { + stream.close(); + resolve(false); + }, 500); + }).catch(err => { + stream.close(); + clearTimeout(timer); + reject(err); + }); + }); +}; + +export async function isNoteUpdatedEventFired( + host: Host, + user: { i: string }, + noteId: string, + trigger: () => Promise, + cond: (msg: Parameters[0]) => boolean, +): Promise { + return new Promise(async (resolve, reject) => { + // @ts-expect-error TODO: why? + const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + stream.send('s', { id: noteId }); + stream.on('noteUpdated', msg => { + if (cond(msg)) { + stream.close(); + clearTimeout(timer); + resolve(true); + } + }); + + let timer: NodeJS.Timeout | undefined; + + await trigger().then(() => { + timer = setTimeout(() => { + stream.close(); + resolve(false); + }, 500); + }).catch(err => { + stream.close(); + clearTimeout(timer); + reject(err); + }); + }); +}; + +export async function assertNotificationReceived( + receiverHost: Host, + receiver: LoginUser, + trigger: () => Promise, + cond: (notification: Misskey.entities.Notification) => boolean, + expect: boolean, +) { + const streamingFired = await isFired(receiverHost, receiver, 'main', trigger, 'notification', cond); + strictEqual(streamingFired, expect); + + const endpointFired = await receiver.client.request('i/notifications', {}) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + .then(([notification]) => notification != null ? cond(notification) : false); + strictEqual(endpointFired, expect); +} diff --git a/packages/backend/test-federation/tsconfig.json b/packages/backend/test-federation/tsconfig.json new file mode 100644 index 0000000000..3a1cb3b9f3 --- /dev/null +++ b/packages/backend/test-federation/tsconfig.json @@ -0,0 +1,114 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "NodeNext", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./built", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": [ + "daemon.ts", + "./test/**/*.ts" + ] +} diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index f26e45f6d3..b109337745 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -136,13 +136,7 @@ describe('2要素認証', () => { keyName: string, credentialId: Buffer, requestOptions: PublicKeyCredentialRequestOptionsJSON, - }): { - username: string, - password: string, - credential: AuthenticationResponseJSON, - 'g-recaptcha-response'?: string | null, - 'hcaptcha-response'?: string | null, - } => { + }): misskey.entities.SigninFlowRequest => { // AuthenticatorAssertionResponse.authenticatorData // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData const authenticatorData = Buffer.concat([ @@ -202,17 +196,21 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(doneResponse.status, 200); - const usersShowResponse = await api('users/show', { - username, - }, alice); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); + const signinWithoutTokenResponse = await api('signin-flow', { + ...signinParam(), + }); + assert.strictEqual(signinWithoutTokenResponse.status, 200); + assert.deepStrictEqual(signinWithoutTokenResponse.body, { + finished: false, + next: 'totp', + }); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), token: otpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け @@ -253,27 +251,23 @@ describe('2要素認証', () => { assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.name, keyName); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true); - - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), }); assert.strictEqual(signinResponse.status, 200); - assert.strictEqual(signinResponse.body.i, undefined); - assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined); - assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined); - assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url')); + assert.strictEqual(signinResponse.body.finished, false); + assert.strictEqual(signinResponse.body.next, 'passkey'); + assert.notEqual(signinResponse.body.authRequest.challenge, undefined); + assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined); + assert.strictEqual(signinResponse.body.authRequest.allowCredentials && signinResponse.body.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url')); - const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ + const signinResponse2 = await api('signin-flow', signinWithSecurityKeyParam({ keyName, credentialId, - requestOptions: signinResponse.body, - } as any)); + requestOptions: signinResponse.body.authRequest, + })); assert.strictEqual(signinResponse2.status, 200); + assert.strictEqual(signinResponse2.body.finished, true); assert.notEqual(signinResponse2.body.i, undefined); // 後片付け @@ -315,28 +309,30 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(passwordLessResponse.status, 204); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true); + const iResponse = await api('i', {}, alice); + assert.strictEqual(iResponse.status, 200); + assert.strictEqual(iResponse.body.usePasswordLessLogin, true); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), password: '', }); assert.strictEqual(signinResponse.status, 200); - assert.strictEqual(signinResponse.body.i, undefined); + assert.strictEqual(signinResponse.body.finished, false); + assert.strictEqual(signinResponse.body.next, 'passkey'); + assert.notEqual(signinResponse.body.authRequest.challenge, undefined); + assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined); - const signinResponse2 = await api('signin', { + const signinResponse2 = await api('signin-flow', { ...signinWithSecurityKeyParam({ keyName, credentialId, - requestOptions: signinResponse.body, + requestOptions: signinResponse.body.authRequest, } as any), password: '', }); assert.strictEqual(signinResponse2.status, 200); + assert.strictEqual(signinResponse2.body.finished, true); assert.notEqual(signinResponse2.body.i, undefined); // 後片付け @@ -424,11 +420,11 @@ describe('2要素認証', () => { assert.strictEqual(keyDoneResponse.status, 200); // テストの実行順によっては複数残ってるので全部消す - const iResponse = await api('i', { + const beforeIResponse = await api('i', { }, alice); - assert.strictEqual(iResponse.status, 200); - assert.ok(iResponse.body.securityKeysList); - for (const key of iResponse.body.securityKeysList) { + assert.strictEqual(beforeIResponse.status, 200); + assert.ok(beforeIResponse.body.securityKeysList); + for (const key of beforeIResponse.body.securityKeysList) { const removeKeyResponse = await api('i/2fa/remove-key', { token: otpToken(registerResponse.body.secret), password, @@ -437,17 +433,16 @@ describe('2要素認証', () => { assert.strictEqual(removeKeyResponse.status, 200); } - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false); + const afterIResponse = await api('i', {}, alice); + assert.strictEqual(afterIResponse.status, 200); + assert.strictEqual(afterIResponse.body.securityKeys, false); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), token: otpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け @@ -468,11 +463,9 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(doneResponse.status, 200); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); + const iResponse = await api('i', {}, alice); + assert.strictEqual(iResponse.status, 200); + assert.strictEqual(iResponse.body.twoFactorEnabled, true); const unregisterResponse = await api('i/2fa/unregister', { token: otpToken(registerResponse.body.secret), @@ -480,10 +473,11 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(unregisterResponse.status, 204); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 556ae35eb2..cf79688de6 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -66,9 +66,9 @@ describe('Endpoints', () => { }); }); - describe('signin', () => { + describe('signin-flow', () => { test('間違ったパスワードでサインインできない', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', password: 'bar', }); @@ -77,7 +77,7 @@ describe('Endpoints', () => { }); test('クエリをインジェクションできない', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', // @ts-expect-error password must be string password: { @@ -89,7 +89,7 @@ describe('Endpoints', () => { }); test('正しい情報でサインインできる', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', password: 'test1', }); diff --git a/packages/backend/test/e2e/synalio/abuse-report.ts b/packages/backend/test/e2e/synalio/abuse-report.ts index 5bf20cfc89..0725d31793 100644 --- a/packages/backend/test/e2e/synalio/abuse-report.ts +++ b/packages/backend/test/e2e/synalio/abuse-report.ts @@ -157,7 +157,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: webhookBody1.body.id, - forward: false, }, admin); }); @@ -214,7 +213,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: abuseReportId, - forward: false, }, admin); }); @@ -257,7 +255,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: webhookBody1.body.id, - forward: false, }, admin); }).catch(e => e.message); @@ -288,7 +285,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: abuseReportId, - forward: false, }, admin); }).catch(e => e.message); @@ -319,7 +315,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: abuseReportId, - forward: false, }, admin); }).catch(e => e.message); @@ -350,7 +345,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: abuseReportId, - forward: false, }, admin); }).catch(e => e.message); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index e4e21b61ca..ddfd9871c1 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -83,9 +83,6 @@ describe('ユーザー', () => { publicReactions: user.publicReactions, followingVisibility: user.followingVisibility, followersVisibility: user.followersVisibility, - twoFactorEnabled: user.twoFactorEnabled, - usePasswordLessLogin: user.usePasswordLessLogin, - securityKeys: user.securityKeys, roles: user.roles, memo: user.memo, }); @@ -150,6 +147,9 @@ describe('ユーザー', () => { achievements: user.achievements, loggedInDays: user.loggedInDays, policies: user.policies, + twoFactorEnabled: user.twoFactorEnabled, + usePasswordLessLogin: user.usePasswordLessLogin, + securityKeys: user.securityKeys, ...(security ? { email: user.email, emailVerified: user.emailVerified, @@ -344,9 +344,6 @@ describe('ユーザー', () => { assert.strictEqual(response.publicReactions, true); assert.strictEqual(response.followingVisibility, 'public'); assert.strictEqual(response.followersVisibility, 'public'); - assert.strictEqual(response.twoFactorEnabled, false); - assert.strictEqual(response.usePasswordLessLogin, false); - assert.strictEqual(response.securityKeys, false); assert.deepStrictEqual(response.roles, []); assert.strictEqual(response.memo, null); @@ -387,6 +384,9 @@ describe('ユーザー', () => { assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.loggedInDays, 0); assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); + assert.strictEqual(response.twoFactorEnabled, false); + assert.strictEqual(response.usePasswordLessLogin, false); + assert.strictEqual(response.securityKeys, false); assert.notStrictEqual(response.email, undefined); assert.strictEqual(response.emailVerified, false); assert.deepStrictEqual(response.securityKeysList, []); @@ -620,6 +620,9 @@ describe('ユーザー', () => { { label: 'Moderatorになっている', user: () => userModerator, me: () => userModerator, selector: (user: misskey.entities.MeDetailed) => user.isModerator }, // @ts-expect-error UserDetailedNotMe doesn't include isModerator { label: '自分以外から見たときはModeratorか判定できない', user: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.isModerator, expected: () => undefined }, + { label: '自分から見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => alice, selector: (user: misskey.entities.MeDetailed) => user.twoFactorEnabled, expected: () => false }, + { label: '自分以外から見た場合に二要素認証関連のプロパティがセットされていない', user: () => alice, me: () => bob, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => undefined }, + { label: 'モデレーターから見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => false }, { label: 'サイレンスになっている', user: () => userSilenced, selector: (user: misskey.entities.UserDetailed) => user.isSilenced }, // FIXME: 落ちる //{ label: 'サスペンドになっている', user: () => userSuspended, selector: (user: misskey.entities.UserDetailed) => user.isSuspended }, diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts index 93c87fd0c2..235af29f0d 100644 --- a/packages/backend/test/unit/AbuseReportNotificationService.ts +++ b/packages/backend/test/unit/AbuseReportNotificationService.ts @@ -26,6 +26,7 @@ import { ModerationLogService } from '@/core/ModerationLogService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; process.env.NODE_ENV = 'test'; @@ -110,6 +111,9 @@ describe('AbuseReportNotificationService', () => { { provide: SystemWebhookService, useFactory: () => ({ enqueueSystemWebhook: jest.fn() }), }, + { + provide: UserEntityService, useFactory: () => ({ pack: (v: any) => v }), + }, { provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }), }, diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts index b944794649..1e3605aafc 100644 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => { if (token === HttpRequestService) { return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() }; } else if (token === FederatedInstanceService) { - return { fetch: jest.fn() }; + return { fetchOrRegister: jest.fn() }; } else if (token === DI.redis) { return mockRedis; } @@ -75,7 +75,7 @@ describe('FetchInstanceMetadataService', () => { test('Lock and update', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); @@ -83,14 +83,14 @@ describe('FetchInstanceMetadataService', () => { await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1); expect(httpRequestService.getJson).toHaveBeenCalled(); }); test('Lock and don\'t update', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); @@ -98,14 +98,14 @@ describe('FetchInstanceMetadataService', () => { await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); }); test('Do nothing when lock not acquired', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); @@ -114,14 +114,14 @@ describe('FetchInstanceMetadataService', () => { await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(0); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); }); test('Do when lock not acquired but forced', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); @@ -130,7 +130,7 @@ describe('FetchInstanceMetadataService', () => { await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true); expect(tryLockSpy).toHaveBeenCalledTimes(0); expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalled(); }); }); diff --git a/packages/backend/test/unit/FlashService.ts b/packages/backend/test/unit/FlashService.ts new file mode 100644 index 0000000000..12ffaf3421 --- /dev/null +++ b/packages/backend/test/unit/FlashService.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { FlashService } from '@/core/FlashService.js'; +import { IdService } from '@/core/IdService.js'; +import { FlashsRepository, MiFlash, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; + +describe('FlashService', () => { + let app: TestingModule; + let service: FlashService; + + // -------------------------------------------------------------------------------------- + + let flashsRepository: FlashsRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let idService: IdService; + + // -------------------------------------------------------------------------------------- + + let root: MiUser; + let alice: MiUser; + let bob: MiUser; + + // -------------------------------------------------------------------------------------- + + async function createFlash(data: Partial) { + return flashsRepository.insert({ + id: idService.gen(), + updatedAt: new Date(), + userId: root.id, + title: 'title', + summary: 'summary', + script: 'script', + permissions: [], + likedCount: 0, + ...data, + }).then(x => flashsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + // -------------------------------------------------------------------------------------- + + beforeEach(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + FlashService, + IdService, + ], + }).compile(); + + service = app.get(FlashService); + + flashsRepository = app.get(DI.flashsRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + idService = app.get(IdService); + + root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); + bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false }); + }); + + afterEach(async () => { + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + await flashsRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('featured', () => { + test('should return featured flashes', async () => { + const flash1 = await createFlash({ likedCount: 1 }); + const flash2 = await createFlash({ likedCount: 2 }); + const flash3 = await createFlash({ likedCount: 3 }); + + const result = await service.featured({ + offset: 0, + limit: 10, + }); + + expect(result).toEqual([flash3, flash2, flash1]); + }); + + test('should return featured flashes public visibility only', async () => { + const flash1 = await createFlash({ likedCount: 1, visibility: 'public' }); + const flash2 = await createFlash({ likedCount: 2, visibility: 'public' }); + const flash3 = await createFlash({ likedCount: 3, visibility: 'private' }); + + const result = await service.featured({ + offset: 0, + limit: 10, + }); + + expect(result).toEqual([flash2, flash1]); + }); + + test('should return featured flashes with offset', async () => { + const flash1 = await createFlash({ likedCount: 1 }); + const flash2 = await createFlash({ likedCount: 2 }); + const flash3 = await createFlash({ likedCount: 3 }); + + const result = await service.featured({ + offset: 1, + limit: 10, + }); + + expect(result).toEqual([flash2, flash1]); + }); + + test('should return featured flashes with limit', async () => { + const flash1 = await createFlash({ likedCount: 1 }); + const flash2 = await createFlash({ likedCount: 2 }); + const flash3 = await createFlash({ likedCount: 3 }); + + const result = await service.featured({ + offset: 0, + limit: 2, + }); + + expect(result).toEqual([flash3, flash2]); + }); + }); +}); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 6bf0b5275c..9c1b1008d6 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -277,9 +277,9 @@ describe('RoleService', () => { }); describe('getModeratorIds', () => { - test('includeAdmins = false, excludeExpire = false', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = false, includeRoot = false, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -295,13 +295,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(false, false); + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: false, + excludeExpire: false, + }); expect(result).toEqual([modeUser1.id, modeUser2.id]); }); - test('includeAdmins = false, excludeExpire = true', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = false, includeRoot = false, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -317,13 +321,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(false, true); + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: false, + excludeExpire: true, + }); expect(result).toEqual([modeUser1.id]); }); - test('includeAdmins = true, excludeExpire = false', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = true, includeRoot = false, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -339,13 +347,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(true, false); + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: false, + excludeExpire: false, + }); expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]); }); - test('includeAdmins = true, excludeExpire = true', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = true, includeRoot = false, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -361,9 +373,111 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(true, true); + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: false, + excludeExpire: true, + }); expect(result).toEqual([adminUser1.id, modeUser1.id]); }); + + test('includeAdmins = false, includeRoot = true, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: false, + }); + expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]); + }); + + test('root has moderator role', async () => { + const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser({ isRoot: true }), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: rootUser.id, roleId: role2.id }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: false, + }); + expect(result).toEqual([modeUser1.id, rootUser.id]); + }); + + test('root has administrator role', async () => { + const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser({ isRoot: true }), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: rootUser.id, roleId: role1.id }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: true, + excludeExpire: false, + }); + expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]); + }); + + test('root has moderator role(expire)', async () => { + const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser({ isRoot: true }), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: modeUser1.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: rootUser.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: true, + }); + expect(result).toEqual([rootUser.id]); + }); }); describe('conditional role', () => { diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts new file mode 100644 index 0000000000..1506283a3c --- /dev/null +++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -0,0 +1,379 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as lolex from '@sinonjs/fake-timers'; +import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; +import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '@/queue/QueueLoggerService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; + +const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0)); + +describe('CheckModeratorsActivityProcessorService', () => { + let app: TestingModule; + let clock: lolex.InstalledClock; + let service: CheckModeratorsActivityProcessorService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let idService: IdService; + let roleService: jest.Mocked; + let announcementService: jest.Mocked; + let emailService: jest.Mocked; + let systemWebhookService: jest.Mocked; + + let systemWebhook1: MiSystemWebhook; + let systemWebhook2: MiSystemWebhook; + let systemWebhook3: MiSystemWebhook; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}, profile: Partial = {}): Promise { + const id = idService.gen(); + const user = await usersRepository + .insert({ + id: id, + username: `user_${id}`, + usernameLower: `user_${id}`.toLowerCase(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + ...profile, + }); + + return user; + } + + function crateSystemWebhook(data: Partial = {}): MiSystemWebhook { + return { + id: idService.gen(), + isActive: true, + updatedAt: new Date(), + latestSentAt: null, + latestStatus: null, + name: 'test', + url: 'https://example.com', + secret: 'test', + on: [], + ...data, + }; + } + + function mockModeratorRole(users: MiUser[]) { + roleService.getModerators.mockReset(); + roleService.getModerators.mockResolvedValue(users); + } + + // -------------------------------------------------------------------------------------- + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CheckModeratorsActivityProcessorService, + IdService, + { + provide: RoleService, useFactory: () => ({ getModerators: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ fetch: jest.fn() }), + }, + { + provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }), + }, + { + provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }), + }, + { + provide: SystemWebhookService, useFactory: () => ({ + fetchActiveSystemWebhooks: jest.fn(), + enqueueSystemWebhook: jest.fn(), + }), + }, + { + provide: QueueLoggerService, useFactory: () => ({ + logger: ({ + createSubLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + succ: jest.fn(), + }), + }), + }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + + service = app.get(CheckModeratorsActivityProcessorService); + idService = app.get(IdService); + roleService = app.get(RoleService) as jest.Mocked; + announcementService = app.get(AnnouncementService) as jest.Mocked; + emailService = app.get(EmailService) as jest.Mocked; + systemWebhookService = app.get(SystemWebhookService) as jest.Mocked; + + app.enableShutdownHooks(); + }); + + beforeEach(async () => { + clock = lolex.install({ + now: new Date(baseDate), + shouldClearNativeTimers: true, + }); + + systemWebhook1 = crateSystemWebhook({ on: ['inactiveModeratorsWarning'] }); + systemWebhook2 = crateSystemWebhook({ on: ['inactiveModeratorsWarning', 'inactiveModeratorsInvitationOnlyChanged'] }); + systemWebhook3 = crateSystemWebhook({ on: ['abuseReport'] }); + + emailService.sendEmail.mockReturnValue(Promise.resolve()); + announcementService.create.mockReturnValue(Promise.resolve({} as never)); + systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]); + systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never)); + }); + + afterEach(async () => { + clock.uninstall(); + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + roleService.getModerators.mockReset(); + announcementService.create.mockReset(); + emailService.sendEmail.mockReset(); + systemWebhookService.enqueueSystemWebhook.mockReset(); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('evaluateModeratorsInactiveDays', () => { + test('[isModeratorsInactive] inactiveなモデレーターがいても他のモデレーターがアクティブなら"運営が非アクティブ"としてみなされない', async () => { + const [user1, user2, user3, user4] = await Promise.all([ + // 期限よりも1秒新しいタイミングでアクティブ化(セーフ) + createUser({ lastActiveDate: subDays(addSeconds(baseDate, 1), 7) }), + // 期限ちょうどにアクティブ化(セーフ) + createUser({ lastActiveDate: subDays(baseDate, 7) }), + // 期限よりも1秒古いタイミングでアクティブ化(アウト) + createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }), + // 対象外 + createUser({ lastActiveDate: null }), + ]); + + mockModeratorRole([user1, user2, user3, user4]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user3]); + }); + + test('[isModeratorsInactive] 全員非アクティブなら"運営が非アクティブ"としてみなされる', async () => { + const [user1, user2] = await Promise.all([ + // 期限よりも1秒古いタイミングでアクティブ化(アウト) + createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }), + // 対象外 + createUser({ lastActiveDate: null }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1]); + }); + + test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り24時間->猶予1日として計算されるはずである + createUser({ lastActiveDate: subDays(baseDate, 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(1); + expect(result.remainingTime.asHours).toBe(24); + }); + + test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り25時間->猶予1日として計算されるはずである + createUser({ lastActiveDate: subDays(addHours(baseDate, 1), 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(1); + expect(result.remainingTime.asHours).toBe(25); + }); + + test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り23時間->猶予0日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(0); + expect(result.remainingTime.asHours).toBe(23); + }); + + test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限ちょうど->猶予0日として計算されるはずである + createUser({ lastActiveDate: subDays(baseDate, 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(0); + expect(result.remainingTime.asHours).toBe(0); + }); + + test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限より1時間超過->猶予-1日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1, user2]); + expect(result.remainingTime.asDays).toBe(-1); + expect(result.remainingTime.asHours).toBe(-1); + }); + + test('[remainingTime] 期限より25時間超過している場合、猶予-2日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 10) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限より1時間超過->猶予-1日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 25), 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1, user2]); + expect(result.remainingTime.asDays).toBe(-2); + expect(result.remainingTime.asHours).toBe(-25); + }); + }); + + describe('notifyInactiveModeratorsWarning', () => { + test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => { + const [user1, user2, user3, user4, root] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + createUser({}, { email: 'user2@example.com', emailVerified: false }), + createUser({}, { email: null, emailVerified: false }), + createUser({}, { email: 'user4@example.com', emailVerified: true }), + createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1, user2, user3, root]); + await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 }); + + expect(emailService.sendEmail).toHaveBeenCalledTimes(2); + expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com'); + expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com'); + }); + + test('[systemWebhook] "inactiveModeratorsWarning"が有効なSystemWebhookに対して送信される', async () => { + const [user1] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1]); + await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 }); + + expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(2); + expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook1); + expect(systemWebhookService.enqueueSystemWebhook.mock.calls[1][0]).toEqual(systemWebhook2); + }); + }); + + describe('notifyChangeToInvitationOnly', () => { + test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => { + const [user1, user2, user3, user4, root] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + createUser({}, { email: 'user2@example.com', emailVerified: false }), + createUser({}, { email: null, emailVerified: false }), + createUser({}, { email: 'user4@example.com', emailVerified: true }), + createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1, user2, user3, root]); + await service.notifyChangeToInvitationOnly(); + + expect(announcementService.create).toHaveBeenCalledTimes(4); + expect(announcementService.create.mock.calls[0][0].userId).toBe(user1.id); + expect(announcementService.create.mock.calls[1][0].userId).toBe(user2.id); + expect(announcementService.create.mock.calls[2][0].userId).toBe(user3.id); + expect(announcementService.create.mock.calls[3][0].userId).toBe(root.id); + + expect(emailService.sendEmail).toHaveBeenCalledTimes(2); + expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com'); + expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com'); + }); + + test('[systemWebhook] "inactiveModeratorsInvitationOnlyChanged"が有効なSystemWebhookに対して送信される', async () => { + const [user1] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1]); + await service.notifyChangeToInvitationOnly(); + + expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1); + expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook2); + }); + }); +}); diff --git a/packages/cherrypick-js/README.md b/packages/cherrypick-js/README.md index 5f3eed9436..e266fbf524 100644 --- a/packages/cherrypick-js/README.md +++ b/packages/cherrypick-js/README.md @@ -4,7 +4,7 @@ [![Test](https://github.com/misskey-dev/misskey.js/actions/workflows/test.yml/badge.svg)](https://github.com/misskey-dev/misskey.js/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/misskey-dev/misskey.js/branch/develop/graph/badge.svg?token=PbrTtk3nVD)](https://codecov.io/gh/misskey-dev/misskey.js) -[![NPM](https://nodei.co/npm/misskey-js.png?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/misskey-js) +[![NPM](https://nodei.co/npm/cherrypick-js.png?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/cherrypick-js) JavaScript(TypeScript)用の公式CherryPickSDKです。ブラウザ/Node.js上で動作します。 diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index 3ee013adeb..38786731e1 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -4,7 +4,9 @@ ```ts +import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; import { EventEmitter } from 'eventemitter3'; +import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'; // Warning: (ae-forgotten-export) The symbol "components" needs to be exported by the entry point index.d.ts // @@ -241,6 +243,9 @@ type AdminFederationRemoveAllFollowingRequest = operations['admin___federation__ // @public (undocumented) type AdminFederationUpdateInstanceRequest = operations['admin___federation___update-instance']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminForwardAbuseUserReportRequest = operations['admin___forward-abuse-user-report']['requestBody']['content']['application/json']; + // @public (undocumented) type AdminGetIndexStatsResponse = operations['admin___get-index-stats']['responses']['200']['content']['application/json']; @@ -406,6 +411,9 @@ type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requ // @public (undocumented) type AdminUnsuspendUserRequest = operations['admin___unsuspend-user']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminUpdateAbuseUserReportRequest = operations['admin___update-abuse-user-report']['requestBody']['content']['application/json']; + // @public (undocumented) type AdminUpdateMetaRequest = operations['admin___update-meta']['requestBody']['content']['application/json']; @@ -1216,13 +1224,25 @@ export type Endpoints = Overwrite): reason is APIError { - return reason[MK_API_ERROR] === true; + return reason[CP_API_ERROR] === true; } export type FetchLike = (input: string, init?: { @@ -118,7 +118,7 @@ export class APIClient { resolve(body); } else { reject({ - [MK_API_ERROR]: true, + [CP_API_ERROR]: true, ...body.error, }); } diff --git a/packages/cherrypick-js/src/api.types.ts b/packages/cherrypick-js/src/api.types.ts index 4c3f2e1578..838949f8e1 100644 --- a/packages/cherrypick-js/src/api.types.ts +++ b/packages/cherrypick-js/src/api.types.ts @@ -3,8 +3,9 @@ import { UserDetailed } from './autogen/models.js'; import { AdminRolesCreateRequest, AdminRolesCreateResponse, UsersShowRequest } from './autogen/entities.js'; import { PartialRolePolicyOverride, - SigninRequest, - SigninResponse, + SigninFlowRequest, + SigninFlowResponse, + SigninWithPasskeyInitResponse, SigninWithPasskeyRequest, SigninWithPasskeyResponse, SignupPendingRequest, @@ -80,14 +81,26 @@ export type Endpoints = Overwrite< res: SignupPendingResponse; }, // api.jsonには載せないものなのでここで定義 - 'signin': { - req: SigninRequest; - res: SigninResponse; + 'signin-flow': { + req: SigninFlowRequest; + res: SigninFlowResponse; }, 'signin-with-passkey': { req: SigninWithPasskeyRequest; - res: SigninWithPasskeyResponse; - } + res: { + $switch: { + $cases: [ + [ + { + context: string; + }, + SigninWithPasskeyResponse, + ], + ]; + $default: SigninWithPasskeyInitResponse; + }, + }, + }, 'admin/roles/create': { req: Overwrite; res: AdminRolesCreateResponse; diff --git a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts index b496cdff06..04396b05e2 100644 --- a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts +++ b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts @@ -772,6 +772,28 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/cherrypick-js/src/autogen/endpoint.ts b/packages/cherrypick-js/src/autogen/endpoint.ts index 5571be9fbe..a8ef31d12b 100644 --- a/packages/cherrypick-js/src/autogen/endpoint.ts +++ b/packages/cherrypick-js/src/autogen/endpoint.ts @@ -93,6 +93,8 @@ import type { AdminResetPasswordRequest, AdminResetPasswordResponse, AdminResolveAbuseUserReportRequest, + AdminForwardAbuseUserReportRequest, + AdminUpdateAbuseUserReportRequest, AdminSendEmailRequest, AdminServerInfoResponse, AdminShowModerationLogsRequest, @@ -492,6 +494,7 @@ import type { FlashCreateRequest, FlashCreateResponse, FlashDeleteRequest, + FlashFeaturedRequest, FlashFeaturedResponse, FlashGenTokenRequest, FlashGenTokenResponse, @@ -694,6 +697,8 @@ export type Endpoints = { 'admin/relays/remove': { req: AdminRelaysRemoveRequest; res: EmptyResponse }; 'admin/reset-password': { req: AdminResetPasswordRequest; res: AdminResetPasswordResponse }; 'admin/resolve-abuse-user-report': { req: AdminResolveAbuseUserReportRequest; res: EmptyResponse }; + 'admin/forward-abuse-user-report': { req: AdminForwardAbuseUserReportRequest; res: EmptyResponse }; + 'admin/update-abuse-user-report': { req: AdminUpdateAbuseUserReportRequest; res: EmptyResponse }; 'admin/send-email': { req: AdminSendEmailRequest; res: EmptyResponse }; 'admin/server-info': { req: EmptyRequest; res: AdminServerInfoResponse }; 'admin/show-moderation-logs': { req: AdminShowModerationLogsRequest; res: AdminShowModerationLogsResponse }; @@ -957,7 +962,7 @@ export type Endpoints = { 'pages/update': { req: PagesUpdateRequest; res: EmptyResponse }; 'flash/create': { req: FlashCreateRequest; res: FlashCreateResponse }; 'flash/delete': { req: FlashDeleteRequest; res: EmptyResponse }; - 'flash/featured': { req: EmptyRequest; res: FlashFeaturedResponse }; + 'flash/featured': { req: FlashFeaturedRequest; res: FlashFeaturedResponse }; 'flash/gen-token': { req: FlashGenTokenRequest; res: FlashGenTokenResponse }; 'flash/like': { req: FlashLikeRequest; res: EmptyResponse }; 'flash/show': { req: FlashShowRequest; res: FlashShowResponse }; diff --git a/packages/cherrypick-js/src/autogen/entities.ts b/packages/cherrypick-js/src/autogen/entities.ts index f8fb57377f..799cfbec6d 100644 --- a/packages/cherrypick-js/src/autogen/entities.ts +++ b/packages/cherrypick-js/src/autogen/entities.ts @@ -96,6 +96,8 @@ export type AdminRelaysRemoveRequest = operations['admin___relays___remove']['re export type AdminResetPasswordRequest = operations['admin___reset-password']['requestBody']['content']['application/json']; export type AdminResetPasswordResponse = operations['admin___reset-password']['responses']['200']['content']['application/json']; export type AdminResolveAbuseUserReportRequest = operations['admin___resolve-abuse-user-report']['requestBody']['content']['application/json']; +export type AdminForwardAbuseUserReportRequest = operations['admin___forward-abuse-user-report']['requestBody']['content']['application/json']; +export type AdminUpdateAbuseUserReportRequest = operations['admin___update-abuse-user-report']['requestBody']['content']['application/json']; export type AdminSendEmailRequest = operations['admin___send-email']['requestBody']['content']['application/json']; export type AdminServerInfoResponse = operations['admin___server-info']['responses']['200']['content']['application/json']; export type AdminShowModerationLogsRequest = operations['admin___show-moderation-logs']['requestBody']['content']['application/json']; @@ -495,6 +497,7 @@ export type PagesUpdateRequest = operations['pages___update']['requestBody']['co export type FlashCreateRequest = operations['flash___create']['requestBody']['content']['application/json']; export type FlashCreateResponse = operations['flash___create']['responses']['200']['content']['application/json']; export type FlashDeleteRequest = operations['flash___delete']['requestBody']['content']['application/json']; +export type FlashFeaturedRequest = operations['flash___featured']['requestBody']['content']['application/json']; export type FlashFeaturedResponse = operations['flash___featured']['responses']['200']['content']['application/json']; export type FlashGenTokenRequest = operations['flash___gen-token']['requestBody']['content']['application/json']; export type FlashGenTokenResponse = operations['flash___gen-token']['responses']['200']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index 3b7ad95849..3717a0f621 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -643,6 +643,24 @@ export type paths = { */ post: operations['admin___resolve-abuse-user-report']; }; + '/admin/forward-abuse-user-report': { + /** + * admin/forward-abuse-user-report + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* + */ + post: operations['admin___forward-abuse-user-report']; + }; + '/admin/update-abuse-user-report': { + /** + * admin/update-abuse-user-report + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* + */ + post: operations['admin___update-abuse-user-report']; + }; '/admin/send-email': { /** * admin/send-email @@ -4095,16 +4113,13 @@ export type components = { followingVisibility: 'public' | 'followers' | 'private'; /** @enum {string} */ followersVisibility: 'public' | 'followers' | 'private'; - /** @default false */ - twoFactorEnabled: boolean; - /** @default false */ - usePasswordLessLogin: boolean; - /** @default false */ - securityKeys: boolean; roles: components['schemas']['RoleLite'][]; followedMessage?: string | null; memo: string | null; moderationNote?: string; + twoFactorEnabled?: boolean; + usePasswordLessLogin?: boolean; + securityKeys?: boolean; isFollowing?: boolean; isFollowed?: boolean; hasPendingFollowRequestFromYou?: boolean; @@ -4295,6 +4310,12 @@ export type components = { }[]; loggedInDays: number; policies: components['schemas']['RolePolicies']; + /** @default false */ + twoFactorEnabled: boolean; + /** @default false */ + usePasswordLessLogin: boolean; + /** @default false */ + securityKeys: boolean; email?: string | null; emailVerified?: boolean | null; securityKeysList?: { @@ -4656,7 +4677,14 @@ export type components = { exportedEntity: 'antenna' | 'blocking' | 'clip' | 'customEmoji' | 'favorite' | 'following' | 'muting' | 'note' | 'userList'; /** Format: id */ fileId: string; - }) | ({ + }) | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'login'; + } | ({ /** Format: id */ id: string; /** Format: date-time */ @@ -5327,6 +5355,7 @@ export type components = { recaptchaSiteKey: string | null; enableTurnstile: boolean; turnstileSiteKey: string | null; + enableTestcaptcha: boolean; swPublickey: string | null; /** @default /assets/ai.png */ mascotImageUrl: string; @@ -5404,7 +5433,7 @@ export type components = { latestSentAt: string | null; latestStatus: number | null; name: string; - on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; + on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[]; url: string; secret: string; }; @@ -5459,6 +5488,7 @@ export type operations = { recaptchaSiteKey: string | null; enableTurnstile: boolean; turnstileSiteKey: string | null; + enableTestcaptcha: boolean; swPublickey: string | null; /** @default /assets/ai.png */ mascotImageUrl: string | null; @@ -5481,6 +5511,7 @@ export type operations = { blockedHosts: string[]; sensitiveWords: string[]; prohibitedWords: string[]; + prohibitedWordsForNameOfUser: string[]; bannedEmailDomains?: string[]; preservedUsernames: string[]; hcaptchaSecretKey: string | null; @@ -5533,6 +5564,7 @@ export type operations = { truemailAuthKey: string | null; enableChartsForRemoteUser: boolean; enableChartsForFederatedInstances: boolean; + enableStatsForFederatedInstances: boolean; enableServerMachineStats: boolean; enableIdenticonGeneration: boolean; manifestJsonOverride: string; @@ -5895,8 +5927,6 @@ export type operations = { * @enum {string} */ targetUserOrigin?: 'combined' | 'local' | 'remote'; - /** @default false */ - forwarded?: boolean; }; }; }; @@ -5923,7 +5953,11 @@ export type operations = { assigneeId: string | null; reporter: components['schemas']['UserDetailedNotMe']; targetUser: components['schemas']['UserDetailedNotMe']; - assignee?: components['schemas']['UserDetailedNotMe'] | null; + assignee: components['schemas']['UserDetailedNotMe'] | null; + forwarded: boolean; + /** @enum {string|null} */ + resolvedAs: 'accept' | 'reject' | null; + moderationNote: string; })[]; }; }; @@ -6257,6 +6291,7 @@ export type operations = { 'application/json': { username: string; password: string; + setupPassword?: string | null; }; }; }; @@ -9498,8 +9533,113 @@ export type operations = { 'application/json': { /** Format: misskey:id */ reportId: string; - /** @default false */ - forward?: boolean; + /** @enum {string|null} */ + resolvedAs?: 'accept' | 'reject' | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/forward-abuse-user-report + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* + */ + 'admin___forward-abuse-user-report': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + reportId: string; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/update-abuse-user-report + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:resolve-abuse-user-report* + */ + 'admin___update-abuse-user-report': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + reportId: string; + moderationNote?: string; }; }; }; @@ -10148,6 +10288,7 @@ export type operations = { blockedHosts?: string[] | null; sensitiveWords?: string[] | null; prohibitedWords?: string[] | null; + prohibitedWordsForNameOfUser?: string[] | null; themeColor?: string | null; mascotImageUrl?: string | null; bannerUrl?: string | null; @@ -10181,6 +10322,7 @@ export type operations = { enableTurnstile?: boolean; turnstileSiteKey?: string | null; turnstileSecretKey?: string | null; + enableTestcaptcha?: boolean; /** @enum {string} */ sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote'; /** @enum {string} */ @@ -10251,6 +10393,7 @@ export type operations = { truemailAuthKey?: string | null; enableChartsForRemoteUser?: boolean; enableChartsForFederatedInstances?: boolean; + enableStatsForFederatedInstances?: boolean; enableServerMachineStats?: boolean; enableIdenticonGeneration?: boolean; serverRules?: string[]; @@ -10958,7 +11101,7 @@ export type operations = { 'application/json': { isActive: boolean; name: string; - on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; + on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[]; url: string; secret: string; }; @@ -11068,7 +11211,7 @@ export type operations = { content: { 'application/json': { isActive?: boolean; - on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; + on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[]; }; }; }; @@ -11181,7 +11324,7 @@ export type operations = { id: string; isActive: boolean; name: string; - on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; + on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[]; url: string; secret: string; }; @@ -11240,7 +11383,7 @@ export type operations = { /** Format: misskey:id */ webhookId: string; /** @enum {string} */ - type: 'abuseReport' | 'abuseReportResolved' | 'userCreated'; + type: 'abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged'; override?: { url?: string; secret?: string; @@ -19406,8 +19549,8 @@ export type operations = { untilId?: string; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote')[]; }; }; }; @@ -19474,8 +19617,8 @@ export type operations = { untilId?: string; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'note:grouped' | 'pollVote')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'note:grouped' | 'pollVote')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'note:grouped' | 'pollVote')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'note:grouped' | 'pollVote')[]; }; }; }; @@ -25476,6 +25619,16 @@ export type operations = { * **Credential required**: *No* */ flash___featured: { + requestBody: { + content: { + 'application/json': { + /** @default 0 */ + offset?: number; + /** @default 10 */ + limit?: number; + }; + }; + }; responses: { /** @description OK (with results) */ 200: { diff --git a/packages/cherrypick-js/src/consts.ts b/packages/cherrypick-js/src/consts.ts index fe64319128..90847da702 100644 --- a/packages/cherrypick-js/src/consts.ts +++ b/packages/cherrypick-js/src/consts.ts @@ -144,6 +144,8 @@ export const moderationLogTypes = [ 'markSensitiveDriveFile', 'unmarkSensitiveDriveFile', 'resolveAbuseReport', + 'forwardAbuseReport', + 'updateAbuseReportNote', 'createInvitation', 'createAd', 'updateAd', @@ -332,7 +334,18 @@ export type ModerationLogPayloads = { resolveAbuseReport: { reportId: string; report: ReceivedAbuseReport; - forwarded: boolean; + forwarded?: boolean; + resolvedAs?: string | null; + }; + forwardAbuseReport: { + reportId: string; + report: ReceivedAbuseReport; + }; + updateAbuseReportNote: { + reportId: string; + report: ReceivedAbuseReport; + before: string; + after: string; }; createInvitation: { invitations: InviteCode[]; diff --git a/packages/cherrypick-js/src/entities.ts b/packages/cherrypick-js/src/entities.ts index 4f8a449177..26c888665c 100644 --- a/packages/cherrypick-js/src/entities.ts +++ b/packages/cherrypick-js/src/entities.ts @@ -10,6 +10,7 @@ import { User, UserDetailedNotMe, } from './autogen/models.js'; +import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'; export * from './autogen/entities.js'; export * from './autogen/models.js'; @@ -152,6 +153,12 @@ export type ModerationLog = { } | { type: 'resolveAbuseReport'; info: ModerationLogPayloads['resolveAbuseReport']; +} | { + type: 'forwardAbuseReport'; + info: ModerationLogPayloads['forwardAbuseReport']; +} | { + type: 'updateAbuseReportNote'; + info: ModerationLogPayloads['updateAbuseReportNote']; } | { type: 'unsetUserAvatar'; info: ModerationLogPayloads['unsetUserAvatar']; @@ -250,6 +257,7 @@ export type SignupRequest = { 'hcaptcha-response'?: string | null; 'g-recaptcha-response'?: string | null; 'turnstile-response'?: string | null; + 'm-captcha-response'?: string | null; } export type SignupResponse = MeDetailed & { @@ -265,26 +273,42 @@ export type SignupPendingResponse = { i: string, }; -export type SigninRequest = { +export type SigninFlowRequest = { username: string; - password: string; + password?: string; token?: string; + credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string | null; + 'g-recaptcha-response'?: string | null; + 'turnstile-response'?: string | null; + 'm-captcha-response'?: string | null; +}; + +export type SigninFlowResponse = { + finished: true; + id: User['id']; + i: string; +} | { + finished: false; + next: 'captcha' | 'password' | 'totp'; +} | { + finished: false; + next: 'passkey'; + authRequest: PublicKeyCredentialRequestOptionsJSON; }; export type SigninWithPasskeyRequest = { - credential?: object; + credential?: AuthenticationResponseJSON; context?: string; }; +export type SigninWithPasskeyInitResponse = { + option: PublicKeyCredentialRequestOptionsJSON; + context: string; +}; + export type SigninWithPasskeyResponse = { - option?: object; - context?: string; - signinResponse?: SigninResponse; -}; - -export type SigninResponse = { - id: User['id'], - i: string, + signinResponse: SigninFlowResponse & { finished: true }; }; type Values> = T[keyof T]; diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index ef7345cd49..6a891b62e6 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -21,7 +21,7 @@ "@tabler/icons-webfont": "3.3.0", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.1.4", - "@vue/compiler-sfc": "3.5.10", + "@vue/compiler-sfc": "3.5.11", "astring": "1.9.0", "buraha": "0.0.1", "mfc-js": "0.24.0-cherrypick.9", @@ -30,8 +30,8 @@ "frontend-shared": "workspace:*", "punycode": "2.3.1", "rollup": "4.22.5", - "sass": "1.79.3", - "shiki": "1.12.0", + "sass": "1.79.4", + "shiki": "1.21.0", "temml": "0.10.29", "tinycolor2": "1.6.0", "tsc-alias": "1.8.10", @@ -40,7 +40,7 @@ "uuid": "10.0.0", "json5": "2.2.3", "vite": "5.4.8", - "vue": "3.5.10" + "vue": "3.5.11" }, "devDependencies": { "@biomejs/biome": "1.9.3", @@ -56,10 +56,10 @@ "@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/parser": "7.17.0", "@vitest/coverage-v8": "1.6.0", - "@vue/runtime-core": "3.5.10", + "@vue/runtime-core": "3.5.11", "acorn": "8.12.1", "cross-env": "7.0.3", - "eslint-plugin-import": "2.30.0", + "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "9.28.0", "fast-glob": "3.3.2", "happy-dom": "10.0.3", diff --git a/packages/frontend-embed/src/components/EmCustomEmoji.vue b/packages/frontend-embed/src/components/EmCustomEmoji.vue index e4149cf363..59b670cdc6 100644 --- a/packages/frontend-embed/src/components/EmCustomEmoji.vue +++ b/packages/frontend-embed/src/components/EmCustomEmoji.vue @@ -38,8 +38,6 @@ const props = defineProps<{ host?: string | null; url?: string; useOriginalSize?: boolean; - menu?: boolean; - menuReaction?: boolean; fallbackToImage?: boolean; }>(); diff --git a/packages/frontend-embed/src/components/EmLoading.vue b/packages/frontend-embed/src/components/EmLoading.vue index 13cb61062b..4eddbf769a 100644 --- a/packages/frontend-embed/src/components/EmLoading.vue +++ b/packages/frontend-embed/src/components/EmLoading.vue @@ -64,7 +64,7 @@ const props = withDefaults(defineProps<{ --size: 48px; &.colored { - color: var(--accent); + color: var(--MI_THEME-accent); } &.inline { @@ -103,7 +103,7 @@ const props = withDefaults(defineProps<{ } .path { - stroke: var(--accent); + stroke: var(--MI_THEME-accent); stroke-linecap: round; animation: dash 1.2s ease-in-out infinite; } diff --git a/packages/frontend-embed/src/components/EmMediaBanner.vue b/packages/frontend-embed/src/components/EmMediaBanner.vue index ce574dc188..29bc3d79ad 100644 --- a/packages/frontend-embed/src/components/EmMediaBanner.vue +++ b/packages/frontend-embed/src/components/EmMediaBanner.vue @@ -31,17 +31,17 @@ defineProps<{ display: flex; align-items: center; width: 100%; - padding: var(--margin); + padding: var(--MI-margin); margin-top: 4px; - border: 1px solid var(--inputBorder); - border-radius: var(--radius); - background-color: var(--panel); + border: 1px solid var(--MI_THEME-inputBorder); + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); transition: background-color .1s, border-color .1s; &:hover { text-decoration: none; - border-color: var(--inputBorderHover); - background-color: var(--buttonHoverBg); + border-color: var(--MI_THEME-inputBorderHover); + background-color: var(--MI_THEME-buttonHoverBg); } } diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue index 10b496d27b..f3f8b88adf 100644 --- a/packages/frontend-embed/src/components/EmMediaImage.vue +++ b/packages/frontend-embed/src/components/EmMediaImage.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
GIF
ALT
-
+
@@ -94,8 +94,8 @@ async function onclick(ev: MouseEvent) { display: block; position: absolute; border-radius: 6px; - background-color: var(--fg); - color: var(--accentLighten); + background-color: var(--MI_THEME-fg); + color: var(--MI_THEME-accentLighten); font-size: 12px; opacity: .5; padding: 5px 8px; @@ -114,19 +114,19 @@ async function onclick(ev: MouseEvent) { .visible { position: relative; - //box-shadow: 0 0 0 1px var(--divider) inset; - background: var(--bg); + //box-shadow: 0 0 0 1px var(--MI_THEME-divider) inset; + background: var(--MI_THEME-bg); background-size: 16px 16px; } html[data-color-scheme=dark] .visible { --c: rgb(255 255 255 / 2%); - background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); } html[data-color-scheme=light] .visible { --c: rgb(0 0 0 / 2%); - background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); } .imageContainer { @@ -150,10 +150,10 @@ html[data-color-scheme=light] .visible { } .indicator { - /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */ + /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: 6px; - color: var(--accentLighten); + color: var(--MI_THEME-accentLighten); display: inline-block; font-weight: bold; font-size: 0.8em; diff --git a/packages/frontend-embed/src/components/EmMediaVideo.vue b/packages/frontend-embed/src/components/EmMediaVideo.vue index 979371c5c2..6c4328f66a 100644 --- a/packages/frontend-embed/src/components/EmMediaVideo.vue +++ b/packages/frontend-embed/src/components/EmMediaVideo.vue @@ -29,9 +29,9 @@ defineProps<{ width: 100%; height: auto; aspect-ratio: 16 / 9; - padding: var(--margin); - border: 1px solid var(--divider); - border-radius: var(--radius); + padding: var(--MI-margin); + border: 1px solid var(--MI_THEME-divider); + border-radius: var(--MI-radius); background-color: #000; &:hover { @@ -49,7 +49,7 @@ defineProps<{ } .videoOverlayPlayButton { - background: var(--accent); + background: var(--MI_THEME-accent); color: #fff; padding: 1rem; border-radius: 99rem; diff --git a/packages/frontend-embed/src/components/EmMention.vue b/packages/frontend-embed/src/components/EmMention.vue index a631783507..a71364237d 100644 --- a/packages/frontend-embed/src/components/EmMention.vue +++ b/packages/frontend-embed/src/components/EmMention.vue @@ -27,7 +27,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us const url = `/${canonical}`; -const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--mention')); +const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-mention')); bg.setAlpha(0.1); const bgCss = bg.toRgbString(); @@ -37,7 +37,7 @@ const bgCss = bg.toRgbString(); display: inline-block; padding: 4px 8px 4px 4px; border-radius: 999px; - color: var(--mention); + color: var(--MI_THEME-mention); } .host { diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts index 01f3df3988..2ce43e030f 100644 --- a/packages/frontend-embed/src/components/EmMfm.ts +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -27,8 +27,8 @@ const QUOTE_STYLE = ` display: block; margin: 8px; padding: 6px 0 6px 12px; -color: var(--fg); -border-left: solid 3px var(--fg); +color: var(--MI_THEME-fg); +border-left: solid 3px var(--MI_THEME-fg); opacity: 0.7; `.split('\n').join(' '); @@ -42,9 +42,6 @@ type MfmProps = { rootScale?: number; nyaize?: boolean | 'respect'; parsedNodes?: mfm.MfmNode[] | null; - enableEmojiMenu?: boolean; - enableEmojiMenuReaction?: boolean; - linkNavigationBehavior?: string; }; type MfmEvents = { @@ -53,8 +50,6 @@ type MfmEvents = { // eslint-disable-next-line import/no-default-export export default function (props: MfmProps, { emit }: { emit: SetupContext['emit'] }) { - provide('linkNavigationBehavior', props.linkNavigationBehavior); - const isNote = props.isNote ?? true; const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false; @@ -260,7 +255,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext= 2.5, - menu: props.enableEmojiMenu, - menuReaction: props.enableEmojiMenuReaction, fallbackToImage: false, })]; } else { diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue index 4dae05c559..1e1beed377 100644 --- a/packages/frontend-embed/src/components/EmNote.vue +++ b/packages/frontend-embed/src/components/EmNote.vue @@ -179,14 +179,8 @@ const isDeleted = ref(false); font-size: 1.05em; overflow: clip; contain: content; - - // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 - // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう - // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 - // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる - // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) - //content-visibility: auto; - //contain-intrinsic-size: 0 128px; + content-visibility: auto; + contain-intrinsic-size: 0 150px; &:focus-visible { outline: none; @@ -204,8 +198,8 @@ const isDeleted = ref(false); margin: auto; width: calc(100% - 8px); height: calc(100% - 8px); - border: dashed 2px var(--focus); - border-radius: var(--radius); + border: dashed 2px var(--MI_THEME-focus); + border-radius: var(--MI-radius); box-sizing: border-box; } } @@ -227,9 +221,9 @@ const isDeleted = ref(false); right: 12px; padding: 0 4px; margin-bottom: 0 !important; - background: var(--popup); + background: var(--MI_THEME-popup); border-radius: 8px; - box-shadow: 0 4px 32px var(--shadow); + box-shadow: 0 4px 32px var(--MI_THEME-shadow); } .footerButton { @@ -274,7 +268,7 @@ const isDeleted = ref(false); padding: 24px 38px 16px; line-height: 28px; white-space: pre; - color: var(--renote); + color: var(--MI_THEME-renote); & + .article { padding-top: 8px; @@ -291,7 +285,7 @@ const isDeleted = ref(false); width: 28px; height: 28px; margin: 0 8px 0 0; - background: var(--panel); + background: var(--MI_THEME-panel); } .renoteText { @@ -306,7 +300,7 @@ const isDeleted = ref(false); text-decoration: none; &:hover { - color: var(--renoteHover); + color: var(--MI_THEME-renoteHover); text-decoration: none; } } @@ -339,7 +333,7 @@ const isDeleted = ref(false); width: 28px; height: 28px; margin: 0 8px 0 0; - background: var(--panel); + background: var(--MI_THEME-panel); } .collapsedRenoteTargetText { @@ -379,9 +373,9 @@ const isDeleted = ref(false); width: 58px; height: 58px; position: sticky !important; - top: calc(22px + var(--stickyTop, 0px)); + top: calc(22px + var(--MI-stickyTop, 0px)); left: 0; - background: var(--panel); + background: var(--MI_THEME-panel); transition: top 0.5s; } @@ -402,12 +396,12 @@ const isDeleted = ref(false); width: 100%; margin-top: 14px; position: sticky; - bottom: calc(var(--stickyBottom, 0px) + 14px); + bottom: calc(var(--MI-stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; - background: var(--popup); + background: var(--MI_THEME-popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -429,16 +423,16 @@ const isDeleted = ref(false); z-index: 2; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); &:hover > .collapsedLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } .collapsedLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -450,13 +444,13 @@ const isDeleted = ref(false); } .replyIcon { - color: var(--accent); + color: var(--MI_THEME-accent); margin-right: 0.5em; } .translation { - border: solid 0.5px var(--divider); - border-radius: var(--radius); + border: solid 0.5px var(--MI_THEME-divider); + border-radius: var(--MI-radius); padding: 12px; margin-top: 8px; } @@ -475,7 +469,7 @@ const isDeleted = ref(false); .quoteNote { padding: 24px; - border: solid 1px var(--renote); + border: solid 1px var(--MI_THEME-renote); border-radius: 8px; overflow: clip; } @@ -499,7 +493,7 @@ const isDeleted = ref(false); } &:hover { - color: var(--fgHighlighted); + color: var(--MI_THEME-fgHighlighted); } } @@ -576,7 +570,7 @@ const isDeleted = ref(false); margin: 0 10px 0 0; width: 46px; height: 46px; - top: calc(14px + var(--stickyTop, 0px)); + top: calc(14px + var(--MI-stickyTop, 0px)); } } diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue index 89f03cb20f..85d62bac22 100644 --- a/packages/frontend-embed/src/components/EmNoteDetailed.vue +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -228,8 +228,8 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); margin: auto; width: calc(100% - 8px); height: calc(100% - 8px); - border: dashed 2px var(--focus); - border-radius: var(--radius); + border: dashed 2px var(--MI_THEME-focus); + border-radius: var(--MI-radius); box-sizing: border-box; } } @@ -246,7 +246,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); padding: 16px 32px 8px 32px; line-height: 28px; white-space: pre; - color: var(--renote); + color: var(--MI_THEME-renote); } .renoteAvatar { @@ -256,7 +256,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); height: 28px; margin: 0 8px 0 0; border-radius: 6px; - background: var(--panel); + background: var(--MI_THEME-panel); } .renoteText { @@ -271,7 +271,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); text-decoration: none; &:hover { - color: var(--renoteHover); + color: var(--MI_THEME-renoteHover); text-decoration: none; } } @@ -315,7 +315,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); flex-shrink: 0; width: 58px; height: 58px; - background: var(--panel); + background: var(--MI_THEME-panel); } .noteHeaderBody { @@ -348,7 +348,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); } &:hover { - color: var(--nameHover); + color: var(--MI_THEME-nameHover); text-decoration: none; } } @@ -359,7 +359,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); padding: 4px 6px; font-size: 80%; line-height: 1; - border: solid 0.5px var(--divider); + border: solid 0.5px var(--MI_THEME-divider); border-radius: 4px; } @@ -407,14 +407,14 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); } .noteReplyTarget { - color: var(--accent); + color: var(--MI_THEME-accent); margin-right: 0.5em; } .rn { margin-left: 4px; font-style: oblique; - color: var(--renote); + color: var(--MI_THEME-renote); } .reactionOmitted { @@ -434,7 +434,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); .quoteNote { padding: 24px; - border: solid 1px var(--renote); + border: solid 1px var(--MI_THEME-renote); border-radius: 8px; overflow: clip; } @@ -448,12 +448,12 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); width: 100%; margin-top: 14px; position: sticky; - bottom: calc(var(--stickyBottom, 0px) + 14px); + bottom: calc(var(--MI-stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; - background: var(--popup); + background: var(--MI_THEME-popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -474,16 +474,16 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); z-index: 2; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-X15) srgb r g b / 0)); &:hover > .collapsedLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } .collapsedLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -506,7 +506,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); } &:hover { - color: var(--fgHighlighted); + color: var(--MI_THEME-fgHighlighted); } } @@ -522,7 +522,7 @@ const collapsed = ref(appearNote.value.cw == null && (isLong || (isMFM))); opacity: 0.7; &.reacted { - color: var(--accent); + color: var(--MI_THEME-accent); } } diff --git a/packages/frontend-embed/src/components/EmNoteHeader.vue b/packages/frontend-embed/src/components/EmNoteHeader.vue index e251c8366c..5e58c06235 100644 --- a/packages/frontend-embed/src/components/EmNoteHeader.vue +++ b/packages/frontend-embed/src/components/EmNoteHeader.vue @@ -96,7 +96,7 @@ defineProps<{ } &:hover { - color: var(--nameHover); + color: var(--MI_THEME-nameHover); text-decoration: none; } } @@ -107,7 +107,7 @@ defineProps<{ margin: 0 .5em 0 0; padding: 1px 6px; font-size: 80%; - border: solid 0.5px var(--divider); + border: solid 0.5px var(--MI_THEME-divider); border-radius: 3px; } @@ -152,7 +152,7 @@ defineProps<{ } .danger { - color: var(--accent); + color: var(--MI_THEME-accent); } @container (max-width: 500px) { diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue index 081b1dc477..fce7ee045f 100644 --- a/packages/frontend-embed/src/components/EmNoteSimple.vue +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -55,9 +55,9 @@ const showContent = ref(false); height: 34px; border-radius: 8px; position: sticky !important; - top: calc(16px + var(--stickyTop, 0px)); + top: calc(16px + var(--MI-stickyTop, 0px)); left: 0; - background: var(--panel); + background: var(--MI_THEME-panel); } .main { diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue index 5dc104fc74..d3f69dd7a8 100644 --- a/packages/frontend-embed/src/components/EmNoteSub.vue +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -134,7 +134,7 @@ if (props.detail) { } .reply, .more { - border-left: solid 0.5px var(--divider); + border-left: solid 0.5px var(--MI_THEME-divider); margin-top: 10px; } @@ -155,7 +155,7 @@ if (props.detail) { .muted { text-align: center; padding: 8px !important; - border: 1px solid var(--divider); + border: 1px solid var(--MI_THEME-divider); margin: 8px 8px 0 8px; border-radius: 8px; } diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue index 3418d97f77..4211261e19 100644 --- a/packages/frontend-embed/src/components/EmNotes.vue +++ b/packages/frontend-embed/src/components/EmNotes.vue @@ -43,10 +43,10 @@ defineExpose({ diff --git a/packages/frontend-embed/src/components/EmPoll.vue b/packages/frontend-embed/src/components/EmPoll.vue index 2c02ef1547..1050dbf3f3 100644 --- a/packages/frontend-embed/src/components/EmPoll.vue +++ b/packages/frontend-embed/src/components/EmPoll.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
  • - + ({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }}) @@ -52,8 +52,8 @@ const total = computed(() => sum(props.poll.choices.map(x => x.votes))); position: relative; margin: 4px 0; padding: 4px; - //border: solid 0.5px var(--divider); - background: var(--accentedBg); + //border: solid 0.5px var(--MI_THEME-divider); + background: var(--MI_THEME-accentedBg); border-radius: 4px; overflow: clip; } @@ -63,8 +63,8 @@ const total = computed(() => sum(props.poll.choices.map(x => x.votes))); top: 0; left: 0; height: 100%; - background: var(--accent); - background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB)); + background: var(--MI_THEME-accent); + background: linear-gradient(90deg,var(--MI_THEME-buttonGradateA),var(--MI_THEME-buttonGradateB)); transition: width 1s ease; } @@ -72,11 +72,11 @@ const total = computed(() => sum(props.poll.choices.map(x => x.votes))); position: relative; display: inline-block; padding: 3px 5px; - background: var(--panel); + background: var(--MI_THEME-panel); border-radius: 3px; } .info { - color: var(--fg); + color: var(--MI_THEME-fg); } diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue index c8601806da..0595494e2e 100644 --- a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue +++ b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue @@ -38,10 +38,10 @@ const props = defineProps<{ justify-content: center; &.canToggle { - background: var(--buttonBg); + background: var(--MI_THEME-buttonBg); &:hover { - background: var(--buttonHoverBg, rgba(0, 0, 0, 0.1)); + background: var(--MI_THEME-buttonHoverBg, rgba(0, 0, 0, 0.1)); } } @@ -72,12 +72,12 @@ const props = defineProps<{ } &.reacted, &.reacted:hover { - background: var(--accentedBg); - color: var(--accent); - box-shadow: 0 0 0 1px var(--accent) inset; + background: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); + box-shadow: 0 0 0 1px var(--MI_THEME-accent) inset; > .count { - color: var(--accent); + color: var(--MI_THEME-accent); } > .icon { diff --git a/packages/frontend-embed/src/components/EmSubNoteContent.vue b/packages/frontend-embed/src/components/EmSubNoteContent.vue index 79e6332f66..e4de721d23 100644 --- a/packages/frontend-embed/src/components/EmSubNoteContent.vue +++ b/packages/frontend-embed/src/components/EmSubNoteContent.vue @@ -104,12 +104,12 @@ const collapseLabel = computed(() => { left: 0; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); z-index: 2; > .fadeLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -118,7 +118,7 @@ const collapseLabel = computed(() => { &:hover { > .fadeLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } } @@ -136,13 +136,13 @@ const collapseLabel = computed(() => { .reply { margin-right: 6px; - color: var(--accent); + color: var(--MI_THEME-accent); } .rp { margin-left: 4px; font-style: oblique; - color: var(--renote); + color: var(--MI_THEME-renote); } .footer { @@ -159,7 +159,7 @@ const collapseLabel = computed(() => { } &:hover { - color: var(--fgHighlighted); + color: var(--MI_THEME-fgHighlighted); } } @@ -179,12 +179,12 @@ const collapseLabel = computed(() => { width: 100%; margin-top: 14px; position: sticky; - bottom: calc(var(--stickyBottom, 0px) + 14px); + bottom: calc(var(--MI-stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; - background: var(--popup); + background: var(--MI_THEME-popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; diff --git a/packages/frontend-embed/src/components/EmTime.vue b/packages/frontend-embed/src/components/EmTime.vue index f12eaa5ea2..d8e73c24d1 100644 --- a/packages/frontend-embed/src/components/EmTime.vue +++ b/packages/frontend-embed/src/components/EmTime.vue @@ -98,10 +98,10 @@ if (!invalid && props.origin === null && (props.mode === 'relative' || props.mod diff --git a/packages/frontend-embed/src/components/EmTimelineContainer.vue b/packages/frontend-embed/src/components/EmTimelineContainer.vue index 6c30b1102d..60fd67ced9 100644 --- a/packages/frontend-embed/src/components/EmTimelineContainer.vue +++ b/packages/frontend-embed/src/components/EmTimelineContainer.vue @@ -20,7 +20,7 @@ withDefaults(defineProps<{ diff --git a/packages/frontend-embed/src/pages/tag.vue b/packages/frontend-embed/src/pages/tag.vue index fbd8ed30a4..e60cf1afd1 100644 --- a/packages/frontend-embed/src/pages/tag.vue +++ b/packages/frontend-embed/src/pages/tag.vue @@ -83,7 +83,7 @@ function top(ev: MouseEvent) { display: flex; min-width: 0; align-items: center; - gap: var(--margin); + gap: var(--MI-margin); overflow: hidden; .headerClipIconRoot { @@ -93,8 +93,8 @@ function top(ev: MouseEvent) { line-height: 32px; font-size: 14px; text-align: center; - background-color: var(--accentedBg); - color: var(--accent); + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); border-radius: 50%; } diff --git a/packages/frontend-embed/src/pages/user-timeline.vue b/packages/frontend-embed/src/pages/user-timeline.vue index 5b11e91c68..024746a5fb 100644 --- a/packages/frontend-embed/src/pages/user-timeline.vue +++ b/packages/frontend-embed/src/pages/user-timeline.vue @@ -117,7 +117,7 @@ function top(ev: MouseEvent) { display: flex; min-width: 0; align-items: center; - gap: var(--margin); + gap: var(--MI-margin); overflow: hidden; .avatarLink { diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss index 26fb088cac..5308ed7d4e 100644 --- a/packages/frontend-embed/src/style.scss +++ b/packages/frontend-embed/src/style.scss @@ -14,24 +14,24 @@ $default-font: "Pretendard JP", Pretendard, -apple-system, BlinkMacSystemFont, s $monospace-font: "Pretendard JP", Pretendard, "JetBrains Mono", "Fira code", "Fira Mono", Consolas, Menlo, Courier, monospace !important; :root { - --radius: 12px; - --marginFull: 14px; - --marginHalf: 10px; + --MI-radius: 12px; + --MI-marginFull: 14px; + --MI-marginHalf: 10px; - --margin: var(--marginFull); + --MI-margin: var(--MI-marginFull); - --cherry: rgb(255, 188, 220); - --pick: rgb(177, 211, 255); - --misskey: rgb(134, 179, 0); - --cast: rgb(181, 151, 246); - --ella: rgb(150, 198, 234); + --CP-cherry: rgb(255, 188, 220); + --CP-pick: rgb(177, 211, 255); + --CP-misskey: rgb(134, 179, 0); + --CP-cast: rgb(181, 151, 246); + --CP-ella: rgb(150, 198, 234); } html { background-color: transparent; color-scheme: light dark; - color: var(--fg); - accent-color: var(--accent); + color: var(--MI_THEME-fg); + accent-color: var(--MI_THEME-accent); overflow: clip; overflow-wrap: break-word; font-family: $default-font; @@ -42,7 +42,7 @@ html { -webkit-text-size-adjust: 100%; &, * { - scrollbar-color: var(--scrollbarHandle) transparent; + scrollbar-color: var(--MI_THEME-scrollbarHandle) transparent; scrollbar-width: thin; &::-webkit-scrollbar { @@ -55,14 +55,14 @@ html { } &::-webkit-scrollbar-thumb { - background: var(--scrollbarHandle); + background: var(--MI_THEME-scrollbarHandle); &:hover { - background: var(--scrollbarHandleHover); + background: var(--MI_THEME-scrollbarHandleHover); } &:active { - background: var(--accent); + background: var(--MI_THEME-accent); } } } @@ -106,7 +106,7 @@ rt { } :focus-visible { - outline: var(--focus) solid 2px; + outline: var(--MI_THEME-focus) solid 2px; outline-offset: -2px; &:hover { @@ -164,38 +164,38 @@ rt { ._buttonGray { @extend ._button; - background: var(--buttonBg); + background: var(--MI_THEME-buttonBg); &:not(:disabled):hover { - background: var(--buttonHoverBg); + background: var(--MI_THEME-buttonHoverBg); } } ._buttonPrimary { @extend ._button; - color: var(--fgOnAccent); - background: var(--accent); + color: var(--MI_THEME-fgOnAccent); + background: var(--MI_THEME-accent); &:not(:disabled):hover { - background: hsl(from var(--accent) h s calc(l + 5)); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 5)); } &:not(:disabled):active { - background: hsl(from var(--accent) h s calc(l - 5)); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 5)); } } ._buttonGradate { @extend ._buttonPrimary; - color: var(--fgOnAccent); - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + color: var(--MI_THEME-fgOnAccent); + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); &:not(:disabled):hover { - background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); } &:not(:disabled):active { - background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); } } @@ -212,13 +212,13 @@ rt { } ._help { - color: var(--accent); + color: var(--MI_THEME-accent); cursor: help; } ._textButton { @extend ._button; - color: var(--accent); + color: var(--MI_THEME-accent); &:focus-visible { outline-offset: 2px; @@ -230,13 +230,13 @@ rt { } ._panel { - background: var(--panel); - border-radius: var(--radius); + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); overflow: clip; } ._margin { - margin: var(--margin) 0; + margin: var(--MI-margin) 0; } ._gaps_m { @@ -254,7 +254,7 @@ rt { ._gaps { display: flex; flex-direction: column; - gap: var(--margin); + gap: var(--MI-margin); } ._buttons { @@ -276,24 +276,24 @@ rt { padding: 10px; box-sizing: border-box; text-align: center; - border: solid 0.5px var(--divider); - border-radius: var(--radius); + border: solid 0.5px var(--MI_THEME-divider); + border-radius: var(--MI-radius); &:active { - border-color: var(--accent); + border-color: var(--MI_THEME-accent); } } ._popup { - background: var(--popup); - border-radius: var(--radius); + background: var(--MI_THEME-popup); + border-radius: var(--MI-radius); contain: content; } ._acrylic { - background: var(--acrylicPanel); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + background: var(--MI_THEME-acrylicPanel); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); } ._fullinfo { @@ -309,7 +309,7 @@ rt { } ._link { - color: var(--link); + color: var(--MI_THEME-link); } ._caption { diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts index be98ceb9e4..ef217da75f 100644 --- a/packages/frontend-embed/src/theme.ts +++ b/packages/frontend-embed/src/theme.ts @@ -61,7 +61,7 @@ export function applyTheme(theme: Theme, persist = true) { } for (const [k, v] of Object.entries(props)) { - document.documentElement.style.setProperty(`--${k}`, v.toString()); + document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); } // iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照 diff --git a/packages/frontend-embed/src/ui.vue b/packages/frontend-embed/src/ui.vue index c0cd10bcae..efe180655a 100644 --- a/packages/frontend-embed/src/ui.vue +++ b/packages/frontend-embed/src/ui.vue @@ -88,14 +88,14 @@ onUnmounted(() => { diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index d4a97d3879..80504a4a9b 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -32,9 +32,9 @@ misskeyApi('users/show', { userId: props.movedTo }).then(u => user.value = u); .root { padding: 16px; font-size: 90%; - background: var(--infoWarnBg); - color: var(--error); - border-radius: var(--radius); + background: var(--MI_THEME-infoWarnBg); + color: var(--MI_THEME-error); + border-radius: var(--MI-radius); } .link { diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index c47db0e8ae..f715d1c733 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -193,12 +193,12 @@ tick(); function calcColors() { const computedStyle = getComputedStyle(document.documentElement); - const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark(); - const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + const dark = tinycolor(computedStyle.getPropertyValue('--MI_THEME-bg')).isDark(); + const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; //minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; sHandColor.value = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; - mHandColor.value = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString(); + mHandColor.value = tinycolor(computedStyle.getPropertyValue('--MI_THEME-fg')).toHexString(); hHandColor.value = accent; nowColor.value = accent; } diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index b36cf3bca1..cb3b2f34bf 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -9,9 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
    - - - + + +
    @@ -108,8 +108,8 @@ onMounted(() => { min-width: 320px; max-width: 480px; box-sizing: border-box; - background: var(--panel); - border-radius: var(--radius); + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); } .header { diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index 968486abd8..ca8533378b 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -193,6 +193,6 @@ function addUser() { .actions { margin-top: 16px; padding: 24px 0; - border-top: solid 0.5px var(--divider); + border-top: solid 0.5px var(--MI_THEME-divider); } diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index b50a7fea5c..e52ab5ccad 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -106,7 +106,7 @@ const containerStyle = computed(() => { const border = isBordered ? { borderWidth: c.borderWidth ?? '1px', - borderColor: c.borderColor ?? 'var(--divider)', + borderColor: c.borderColor ?? 'var(--MI_THEME-divider)', borderStyle: c.borderStyle ?? 'solid', } : undefined; @@ -165,7 +165,7 @@ function openPostForm() { } .postForm { - background: var(--bg); + background: var(--MI_THEME-bg); border-radius: 8px; } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 29d24c06d7..31d3488594 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -421,16 +421,16 @@ onBeforeUnmount(() => { text-overflow: ellipsis; &:hover { - background: var(--X3); + background: var(--MI_THEME-X3); } &[data-selected='true'] { - background: var(--accent); + background: var(--MI_THEME-accent); color: #fff !important; } &:active { - background: var(--accentDarken); + background: var(--MI_THEME-accentDarken); color: #fff !important; } } diff --git a/packages/frontend/src/components/MkBalloon.vue b/packages/frontend/src/components/MkBalloon.vue index 57df4509a4..308d4b4736 100644 --- a/packages/frontend/src/components/MkBalloon.vue +++ b/packages/frontend/src/components/MkBalloon.vue @@ -29,7 +29,7 @@ withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkCodeInline.vue b/packages/frontend/src/components/MkCodeInline.vue index 6add80d1bc..04b6e54108 100644 --- a/packages/frontend/src/components/MkCodeInline.vue +++ b/packages/frontend/src/components/MkCodeInline.vue @@ -18,7 +18,7 @@ const props = defineProps<{ display: inline-block; font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; overflow-wrap: anywhere; - background: var(--bg); + background: var(--MI_THEME-bg); padding: .1em; border-radius: .3em; } diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index f5c580789b..55a32664de 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -60,7 +60,7 @@ const onInput = () => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); &:empty { display: none; @@ -72,8 +72,8 @@ const onInput = () => { &.focused { > .inputCore { - border-color: var(--accent) !important; - //box-shadow: 0 0 0 4px var(--focus); + border-color: var(--MI_THEME-accent) !important; + //box-shadow: 0 0 0 4px var(--MI_THEME-focus); } } @@ -98,9 +98,9 @@ const onInput = () => { font: inherit; font-weight: normal; font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 1px var(--panel); + color: var(--MI_THEME-fg); + background: var(--MI_THEME-panel); + border: solid 1px var(--MI_THEME-panel); border-radius: 6px; outline: none; box-shadow: none; @@ -108,7 +108,7 @@ const onInput = () => { transition: border-color 0.1s ease-out; &:hover { - border-color: var(--inputBorderHover) !important; + border-color: var(--MI_THEME-inputBorderHover) !important; } } diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 69fdee6a82..d84658473b 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -165,11 +165,11 @@ onUnmounted(() => { .header { position: sticky; - top: var(--stickyTop, 0px); + top: var(--MI-stickyTop, 0px); left: 0; - color: var(--panelHeaderFg); - background: var(--panelHeaderBg); - border-bottom: solid 0.5px var(--panelHeaderDivider); + color: var(--MI_THEME-panelHeaderFg); + background: var(--MI_THEME-panelHeaderBg); + border-bottom: solid 0.5px var(--MI_THEME-panelHeaderDivider); z-index: 2; line-height: 1.4em; } @@ -201,7 +201,7 @@ onUnmounted(() => { } .content { - --stickyTop: 0px; + --MI-stickyTop: 0px; &.omitted { position: relative; @@ -216,11 +216,11 @@ onUnmounted(() => { left: 0; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); > .fadeLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -229,7 +229,7 @@ onUnmounted(() => { &:hover { > .fadeLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } } diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 8c74fa1735..e0e1952cec 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -126,7 +126,7 @@ onMounted(() => { const computedStyle = getComputedStyle(document.documentElement); const selection = cropper.getCropperSelection()!; - selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); selection.aspectRatio = props.aspectRatio; selection.initialAspectRatio = props.aspectRatio; selection.outlined = true; @@ -172,8 +172,8 @@ onMounted(() => { display: flex; align-items: center; justify-content: center; - -webkit-backdrop-filter: var(--blur, blur(10px)); - backdrop-filter: var(--blur, blur(10px)); + -webkit-backdrop-filter: var(--MI-blur, blur(10px)); + backdrop-filter: var(--MI-blur, blur(10px)); background: rgba(0, 0, 0, 0.5); } diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index 01898d5bd3..d97d0a1e9e 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -85,8 +85,8 @@ function cancel() { .emojiImgWrapper { max-width: 100%; height: 40cqh; - background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--X5) 8px, var(--X5) 14px); - border-radius: var(--radius); + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-X5) 8px, var(--MI_THEME-X5) 14px); + border-radius: var(--MI-radius); margin: auto; overflow-y: hidden; } @@ -101,8 +101,8 @@ function cancel() { display: inline-block; word-break: break-all; padding: 3px 10px; - background-color: var(--X5); - border: solid 1px var(--divider); - border-radius: var(--radius); + background-color: var(--MI_THEME-X5); + border: solid 1px var(--MI_THEME-divider); + border-radius: var(--MI-radius); } diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index f1c9c3b73c..adbbffbd20 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -9,6 +9,7 @@ import MkAd from '@/components/global/MkAd.vue'; import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { instance } from '@/instance.js'; import { defaultStore } from '@/store.js'; import { MisskeyEntity } from '@/types/date-separated-list.js'; @@ -104,11 +105,13 @@ export default defineComponent({ return [el, separator]; } else { - if (props.ad && item._shouldInsertAd_) { - return [h(MkAd, { + if (props.ad && instance.ads.length > 0 && item._shouldInsertAd_) { + return [h('div', { key: item.id + ':ad', + class: $style['ad-wrapper'], + }, [h(MkAd, { prefer: ['horizontal', 'horizontal-big'], - }), el]; + })]), el]; } else { return el; } @@ -189,7 +192,7 @@ export default defineComponent({ } &:not(.date-separated-list-nogap) > *:not(:last-child) { - margin-bottom: var(--margin); + margin-bottom: var(--MI-margin); } } @@ -201,7 +204,7 @@ export default defineComponent({ box-shadow: none; &:not(:last-child) { - border-bottom: solid 0.5px var(--divider); + border-bottom: solid 0.5px var(--MI_THEME-divider); } } } @@ -242,7 +245,7 @@ export default defineComponent({ line-height: 32px; text-align: center; font-size: 12px; - color: var(--dateLabelFg); + color: var(--MI_THEME-dateLabelFg); } .date-1 { @@ -260,5 +263,11 @@ export default defineComponent({ .date-2-icon { margin-left: 8px; } + +.ad-wrapper { + padding: 8px; + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px); +} diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 72a3764b73..4b6c556d9d 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -186,7 +186,7 @@ function onInputKeydown(evt: KeyboardEvent) { max-width: 480px; box-sizing: border-box; text-align: center; - background: var(--panel); + background: var(--MI_THEME-panel); border-radius: 16px; } @@ -208,15 +208,15 @@ function onInputKeydown(evt: KeyboardEvent) { } .type_success { - color: var(--success); + color: var(--MI_THEME-success); } .type_error { - color: var(--error); + color: var(--MI_THEME-error); } .type_warning { - color: var(--warn); + color: var(--MI_THEME-warn); } .title { @@ -235,7 +235,7 @@ function onInputKeydown(evt: KeyboardEvent) { .caption { margin: 4px 0 0 0; - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); } .buttons { diff --git a/packages/frontend/src/components/MkDivider.vue b/packages/frontend/src/components/MkDivider.vue index e4e3af99e4..f72f091383 100644 --- a/packages/frontend/src/components/MkDivider.vue +++ b/packages/frontend/src/components/MkDivider.vue @@ -27,6 +27,6 @@ defineProps<{ diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index 9088f4cfe0..09bb902811 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -87,12 +87,12 @@ function donateCherryPick(ev: MouseEvent) { .root { position: fixed; z-index: v-bind(zIndex); - bottom: var(--margin); + bottom: var(--MI-margin); left: 0; right: 0; margin: auto; box-sizing: border-box; - width: calc(100% - (var(--margin) * 2)); + width: calc(100% - (var(--MI-margin) * 2)); max-width: 500px; display: flex; } @@ -101,7 +101,7 @@ function donateCherryPick(ev: MouseEvent) { text-align: center; padding-top: 25px; width: 100px; - color: var(--accent); + color: var(--MI_THEME-accent); } @media (max-width: 500px) { .icon { diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index cf4707a8ef..76358316a7 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -148,14 +148,14 @@ function onDragend() { } &.isSelected { - background: var(--accent); + background: var(--MI_THEME-accent); &:hover { - background: var(--accentLighten); + background: var(--MI_THEME-accentLighten); } &:active { - background: var(--accentDarken); + background: var(--MI_THEME-accentDarken); } > .label { @@ -244,7 +244,7 @@ function onDragend() { font-size: 0.8em; text-align: center; word-break: break-all; - color: var(--fg); + color: var(--MI_THEME-fg); overflow: hidden; } diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 2583357e3d..33fe8e21ea 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -314,7 +314,7 @@ function onContextmenu(ev: MouseEvent) { position: relative; padding: 8px; height: 64px; - background: var(--driveFolderBg); + background: var(--MI_THEME-driveFolderBg); border-radius: 4px; cursor: pointer; @@ -327,7 +327,7 @@ function onContextmenu(ev: MouseEvent) { right: -4px; bottom: -4px; left: -4px; - border: 2px dashed var(--focus); + border: 2px dashed var(--MI_THEME-focus); border-radius: 4px; } } @@ -346,13 +346,13 @@ function onContextmenu(ev: MouseEvent) { width: 18px; height: 18px; background: #fff; - border: solid 2px var(--divider); + border: solid 2px var(--MI_THEME-divider); border-radius: 4px; box-sizing: border-box; &.checked { - border-color: var(--accent); - background: var(--accent); + border-color: var(--MI_THEME-accent); + background: var(--MI_THEME-accent); &::after { content: "\ea5e"; @@ -369,14 +369,13 @@ function onContextmenu(ev: MouseEvent) { } &:hover { - background: var(--accentedBg); + background: var(--MI_THEME-accentedBg); } } .name { margin: 0; font-size: 0.9em; - color: var(--desktopDriveFolderFg); } .icon { @@ -389,6 +388,5 @@ function onContextmenu(ev: MouseEvent) { margin: 4px 4px; font-size: 0.8em; text-align: right; - color: var(--desktopDriveFolderFg); } diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 118f7c5c9b..47abf5ed3f 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -721,7 +721,7 @@ onBeforeUnmount(() => { box-sizing: border-box; overflow: auto; font-size: 0.9em; - box-shadow: 0 1px 0 var(--divider); + box-shadow: 0 1px 0 var(--MI_THEME-divider); user-select: none; } @@ -768,7 +768,7 @@ onBeforeUnmount(() => { .main { flex: 1; overflow: auto; - padding: var(--margin); + padding: var(--MI-margin); user-select: none; &.fetching { @@ -815,7 +815,7 @@ onBeforeUnmount(() => { top: 38px; width: 100%; height: calc(100% - 38px); - border: dashed 2px var(--focus); + border: dashed 2px var(--MI_THEME-focus); pointer-events: none; } diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 20475e69e8..8578dc3505 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -70,7 +70,7 @@ const isThumbnailAvailable = computed(() => { .root { position: relative; display: flex; - background: var(--panel); + background: var(--MI_THEME-panel); border-radius: 8px; overflow: clip; } @@ -84,7 +84,7 @@ const isThumbnailAvailable = computed(() => { height: 100%; pointer-events: none; border-radius: inherit; - box-shadow: inset 0 0 0 4px var(--warn); + box-shadow: inset 0 0 0 4px var(--MI_THEME-warn); } .iconSub { diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index c060c3a659..c2bb516c7c 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -306,9 +306,9 @@ onUnmounted(() => { .embedCodeGenPreviewRoot { position: relative; - background-color: var(--bg); + background-color: var(--MI_THEME-bg); background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--panel) 6px, var(--panel) 12px); + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); cursor: not-allowed; } @@ -381,8 +381,8 @@ onUnmounted(() => { .embedCodeGenResultHeadingIcon { margin: 0 auto; - background-color: var(--accentedBg); - color: var(--accent); + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); text-align: center; height: 64px; width: 64px; diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index 5d2900fd12..5823f43dc6 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only