diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 25d9cfc1fb..514abdfb20 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "workspaceFolder": "/workspace", "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "22.11.0" + "version": "22.15.0" }, "ghcr.io/devcontainers-extra/features/pnpm:2": { "version": "10.10.0" diff --git a/.github/min.node-version b/.github/min.node-version new file mode 100644 index 0000000000..d5a159609d --- /dev/null +++ b/.github/min.node-version @@ -0,0 +1 @@ +20.10.0 diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 2de73aff09..933404dfa5 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -17,7 +17,6 @@ jobs: strategy: matrix: - node-version: [22.11.0] api-json-name: [api-base.json, api-head.json] include: - api-json-name: api-base.json @@ -32,10 +31,10 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index 13390f3aae..c156de1a8b 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -15,22 +15,17 @@ jobs: contents: read id-token: write - strategy: - matrix: - node-version: [22.11.0] - steps: - uses: actions/checkout@v4.2.2 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.node-version' cache: 'pnpm' - registry-url: 'https://registry.npmjs.org' - name: Publish package run: | pnpm i --frozen-lockfile diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index af54a0b32b..b1d95c1b33 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -38,7 +38,7 @@ jobs: run: git checkout "$(git rev-list --parents -n1 HEAD | cut -d" " -f3)" - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Use Node.js 20.x + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: node-version-file: '.node-version' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index ba4eb27a58..9d611c9964 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -22,10 +22,11 @@ jobs: unit: name: Unit tests (backend) runs-on: ubuntu-latest - strategy: matrix: - node-version: [22.11.0] + node-version-file: + - .node-version + - .github/min.node-version services: postgres: @@ -61,10 +62,10 @@ jobs: exit 1 fi done - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml @@ -84,10 +85,11 @@ jobs: e2e: name: E2E tests (backend) runs-on: ubuntu-latest - strategy: matrix: - node-version: [22.11.0] + node-version-file: + - .node-version + - .github/min.node-version services: postgres: @@ -108,10 +110,10 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml index c739688dc9..737b543a73 100644 --- a/.github/workflows/test-federation.yml +++ b/.github/workflows/test-federation.yml @@ -21,7 +21,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [22.11.0] + node-version-file: + - .node-version + - .github/min.node-version steps: - uses: actions/checkout@v4 with: @@ -43,10 +45,10 @@ jobs: exit 1 fi done - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' - name: Build Misskey run: | @@ -54,6 +56,7 @@ jobs: pnpm build - name: Setup run: | + echo "NODE_VERSION=$(cat ${{ matrix.node-version-file }})" >> $GITHUB_ENV cd packages/backend/test-federation bash ./setup.sh sudo chmod 644 ./certificates/*.test.key diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 25d263f102..94e43cf91e 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -27,20 +27,16 @@ jobs: name: Unit tests (frontend) runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22.11.0] - steps: - uses: actions/checkout@v4.2.2 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml @@ -64,7 +60,6 @@ jobs: strategy: fail-fast: false matrix: - node-version: [22.11.0] browser: [chrome] services: @@ -92,10 +87,10 @@ jobs: # if: ${{ matrix.browser == 'firefox' }} - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Copy Configure diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 5b3aed9712..f6d16bbd76 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -20,11 +20,6 @@ jobs: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22.11.0] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - steps: - name: Checkout uses: actions/checkout@v4.2.2 @@ -32,10 +27,10 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Setup Node.js ${{ matrix.node-version }} + - name: Setup Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.node-version' cache: 'pnpm' - name: Install dependencies diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 70685e908e..751c374608 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -15,20 +15,16 @@ jobs: name: Production build runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22.11.0] - steps: - uses: actions/checkout@v4.2.2 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index 77feb2b373..edff7dbecb 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -16,20 +16,16 @@ jobs: validate-api-json: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22.11.0] - steps: - uses: actions/checkout@v4.2.2 with: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: '.node-version' cache: 'pnpm' - name: Install Redocly CLI run: npm i -g @redocly/cli diff --git a/.node-version b/.node-version index 7af24b7ddb..b8ffd70759 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.11.0 +22.15.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2705d33fb5..ddf4c4ecc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## 2025.5.0 + +### Note +- DockerのNode.jsが22.15.0に更新されました + +### General +- + +### Client +- Feat: マウスでもタイムラインを引っ張って更新できるように + - アクセシビリティ設定からオフにすることもできます +- Enhance: タイムラインのパフォーマンスを向上 +- Enhance: バックアップされた設定のプロファイルを削除できるように +- Fix: 一部のブラウザでアコーディオンメニューのアニメーションが動作しない問題を修正 +- Fix: ダイアログのお知らせが画面からはみ出ることがある問題を修正 +- Fix: ユーザーポップアップでエラーが生じてもインジケーターが表示され続けてしまう問題を修正 + +### Server +- Enhance: 凍結されたユーザのノートが各種タイムラインで表示されないように `#15775` +- Enhance: 連合先のソフトウェア及びバージョン名により配信停止を行えるように `#15727` +- Enhance: 2025.4.1 で追加されたインデックスの再生成をノートの追加しながら行えるようになりました。 `#15915` + - `MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY` 環境変数を `1` にセットしていると、巨大なテーブルの既存のカラムに関するインデックス再生成が`CREATE INDEX CONCURRENTLY`を使用するようになりました。 + - 複数のサーバープロセスをクラスタリングしているサーバーにおいて、一部のプロセスが起動している状態でこのオプションを有効にしてマイグレーションすることにより、ダウンタイムを削減することができます。 + - ただし、このオプションを有効にする場合、インデックスの作成にかかる時間が倍~3倍以上になることがあります。 + - また、大きなインスタンスである場合にはインデックスの作成に失敗し、複数回再試行する必要がある可能性があります。 +- Fix: チャンネルのフォロー一覧の結果が一部正しくないのを修正 (#12175) +- Fix: ファイルをアップロードした際にファイル名が常に untitled になる問題を修正 +- Fix: ファイルのアップロードに失敗することがある問題を修正 + - 投稿フォーム上で画像のクロップを行うと、`Invalid Param.`エラーでノートが投稿出来なくなる問題も解決されます。 + - この事象によって既にノートが投稿出来ない状態になっている場合は、投稿フォーム右上のメニューから、下書きデータの「リセット」を行ってください。 + ## 2025.4.1 ### General diff --git a/Dockerfile b/Dockerfile index 9d5596f1f1..aafaa9dc6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.4 -ARG NODE_VERSION=22.11.0-bookworm +ARG NODE_VERSION=22.15.0-bookworm # build assets & compile TypeScript diff --git a/assets/ui-icons.afdesign b/assets/ui-icons.afdesign new file mode 100644 index 0000000000..79350f51d9 Binary files /dev/null and b/assets/ui-icons.afdesign differ diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 23d20b8e13..12779fafa4 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -220,6 +220,7 @@ silenceThisInstance: "Silencia aquesta instància " mediaSilenceThisInstance: "Silenciar els arxius d'aquesta instància " operations: "Accions" software: "Programari" +softwareName: "Nom del programari" version: "Versió" metadata: "Metadades" withNFiles: "{n} fitxer(s)" @@ -1347,6 +1348,7 @@ readonly: "Només lectura" goToDeck: "Tornar al tauler" federationJobs: "Treballs sindicats " driveAboutTip: "Al Disc veure's una llista de tots els arxius que has anat pujant.
\nPots tornar-los a fer servir adjuntant-los a notes noves o pots adelantar-te i pujar arxius per publicar-los més tard!
\nTingués en compte que si esborres un arxiu també desapareixerà de tots els llocs on l'has fet servir (notes, pàgines, avatars, imatges de capçalera, etc.)
\nTambé pots crear carpetes per organitzar les." +scrollToClose: "Desplaçar per tancar" _chat: noMessagesYet: "Encara no tens missatges " newMessage: "Missatge nou" @@ -1423,6 +1425,8 @@ _settings: ifOn: "Quan s'activa" ifOff: "Quan es desactiva" enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius" + enablePullToRefresh: "Lliscar i actualitzar " + enablePullToRefresh_description: "Amb el ratolí, llisca mentre prems la roda." _chat: showSenderName: "Mostrar el nom del remitent" sendOnEnter: "Introdueix per enviar" @@ -1468,6 +1472,7 @@ _delivery: manuallySuspended: "Suspendre manualment" goneSuspended: "Servidor suspès perquè el servidor s'ha esborrat" autoSuspendedForNotResponding: "Servidor suspès perquè el servidor no respon" + softwareSuspended: "Suspès perquè el programari ha deixat de desenvolupar-se " _bubbleGame: howToPlay: "Com es juga" hold: "Mantenir" @@ -1599,6 +1604,8 @@ _serverSettings: openRegistration: "Registres oberts" openRegistrationWarning: "Obrir els registres és arriscat. Es recomana obrir-los només si el servidor és monitorat constantment i per respondre immediatament davant qualsevol problema." thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa." + deliverSuspendedSoftware: "Programari que ja no es distribueix" + deliverSuspendedSoftwareDescription: "Pots especificar un rang de noms i versions del programari del servidor per detenir l'entrega, per exemple, degut a vulnerabilitats. Aquesta informació la proporciona el servidor i la seva fiabilitat no es garantitzada. Es pot fer servir una especificació de rang sencer per especificar una versió, però es recomana especificar una versió anterior, com >= 2024.3.1-0, perquè especificar >= 2024.3.1 no incloure versions personalitzades com 2024.3.1-custom.0." _accountMigration: moveFrom: "Migrar un altre compte a aquest" moveFromSub: "Crear un àlies per un altre compte" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 41c3f495a2..4ca8f27790 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -220,6 +220,7 @@ silenceThisInstance: "Instanz stummschalten" mediaSilenceThisInstance: "Medien dieses Servers stummschalten" operations: "Aktionen" software: "Software" +softwareName: "Software Name" version: "Version" metadata: "Metadaten" withNFiles: "{n} Datei(en)" @@ -1347,6 +1348,7 @@ readonly: "Nur Lesezugriff" goToDeck: "Zurück zum Deck" federationJobs: "Föderation Jobs" driveAboutTip: "In Drive sehen Sie eine Liste der Dateien, die Sie in der Vergangenheit hochgeladen haben.
\nSie können diese Dateien wiederverwenden um sie zu beispiel an Notizen anzuhängen, oder sie können Dateien vorab hochzuladen, um sie später zu versenden!
\nWenn Sie eine Datei löschen, verschwindet sie auch von allen Stellen, an denen Sie sie verwendet haben (Notizen, Seiten, Avatare, Banner usw.).
\nSie können auch Ordner erstellen, um sie zu organisieren." +scrollToClose: "Zum Schließen scrollen" _chat: noMessagesYet: "Noch keine Nachrichten" newMessage: "Neue Nachricht" @@ -1423,6 +1425,8 @@ _settings: ifOn: "Wenn eingeschaltet" ifOff: "Wenn ausgeschaltet" enableSyncThemesBetweenDevices: "Synchronisierung von installierten Themen auf verschiedenen Endgeräten" + enablePullToRefresh: "Ziehen zum Aktualisieren" + enablePullToRefresh_description: "Bei Benutzung einer Maus, mit gedrücktem Mausrad ziehen" _chat: showSenderName: "Name des Absenders anzeigen" sendOnEnter: "Eingabetaste sendet Nachricht" @@ -1468,6 +1472,7 @@ _delivery: manuallySuspended: "Manuell gesperrt" goneSuspended: "Gesperrt wegen Löschung des Servers" autoSuspendedForNotResponding: "Gesperrt, weil der Server nicht antwortet" + softwareSuspended: "Ausgesetzt, weil die Software nicht mehr beliefert wird" _bubbleGame: howToPlay: "Wie man spielt" hold: "Halten" @@ -1599,6 +1604,8 @@ _serverSettings: openRegistration: "Registrierung von Konten aktivieren" openRegistrationWarning: "Das Aktivieren von Registrierungen ist riskant. Es wird empfohlen, sie nur dann zu aktivieren, wenn der Server ständig überwacht wird und im Falle eines Problems sofort reagiert werden kann." thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Wenn über einen bestimmten Zeitraum keine Moderatorenaktivität festgestellt wird, wird diese Einstellung automatisch deaktiviert, um Spam zu verhindern." + deliverSuspendedSoftware: "Software, die nicht mehr beliefert wird" + deliverSuspendedSoftwareDescription: "Sie können eine Auswahl von Namen und Versionen verschiedener Serversoftware angeben, um die Zustellung zu stoppen, z. B. aufgrund von Sicherheitslücken. Diese Versionsinformationen werden vom Server bereitgestellt und ihre Zuverlässigkeit ist nicht garantiert. Es wird jedoch empfohlen, eine Vorabversion anzugeben, wie z. B. >= 2024.3.1-0, da die Angabe >= 2024.3.1 keine benutzerdefinierten Versionen wie 2024.3.1-custom.0 einschließt." _accountMigration: moveFrom: "Von einem anderen Konto zu diesem migrieren" moveFromSub: "Alias für ein anderes Konto erstellen" diff --git a/locales/en-US.yml b/locales/en-US.yml index 8e4063ae8a..4efd1a36f6 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -220,6 +220,7 @@ silenceThisInstance: "Silence this instance" mediaSilenceThisInstance: "Media-silence this server" operations: "Operations" software: "Software" +softwareName: "Software" version: "Version" metadata: "Metadata" withNFiles: "{n} file(s)" @@ -1347,6 +1348,7 @@ readonly: "Read only" goToDeck: "Return to Deck" federationJobs: "Federation Jobs" driveAboutTip: "In Drive, a list of files you've uploaded in the past will be displayed.
\nYou can reuse these files when attaching them to notes, or you can upload files in advance to post later.
\nBe careful when deleting a file, as it will not be available in all places where it was used (such as notes, pages, avatars, banners, etc.).
\nYou can also create folders to organize your files." +scrollToClose: "Scroll to close" _chat: noMessagesYet: "No messages yet" newMessage: "New message" @@ -1423,6 +1425,8 @@ _settings: ifOn: "When turned on" ifOff: "When turned off" enableSyncThemesBetweenDevices: "Synchronize installed themes across devices" + enablePullToRefresh: "Pull to Refresh" + enablePullToRefresh_description: "When using a mouse, drag while pressing in the scroll wheel." _chat: showSenderName: "Show sender's name" sendOnEnter: "Press Enter to send" @@ -1468,6 +1472,7 @@ _delivery: manuallySuspended: "Manually suspended" goneSuspended: "Server is suspended due to server deletion" autoSuspendedForNotResponding: "Server is suspended due to no responding" + softwareSuspended: "Suspended as this software is no longer being distributed to" _bubbleGame: howToPlay: "How to play" hold: "Hold" @@ -1599,6 +1604,8 @@ _serverSettings: openRegistration: "Make the account creation open" openRegistrationWarning: "Opening registration carries risks. It is recommended to only enable it if you have a system in place to continuously monitor the server and respond immediately in case of any issues." thisSettingWillAutomaticallyOffWhenModeratorsInactive: "If no moderator activity is detected for a while, this setting will be automatically turned off to prevent spam." + deliverSuspendedSoftware: "Suspended Software" + deliverSuspendedSoftwareDescription: "You can specify a range of names and versions of the server's software to stop delivery for vulnerability or other reasons. This version information is provided by the server and is not guaranteed to be reliable. A semver range specification can be used to specify the version, but specifying >= 2024.3.1 will not include custom versions such as 2024.3.1-custom.0, so it is recommended that a prerelease specification be used, such as >= 2024.3.1-0" _accountMigration: moveFrom: "Migrate another account to this one" moveFromSub: "Create alias to another account" diff --git a/locales/index.d.ts b/locales/index.d.ts index 1ee01d52f4..281568a5fd 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -898,6 +898,10 @@ export interface Locale extends ILocale { * ソフトウェア */ "software": string; + /** + * ソフトウェア名 + */ + "softwareName": string; /** * バージョン */ @@ -5409,6 +5413,10 @@ export interface Locale extends ILocale { * フォルダを作って整理することもできます。 */ "driveAboutTip": string; + /** + * スクロールして閉じる + */ + "scrollToClose": string; "_chat": { /** * まだメッセージはありません @@ -5705,6 +5713,14 @@ export interface Locale extends ILocale { * デバイス間でインストールしたテーマを同期 */ "enableSyncThemesBetweenDevices": string; + /** + * ひっぱって更新 + */ + "enablePullToRefresh": string; + /** + * マウスでは、ホイールを押し込みながらドラッグします。 + */ + "enablePullToRefresh_description": string; "_chat": { /** * 送信者の名前を表示 @@ -5729,6 +5745,10 @@ export interface Locale extends ILocale { * 例: 「メインPC」、「スマホ」など */ "profileNameDescription2": string; + /** + * プロファイルの管理 + */ + "manageProfiles": string; }; "_preferencesBackup": { /** @@ -5871,6 +5891,10 @@ export interface Locale extends ILocale { * サーバー応答なしのため停止中 */ "autoSuspendedForNotResponding": string; + /** + * 配信停止中のソフトウェアであるため停止中 + */ + "softwareSuspended": string; }; }; "_bubbleGame": { @@ -6356,6 +6380,14 @@ export interface Locale extends ILocale { * 一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。 */ "thisSettingWillAutomaticallyOffWhenModeratorsInactive": string; + /** + * 配信停止中のソフトウェア + */ + "deliverSuspendedSoftware": string; + /** + * 脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。 + */ + "deliverSuspendedSoftwareDescription": string; }; "_accountMigration": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index edb7914110..f0512925f3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -220,6 +220,7 @@ silenceThisInstance: "サーバーをサイレンス" mediaSilenceThisInstance: "サーバーをメディアサイレンス" operations: "操作" software: "ソフトウェア" +softwareName: "ソフトウェア名" version: "バージョン" metadata: "メタデータ" withNFiles: "{n}つのファイル" @@ -1347,6 +1348,7 @@ readonly: "読み取り専用" goToDeck: "デッキへ戻る" federationJobs: "連合ジョブ" driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。
\nノートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。
\nファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。
\nフォルダを作って整理することもできます。" +scrollToClose: "スクロールして閉じる" _chat: noMessagesYet: "まだメッセージはありません" @@ -1426,6 +1428,8 @@ _settings: ifOn: "オンのとき" ifOff: "オフのとき" enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期" + enablePullToRefresh: "ひっぱって更新" + enablePullToRefresh_description: "マウスでは、ホイールを押し込みながらドラッグします。" _chat: showSenderName: "送信者の名前を表示" @@ -1435,6 +1439,7 @@ _preferencesProfile: profileName: "プロファイル名" profileNameDescription: "このデバイスを識別する名前を設定してください。" profileNameDescription2: "例: 「メインPC」、「スマホ」など" + manageProfiles: "プロファイルの管理" _preferencesBackup: autoBackup: "自動バックアップ" @@ -1477,6 +1482,7 @@ _delivery: manuallySuspended: "手動停止中" goneSuspended: "サーバー削除のため停止中" autoSuspendedForNotResponding: "サーバー応答なしのため停止中" + softwareSuspended: "配信停止中のソフトウェアであるため停止中" _bubbleGame: howToPlay: "遊び方" @@ -1615,6 +1621,8 @@ _serverSettings: openRegistration: "アカウントの作成をオープンにする" openRegistrationWarning: "登録を開放することはリスクが伴います。サーバーを常に監視し、トラブルが発生した際にすぐに対応できる体制がある場合のみオンにすることを推奨します。" thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。" + deliverSuspendedSoftware: "配信停止中のソフトウェア" + deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index e81af534e7..c801126435 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -5,6 +5,7 @@ introMisskey: "Добро пожаловать! Misskey — это децент poweredByMisskeyDescription: "{name} – сервис на платформе с открытым исходным кодом Misskey, называемый экземпляром Misskey." monthAndDay: "{day}.{month}" search: "Поиск" +reset: "Сброс" notifications: "Уведомления" username: "Имя пользователя" password: "Пароль" @@ -48,6 +49,7 @@ pin: "Закрепить в профиле" unpin: "Открепить от профиля" copyContent: "Скопировать содержимое" copyLink: "Скопировать ссылку" +copyRemoteLink: "Скопировать ссылку на репост" copyLinkRenote: "Скопировать ссылку на репост" delete: "Удалить" deleteAndEdit: "Удалить и отредактировать" @@ -215,8 +217,10 @@ perDay: "По дням" stopActivityDelivery: "Остановить отправку обновлений активности" blockThisInstance: "Блокировать этот инстанс" silenceThisInstance: "Заглушить этот инстанс" +mediaSilenceThisInstance: "Заглушить сервер" operations: "Операции" software: "Программы" +softwareName: "Software Name" version: "Версия" metadata: "Метаданные" withNFiles: "Файлы, {n} шт." @@ -235,7 +239,11 @@ clearCachedFilesConfirm: "Удалить все закэшированные ф blockedInstances: "Заблокированные инстансы" blockedInstancesDescription: "Введите список инстансов, которые хотите заблокировать. Они больше не смогут обмениваться с вашим инстансом." silencedInstances: "Заглушённые инстансы" +silencedInstancesDescription: "Перечислите имена серверов, которые вы хотите отключить, разделив их новой строкой. Все учетные записи, принадлежащие к указанным в списке серверам, будут заблокированы и смогут отправлять запросы только на повторное использование и не смогут указывать локальные учетные записи, если они не будут отслеживаться. Это не повлияет на заблокированные серверы." +mediaSilencedInstances: "Заглушённые сервера" +mediaSilencedInstancesDescription: "Укажите названия серверов, для которых вы хотите отключить доступ к файлам, по одному серверу в строке. Все учетные записи, принадлежащие к перечисленным серверам, будут считаться конфиденциальными и не смогут использовать пользовательские эмодзи. Это никак не повлияет на заблокированные серверы." federationAllowedHosts: "Серверы, поддерживающие федерацию" +federationAllowedHostsDescription: "Укажите имена серверов, для которых вы хотите разрешить объединение, разделив их разделителями строк." muteAndBlock: "Скрытие и блокировка" mutedUsers: "Скрытые пользователи" blockedUsers: "Заблокированные пользователи" @@ -294,6 +302,7 @@ uploadFromUrlMayTakeTime: "Загрузка может занять некото explore: "Обзор" messageRead: "Прочитали" noMoreHistory: "История закончилась" +startChat: "Начать чат" nUsersRead: "Прочитали {n}" agreeTo: "Я соглашаюсь с {0}" agree: "Согласен" @@ -416,6 +425,7 @@ antennaExcludeBots: "Исключать ботов" antennaKeywordsDescription: "Пишите слова через пробел в одной строке, чтобы ловить их появление вместе; на отдельных строках располагайте слова, или группы слов, чтобы ловить любые из них." notifyAntenna: "Уведомлять о новых заметках" withFileAntenna: "Только заметки с вложениями" +excludeNotesInSensitiveChannel: "Исключить заметки из конфиденциальных каналов" enableServiceworker: "Включить ServiceWorker" antennaUsersDescription: "Пишите каждое название аккаута на отдельной строке" caseSensitive: "С учётом регистра" @@ -446,6 +456,8 @@ totpDescription: "Описание приложения-аутентификат moderator: "Модератор" moderation: "Модерация" moderationNote: "Примечания модератора" +moderationNoteDescription: "Вы можете заполнять заметки, которые будут доступны только модераторам." +addModerationNote: "" moderationLogs: "Журнал модерации" nUsersMentioned: "Упомянуло пользователей: {n}" securityKeyAndPasskey: "Ключ безопасности и парольная фраза" @@ -506,6 +518,8 @@ emojiStyle: "Стиль эмодзи" native: "Системные" menuStyle: "Стиль меню" style: "Стиль" +drawer: "Панель" +popup: "Всплывающие окна" showNoteActionsOnlyHover: "Показывать кнопки у заметок только при наведении" showReactionsCount: "Видеть количество реакций на заметках" noHistory: "История пока пуста" @@ -560,6 +574,7 @@ serverLogs: "Журнал сервера" deleteAll: "Удалить всё" showFixedPostForm: "Показывать поле для ввода новой заметки наверху ленты" showFixedPostFormInChannel: "Показывать поле для ввода новой заметки наверху ленты (каналы)" +withRepliesByDefaultForNewlyFollowed: "По умолчанию включайте ответы новых пользователей, на которых вы подписались, во временную шкалу" newNoteRecived: "Появилась новая заметка" sounds: "Звуки" sound: "Звуки" @@ -572,6 +587,7 @@ masterVolume: "Основная регулировка громкости" notUseSound: "Выключить звук" useSoundOnlyWhenActive: "Воспроизводить звук только когда Misskey активен." details: "Подробнее" +renoteDetails: "Узнать больше" chooseEmoji: "Выберите эмодзи" unableToProcess: "Не удаётся завершить операцию" recentUsed: "Последние использованные" @@ -587,6 +603,8 @@ ascendingOrder: "по возрастанию" descendingOrder: "По убыванию" scratchpad: "Когтеточка" scratchpadDescription: "«Когтеточка» — это место для опытов с AiScript. Здесь можно писать программы, взаимодействующие с Misskey, запускать и смотреть что из этого получается." +uiInspector: "Средство проверки пользовательского интерфейса" +uiInspectorDescription: "Вы можете просмотреть список экземпляров компонентов пользовательского интерфейса, существующих в памяти. Элементы пользовательского интерфейса генерируются с помощью серии функций Ui:C:." output: "Выходы" script: "Скрипт" disablePagesScript: "Отключить скрипты на «Страницах»" @@ -667,14 +685,19 @@ smtpSecure: "Использовать SSL/TLS для SMTP-соединений" smtpSecureInfo: "Выключите при использовании STARTTLS." testEmail: "Проверка доставки электронной почты" wordMute: "Скрытие слов" +wordMuteDescription: "Сведите к минимуму записи, содержащие указанное утверждение. Нажмите на свернутую запись, чтобы отобразить ее." hardWordMute: "Строгое скрытие слов" +showMutedWord: "Отображать слово без уведомления (звука)" +hardWordMuteDescription: "Скрыть заметки, содержащие указанное слово или фразу. В отличие от word mute, заметка будет полностью скрыта от просмотра." regexpError: "Ошибка в регулярном выражении" regexpErrorDescription: "В списке {tab} скрытых слов, в строке {line} обнаружена синтаксическая ошибка:" instanceMute: "Глушение инстансов" userSaysSomething: "{name} что-то сообщает" +userSaysSomethingAbout: "{name} что-то говорил о「{word}」" makeActive: "Активировать" display: "Отображение" copy: "Копировать" +copiedToClipboard: "Скопированы в буфер обмена" metrics: "Метрики" overview: "Обзор" logs: "Журналы" @@ -840,6 +863,7 @@ administration: "Управление" accounts: "Учётные записи" switch: "Переключение" noMaintainerInformationWarning: "Не заполнены сведения об администраторах" +noInquiryUrlWarning: "URL-адрес контактной формы еще не задан." noBotProtectionWarning: "Ботозащита не настроена" configure: "Настроить" postToGallery: "Опубликовать в галерею" @@ -904,6 +928,7 @@ followersVisibility: "Видимость подписчиков" continueThread: "Показать следующие ответы" deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" incorrectPassword: "Пароль неверен." +incorrectTotp: "Введен неверный одноразовый пароль или срок его действия истек." voteConfirm: "Отдать голос за «{choice}»?" hide: "Спрятать" useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве" @@ -928,6 +953,9 @@ oneHour: "1 час" oneDay: "1 день" oneWeek: "1 неделя" oneMonth: "1 месяц" +threeMonths: "3 месяца" +oneYear: "1 год" +threeDays: "3 дня" reflectMayTakeTime: "Изменения могут занять время для отображения" failedToFetchAccountInformation: "Не удалось получить информацию об аккаунте" rateLimitExceeded: "Ограничение скорости превышено" @@ -952,6 +980,7 @@ document: "Документ" numberOfPageCache: "Количество сохранённых страниц в кэше" numberOfPageCacheDescription: "Описание количества страниц в кэше" logoutConfirm: "Вы хотите выйти из аккаунта?" +logoutWillClearClientData: "Когда вы выйдете из системы, информация о конфигурации клиента будет удалена из браузера.Чтобы иметь возможность восстановить информацию о вашей конфигурации при повторном входе в систему, пожалуйста, включите опцию автоматического резервного копирования в настройках." lastActiveDate: "Последняя дата использования" statusbar: "Статусбар" pleaseSelect: "Пожалуйста, выберите" @@ -1001,6 +1030,7 @@ neverShow: "Больше не показывать" remindMeLater: "Напомнить позже" didYouLikeMisskey: "Вам нравится Misskey?" pleaseDonate: "Сайт {host} работает на Misskey. Это бесплатное программное обеспечение, и ваши пожертвования очень бы помогли продолжать его разработку!" +correspondingSourceIsAvailable: "Соответствующий исходный код можно найти по адресу {anchor} " roles: "Роли" role: "Роль" noRole: "Нет роли" @@ -1056,6 +1086,7 @@ prohibitedWords: "Запрещённые слова" prohibitedWordsDescription: "Включает вывод ошибки при попытке опубликовать пост, содержащий указанное слово/набор слов.\nМножество слов может быть указано, разделяемые новой строкой." prohibitedWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение." hiddenTags: "Скрытые хештеги" +hiddenTagsDescription: "Установленные теги не будут отображаться в тренде, можно установить несколько тегов." notesSearchNotAvailable: "Поиск заметок недоступен" license: "Лицензия" unfavoriteConfirm: "Удалить избранное?" @@ -1066,6 +1097,7 @@ retryAllQueuesConfirmTitle: "Хотите попробовать ещё раз?" retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться" enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей" enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов" +enableStatsForFederatedInstances: "Получить информацию об удаленном сервере" showClipButtonInNoteFooter: "Показать кнопку добавления в подборку в меню действий с заметкой" reactionsDisplaySize: "Размер реакций" limitWidthOfReaction: "Ограничить максимальную ширину реакций и отображать их в уменьшенном размере." @@ -1101,6 +1133,7 @@ preservedUsernames: "Зарезервированные имена пользо preservedUsernamesDescription: "Перечислите зарезервированные имена пользователей, отделяя их строками. Они станут недоступны при создании учётной записи. Это ограничение не применяется при создании учётной записи администраторами. Также, уже существующие учётные записи останутся без изменений." createNoteFromTheFile: "Создать заметку из этого файла" archive: "Архив" +archived: "Архивировано" unarchive: "Разархивировать" channelArchiveConfirmTitle: "Переместить {name} в архив?" channelArchiveConfirmDescription: "Архивированные каналы перестанут отображаться в списке каналов или результатах поиска. В них также нельзя будет добавлять новые записи." @@ -1121,6 +1154,7 @@ rolesThatCanBeUsedThisEmojiAsReaction: "Роли тех, кому можно и rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Если здесь ничего не указать, в качестве реакции эту эмодзи сможет использовать каждый." rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Эти роли должны быть общедоступными." cancelReactionConfirm: "Вы действительно хотите удалить свою реакцию?" +changeReactionConfirm: "Вы действительно хотите удалить свою реакцию?" later: "Позже" goToMisskey: "К Misskey" additionalEmojiDictionary: "Дополнительные словари эмодзи" @@ -1130,9 +1164,16 @@ enableServerMachineStats: "Опубликовать характеристики enableIdenticonGeneration: "Включить генерацию иконки пользователя" turnOffToImprovePerformance: "Отключение этого параметра может повысить производительность." createInviteCode: "Создать код приглашения" +createWithOptions: "Используйте параметры для создания" createCount: "Количество приглашений" +inviteCodeCreated: "Создан пригласительный код" +inviteLimitExceeded: "Достигнут предел количества пригласительных кодов, которые могут быть созданы." +createLimitRemaining: "Пригласительные коды, которые могут быть созданы: {limit} " +inviteLimitResetCycle: "За определенное {time} Вы можете создать неограниченное количество пригласительных кодов {limit} " expirationDate: "Дата истечения" noExpirationDate: "Бессрочно" +inviteCodeUsedAt: "Дата и время, когда был использован пригласительный код" +registeredUserUsingInviteCode: "Пользователи, которые использовали пригласительный код" unused: "Неиспользованное" used: "Использован" expired: "Срок действия приглашения истёк" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 1508fca431..28053bb7b0 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -220,6 +220,7 @@ silenceThisInstance: "静音此服务器" mediaSilenceThisInstance: "隐藏此服务器的媒体文件" operations: "操作" software: "软件" +softwareName: "软件名" version: "版本" metadata: "元数据" withNFiles: "{n} 个文件" @@ -1422,6 +1423,8 @@ _settings: showNavbarSubButtons: "在导航栏中显示副按钮" ifOn: "启用时" ifOff: "关闭时" + enablePullToRefresh: "开启下拉刷新" + enablePullToRefresh_description: "使用鼠标时按下滚轮来拖动" _chat: showSenderName: "显示发送者的名字" sendOnEnter: "回车键发送" @@ -1467,6 +1470,7 @@ _delivery: manuallySuspended: "手动停止中" goneSuspended: "因服务器被删除而停止" autoSuspendedForNotResponding: "因服务器无应答而停止" + softwareSuspended: "因有不可用的软件而停止" _bubbleGame: howToPlay: "游戏说明" hold: "抓住" @@ -1598,6 +1602,7 @@ _serverSettings: openRegistration: "开放注册" openRegistrationWarning: "开放注册有风险。建议仅当能够持续监控服务器并在出现问题时能够立即响应时才打开它。" thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。" + deliverSuspendedSoftware: "不可用的软件" _accountMigration: moveFrom: "从别的账号迁移到此账户" moveFromSub: "为另一个账户建立别名" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 152f34feeb..1c15fd48d1 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -220,6 +220,7 @@ silenceThisInstance: "禁言此伺服器" mediaSilenceThisInstance: "將這個伺服器的媒體設為禁言" operations: "操作" software: "軟體" +softwareName: "軟體名稱" version: "版本" metadata: "詮釋資料" withNFiles: "{n} 個檔案" @@ -1347,6 +1348,7 @@ readonly: "唯讀" goToDeck: "回去甲板" federationJobs: "聯邦通訊作業" driveAboutTip: "在「雲端硬碟」中,會顯示過去上傳的檔案列表。
\n可以在附加到貼文時重新利用,或者事先上傳之後再用於發布。
\n請注意,刪除檔案後,之前使用過該檔案的所有地方(貼文、頁面、大頭貼、橫幅等)也會一併無法顯示。
\n也可以建立資料夾來整理檔案。" +scrollToClose: "用滾輪關閉" _chat: noMessagesYet: "尚無訊息" newMessage: "新訊息" @@ -1423,6 +1425,8 @@ _settings: ifOn: "開啟時" ifOff: "關閉時" enableSyncThemesBetweenDevices: "在裝置之間同步已安裝的主題" + enablePullToRefresh: "下拉更新" + enablePullToRefresh_description: "使用滑鼠,按下並拖曳滾輪。" _chat: showSenderName: "顯示發送者的名稱" sendOnEnter: "按下 Enter 發送訊息" @@ -1468,6 +1472,7 @@ _delivery: manuallySuspended: "手動暫停中" goneSuspended: "因為伺服器刪除所以暫停中" autoSuspendedForNotResponding: "因為伺服器沒有回應所以暫停中" + softwareSuspended: "此軟體因已停止發佈,目前無法使用" _bubbleGame: howToPlay: "玩法說明" hold: "保留" @@ -1599,6 +1604,8 @@ _serverSettings: openRegistration: "允許建立帳戶" openRegistrationWarning: "開放註冊伴隨著風險。 建議只有在伺服器受到持續監控,並準備好在出現問題時能立即處理的情況下才開放註冊。" thisSettingWillAutomaticallyOffWhenModeratorsInactive: "如果在一段期間內沒有偵測到任何審查員活動,此設定將自動關閉,以防止垃圾內容。" + deliverSuspendedSoftware: "已停止發佈的軟體" + deliverSuspendedSoftwareDescription: "由於脆弱性等原因,可以指定伺服器軟體的名稱與版本範圍來停止其發佈。這些版本資訊是由伺服器所提供,其可靠性無法保證。版本的指定可以使用 semver(語意化版本控制) 的範圍語法,但如果指定為 >= 2024.3.1,則像 2024.3.1-custom.0 這樣的自訂版本將不會被包含在內,因此建議使用 >= 2024.3.1-0 的方式來同時包含預發佈版本。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" diff --git a/package.json b/package.json index 8649d1eac7..2f3d77b319 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.4.1", + "version": "2025.5.0-rc.0", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index ae7b2baf49..d15a703ba2 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -1,4 +1,5 @@ import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; import sharedConfig from '../shared/eslint.config.js'; export default [ @@ -6,6 +7,13 @@ export default [ { ignores: ['**/node_modules', 'built', '@types/**/*', 'migration'], }, + { + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, { files: ['**/*.ts', '**/*.tsx'], languageOptions: { diff --git a/packages/backend/jest.js b/packages/backend/jest.js new file mode 100644 index 0000000000..0cb2c2ab77 --- /dev/null +++ b/packages/backend/jest.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import child_process from 'node:child_process'; +import path from 'node:path'; +import url from 'node:url'; + +import semver from 'semver'; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const args = []; +args.push(...[ + ...semver.satisfies(process.version, '^20.17.0 || ^22.0.0') ? ['--no-experimental-require-module'] : [], + '--experimental-vm-modules', + '--experimental-import-meta-resolve', + path.join(__dirname, 'node_modules/jest/bin/jest.js'), + ...process.argv.slice(2), +]); + +child_process.spawn(process.execPath, args, { stdio: 'inherit' }); diff --git a/packages/backend/migration/1743403874305-DeliverSuspendedSoftware.js b/packages/backend/migration/1743403874305-DeliverSuspendedSoftware.js new file mode 100644 index 0000000000..19983a72bd --- /dev/null +++ b/packages/backend/migration/1743403874305-DeliverSuspendedSoftware.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class DeliverSuspendedSoftware1743403874305 { + name = 'DeliverSuspendedSoftware1743403874305' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "deliverSuspendedSoftware" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "deliverSuspendedSoftware"`); + } +} diff --git a/packages/backend/migration/1745378064470-composite-note-index.js b/packages/backend/migration/1745378064470-composite-note-index.js index 49e835d38c..12108a6b3c 100644 --- a/packages/backend/migration/1745378064470-composite-note-index.js +++ b/packages/backend/migration/1745378064470-composite-note-index.js @@ -3,11 +3,25 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js"; + export class CompositeNoteIndex1745378064470 { name = 'CompositeNoteIndex1745378064470'; + transaction = isConcurrentIndexMigrationEnabled() ? false : undefined; async up(queryRunner) { - await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + const concurrently = isConcurrentIndexMigrationEnabled(); + + if (concurrently) { + const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`); + if (hasValidIndex.length === 0 || hasValidIndex[0].indisvalid !== true) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); + await queryRunner.query(`CREATE INDEX CONCURRENTLY "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + } + } else { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + } + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`); // Flush all cached Linear Scan Plans and redo statistics for composite index // this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly @@ -15,7 +29,8 @@ export class CompositeNoteIndex1745378064470 { } async down(queryRunner) { + const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : ''; await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); - await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); + await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); } } diff --git a/packages/backend/migration/js/migration-config.js b/packages/backend/migration/js/migration-config.js new file mode 100644 index 0000000000..8cfbb21470 --- /dev/null +++ b/packages/backend/migration/js/migration-config.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function isConcurrentIndexMigrationEnabled() { + return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1'; +} diff --git a/packages/backend/ormconfig.js b/packages/backend/ormconfig.js index 229e5bf1fe..f979c36ad7 100644 --- a/packages/backend/ormconfig.js +++ b/packages/backend/ormconfig.js @@ -1,6 +1,7 @@ import { DataSource } from 'typeorm'; import { loadConfig } from './built/config.js'; import { entities } from './built/postgres.js'; +import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js"; const config = loadConfig(); @@ -14,4 +15,5 @@ export default new DataSource({ extra: config.db.extra, entities: entities, migrations: ['migration/*.js'], + migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all', }); diff --git a/packages/backend/package.json b/packages/backend/package.json index c4de44df18..36f7781908 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,12 +22,12 @@ "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", - "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs", - "jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs", - "jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs", - "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs", - "jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs", - "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", + "jest": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs", + "jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs", + "jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs", + "jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs", + "jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs", + "jest-clear": "cross-env NODE_ENV=test node ./jest.js --clearCache", "test": "pnpm jest", "test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e", "test:fed": "pnpm jest:fed", @@ -78,7 +78,7 @@ "@fastify/multipart": "9.0.3", "@fastify/static": "8.1.1", "@fastify/view": "10.0.2", - "@misskey-dev/sharp-read-bmp": "1.3.0", + "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.2.1", "@napi-rs/canvas": "0.1.69", "@nestjs/common": "11.1.0", @@ -168,7 +168,8 @@ "rxjs": "7.8.2", "sanitize-html": "2.16.0", "secure-json-parse": "3.0.2", - "sharp": "0.34.1", + "sharp": "0.33.5", + "semver": "7.7.1", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 5544eeeddd..435bd8dd45 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -24,8 +24,13 @@ const $config: Provider = { const $db: Provider = { provide: DI.db, useFactory: async (config) => { - const db = createPostgresDataSource(config); - return await db.initialize(); + try { + const db = createPostgresDataSource(config); + return await db.initialize(); + } catch (e) { + console.log(e); + throw e; + } }, inject: [DI.config], }; diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 4fc1193f32..8d2de89efd 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -9,87 +9,7 @@ import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { NotificationService } from '@/core/NotificationService.js'; - -export const ACHIEVEMENT_TYPES = [ - 'notes1', - 'notes10', - 'notes100', - 'notes500', - 'notes1000', - 'notes5000', - 'notes10000', - 'notes20000', - 'notes30000', - 'notes40000', - 'notes50000', - 'notes60000', - 'notes70000', - 'notes80000', - 'notes90000', - 'notes100000', - 'login3', - 'login7', - 'login15', - 'login30', - 'login60', - 'login100', - 'login200', - 'login300', - 'login400', - 'login500', - 'login600', - 'login700', - 'login800', - 'login900', - 'login1000', - 'passedSinceAccountCreated1', - 'passedSinceAccountCreated2', - 'passedSinceAccountCreated3', - 'loggedInOnBirthday', - 'loggedInOnNewYearsDay', - 'noteClipped1', - 'noteFavorited1', - 'myNoteFavorited1', - 'profileFilled', - 'markedAsCat', - 'following1', - 'following10', - 'following50', - 'following100', - 'following300', - 'followers1', - 'followers10', - 'followers50', - 'followers100', - 'followers300', - 'followers500', - 'followers1000', - 'collectAchievements30', - 'viewAchievements3min', - 'iLoveMisskey', - 'foundTreasure', - 'client30min', - 'client60min', - 'noteDeletedWithin1min', - 'postedAtLateNight', - 'postedAt0min0sec', - 'selfQuote', - 'htl20npm', - 'viewInstanceChart', - 'outputHelloWorldOnScratchpad', - 'open3windows', - 'driveFolderCircularReference', - 'reactWithoutRead', - 'clickedClickHere', - 'justPlainLucky', - 'setNameToSyuilo', - 'cookieClicked', - 'brainDiver', - 'smashTestNotificationButton', - 'tutorialCompleted', - 'bubbleGameExplodingHead', - 'bubbleGameDoubleExplodingHead', -] as const; +import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js'; @Injectable() export class AchievementService { diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 1ffeb4b3a4..6253f792ed 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -36,6 +36,7 @@ type TimelineOptions = { excludeNoFiles?: boolean; excludeReplies?: boolean; excludePureRenotes: boolean; + ignoreAuthorFromUserSuspension?: boolean; dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise, }; @@ -139,6 +140,23 @@ export class FanoutTimelineEndpointService { }; } + { + const parentFilter = filter; + filter = (note) => { + const noteJoined = note as MiNote & { + renoteUser: MiUser | null; + replyUser: MiUser | null; + }; + if (!ps.ignoreAuthorFromUserSuspension) { + if (note.user!.isSuspended) return false; + } + if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false; + if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false; + + return parentFilter(note); + }; + } + const redisTimeline: MiNote[] = []; let readFromRedis = 0; let lastSuccessfulRate = 1; // rateをキャッシュする? diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 119eb49c02..b9cef5b0ec 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -43,29 +43,36 @@ export class QueryService { ) { } - public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder { + public makePaginationQuery( + q: SelectQueryBuilder, + sinceId?: string | null, + untilId?: string | null, + sinceDate?: number | null, + untilDate?: number | null, + targetColumn = 'id', + ): SelectQueryBuilder { if (sinceId && untilId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); - q.orderBy(`${q.alias}.id`, 'DESC'); + q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } else if (sinceId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.orderBy(`${q.alias}.id`, 'ASC'); + q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.${targetColumn}`, 'ASC'); } else if (untilId) { - q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); - q.orderBy(`${q.alias}.id`, 'DESC'); + q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } else if (sinceDate && untilDate) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); - q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) }); - q.orderBy(`${q.alias}.id`, 'DESC'); + q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); + q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) }); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } else if (sinceDate) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); - q.orderBy(`${q.alias}.id`, 'ASC'); + q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); + q.orderBy(`${q.alias}.${targetColumn}`, 'ASC'); } else if (untilDate) { - q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) }); - q.orderBy(`${q.alias}.id`, 'DESC'); + q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) }); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } else { - q.orderBy(`${q.alias}.id`, 'DESC'); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } return q; } @@ -287,4 +294,26 @@ export class QueryService { .andWhere(instanceSuspension('renoteUser')); } } + + // Requirements: user replyUser renoteUser must be joined + @bindThis + public generateSuspendedUserQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): void { + if (excludeAuthor) { + const brakets = (user: string) => new Brackets(qb => qb + .where(`note.${user}Id IS NULL`) + .orWhere(`user.id = ${user}.id`) + .orWhere(`${user}.isSuspended = FALSE`)); + q + .andWhere(brakets('replyUser')) + .andWhere(brakets('renoteUser')); + } else { + const brakets = (user: string) => new Brackets(qb => qb + .where(`note.${user}Id IS NULL`) + .orWhere(`${user}.isSuspended = FALSE`)); + q + .andWhere('user.isSuspended = FALSE') + .andWhere(brakets('replyUser')) + .andWhere(brakets('renoteUser')); + } + } } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index d94281920e..20a776ded8 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -235,6 +235,7 @@ export class SearchService { this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); @@ -297,11 +298,17 @@ export class SearchService { ]) : [new Set(), new Set()]; - const query = this.notesRepository.createQueryBuilder('note'); + const query = this.notesRepository.createQueryBuilder('note') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) }); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 23fb928ac9..67ec6cc7b0 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -6,10 +6,12 @@ import { URL, domainToASCII } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import RE2 from 're2'; +import semver from 'semver'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; -import { MiMeta } from '@/models/Meta.js'; +import { MiMeta, SoftwareSuspension } from '@/models/Meta.js'; +import { MiInstance } from '@/models/Instance.js'; @Injectable() export class UtilityService { @@ -143,4 +145,20 @@ export class UtilityService { const host = this.extractDbHost(uri); return this.isFederationAllowedHost(host); } + + @bindThis + public isDeliverSuspendedSoftware(software: Pick): SoftwareSuspension | undefined { + if (software.softwareName == null) return undefined; + if (software.softwareVersion == null) { + // software version is null; suspend iff versionRange is * + return this.meta.deliverSuspendedSoftware.find(x => + x.software === software.softwareName + && x.versionRange.trim() === '*'); + } else { + const softwareVersion = software.softwareVersion; + return this.meta.deliverSuspendedSoftware.find(x => + x.software === software.softwareName + && semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true })); + } + } } diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 284537b986..3688cfb363 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -31,6 +31,7 @@ export class InstanceEntityService { me?: { id: MiUser['id']; } | null | undefined, ): Promise> { const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; + const softwareSuspended = this.utilityService.isDeliverSuspendedSoftware(instance); return { id: instance.id, @@ -41,8 +42,8 @@ export class InstanceEntityService { followingCount: instance.followingCount, followersCount: instance.followersCount, isNotResponding: instance.isNotResponding, - isSuspended: instance.suspensionState !== 'none', - suspensionState: instance.suspensionState, + isSuspended: instance.suspensionState !== 'none' || Boolean(softwareSuspended), + suspensionState: instance.suspensionState === 'none' && softwareSuspended ? 'softwareSuspended' : instance.suspensionState, isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host), softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 27aa3d89de..e4eb10efca 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -67,6 +67,7 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js'; import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js'; import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js'; +import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -78,6 +79,8 @@ export const refs = { User: packedUserSchema, UserList: packedUserListSchema, + Achievement: packedAchievementSchema, + AchievementName: packedAchievementNameSchema, Ad: packedAdSchema, Announcement: packedAnnouncementSchema, App: packedAppSchema, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 1fbf5371bc..46f3b2e3c0 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -664,4 +664,14 @@ export class MiMeta { nullable: true, }) public googleAnalyticsMeasurementId: string | null; + + @Column('jsonb', { + default: [], + }) + public deliverSuspendedSoftware: SoftwareSuspension[]; } + +export type SoftwareSuspension = { + software: string, + versionRange: string, +}; diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index c5ca2b5776..3dcbdb735b 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -10,6 +10,16 @@ import { MiUser } from './User.js'; import { MiChannel } from './Channel.js'; import type { MiDriveFile } from './DriveFile.js'; +// Note: When you create a new index for existing column of this table, +// it might be better to index concurrently under isConcurrentIndexMigrationEnabled flag +// by editing generated migration file since this table is very large, +// and it will make a long lock to create index in most cases. +// Please note that `CREATE INDEX CONCURRENTLY` is not supported in transaction, +// so you need to set `transaction = false` in migration if isConcurrentIndexMigrationEnabled() is true. +// Please refer 1745378064470-composite-note-index.js for example. +// You should not use `@Index({ concurrent: true })` decorator because database initialization for test will fail +// because it will always run CREATE INDEX in transaction based on decorators. +// Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production, @Index(['userId', 'id']) @Entity('note') export class MiNote { @@ -229,7 +239,6 @@ export class MiNote { comment: '[Denormalized]', }) public renoteUserHost: string | null; - //#endregion constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 5544555296..c4c1fa5ec9 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -274,7 +274,7 @@ export class MiUserProfile { default: [], }) public achievements: { - name: string; + name: typeof ACHIEVEMENT_TYPES[number]; unlockedAt: number; }[]; @@ -295,3 +295,84 @@ export class MiUserProfile { } } } + +export const ACHIEVEMENT_TYPES = [ + 'notes1', + 'notes10', + 'notes100', + 'notes500', + 'notes1000', + 'notes5000', + 'notes10000', + 'notes20000', + 'notes30000', + 'notes40000', + 'notes50000', + 'notes60000', + 'notes70000', + 'notes80000', + 'notes90000', + 'notes100000', + 'login3', + 'login7', + 'login15', + 'login30', + 'login60', + 'login100', + 'login200', + 'login300', + 'login400', + 'login500', + 'login600', + 'login700', + 'login800', + 'login900', + 'login1000', + 'passedSinceAccountCreated1', + 'passedSinceAccountCreated2', + 'passedSinceAccountCreated3', + 'loggedInOnBirthday', + 'loggedInOnNewYearsDay', + 'noteClipped1', + 'noteFavorited1', + 'myNoteFavorited1', + 'profileFilled', + 'markedAsCat', + 'following1', + 'following10', + 'following50', + 'following100', + 'following300', + 'followers1', + 'followers10', + 'followers50', + 'followers100', + 'followers300', + 'followers500', + 'followers1000', + 'collectAchievements30', + 'viewAchievements3min', + 'iLoveMisskey', + 'foundTreasure', + 'client30min', + 'client60min', + 'noteDeletedWithin1min', + 'postedAtLateNight', + 'postedAt0min0sec', + 'selfQuote', + 'htl20npm', + 'viewInstanceChart', + 'outputHelloWorldOnScratchpad', + 'open3windows', + 'driveFolderCircularReference', + 'reactWithoutRead', + 'clickedClickHere', + 'justPlainLucky', + 'setNameToSyuilo', + 'cookieClicked', + 'brainDiver', + 'smashTestNotificationButton', + 'tutorialCompleted', + 'bubbleGameExplodingHead', + 'bubbleGameDoubleExplodingHead', +] as const; diff --git a/packages/backend/src/models/json-schema/achievement.ts b/packages/backend/src/models/json-schema/achievement.ts new file mode 100644 index 0000000000..39a621a570 --- /dev/null +++ b/packages/backend/src/models/json-schema/achievement.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js'; + +export const packedAchievementNameSchema = { + type: 'string', + enum: ACHIEVEMENT_TYPES, + optional: false, +} as const; + +export const packedAchievementSchema = { + type: 'object', + properties: { + name: { + ref: 'AchievementName', + }, + unlockedAt: { + type: 'number', + optional: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 912a0399d8..85f84952f1 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -48,7 +48,7 @@ export const packedFederationInstanceSchema = { suspensionState: { type: 'string', nullable: false, optional: false, - enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'], + enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding', 'softwareSuspended'], }, isBlocked: { type: 'boolean', diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 7f23d2d6a1..6de120c8d7 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; import { notificationTypes, userExportableEntities } from '@/types.js'; const baseSchema = { @@ -312,9 +311,7 @@ export const packedNotificationSchema = { enum: ['achievementEarned'], }, achievement: { - type: 'string', - optional: false, nullable: false, - enum: ACHIEVEMENT_TYPES, + ref: 'AchievementName', }, }, }, { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index e475296702..2b5f706ff9 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -630,18 +630,7 @@ export const packedMeDetailedOnlySchema = { type: 'array', nullable: false, optional: false, items: { - type: 'object', - nullable: false, optional: false, - properties: { - name: { - type: 'string', - nullable: false, optional: false, - }, - unlockedAt: { - type: 'number', - nullable: false, optional: false, - }, - }, + ref: 'Achievement', }, }, loggedInDays: { diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 5a16496011..391ccdac05 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -71,6 +71,15 @@ export class DeliverProcessorService { return 'skip (suspended)'; } + const i = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(host) + : this.federatedInstanceService.fetch(host)); + + // suspend server by software + if (i != null && this.utilityService.isDeliverSuspendedSoftware(i)) { + return 'skip (software suspended)'; + } + try { await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest); @@ -79,10 +88,6 @@ export class DeliverProcessorService { // 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) { diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 7decdd2c10..c859f1d82c 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -7,7 +7,7 @@ import cluster from 'node:cluster'; import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import Fastify, { FastifyInstance } from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import fastifyStatic from '@fastify/static'; import fastifyRawBody from 'fastify-raw-body'; import { IsNull } from 'typeorm'; @@ -73,7 +73,7 @@ export class ServerService implements OnApplicationShutdown { } @bindThis - public async launch() { + public async launch(): Promise { const fastify = Fastify({ trustProxy: true, logger: false, @@ -133,8 +133,8 @@ export class ServerService implements OnApplicationShutdown { reply.header('content-type', 'text/plain; charset=utf-8'); reply.header('link', `<${encodeURI(location)}>; rel="canonical"`); done(null, [ - 'Refusing to relay remote ActivityPub object lookup.', - '', + "Refusing to relay remote ActivityPub object lookup.", + "", `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`, ].join('\n')); }); @@ -301,7 +301,6 @@ export class ServerService implements OnApplicationShutdown { } await fastify.ready(); - return fastify; } @bindThis @@ -310,6 +309,13 @@ export class ServerService implements OnApplicationShutdown { await this.#fastify.close(); } + /** + * Get the Fastify instance for testing. + */ + public get fastify(): FastifyInstance { + return this.#fastify; + } + @bindThis async onApplicationShutdown(signal: string): Promise { await this.dispose(); diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 960c7b5476..a42fdaf730 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -6,11 +6,8 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; -import { Transform } from 'node:stream'; -import { type MultipartFile } from '@fastify/multipart'; import { Inject, Injectable } from '@nestjs/common'; import * as Sentry from '@sentry/node'; -import { AttachmentFile } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -19,7 +16,7 @@ import type Logger from '@/logger.js'; import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; -import { type RolePolicies, RoleService } from '@/core/RoleService.js'; +import { RoleService } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; @@ -203,6 +200,18 @@ export class ApiCallService implements OnApplicationShutdown { return; } + const [path, cleanup] = await createTemp(); + await stream.pipeline(multipartData.file, fs.createWriteStream(path)); + + // ファイルサイズが制限を超えていた場合 + // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある + if (multipartData.file.truncated) { + cleanup(); + reply.code(413); + reply.send(); + return; + } + const fields = {} as Record; for (const [k, v] of Object.entries(multipartData.fields)) { fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; @@ -217,7 +226,10 @@ export class ApiCallService implements OnApplicationShutdown { return; } this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, fields, multipartData, request).then((res) => { + this.call(endpoint, user, app, fields, { + name: multipartData.filename, + path: path, + }, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { this.#sendApiError(reply, err); @@ -282,7 +294,10 @@ export class ApiCallService implements OnApplicationShutdown { user: MiLocalUser | null | undefined, token: MiAccessToken | null | undefined, data: any, - multipartFile: MultipartFile | null, + file: { + name: string; + path: string; + } | null, request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, ) { const isSecure = user != null && token == null; @@ -356,37 +371,6 @@ export class ApiCallService implements OnApplicationShutdown { } } - // Cast non JSON input - if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { - for (const k of Object.keys(ep.params.properties)) { - const param = ep.params.properties![k]; - if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { - try { - data[k] = JSON.parse(data[k]); - } catch (e) { - throw new ApiError({ - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', - }, { - param: k, - reason: `cannot cast to ${param.type}`, - }); - } - } - } - } - - if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) - || (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { - throw new ApiError({ - message: 'Your app does not have the necessary permissions to use this endpoint.', - code: 'PERMISSION_DENIED', - kind: 'permission', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', - }); - } - if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { @@ -420,91 +404,49 @@ export class ApiCallService implements OnApplicationShutdown { } } - let attachmentFile: AttachmentFile | null = null; - let cleanup = () => {}; - if (ep.meta.requireFile && request.method === 'POST' && multipartFile) { - const policies = await this.roleService.getUserPolicies(user!.id); - const result = await this.handleAttachmentFile( - Math.min((policies.maxFileSizeMb * 1024 * 1024), this.config.maxFileSize), - multipartFile, - ); - attachmentFile = result.attachmentFile; - cleanup = result.cleanup; + if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) + || (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { + throw new ApiError({ + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + kind: 'permission', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }); + } + + // Cast non JSON input + if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { + for (const k of Object.keys(ep.params.properties)) { + const param = ep.params.properties![k]; + if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { + try { + data[k] = JSON.parse(data[k]); + } catch (e) { + throw new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', + }, { + param: k, + reason: `cannot cast to ${param.type}`, + }); + } + } + } } // API invoking if (this.config.sentryForBackend) { return await Sentry.startSpan({ name: 'API: ' + ep.name, - }, () => { - return ep.exec(data, user, token, attachmentFile, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)) - .finally(() => cleanup()); - }); + }, () => ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); } else { - return await ep.exec(data, user, token, attachmentFile, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)) - .finally(() => cleanup()); + return await ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); } } - @bindThis - private async handleAttachmentFile( - fileSizeLimit: number, - multipartFile: MultipartFile, - ) { - function createTooLongError() { - return new ApiError({ - httpStatusCode: 413, - kind: 'client', - message: 'File size is too large.', - code: 'FILE_SIZE_TOO_LARGE', - id: 'ff827ce8-9b4b-4808-8511-422222a3362f', - }); - } - - function createLimitStream(limit: number) { - let total = 0; - - return new Transform({ - transform(chunk, _, callback) { - total += chunk.length; - if (total > limit) { - callback(createTooLongError()); - } else { - callback(null, chunk); - } - }, - }); - } - - const [path, cleanup] = await createTemp(); - try { - await stream.pipeline( - multipartFile.file, - createLimitStream(fileSizeLimit), - fs.createWriteStream(path), - ); - - // ファイルサイズが制限を超えていた場合 - // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある - if (multipartFile.file.truncated) { - throw createTooLongError(); - } - } catch (err) { - cleanup(); - throw err; - } - - return { - attachmentFile: { - name: multipartFile.filename, - path, - }, - cleanup, - }; - } - @bindThis public dispose(): void { clearInterval(this.userIpHistoriesClearIntervalId); diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index b063487305..e061aa3a8e 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -21,23 +21,23 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); export type Response = Record | void; -export type AttachmentFile = { +type File = { name: string | null; path: string; }; // TODO: paramsの型をT['params']のスキーマ定義から推論する type Executor = - (params: SchemaType, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, cleanup?: () => any, ip?: string | null, headers?: Record | null) => - Promise>>; + (params: SchemaType, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record | null) => + Promise>>; export abstract class Endpoint { - public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record | null) => Promise; + public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => Promise; constructor(meta: T, paramDef: Ps, cb: Executor) { const validate = ajv.compile(paramDef); - this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record | null) => { + this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => { let cleanup: undefined | (() => void) = undefined; if (meta.requireFile) { diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 53e2b2b237..4a106e7175 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -528,6 +528,24 @@ export const meta = { optional: false, nullable: false, }, }, + deliverSuspendedSoftware: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + software: { + type: 'string', + optional: false, nullable: false, + }, + versionRange: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, }, }, } as const; @@ -672,6 +690,7 @@ export default class extends Endpoint { // eslint- urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, federation: instance.federation, federationHosts: instance.federationHosts, + deliverSuspendedSoftware: instance.deliverSuspendedSoftware, }; }); } 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 bc05587668..31eeaa5e38 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -185,6 +185,17 @@ export const paramDef = { type: 'string', }, }, + deliverSuspendedSoftware: { + type: 'array', + items: { + type: 'object', + properties: { + software: { type: 'string' }, + versionRange: { type: 'string' }, + }, + required: ['software', 'versionRange'], + }, + }, }, required: [], } as const; @@ -671,6 +682,10 @@ export default class extends Endpoint { // eslint- set.federation = ps.federation; } + if (ps.deliverSuspendedSoftware !== undefined) { + set.deliverSuspendedSoftware = ps.deliverSuspendedSoftware; + } + if (Array.isArray(ps.federationHosts)) { set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 4708dab73c..f37cdc6658 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -112,6 +112,7 @@ export default class extends Endpoint { // eslint- // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts index d2f36f251e..294b5e4bc4 100644 --- a/packages/backend/src/server/api/endpoints/channels/followed.ts +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -48,7 +48,15 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) + const query = this.queryService + .makePaginationQuery( + this.channelFollowingsRepository.createQueryBuilder(), + ps.sinceId, + ps.untilId, + null, + null, + 'followeeId', + ) .andWhere({ followerId: me.id }); const followings = await query diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 620cdb0f5d..2401ab8208 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -122,6 +122,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 2b65407cea..33f32d1d8a 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -85,9 +85,10 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + // this.queryService.generateSuspendedUserQueryForNote(query); // To avoid problems with removing notes, ignoring suspended user for now if (me) { - this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); } diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 17face8f82..11c255a361 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -61,6 +61,7 @@ export const meta = { message: 'Cannot upload the file because it exceeds the maximum file size.', code: 'MAX_FILE_SIZE_EXCEEDED', id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a', + httpStatusCode: 413, }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts index e70905ef1b..0e42647ef7 100644 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -5,7 +5,8 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; +import { AchievementService } from '@/core/AchievementService.js'; +import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index 218a3c1a4c..712a86eb13 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -71,6 +71,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index e7aba2d306..a57c84d432 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -97,6 +97,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 39b519a599..6a3ee817e4 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -244,6 +244,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 8b2d5397b2..d1dc22f233 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -157,6 +157,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index f5cddd5bad..c3722b1b5a 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -73,6 +73,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedNoteThreadQuery(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 178e311ed1..ce2435b8eb 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -73,6 +73,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index d9aaed2f10..f491cc38ab 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -57,6 +57,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 079231d432..d0781bd8dd 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -82,6 +82,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 42752eaeec..e6d6a1b629 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -200,6 +200,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 58a4223207..ec7c4b0f97 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -185,6 +185,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index b0d3f6d2f9..16b0783a01 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -103,6 +103,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/achievements.ts b/packages/backend/src/server/api/endpoints/users/achievements.ts index f7139b3684..bae216e347 100644 --- a/packages/backend/src/server/api/endpoints/users/achievements.ts +++ b/packages/backend/src/server/api/endpoints/users/achievements.ts @@ -14,15 +14,7 @@ export const meta = { res: { type: 'array', items: { - type: 'object', - properties: { - name: { - type: 'string', - }, - unlockedAt: { - type: 'number', - }, - }, + ref: 'Achievement', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/users/featured-notes.ts b/packages/backend/src/server/api/endpoints/users/featured-notes.ts index 053fd60548..90bd11bc25 100644 --- a/packages/backend/src/server/api/endpoints/users/featured-notes.ts +++ b/packages/backend/src/server/api/endpoints/users/featured-notes.ts @@ -88,6 +88,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index b0585f75fc..0c64df569d 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -130,6 +130,7 @@ export default class extends Endpoint { // eslint- useDbFallback: true, ignoreAuthorFromMute: true, ignoreAuthorFromInstanceBlock: true, + ignoreAuthorFromUserSuspension: true, excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludePureRenotes: !ps.withRenotes, @@ -186,6 +187,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query, true); + this.queryService.generateSuspendedUserQueryForNote(query, true); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId }); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index bb9000a7a0..d6f1ecd8ed 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -99,10 +99,16 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('reaction.userId = :userId', { userId: ps.userId }) - .leftJoinAndSelect('reaction.note', 'note'); + .leftJoinAndSelect('reaction.note', 'note') + .leftJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); const reactions = (await query .limit(ps.limit) diff --git a/packages/backend/test-federation/README.md b/packages/backend/test-federation/README.md index 967d51f085..4ea88c1b80 100644 --- a/packages/backend/test-federation/README.md +++ b/packages/backend/test-federation/README.md @@ -10,15 +10,15 @@ 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 +NODE_VERSION=22 docker compose up --scale tester=0 ``` Then you can run all tests by a following command: ```sh -docker compose run --no-deps --rm tester +NODE_VERSION=22 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 +NODE_VERSION=22 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.tpl.yml b/packages/backend/test-federation/compose.tpl.yml index a7e907c3ee..e4483acd7a 100644 --- a/packages/backend/test-federation/compose.tpl.yml +++ b/packages/backend/test-federation/compose.tpl.yml @@ -12,7 +12,7 @@ services: retries: 20 misskey: - image: node:20 + image: node:${NODE_VERSION} env_file: - ./.config/docker.env environment: diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml index 4df4ced365..bd0ac15a31 100644 --- a/packages/backend/test-federation/compose.yml +++ b/packages/backend/test-federation/compose.yml @@ -16,7 +16,7 @@ services: " tester: - image: node:20 + image: node:${NODE_VERSION} depends_on: a.test: condition: service_healthy @@ -50,6 +50,10 @@ services: source: ../jest.config.fed.cjs target: /misskey/packages/backend/jest.config.fed.cjs read_only: true + - type: bind + source: ../jest.js + target: /misskey/packages/backend/jest.js + read_only: true - type: bind source: ../../misskey-js/built target: /misskey/packages/misskey-js/built @@ -85,7 +89,7 @@ services: command: pnpm -F backend test:fed daemon: - image: node:20 + image: node:${NODE_VERSION} depends_on: redis.test: condition: service_healthy diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts index f9e65aaa84..49c6a0636b 100644 --- a/packages/backend/test/e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -159,8 +159,8 @@ describe('API', () => { user: { token: application3 }, }, { status: 403, - code: 'PERMISSION_DENIED', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + code: 'ROLE_PERMISSION_DENIED', + id: 'c3d38592-54c0-429d-be96-5636b0431a61', }); await failedApiCall({ diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index 7ae1ee4523..570cc61c4b 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -909,7 +909,7 @@ describe('クリップ', () => { assert.deepStrictEqual(res.map(x => x.id), [aliceNote.id]); }); - test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => { + test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートは含まれない)', async () => { const publicClip = await create({ isPublic: true }); await addNote({ clipId: publicClip.id, noteId: aliceNote.id }); await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id }); @@ -919,8 +919,6 @@ describe('クリップ', () => { const res = await notes({ clipId: publicClip.id }, { user: undefined }); const expects = [ aliceNote, aliceHomeNote, - // 認証なしだと非公開ノートは結果には含むけどhideされる。 - hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote), ]; assert.deepStrictEqual( res.sort(compareBy(s => s.id)).map(x => x.id), diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index ce3f931bb0..ca6a639be8 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -232,7 +232,7 @@ describe('UserEntityService', () => { }); test('MeDetailed', async() => { - const achievements = [{ name: 'achievement', unlockedAt: new Date().getTime() }]; + const achievements = [{ name: 'iLoveMisskey' as const, unlockedAt: new Date().getTime() }]; const me = await createUser({}, { birthday: '2000-01-01', achievements: achievements, diff --git a/packages/backend/test/unit/server/api/drive/files/create.ts b/packages/backend/test/unit/server/api/drive/files/create.ts index b98892fa03..9b38f4d744 100644 --- a/packages/backend/test/unit/server/api/drive/files/create.ts +++ b/packages/backend/test/unit/server/api/drive/files/create.ts @@ -3,29 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { S3Client } from '@aws-sdk/client-s3'; import { Test, TestingModule } from '@nestjs/testing'; -import { mockClient } from 'aws-sdk-client-mock'; import { FastifyInstance } from 'fastify'; import request from 'supertest'; +import { randomString } from '../../../../../utils.js'; import { CoreModule } from '@/core/CoreModule.js'; import { RoleService } from '@/core/RoleService.js'; import { DI } from '@/di-symbols.js'; import { GlobalModule } from '@/GlobalModule.js'; -import { MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { DriveFoldersRepository, MiDriveFolder, MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { MiUser } from '@/models/User.js'; import { ServerModule } from '@/server/ServerModule.js'; import { ServerService } from '@/server/ServerService.js'; +import { IdService } from '@/core/IdService.js'; describe('/drive/files/create', () => { let module: TestingModule; let server: FastifyInstance; - const s3Mock = mockClient(S3Client); let roleService: RoleService; + let idService: IdService; let root: MiUser; let role_tinyAttachment: MiRole; + let folder: MiDriveFolder; + beforeAll(async () => { module = await Test.createTestingModule({ imports: [GlobalModule, CoreModule, ServerModule], @@ -33,21 +35,34 @@ describe('/drive/files/create', () => { module.enableShutdownHooks(); const serverService = module.get(ServerService); - server = await serverService.launch(); + await serverService.launch(); + server = serverService.fastify; + + idService = module.get(IdService); const usersRepository = module.get(DI.usersRepository); + await usersRepository.delete({}); root = await usersRepository.insert({ - id: 'root', + id: idService.gen(), username: 'root', usernameLower: 'root', token: '1234567890123456', }).then(x => usersRepository.findOneByOrFail(x.identifiers[0])); const userProfilesRepository = module.get(DI.userProfilesRepository); + await userProfilesRepository.delete({}); await userProfilesRepository.insert({ userId: root.id, }); + const driveFoldersRepository = module.get(DI.driveFoldersRepository); + folder = await driveFoldersRepository.insertOne({ + id: idService.gen(), + name: 'root-folder', + parentId: null, + userId: root.id, + }); + roleService = module.get(RoleService); role_tinyAttachment = await roleService.create({ name: 'test-role001', @@ -65,8 +80,8 @@ describe('/drive/files/create', () => { }); beforeEach(async () => { - s3Mock.reset(); - await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {}); + await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => { + }); }); afterAll(async () => { @@ -74,35 +89,76 @@ describe('/drive/files/create', () => { await module.close(); }); - test('200 ok', async () => { - const result = await request(server.server) + async function postFile(props: { + name: string, + comment: string, + isSensitive: boolean, + force: boolean, + fileContent: Buffer | string, + }) { + const { name, comment, isSensitive, force, fileContent } = props; + + return await request(server.server) .post('/api/drive/files/create') .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(1024 * 1024))); + .attach('file', fileContent) + .field('name', name) + .field('comment', comment) + .field('isSensitive', isSensitive) + .field('force', force) + .field('folderId', folder.id) + .field('i', root.token ?? ''); + } + + test('200 ok', async () => { + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(1000 * 1000)), + }); expect(result.statusCode).toBe(200); + expect(result.body.name).toBe(name + '.unknown'); + expect(result.body.comment).toBe(comment); + expect(result.body.isSensitive).toBe(true); + expect(result.body.folderId).toBe(folder.id); }); test('200 ok(with role)', async () => { await roleService.assign(root.id, role_tinyAttachment.id); - const result = await request(server.server) - .post('/api/drive/files/create') - .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(10))); + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(10)), + }); expect(result.statusCode).toBe(200); + expect(result.body.name).toBe(name + '.unknown'); + expect(result.body.comment).toBe(comment); + expect(result.body.isSensitive).toBe(true); + expect(result.body.folderId).toBe(folder.id); }); test('413 too large', async () => { await roleService.assign(root.id, role_tinyAttachment.id); - const result = await request(server.server) - .post('/api/drive/files/create') - .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(11))); + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(11)), + }); expect(result.statusCode).toBe(413); - expect(result.body.error.code).toBe('FILE_SIZE_TOO_LARGE'); + expect(result.body.error.code).toBe('MAX_FILE_SIZE_EXCEEDED'); }); }); diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 6a2d6afb38..19193e20fd 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -34,7 +34,7 @@ "tsconfig-paths": "4.2.0", "typescript": "5.8.3", "uuid": "11.1.0", - "vite": "6.3.3", + "vite": "6.3.4", "vue": "3.5.13" }, "devDependencies": { diff --git a/packages/frontend-embed/src/pages/not-found.vue b/packages/frontend-embed/src/pages/not-found.vue index 061254a39a..68897ca7e1 100644 --- a/packages/frontend-embed/src/pages/not-found.vue +++ b/packages/frontend-embed/src/pages/not-found.vue @@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -83,7 +80,6 @@ import XFile from './MkFormDialog.file.vue'; import type { Form } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = defineProps<{ title: string; diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index eba8a73aec..380fb7b2d8 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkSwiper.vue b/packages/frontend/src/components/MkSwiper.vue index 1d0ffaea11..b66bfb0e9d 100644 --- a/packages/frontend/src/components/MkSwiper.vue +++ b/packages/frontend/src/components/MkSwiper.vue @@ -53,12 +53,12 @@ const MIN_SWIPE_DISTANCE = 20; // スワイプ時の動作を発火する最小の距離 const SWIPE_DISTANCE_THRESHOLD = 70; -// スワイプを中断するY方向の移動距離 -const SWIPE_ABORT_Y_THRESHOLD = 75; - // スワイプできる最大の距離 const MAX_SWIPE_DISTANCE = 120; +// スワイプ方向を判定する角度の許容範囲(度数) +const SWIPE_DIRECTION_ANGLE_THRESHOLD = 50; + // ▲ しきい値 ▲ // let startScreenX: number | null = null; @@ -69,6 +69,7 @@ const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === t const pullDistance = ref(0); const isSwipingForClass = ref(false); let swipeAborted = false; +let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null; function touchStart(event: TouchEvent) { if (!prefer.r.enableHorizontalSwipe.value) return; @@ -79,6 +80,7 @@ function touchStart(event: TouchEvent) { startScreenX = event.touches[0].screenX; startScreenY = event.touches[0].screenY; + swipeDirectionLocked = null; // スワイプ方向をリセット } function touchMove(event: TouchEvent) { @@ -95,15 +97,24 @@ function touchMove(event: TouchEvent) { let distanceX = event.touches[0].screenX - startScreenX; let distanceY = event.touches[0].screenY - startScreenY; - if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) { - swipeAborted = true; + // スワイプ方向をロック + if (!swipeDirectionLocked) { + const angle = Math.abs(Math.atan2(distanceY, distanceX) * (180 / Math.PI)); + if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) { + swipeDirectionLocked = 'vertical'; + } else { + swipeDirectionLocked = 'horizontal'; + } + } + // 縦方向のスワイプの場合は中断 + if (swipeDirectionLocked === 'vertical') { + swipeAborted = true; pullDistance.value = 0; isSwiping.value = false; window.setTimeout(() => { isSwipingForClass.value = false; }, 400); - return; } @@ -164,6 +175,8 @@ function touchEnd(event: TouchEvent) { window.setTimeout(() => { isSwipingForClass.value = false; }, 400); + + swipeDirectionLocked = null; // スワイプ方向をリセット } /** 横スワイプに関与する可能性のある要素を調べる */ @@ -190,7 +203,7 @@ watch(tabModel, (newTab, oldTab) => { const newIndex = props.tabs.findIndex(tab => tab.key === newTab); const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab); - if (oldIndex >= 0 && newIndex && oldIndex < newIndex) { + if (oldIndex >= 0 && newIndex >= 0 && oldIndex < newIndex) { transitionName.value = 'swipeAnimationLeft'; } else { transitionName.value = 'swipeAnimationRight'; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 8ca690f2ce..6a265aa836 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue index 20b6e22a46..1e4eaf5639 100644 --- a/packages/frontend/src/pages/chat/room.search.vue +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -24,10 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
- -
{{ i18n.ts.notFound }}
-
+ @@ -38,7 +35,6 @@ import * as Misskey from 'misskey-js'; import XMessage from './XMessage.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 5390a48be5..21be0b18a9 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -68,10 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
- -
{{ i18n.ts.nothing }}
-
+ @@ -82,7 +79,6 @@ import MkInfo from '@/components/MkInfo.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import bytes from '@/filters/bytes.js'; -import { infoImageUrl } from '@/instance.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index 4f57c1209e..b0a18987b4 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +