diff --git a/.config/docker_example.yml b/.config/docker_example.yml index bd0ad2872a..d347882d1a 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -164,12 +164,12 @@ id: 'aidx' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 16 -# inboxJobConcurrency: 4 +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 # Job rate limiter # deliverJobPerSec: 128 -# inboxJobPerSec: 64 +# inboxJobPerSec: 32 # Job attempts # deliverJobMaxAttempts: 12 diff --git a/.config/example.yml b/.config/example.yml index 0d525f61c4..b11cbd1373 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -230,15 +230,15 @@ id: 'aidx' #clusterLimit: 1 # Job concurrency per worker -#deliverJobConcurrency: 16 -#inboxJobConcurrency: 4 +#deliverJobConcurrency: 128 +#inboxJobConcurrency: 16 #relationshipJobConcurrency: 16 # What's relationshipJob?: # Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Job rate limiter -#deliverJobPerSec: 1024 -#inboxJobPerSec: 64 +#deliverJobPerSec: 128 +#inboxJobPerSec: 32 #relationshipJobPerSec: 64 # Job attempts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7ea23e314e..fbf959d449 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "workspaceFolder": "/workspace", "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "20.12.2" + "version": "20.16.0" }, "ghcr.io/devcontainers-contrib/features/corepack:1": {} }, diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index d74d741e02..beefcfd0a2 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -157,12 +157,12 @@ id: 'aidx' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 16 -# inboxJobConcurrency: 4 +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 # Job rate limiter -# deliverJobPerSec: 1024 -# inboxJobPerSec: 64 +# deliverJobPerSec: 128 +# inboxJobPerSec: 32 # Job attempts # deliverJobMaxAttempts: 12 diff --git a/.dockerignore b/.dockerignore index 7dbb06e1d0..f204349160 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,7 +12,6 @@ node_modules/ packages/*/node_modules redis/ files/ -misskey-assets/ fluent-emojis/ .pnp.* diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.yml b/.github/ISSUE_TEMPLATE/01_bug-report.yml index ac2b39cc12..315e712c30 100644 --- a/.github/ISSUE_TEMPLATE/01_bug-report.yml +++ b/.github/ISSUE_TEMPLATE/01_bug-report.yml @@ -53,8 +53,8 @@ body: Examples: * Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4 * Browser: Chrome 113.0.5672.126 - * Server URL: misskey.io - * Misskey: 13.x.x + * Server URL: misskey.example.com + * Misskey: 2024.x.x value: | * Model and OS of the device(s): * Browser: @@ -74,11 +74,11 @@ body: Examples: * Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment - * Misskey: 13.x.x + * Misskey: 2024.x.x * Node: 20.x.x * PostgreSQL: 15.x.x * Redis: 7.x.x - * OS and Architecture: Ubuntu 22.04.2 LTS aarch64 + * OS and Architecture: Ubuntu 24.04.2 LTS aarch64 value: | * Installation Method or Hosting Service: * Misskey: diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 4afafabf2e..81e8134fb7 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -9,7 +9,6 @@ on: paths: - packages/backend/** - .github/workflows/get-api-diff.yml - - .github/workflows/get-api-diff.yml jobs: get-from-misskey: runs-on: ubuntu-latest @@ -18,7 +17,7 @@ jobs: strategy: matrix: - node-version: [20.12.2] + node-version: [20.16.0] api-json-name: [api-base.json, api-head.json] include: - api-json-name: api-base.json diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index 22c04ff297..8dd9ed2513 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - node-version: [20.12.2] + node-version: [20.16.0] steps: - uses: actions/checkout@v4.1.1 diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index bfb79ef090..026550025c 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: - node-version: [20.12.2] + node-version: [20.16.0] services: postgres: @@ -71,7 +71,7 @@ jobs: strategy: matrix: - node-version: [20.12.2] + node-version: [20.16.0] services: postgres: diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index c17a9fd387..fcaef52969 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -26,7 +26,7 @@ jobs: strategy: matrix: - node-version: [20.12.2] + node-version: [20.16.0] steps: - uses: actions/checkout@v4.1.1 @@ -61,7 +61,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20.12.2] + node-version: [20.16.0] browser: [chrome] services: diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 6ee67e8735..9ad71919df 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -21,7 +21,7 @@ jobs: strategy: matrix: - node-version: [20.12.2] + node-version: [20.16.0] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 18d02ec030..8ad8a64766 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [20.12.2] + node-version: [20.16.0] steps: - uses: actions/checkout@v4.1.1 diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index 90f2929a25..06e987f27e 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: - node-version: [20.12.2] + node-version: [20.16.0] steps: - uses: actions/checkout@v4.1.1 diff --git a/.gitignore b/.gitignore index 3466984cf6..45170902b1 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ ormconfig.json temp /packages/frontend/src/**/*.stories.ts tsdoc-metadata.json +misskey-assets # blender backups *.blend1 diff --git a/.gitmodules b/.gitmodules index 225a69a652..3218575273 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "misskey-assets"] - path = misskey-assets - url = https://github.com/misskey-dev/assets.git [submodule "fluent-emojis"] path = fluent-emojis url = https://github.com/misskey-dev/emojis.git diff --git a/.node-version b/.node-version index 87834047a6..8ce7030825 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.12.2 +20.16.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index f120aafa13..980601aa69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,26 @@ ### Note - デッキUIの新着ノートをサウンドで通知する機能の追加(v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。 +- Streaming APIにて入力が不正な場合にはそのメッセージを無視するようになりました。 #14251 ### General - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます -- Feat: 連合に使うHTTP SignaturesがEd25519鍵に対応するように #13464 - - Ed25519署名に対応するサーバーが増えると、deliverで要求されるサーバーリソースが削減されます +- Feat: ユーザ作成時にSystemWebhookを送信可能に #14281 +- Feat: メディアサイレンスを実装 #13842 + - メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。 +- Enhance: 管理画面でアーカイブにしたお知らせを表示・編集できるように - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 +- 翻訳の更新 +- 依存関係の更新 ### Client +- Feat: ユーザーページから「このユーザーのノートを検索」できるように (#14128) +- Feat: 検索ページはクエリを受け付けるようになりました (#14128) +- Enhance: 検索ページのUI改善 (#14128) - Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善 - Enhance: 非ログイン時に他サーバーに遷移するアクションを追加 - Enhance: 非ログイン時のハイライトTLのデザインを改善 @@ -23,6 +31,16 @@ (Cherry-picked from https://github.com/taiyme/misskey/pull/238) - Enhance: AiScriptを0.19.0にアップデート - Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`) +- Enhance: センシティブなメディアを開く際に確認ダイアログを出せるように +- Enhance: 検索(ノート/ユーザー)で `#` から始まる文字列を入力すると、そのハッシュタグのノート/ユーザー一覧ページが表示できるように +- Enhance: 検索(ノート/ユーザー)において、入力に空白が含まれている場合は照会を行わないように +- Enhance: 検索(ノート/ユーザー)において、照会を行うかどうか、ハッシュタグのノート/ユーザー一覧ページを表示するかどうかの確認ダイアログを出すように +- Enhance: 検索(ノート/ユーザー)で `@` から始まる文字列(`@user@host`など)を入力すると、そのユーザーを照会できるように +- Enhance: ドライブのファイル・フォルダをドラッグしなくても移動できるように + (Cherry-picked from https://github.com/nafu-at/misskey/commit/b89c2af6945c6a9f9f10e83f54d2bcf0f240b0b4, https://github.com/nafu-at/misskey/commit/8a7d710c6acb83f50c83f050bd1423c764d60a99) +- Enhance: デッキのアンテナ・リスト選択画面からそれぞれを新規作成できるように +- Enhance: ブラウザのコンテキストメニューを使用できるように +- Enhance: 連合の「連合中」,「購読中」,「配信中」に対してブロックしているサーバー、配信停止しているサーバーを含めないように - Enhance: 「デバイスのダークモードと同期する」を有効にしている際のカラーモード切替設定の挙動を視覚的にわかりやすく - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正 - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968) @@ -35,6 +53,25 @@ - Fix: MkSignin.vueのcredentialRequestからReactivityを削除(ProxyがPasskey認証処理に渡ることを避けるため) - Fix: 「アニメーション画像を再生しない」がオンのときでもサーバーのバナー画像・背景画像がアニメーションしてしまう問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/574) +- Fix: Twitchの埋め込みが開けない問題を修正 +- Fix: 子メニューの高さがウィンドウからはみ出ることがある問題を修正 +- Fix: 個人宛てのダイアログ形式のお知らせが即時表示されない問題を修正 +- Fix: 一部の画像がセンシティブ指定されているときに画面に何も表示されないことがあるのを修正 +- Fix: リアクションしたユーザー一覧のユーザー名がはみ出る問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/672) +- Fix: `/share`ページにおいて絵文字ピッカーを開くことができない問題を修正 +- Fix: deck uiの通知音が重なる問題 (#14029) +- Fix: ダイレクト投稿の"削除して編集"において、宛先が保持されていなかった問題を修正 +- Fix: 投稿フォームへのURL貼り付けによる引用が下書きに保存されていなかった問題を修正 +- Fix: "削除して編集"や下書きにおいて、リアクションの受け入れ設定が保持/保存されていなかった問題を修正 +- Fix: 照会に `#` から始まる文字列を入力してそのハッシュタグのページを表示する際、入力が `#` のみの場合に「指定されたURLに該当するページはありませんでした。」が表示されてしまう問題を修正 +- Fix: 照会に `@` から始まる文字列を入力してユーザーを照会する際、入力が `@` のみの場合に「問題が発生しました」が表示されてしまう問題を修正 +- Fix: 投稿フォームにノートのURLを貼り付けて"引用として添付"した場合、投稿文を空にすることによるRenote化が出来なかった問題を修正 +- Fix: フォロー中のユーザーに関する"TLに他の人への返信を含める"の設定が分かりづらい問題を修正 +- Fix: タイムラインページを開いた時、`TLに他の人への返信を含める`がオフのときに`ファイル付きのみ`をオンにできない問題を修正 +- Fix: deck uiでタイムラインを切り替えた際にTLの設定項目が更新されず、`TLに他の人への返信を含める`のトグルが表示されない問題を修正 +- Fix: ウィジェットのタイムライン選択欄に無効化されたタイムラインが表示される問題を修正 +- Fix: サウンドにドライブの音声を使用している際にドライブの音声が再生できなくなると設定が変更できなくなる問題を修正 ### Server - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) @@ -45,6 +82,7 @@ - Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに - Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに - Enhance: `default.yml`内の`url`, `db.db`, `db.user`, `db.pass`を環境変数から読み込めるように +- Enhance: エンドポイント`api/meta`にプロパティ`noteSearchableScope`が増え、`string`値`local`または`global`を返却します - Fix: チャート生成時にinstance.suspensionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正 - Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006) - Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036) @@ -65,7 +103,22 @@ - Fix: 一般ユーザーから見たユーザーのバッジの一覧に公開されていないものが含まれることがある問題を修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/652) - Fix: ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正 +- Fix: FTT有効時にリモートユーザーのノートがHTLにキャッシュされる問題を修正 +- Fix: 一部の通知がローカル上のリモートユーザーに対して行われていた問題を修正 - Fix: エラーメッセージの誤字を修正 (#14213) +- Fix: ソーシャルタイムラインにローカルタイムラインに表示される自分へのリプライが表示されない問題を修正 +- Fix: リノートのミュートが適用されるまでに時間がかかることがある問題を修正 + (Cherry-picked from https://github.com/Type4ny-Project/Type4ny/commit/e9601029b52e0ad43d9131b555b614e56c84ebc1) +- Fix: Steaming APIが不正なデータを受けた場合の動作が不安定である問題 #14251 +- Fix: `users/search`において `@` から始まる文字列が与えられた際の処理が正しくなかった問題を修正 + - 名前や自己紹介に `@` から始まる文言が含まれるユーザーも検索できるようになります +- Fix: 一部のMisskey以外のソフトウェアからファイルを受け取れない問題 + (Cherry-picked from https://github.com/Secineralyr/misskey.dream/pull/73/commits/652eaff1e8aa00b890d71d2e1e52c263c1e67c76) + - NOTE: `drive_file`の`url`, `uri`, `src`の上限が512から1024に変更されます + Migrationではカラム定義の変更のみが行われます。 + サーバー管理者は各サーバーの必要に応じ`drive_file` `("uri")`に対するインデックスを張りなおすことでより安定しDBの探索が行われる可能性があります。詳細 は [GitHub](https://github.com/misskey-dev/misskey/pull/14323#issuecomment-2257562228)で確認可能です +- Fix: 自分のフォロワー限定投稿に対するリプライがホームタイムラインで見えないことが有る問題を修正 +- Fix: フォローしていないユーザによるフォロワー限定投稿に対するリプライがソーシャルタイムラインで表示されることがある問題を修正 ### Misskey.js - Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 532a2dc66f..72c84c2f18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contribution guide We're glad you're interested in contributing Misskey! In this document you will find the information you need to contribute to the project. -> **Note** +> [!NOTE] > This project uses Japanese as its major language, **but you do not need to translate and write the Issues/PRs in Japanese.** > Also, you might receive comments on your Issue/PR in Japanese, but you do not need to reply to them in Japanese as well.\ > The accuracy of machine translation into Japanese is not high, so it will be easier for us to understand if you write it in the original language. @@ -17,16 +17,31 @@ Before creating an issue, please check the following: - Issues should only be used to feature requests, suggestions, and bug tracking. - Please ask questions or troubleshooting in [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3). -> **Warning** +> [!WARNING] > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. -## Before implementation +### Recommended discussing before implementation +We welcome your proposal. + When you want to add a feature or fix a bug, **first have the design and policy reviewed in an Issue** (if it is not there, please make one). Without this step, there is a high possibility that the PR will not be merged even if it is implemented. At this point, you also need to clarify the goals of the PR you will create, and make sure that the other members of the team are aware of them. PRs that do not have a clear set of do's and don'ts tend to be bloated and difficult to review. -Also, when you start implementation, assign yourself to the Issue (if you cannot do it yourself, ask another member to assign you). By expressing your intention to work the Issue, you can prevent conflicts in the work. +Also, when you start implementation, assign yourself to the Issue (if you cannot do it yourself, ask Committer to assign you). +By expressing your intention to work on the Issue, you can prevent conflicts in the work. + +To the Committers: you should not assign someone on it before the Final Decision. + +### How issues are triaged + +The Committers may: +* close an issue that is not reproducible on latest stable release, +* merge an issue into another issue, +* split an issue into multiple issues, +* or re-open that has been closed for some reason which is not applicable anymore. + +@syuilo reserves the Final Decision rights including whether the project will implement feature and how to implement, these rights are not always exercised. ## Well-known branches - **`master`** branch is tracking the latest release and used for production purposes. @@ -37,14 +52,14 @@ Also, when you start implementation, assign yourself to the Issue (if you cannot ## Creating a PR Thank you for your PR! Before creating a PR, please check the following: - If possible, prefix the title with a keyword that identifies the type of this PR, as shown below. - - `fix` / `refactor` / `feat` / `enhance` / `perf` / `chore` etc - - Also, make sure that the granularity of this PR is appropriate. Please do not include more than one type of change or interest in a single PR. + - `fix` / `refactor` / `feat` / `enhance` / `perf` / `chore` etc + - Also, make sure that the granularity of this PR is appropriate. Please do not include more than one type of change or interest in a single PR. - If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text. - Please add the summary of the changes to [`CHANGELOG.md`](/CHANGELOG.md). However, this is not necessary for changes that do not affect the users, such as refactoring. - Check if there are any documents that need to be created or updated due to this change. - If you have added a feature or fixed a bug, please add a test case if possible. - Please make sure that tests and Lint are passed in advance. - - You can run it with `pnpm test` and `pnpm lint`. [See more info](#testing) + - You can run it with `pnpm test` and `pnpm lint`. [See more info](#testing) - If this PR includes UI changes, please attach a screenshot in the text. Thanks for your cooperation 🤗 @@ -54,8 +69,8 @@ Be willing to comment on the good points and not just the things you want fixed ### Review perspective - Scope - - Are the goals of the PR clear? - - Is the granularity of the PR appropriate? + - Are the goals of the PR clear? + - Is the granularity of the PR appropriate? - Security - Does merging this PR create a vulnerability? - Performance @@ -77,7 +92,7 @@ An actual domain will be assigned so you can test the federation. ## Release ### Release Instructions -1. Commit version changes in the `develop` branch ([package.json](https://github.com/misskey-dev/misskey/blob/develop/package.json)) +1. Commit version changes in the `develop` branch ([package.json](package.json)) 2. Create a release PR. - Into `master` from `develop` branch. - The title must be in the format `Release: x.y.z`. @@ -88,7 +103,7 @@ An actual domain will be assigned so you can test the federation. - The target branch must be `master` - The tag name must be the version -> **Note** +> [!NOTE] > Why this instruction is necessary: > - To perform final QA checks > - To distribute responsibility @@ -106,12 +121,42 @@ If your language is not listed in Crowdin, please open an issue. ![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg) ## Development -During development, it is useful to use the +### Setup +Before developing, you have to set up environment. Misskey requires Redis, PostgreSQL, and FFmpeg. +You would want to install Meilisearch to experiment related features. Technically, meilisearch is not strict requirement, but some features and tests require it. + +There are a few ways to proceed. + +#### Use system-wide software +You could install them in system-wide (such as from package manager). + +#### Use `docker compose` +You could obtain middleware container by typing `docker compose -f $PROJECT_ROOT/compose.local-db.yml up -d`. + +#### Use Devcontainer +Devcontainer also has necessary setting. This method can be done by connecting from VSCode. + +Instead of running `pnpm` locally, you can use Dev Container to set up your development environment. +To use Dev Container, open the project directory on VSCode with Dev Containers installed. +**Note:** If you are using Windows, please clone the repository with WSL. Using Git for Windows will result in broken files due to the difference in how newlines are handled. + +It will run the following command automatically inside the container. +``` bash +git submodule update --init +pnpm install --frozen-lockfile +cp .devcontainer/devcontainer.yml .config/default.yml +pnpm build +pnpm migrate +``` + +After finishing the migration, you can proceed. + +### Start developing +During development, it is useful to use the ``` pnpm dev ``` - command. - Server-side source files and automatically builds them if they are modified. Automatically start the server process(es). @@ -135,26 +180,6 @@ MK_DEV_PREFER=backend pnpm dev - To change the port of Vite, specify with `VITE_PORT` environment variable. - HMR may not work in some environments such as Windows. -### Dev Container -Instead of running `pnpm` locally, you can use Dev Container to set up your development environment. -To use Dev Container, open the project directory on VSCode with Dev Containers installed. -**Note:** If you are using Windows, please clone the repository with WSL. Using Git for Windows will result in broken files due to the difference in how newlines are handled. - -It will run the following command automatically inside the container. -``` bash -git submodule update --init -pnpm install --frozen-lockfile -cp .devcontainer/devcontainer.yml .config/default.yml -pnpm build -pnpm migrate -``` - -After finishing the migration, run the `pnpm dev` command to start the development server. - -``` bash -pnpm dev -``` - ## Testing - Test codes are located in [`/packages/backend/test`](/packages/backend/test). @@ -185,7 +210,7 @@ TODO ## Environment Variable - `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`). -- `MISSKEY_USE_HTTP`: If it's set true, federation requests (like nodeinfo and webfinger) will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. (was `MISSKEY_WEBFINGER_USE_HTTP`) +- `MISSKEY_WEBFINGER_USE_HTTP`: If it's set true, WebFinger requests will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. ## Continuous integration Misskey uses GitHub Actions for executing automated tests. @@ -204,7 +229,7 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド ### ルート定義 ルート定義は、以下の形式のオブジェクトの配列です。 -``` ts +```ts { name?: string; path: string; @@ -217,7 +242,7 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド } ``` -> **Warning** +> [!WARNING] > 現状、ルートは定義された順に評価されます。 > たとえば、`/foo/:id`ルート定義の次に`/foo/bar`ルート定義がされていた場合、後者がマッチすることはありません。 @@ -279,7 +304,7 @@ export const Default = { parameters: { layout: 'centered', }, -} satisfies StoryObj; +} satisfies StoryObj; ``` If you want to opt-out from the automatic generation, create a `MyComponent.stories.impl.ts` file and add the following line to the file. @@ -390,7 +415,7 @@ describe('test', () => { }) .useMocker(... .compile(); - + fooService = app.get(FooService); barService = app.get(BarService) as jest.Mocked; @@ -511,13 +536,13 @@ pnpm dlx typeorm migration:generate -d ormconfig.js -o - 作成されたスクリプトは不必要な変更を含むため除去してください ### JSON SchemaのobjectでanyOfを使うとき -JSON Schemaで、objectに対してanyOfを使う場合、anyOfの中でpropertiesを定義しないこと。 -バリデーションが効かないため。(SchemaTypeもそのように作られており、objectのanyOf内のpropertiesは捨てられます) +JSON Schemaで、objectに対してanyOfを使う場合、anyOfの中でpropertiesを定義しないこと。 +バリデーションが効かないため。(SchemaTypeもそのように作られており、objectのanyOf内のpropertiesは捨てられます) https://github.com/misskey-dev/misskey/pull/10082 テキストhogeおよびfugaについて、片方を必須としつつ両方の指定もありうる場合: -``` +```ts export const paramDef = { type: 'object', properties: { diff --git a/Dockerfile b/Dockerfile index d6ca6b8cdf..e247bbcd77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.4 -ARG NODE_VERSION=20.12.2-bullseye +ARG NODE_VERSION=20.16.0-bullseye # build assets & compile TypeScript diff --git a/chart/files/default.yml b/chart/files/default.yml index 4017588fa0..f98b8ebfee 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -178,12 +178,12 @@ id: "aidx" #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 16 -# inboxJobConcurrency: 4 +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 # Job rate limiter -# deliverJobPerSec: 1024 -# inboxJobPerSec: 64 +# deliverJobPerSec: 128 +# inboxJobPerSec: 32 # Job attempts # deliverJobMaxAttempts: 12 diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 955d672c1d..b6bfbfa682 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1262,8 +1262,6 @@ _sfx: note: "الملاحظات" noteMy: "ملاحظتي" notification: "الإشعارات" - antenna: "الهوائيات" - channel: "إشعارات القنات" _ago: future: "المستقبَل" justNow: "اللحظة" @@ -1566,6 +1564,10 @@ _webhookSettings: active: "مُفعّل" _events: reaction: "عند التفاعل" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "البريد الإلكتروني " _moderationLogTypes: suspend: "علِق" deleteDriveFile: "حُذف الملف" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index abcf07da83..6fb51ea5d8 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -1033,8 +1033,6 @@ _sfx: note: "নোটগুলি" noteMy: "নোট (আপনার)" notification: "বিজ্ঞপ্তি" - antenna: "অ্যান্টেনাগুলি" - channel: "চ্যানেলের বিজ্ঞপ্তি" _ago: future: "ভবিষ্যৎ" justNow: "এইমাত্র" @@ -1346,6 +1344,10 @@ _deck: _webhookSettings: name: "নাম" active: "চালু" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "ইমেইল" _moderationLogTypes: suspend: "স্থগিত করা" resetPassword: "পাসওয়ার্ড রিসেট করুন" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 0345ee0326..7bd9a1bb32 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -1893,8 +1893,6 @@ _sfx: note: "Notes" noteMy: "Nota (per mi)" notification: "Notificacions" - antenna: "Antenes" - channel: "Notificacions dels canals" reaction: "Quan se selecciona una reacció " _soundSettings: driveFile: "Fer servir un fitxer d'àudio del disc" @@ -2225,6 +2223,10 @@ _deck: _webhookSettings: name: "Nom" active: "Activat" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Correu electrònic" _moderationLogTypes: suspend: "Suspèn" resetPassword: "Restableix la contrasenya" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index c8a0b0cb28..aa970f823a 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -1645,8 +1645,6 @@ _sfx: note: "Poznámky" noteMy: "Moje poznámka" notification: "Oznámení" - antenna: "Antény" - channel: "Oznámení kanálu" _ago: future: "Budoucí" justNow: "Teď" @@ -2022,6 +2020,10 @@ _webhookSettings: renote: "Při renotaci poznámky" reaction: "Při obdržení reakce" mention: "Při zmínce" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Email" _moderationLogTypes: suspend: "Zmrazit" resetPassword: "Resetovat heslo" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 9e42e01252..a319af56cb 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -1800,8 +1800,6 @@ _sfx: note: "Notizen" noteMy: "Meine Notizen" notification: "Benachrichtigungen" - antenna: "Antennen" - channel: "Kanalbenachrichtigung" _ago: future: "Zukunft" justNow: "Gerade eben" @@ -2203,6 +2201,10 @@ _webhookSettings: renote: "Wenn du ein Renote erhältst" reaction: "Wenn du eine Reaktion erhältst" mention: "Wenn du erwähnt wirst" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Email" _moderationLogTypes: createRole: "Rolle erstellt" deleteRole: "Rolle gelöscht" diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 2098c7ef50..5eca348e18 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -301,8 +301,6 @@ _theme: _sfx: note: "Σημειώματα" notification: "Ειδοποιήσεις" - antenna: "Αντένες" - channel: "Ειδοποιήσεις καναλιών" _ago: future: "Μελλοντικό" justNow: "Μόλις τώρα" diff --git a/locales/en-US.yml b/locales/en-US.yml index c20a1ac7d8..cfb5783a95 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -60,6 +60,7 @@ copyFileId: "Copy file ID" copyFolderId: "Copy folder ID" copyProfileUrl: "Copy profile URL" searchUser: "Search for a user" +searchThisUsersNotes: "Search this user’s notes" reply: "Reply" loadMore: "Load more" showMore: "Show more" @@ -108,7 +109,7 @@ enterEmoji: "Enter an emoji" renote: "Renote" unrenote: "Remove renote" renoted: "Renoted." -renotedToX: "Renote from {name} users。" +renotedToX: "Renote to {name}." cantRenote: "This post can't be renoted." cantReRenote: "A renote can't be renoted." quote: "Quote" @@ -125,8 +126,8 @@ add: "Add" reaction: "Reactions" reactions: "Reactions" emojiPicker: "Emoji picker" -pinnedEmojisForReactionSettingDescription: "Set the emojis which should be pinned and displayed immediately when reacting." -pinnedEmojisSettingDescription: "Set the emojis to be pinned and displayed when viewing emoji picker" +pinnedEmojisForReactionSettingDescription: "Set the emojis to be pinned and displayed when reacting." +pinnedEmojisSettingDescription: "Set the emojis to be pinned and displayed when viewing emoji picker." emojiPickerDisplay: "Emoji picker display" overwriteFromPinnedEmojisForReaction: "Override from reaction settings" overwriteFromPinnedEmojis: "Override from general settings" @@ -154,6 +155,7 @@ editList: "Edit list" selectChannel: "Select a channel" selectAntenna: "Select an antenna" editAntenna: "Edit antenna" +createAntenna: "Create an antenna" selectWidget: "Select a widget" editWidgets: "Edit widgets" editWidgetsExit: "Done" @@ -180,6 +182,10 @@ addAccount: "Add account" reloadAccountsList: "Reload account list" loginFailed: "Failed to sign in" showOnRemote: "View on remote instance" +continueOnRemote: "リモートで続行" +chooseServerOnMisskeyHub: "Choose a server from the Misskey Hub" +specifyServerHost: "Specify a server host directly" +inputHostName: "Enter the domain" general: "General" wallpaper: "Wallpaper" setWallpaper: "Set wallpaper" @@ -190,6 +196,7 @@ followConfirm: "Are you sure that you want to follow {name}?" proxyAccount: "Proxy account" proxyAccountDescription: "A proxy account is an account that acts as a remote follower for users under certain conditions. For example, when a user adds a remote user to the list, the remote user's activity will not be delivered to the instance if no local user is following that user, so the proxy account will follow instead." host: "Host" +selectSelf: "Select myself" selectUser: "Select a user" recipient: "Recipient" annotation: "Comments" @@ -316,6 +323,7 @@ selectFile: "Select a file" selectFiles: "Select files" selectFolder: "Select a folder" selectFolders: "Select folders" +fileNotSelected: "" renameFile: "Rename file" folderName: "Folder name" createFolder: "Create a folder" @@ -476,6 +484,7 @@ noMessagesYet: "No messages yet" newMessageExists: "There are new messages" onlyOneFileCanBeAttached: "You can only attach one file to a message" signinRequired: "Please register or sign in before continuing" +signinOrContinueOnRemote: "To continue, you need to move your server or sign up / log in to this server." invitations: "Invites" invitationCode: "Invitation code" checking: "Checking..." @@ -836,6 +845,7 @@ administration: "Management" accounts: "Accounts" switch: "Switch" noMaintainerInformationWarning: "Maintainer information is not configured." +noInquiryUrlWarning: "Inquiry URL isn’t set" noBotProtectionWarning: "Bot protection is not configured." configure: "Configure" postToGallery: "Create new gallery post" @@ -1025,6 +1035,7 @@ thisPostMayBeAnnoyingHome: "Post to home timeline" thisPostMayBeAnnoyingCancel: "Cancel" thisPostMayBeAnnoyingIgnore: "Post anyway" collapseRenotes: "Collapse renotes you've already seen" +collapseRenotesDescription: "Collapse notes that you've reacted to or renoted before." internalServerError: "Internal Server Error" internalServerErrorDescription: "The server has run into an unexpected error." copyErrorInfo: "Copy error details" @@ -1098,6 +1109,8 @@ preservedUsernames: "Reserved usernames" preservedUsernamesDescription: "List usernames to reserve separated by linebreaks. These will become unable during normal account creation, but can be used by administrators to manually create accounts. Already existing accounts using these usernames will not be affected." createNoteFromTheFile: "Compose note from this file" archive: "Archive" +archived: "Archived" +unarchive: "Unarchive" channelArchiveConfirmTitle: "Really archive {name}?" channelArchiveConfirmDescription: "An archived channel won't appear in the channel list or search results anymore. New posts can also not be added to it anymore." thisChannelArchived: "This channel has been archived." @@ -1108,6 +1121,7 @@ preventAiLearning: "Reject usage in Machine Learning (Generative AI)" preventAiLearningDescription: "Requests crawlers to not use posted text or image material etc. in machine learning (Predictive / Generative AI) data sets. This is achieved by adding a \"noai\" HTML-Response flag to the respective content. A complete prevention can however not be achieved through this flag, as it may simply be ignored." options: "Options" specifyUser: "Specific user" +specifyHost: "Specify a host" failedToPreviewUrl: "Could not preview" update: "Update" rolesThatCanBeUsedThisEmojiAsReaction: "Roles that can use this emoji as reaction" @@ -1239,6 +1253,11 @@ keepOriginalFilenameDescription: "If you turn off this setting, files names will noDescription: "There is not the explanation" alwaysConfirmFollow: "Always confirm when following" inquiry: "Contact" +tryAgain: "Please try again later" +confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media" +sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?" +createdLists: "Created lists" +createdAntennas: "Created antennas" _delivery: status: "Delivery status" stop: "Suspended" @@ -1373,6 +1392,8 @@ _serverSettings: fanoutTimelineDescription: "Greatly increases performance of timeline retrieval and reduces load on the database when enabled. In exchange, memory usage of Redis will increase. Consider disabling this in case of low server memory or server instability." fanoutTimelineDbFallback: "Fallback to database" fanoutTimelineDbFallbackDescription: "When enabled, the timeline will fall back to the database for additional queries if the timeline is not cached. Disabling it further reduces the server load by eliminating the fallback process, but limits the range of timelines that can be retrieved." + inquiryUrl: "Inquiry URL" + inquiryUrlDescription: "Specify a URL for the inquiry form to the server maintainer or a web page for the contact information." _accountMigration: moveFrom: "Migrate another account to this one" moveFromSub: "Create alias to another account" @@ -1689,6 +1710,7 @@ _role: canManageAvatarDecorations: "Manage avatar decorations" driveCapacity: "Drive capacity" alwaysMarkNsfw: "Always mark files as NSFW" + canUpdateBioMedia: "Allow to edit an icon or a banner image" pinMax: "Maximum number of pinned notes" antennaMax: "Maximum number of antennas" wordMuteMax: "Maximum number of characters allowed in word mutes" @@ -1932,8 +1954,6 @@ _sfx: note: "New note" noteMy: "Own note" notification: "Notifications" - antenna: "Antennas" - channel: "Channel notifications" reaction: "On choosing a reaction" _soundSettings: driveFile: "Use an audio file in Drive." @@ -2059,7 +2079,7 @@ _permissions: "read:admin:invite-codes": "View invite codes" "write:admin:announcements": "Manage announcements" "read:admin:announcements": "View announcements" - "write:admin:avatar-decorations": "Manage avatar decorations" + "write:admin:avatar-decorations": "Can manage avatar decorations" "read:admin:avatar-decorations": "View avatar decorations" "write:admin:federation": "Manage federation data" "write:admin:account": "Manage user account" @@ -2358,6 +2378,7 @@ _deck: alwaysShowMainColumn: "Always show main column" columnAlign: "Align columns" addColumn: "Add column" + newNoteNotificationSettings: "Notification setting for new notes" configureColumn: "Column settings" swapLeft: "Swap with the left column" swapRight: "Swap with the right column" @@ -2396,6 +2417,7 @@ _drivecleaner: orderByCreatedAtAsc: "Ascending Dates" _webhookSettings: createWebhook: "Create Webhook" + modifyWebhook: "Modify Webhook" name: "Name" secret: "Secret" events: "Webhook Events" @@ -2408,6 +2430,26 @@ _webhookSettings: renote: "When renoted" reaction: "When receiving a reaction" mention: "When being mentioned" + _systemEvents: + abuseReport: "When received a new abuse report" + abuseReportResolved: "When resolved abuse reports" + userCreated: "When user is created" + deleteConfirm: "Are you sure you want to delete the Webhook?" +_abuseReport: + _notificationRecipient: + createRecipient: "Add a recipient for abuse reports" + modifyRecipient: "Edit a recipient for abuse reports" + recipientType: "Notification type" + _recipientType: + mail: "Email" + webhook: "Webhook" + _captions: + mail: "Send the email to moderators' email addresses when you receive abuse." + webhook: "Send a notification to SystemWebhook when you receive or resolve abuse." + keywords: "Keywords" + notifiedUser: "Users to notify" + notifiedWebhook: "Webhook to use" + deleteConfirm: "Are you sure that you want to delete the notification recipient?" _moderationLogTypes: createRole: "Role created" deleteRole: "Role deleted" @@ -2445,6 +2487,12 @@ _moderationLogTypes: deleteAvatarDecoration: "Avatar decoration deleted" unsetUserAvatar: "Unset this user's avatar" unsetUserBanner: "Unset this user's banner" + createSystemWebhook: "Create SystemWebhook" + updateSystemWebhook: "Update SystemWebHook" + deleteSystemWebhook: "Delete SystemWebhook" + createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" + updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" + deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" _fileViewer: title: "File details" type: "File type" @@ -2576,3 +2624,8 @@ _mediaControls: pip: "Picture in Picture" playbackRate: "Playback Speed" loop: "Loop playback" +_contextMenu: + title: "Context menu" + app: "Application" + appWithShift: "Application with shift key" + native: "Native" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 5c8249ded5..3ae3ba3b8a 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -302,7 +302,7 @@ location: "Lugar" theme: "Tema" themeForLightMode: "Tema para usar en Modo Linterna" themeForDarkMode: "Tema para usar en Modo Oscuro" -light: "Linterna" +light: "Claro" dark: "Oscuro" lightThemes: "Tema claro" darkThemes: "Tema oscuro" @@ -1920,8 +1920,6 @@ _sfx: note: "Notas" noteMy: "Nota (a mí mismo)" notification: "Notificaciones" - antenna: "Antena receptora" - channel: "Notificaciones del canal" reaction: "Al seleccionar una reacción" _soundSettings: driveFile: "Usar un archivo de audio en Drive" @@ -2394,6 +2392,10 @@ _webhookSettings: renote: "Cuando reciba un \"re-note\"" reaction: "Cuando se recibe una reacción" mention: "Cuando hay una mención" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Correo" _moderationLogTypes: createRole: "Rol creado" deleteRole: "Rol eliminado" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 8d66c3d375..ee08dfddf1 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -1712,8 +1712,6 @@ _sfx: note: "Nouvelle note" noteMy: "Ma note" notification: "Notifications" - antenna: "Réception de l’antenne" - channel: "Notifications de canal" reaction: "Lors de la sélection de la réaction" _soundSettings: driveFile: "Utiliser un effet sonore sur le Disque" @@ -2073,6 +2071,10 @@ _drivecleaner: _webhookSettings: name: "Nom" active: "Activé" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "E-mail " _moderationLogTypes: createRole: "Rôle créé" deleteRole: "Rôle supprimé" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 7f509afa50..de50569e89 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -180,6 +180,10 @@ addAccount: "Tambahkan akun" reloadAccountsList: "Muat ulang daftar akun" loginFailed: "Gagal untuk masuk" showOnRemote: "Lihat profil asli" +continueOnRemote: "Lihat di peladen asal" +chooseServerOnMisskeyHub: "Pilih peladen dari Misskey Hub" +specifyServerHost: "Tentukan domain peladen" +inputHostName: "Masukkan nama domain" general: "Umum" wallpaper: "Wallpaper" setWallpaper: "Atur wallpaper" @@ -316,6 +320,7 @@ selectFile: "Pilih berkas" selectFiles: "Pilih berkas" selectFolder: "Pilih folder" selectFolders: "Pilih folder" +fileNotSelected: "Tidak ada file yang dipilih" renameFile: "Ubah nama berkas" folderName: "Nama folder" createFolder: "Buat folder" @@ -1239,6 +1244,7 @@ keepOriginalFilenameDescription: "Apabila pengaturan ini dimatikan, nama berkas noDescription: "Tidak ada deskripsi" alwaysConfirmFollow: "Selalu konfirmasi ketika mengikuti" inquiry: "Hubungi kami" +tryAgain: "Silahkan coba lagi." _delivery: status: "Status pengiriman" stop: "Ditangguhkan" @@ -1739,7 +1745,7 @@ _emailUnavailable: smtp: "Peladen alamat surel ini tidak merespon" banned: "Kamu tidak dapat mendaftar dengan alamat surel ini" _ffVisibility: - public: "Terbitkan" + public: "Publik" followers: "Tampil untuk pengikut saja" private: "Tersembunyi" _signup: @@ -1932,8 +1938,6 @@ _sfx: note: "Catatan" noteMy: "Catatan (Saya)" notification: "Notifikasi" - antenna: "Penerimaan Antenna" - channel: "Notifikasi Kanal" reaction: "Ketika memilih reaksi" _soundSettings: driveFile: "Menggunakan berkas audio dalam Drive" @@ -2396,6 +2400,7 @@ _drivecleaner: orderByCreatedAtAsc: "Tanggal (Naik)" _webhookSettings: createWebhook: "Buat Webhook" + modifyWebhook: "Sunting Webhook" name: "Nama" secret: "Secret" events: "Webhook Events" @@ -2408,6 +2413,11 @@ _webhookSettings: renote: "Ketika direnote" reaction: "Ketika menerima reaksi" mention: "Ketika sedang disebut" + deleteConfirm: "Apakah kamu yakin ingin menghapus Webhook?" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Surel" _moderationLogTypes: createRole: "Peran telah dibuat" deleteRole: "Peran telah dihapus" diff --git a/locales/index.d.ts b/locales/index.d.ts index 694ee53a1f..c58e4ace7b 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -256,6 +256,10 @@ export interface Locale extends ILocale { * ユーザーを検索 */ "searchUser": string; + /** + * ユーザーのノートを検索 + */ + "searchThisUsersNotes": string; /** * 返信 */ @@ -632,6 +636,10 @@ export interface Locale extends ILocale { * アンテナを編集 */ "editAntenna": string; + /** + * アンテナを作成 + */ + "createAntenna": string; /** * ウィジェットを選択 */ @@ -792,6 +800,10 @@ export interface Locale extends ILocale { * ホスト */ "host": string; + /** + * 自分を選択 + */ + "selectSelf": string; /** * ユーザーを選択 */ @@ -852,6 +864,10 @@ export interface Locale extends ILocale { * サーバーをサイレンス */ "silenceThisInstance": string; + /** + * サーバーをメディアサイレンス + */ + "mediaSilenceThisInstance": string; /** * 操作 */ @@ -936,6 +952,14 @@ export interface Locale extends ILocale { * サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになります。ブロックしたインスタンスには影響しません。 */ "silencedInstancesDescription": string; + /** + * メディアサイレンスしたサーバー + */ + "mediaSilencedInstances": string; + /** + * メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。 + */ + "mediaSilencedInstancesDescription": string; /** * ミュートとブロック */ @@ -4440,6 +4464,14 @@ export interface Locale extends ILocale { * アーカイブ */ "archive": string; + /** + * アーカイブ済み + */ + "archived": string; + /** + * アーカイブ解除 + */ + "unarchive": string; /** * {name}をアーカイブしますか? */ @@ -4480,6 +4512,18 @@ export interface Locale extends ILocale { * ユーザー指定 */ "specifyUser": string; + /** + * 照会しますか? + */ + "lookupConfirm": string; + /** + * ハッシュタグのページを開きますか? + */ + "openTagPageConfirm": string; + /** + * ホスト指定 + */ + "specifyHost": string; /** * プレビューできません */ @@ -5008,6 +5052,22 @@ export interface Locale extends ILocale { * もう一度お試しください。 */ "tryAgain": string; + /** + * センシティブなメディアを表示するとき確認する + */ + "confirmWhenRevealingSensitiveMedia": string; + /** + * センシティブなメディアです。表示しますか? + */ + "sensitiveMediaRevealConfirm": string; + /** + * 作成したリスト + */ + "createdLists": string; + /** + * 作成したアンテナ + */ + "createdAntennas": string; "_delivery": { /** * 配信状態 @@ -7573,6 +7633,10 @@ export interface Locale extends ILocale { * 長い音声を使用するとMisskeyの使用に支障をきたす可能性があります。それでも続行しますか? */ "driveFileDurationWarnDescription": string; + /** + * 音声が読み込めませんでした。設定を変更してください + */ + "driveFileError": string; }; "_ago": { /** @@ -9384,6 +9448,10 @@ export interface Locale extends ILocale { * ユーザーからの通報を処理したとき */ "abuseReportResolved": string; + /** + * ユーザーが作成されたとき + */ + "userCreated": string; }; /** * Webhookを削除しますか? @@ -10086,6 +10154,24 @@ export interface Locale extends ILocale { */ "loop": string; }; + "_contextMenu": { + /** + * コンテキストメニュー + */ + "title": string; + /** + * アプリケーション + */ + "app": string; + /** + * Shiftキーでアプリケーション + */ + "appWithShift": string; + /** + * ブラウザのUI + */ + "native": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 1d12a62cca..64cd26878e 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -108,11 +108,14 @@ enterEmoji: "Inserisci emoji" renote: "Rinota" unrenote: "Elimina la Rinota" renoted: "Rinotata!" +renotedToX: "Rinota da {name}." cantRenote: "È impossibile rinotare questa nota." cantReRenote: "È impossibile rinotare una Rinota." quote: "Citazione" inChannelRenote: "Rinota nel canale" inChannelQuote: "Cita nel canale" +renoteToChannel: "Rinota al canale" +renoteToOtherChannel: "Rinota a un altro canale" pinnedNote: "Nota in primo piano" pinned: "Fissa sul profilo" you: "Tu" @@ -177,6 +180,10 @@ addAccount: "Aggiungi profilo" reloadAccountsList: "Ricarica l'elenco dei profili" loginFailed: "Accesso non riuscito" showOnRemote: "Leggi sull'istanza remota" +continueOnRemote: "Continua da remoto" +chooseServerOnMisskeyHub: "Scegli l'istanza sul sito Misskey Hub" +specifyServerHost: "Indica l'indirizzo dell'istanza" +inputHostName: "Digita il nome del dominio " general: "Generali" wallpaper: "Sfondo" setWallpaper: "Imposta sfondo" @@ -313,6 +320,7 @@ selectFile: "Scelta allegato" selectFiles: "Scelta allegato" selectFolder: "Seleziona cartella" selectFolders: "Seleziona cartella" +fileNotSelected: "Nessun file selezionato" renameFile: "Rinomina file" folderName: "Nome della cartella" createFolder: "Nuova cartella" @@ -468,10 +476,12 @@ retype: "Conferma" noteOf: "Note di {user}" quoteAttached: "Citazione allegata" quoteQuestion: "Vuoi aggiungere una citazione?" +attachAsFileQuestion: "Il testo copiato eccede le dimensioni, vuoi allegarlo?" noMessagesYet: "Ancora nessuna chat" newMessageExists: "Hai ricevuto un nuovo messaggio" onlyOneFileCanBeAttached: "È possibile allegare al messaggio soltanto uno file" signinRequired: "Occorre avere un profilo registrato su questa istanza" +signinOrContinueOnRemote: "Per continuare, devi accedere alla tua istanza o registrarti su questa e poi accedere" invitations: "Invita" invitationCode: "Codice di invito" checking: "Confermando" @@ -695,7 +705,7 @@ reporterOrigin: "Segnalazione da" forwardReport: "Inoltro di un report a un'istanza remota." forwardReportIsAnonymous: "L'istanza remota non vedrà le tue informazioni, apparirai come profilo di sistema, anonimo." send: "Inviare" -abuseMarkAsResolved: "Contrassegna la segnalazione come risolta" +abuseMarkAsResolved: "Risolvi segnalazione" openInNewTab: "Apri in una nuova scheda" openInSideView: "Apri in vista laterale" defaultNavigationBehaviour: "Navigazione preimpostata" @@ -832,6 +842,7 @@ administration: "Gestione" accounts: "Profilo" switch: "Cambia" noMaintainerInformationWarning: "Mancano le informazioni sull'amministratore." +noInquiryUrlWarning: "Non è stata impostata la URL di contatto" noBotProtectionWarning: "Non è stata impostata alcuna protezione dai Bot" configure: "Imposta" postToGallery: "Pubblicare nella galleria" @@ -1021,6 +1032,7 @@ thisPostMayBeAnnoyingHome: "Pubblica sulla timeline principale" thisPostMayBeAnnoyingCancel: "Annulla" thisPostMayBeAnnoyingIgnore: "Pubblica lo stesso" collapseRenotes: "Comprimi le Rinota già viste" +collapseRenotesDescription: "Comprimi le Note con cui hai già interagito." internalServerError: "Errore interno del server" internalServerErrorDescription: "Si è verificato un errore imprevisto all'interno del server" copyErrorInfo: "Copia le informazioni sull'errore" @@ -1233,10 +1245,20 @@ useNativeUIForVideoAudioPlayer: "Riprodurre audio/video usando le funzionalità keepOriginalFilename: "Mantieni il nome file originale" keepOriginalFilenameDescription: "Disattivandola, i file verranno caricati usando nomi casuali." noDescription: "Manca la descrizione" +alwaysConfirmFollow: "Richiedi conferma per i Follow" +inquiry: "Contattaci" +tryAgain: "Per favore riprova" +confirmWhenRevealingSensitiveMedia: "Richiedi conferma prima di mostrare gli allegati espliciti" +sensitiveMediaRevealConfirm: "Questo allegato è esplicito, vuoi vederlo?" _delivery: + status: "Stato della consegna" stop: "Sospensione" + resume: "Riprendi la consegna" _type: none: "Pubblicazione" + manuallySuspended: "Sospesa manualmente" + goneSuspended: "Sospensione server a causa dell'eliminazione" + autoSuspendedForNotResponding: "Sospensione del server a causa di mancata risposta" _bubbleGame: howToPlay: "Come giocare" hold: "Tieni" @@ -1362,6 +1384,8 @@ _serverSettings: fanoutTimelineDescription: "Attivando questa funzionalità migliori notevolmente la capacità delle Timeline di collezionare Note, riducendo il carico sul database. Tuttavia, aumenterà l'impiego di memoria RAM per Redis. Disattiva se il tuo server ha poca RAM o la funzionalità è irregolare." fanoutTimelineDbFallback: "Elaborazione dati alternativa" fanoutTimelineDbFallbackDescription: "Attivando l'elaborazione alternativa, verrà interrogato ulteriormente il database se la timeline non è nella cache. \nDisattivando, si può ridurre ulteriormente il carico del server, evitando l'elaborazione alternativa, ma limitando l'intervallo recuperabile delle timeline." + inquiryUrl: "URL di contatto" + inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione." _accountMigration: moveFrom: "Migra un altro profilo dentro a questo" moveFromSub: "Crea un alias verso un altro profilo remoto" @@ -1678,6 +1702,7 @@ _role: canManageAvatarDecorations: "Gestisce le decorazioni di immagini del profilo" driveCapacity: "Capienza del Drive" alwaysMarkNsfw: "Impostare sempre come esplicito (NSFW)" + canUpdateBioMedia: "Può aggiornare foto profilo e di testata" pinMax: "Quantità massima di Note in primo piano" antennaMax: "Quantità massima di Antenne" wordMuteMax: "Lunghezza massima del filtro parole" @@ -1696,6 +1721,11 @@ _role: roleAssignedTo: "Assegnato a ruoli manualmente" isLocal: "Profilo locale" isRemote: "Profilo remoto" + isCat: "È un gattino" + isBot: "È un bot" + isSuspended: "È sospeso" + isLocked: "È in stato privato" + isExplorable: "Autorizza la pubblicazione nei cataloghi" createdLessThan: "Profilo creato da meno di N" createdMoreThan: "Profilo creato da più di N" followersLessThanOrEq: "Profilo con N follower o meno" @@ -1916,8 +1946,6 @@ _sfx: note: "Nota" noteMy: "Mia nota" notification: "Notifiche" - antenna: "Ricezione dell'antenna" - channel: "Notifiche di canale" reaction: "Quando seleziono una reazione" _soundSettings: driveFile: "Suoni del Drive" @@ -2342,6 +2370,7 @@ _deck: alwaysShowMainColumn: "Mostra sempre la colonna principale" columnAlign: "Allineare colonne" addColumn: "Aggiungi colonna" + newNoteNotificationSettings: "Preferenze per le notifiche di nuove Note" configureColumn: "Impostazioni colonna" swapLeft: "Sposta a sinistra" swapRight: "Sposta a destra" @@ -2380,6 +2409,7 @@ _drivecleaner: orderByCreatedAtAsc: "Dal più vecchio al più recente" _webhookSettings: createWebhook: "Creazione Webhook" + modifyWebhook: "Modifica Webhook" name: "Nome" secret: "Segreto" events: "Quando eseguire il Webhook" @@ -2392,6 +2422,25 @@ _webhookSettings: renote: "Quando la Nota è Rinotata" reaction: "Quando ricevo una reazione" mention: "Quando mi menzionano" + _systemEvents: + abuseReport: "Quando arriva una segnalazione" + abuseReportResolved: "Quando una segnalazione è risolta" + deleteConfirm: "Vuoi davvero eliminare il Webhook?" +_abuseReport: + _notificationRecipient: + createRecipient: "Aggiungi destinatario della segnalazione" + modifyRecipient: "Modifica destinatario della segnalazione" + recipientType: "Tipo di notifica" + _recipientType: + mail: "Email" + webhook: "Webhook" + _captions: + mail: "Quando ricevi un abuso, notifica l'amministrazione via email" + webhook: "Spedire una notifica al SystemWebhook specificato (sia quando si riceve una segnalazione, che quando viene risolta)" + keywords: "Parole chiave" + notifiedUser: "Profili da notificare" + notifiedWebhook: "Webhook da usare" + deleteConfirm: "Vuoi davvero rimuovere il destinatario della notifica?" _moderationLogTypes: createRole: "Ruolo creato" deleteRole: "Ruolo eliminato" @@ -2429,6 +2478,12 @@ _moderationLogTypes: deleteAvatarDecoration: "Eliminazione decorazione della foto profilo" unsetUserAvatar: "Rimossa foto profilo" unsetUserBanner: "Rimossa intestazione profilo" + createSystemWebhook: "Crea un SystemWebhook" + updateSystemWebhook: "Modifica SystemWebhook" + deleteSystemWebhook: "Elimina SystemWebhook" + createAbuseReportNotificationRecipient: "Crea destinatario per le notifiche di segnalazioni" + updateAbuseReportNotificationRecipient: "Aggiorna destinatario notifiche di segnalazioni" + deleteAbuseReportNotificationRecipient: "Elimina destinatario notifiche di segnalazioni" _fileViewer: title: "Dettagli del file" type: "Tipo di file" @@ -2554,6 +2609,8 @@ _urlPreviewSetting: userAgent: "User-Agent" userAgentDescription: "Definire con quale User-Agent si intende identificarsi durante l'acquisizione di un'anteprima. Se è vuoto, useremo il valore predefinito." summaryProxy: "Endpoint proxy che genera l'anteprima" + summaryProxyDescription: "Genera anteprime utilizzando un proxy Summaly anziché Misskey." + summaryProxyDescription2: "I parametri sono collegano al proxy come stringa query. Se il proxy non li supporta, verranno ignorati." _mediaControls: pip: "Sovraimpressione" playbackRate: "Velocità di riproduzione" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index bb3999f0e3..522ad7e22a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -60,6 +60,7 @@ copyFileId: "ファイルIDをコピー" copyFolderId: "フォルダーIDをコピー" copyProfileUrl: "プロフィールURLをコピー" searchUser: "ユーザーを検索" +searchThisUsersNotes: "ユーザーのノートを検索" reply: "返信" loadMore: "もっと見る" showMore: "もっと見る" @@ -154,6 +155,7 @@ editList: "リストを編集" selectChannel: "チャンネルを選択" selectAntenna: "アンテナを選択" editAntenna: "アンテナを編集" +createAntenna: "アンテナを作成" selectWidget: "ウィジェットを選択" editWidgets: "ウィジェットを編集" editWidgetsExit: "編集を終了" @@ -194,6 +196,7 @@ followConfirm: "{name}をフォローしますか?" proxyAccount: "プロキシアカウント" proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがサーバーに配達されないため、代わりにプロキシアカウントがフォローするようにします。" host: "ホスト" +selectSelf: "自分を選択" selectUser: "ユーザーを選択" recipient: "宛先" annotation: "注釈" @@ -209,6 +212,7 @@ perDay: "1日ごと" stopActivityDelivery: "アクティビティの配送を停止" blockThisInstance: "このサーバーをブロック" silenceThisInstance: "サーバーをサイレンス" +mediaSilenceThisInstance: "サーバーをメディアサイレンス" operations: "操作" software: "ソフトウェア" version: "バージョン" @@ -230,6 +234,8 @@ blockedInstances: "ブロックしたサーバー" blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定します。ブロックされたサーバーは、このインスタンスとやり取りできなくなります。" silencedInstances: "サイレンスしたサーバー" silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになります。ブロックしたインスタンスには影響しません。" +mediaSilencedInstances: "メディアサイレンスしたサーバー" +mediaSilencedInstancesDescription: "メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" @@ -1106,6 +1112,8 @@ preservedUsernames: "予約ユーザー名" preservedUsernamesDescription: "予約するユーザー名を改行で列挙します。ここで指定されたユーザー名はアカウント作成時に使えなくなりますが、管理者によるアカウント作成時はこの制限を受けません。また、既に存在するアカウントも影響を受けません。" createNoteFromTheFile: "このファイルからノートを作成" archive: "アーカイブ" +archived: "アーカイブ済み" +unarchive: "アーカイブ解除" channelArchiveConfirmTitle: "{name}をアーカイブしますか?" channelArchiveConfirmDescription: "アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。" thisChannelArchived: "このチャンネルはアーカイブされています。" @@ -1116,6 +1124,9 @@ preventAiLearning: "生成AIによる学習を拒否" preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対して、投稿したノートや画像などのコンテンツを学習の対象にしないように要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されますが、この要求に従うかはそのAI次第であるため、学習を完全に防止するものではありません。" options: "オプション" specifyUser: "ユーザー指定" +lookupConfirm: "照会しますか?" +openTagPageConfirm: "ハッシュタグのページを開きますか?" +specifyHost: "ホスト指定" failedToPreviewUrl: "プレビューできません" update: "更新" rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール" @@ -1248,6 +1259,10 @@ noDescription: "説明文はありません" alwaysConfirmFollow: "フォローの際常に確認する" inquiry: "お問い合わせ" tryAgain: "もう一度お試しください。" +confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示するとき確認する" +sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" +createdLists: "作成したリスト" +createdAntennas: "作成したアンテナ" _delivery: status: "配信状態" @@ -1987,6 +2002,7 @@ _soundSettings: driveFileTypeWarnDescription: "音声ファイルを選択してください" driveFileDurationWarn: "音声が長すぎます" driveFileDurationWarnDescription: "長い音声を使用するとMisskeyの使用に支障をきたす可能性があります。それでも続行しますか?" + driveFileError: "音声が読み込めませんでした。設定を変更してください" _ago: future: "未来" @@ -2489,6 +2505,7 @@ _webhookSettings: _systemEvents: abuseReport: "ユーザーから通報があったとき" abuseReportResolved: "ユーザーからの通報を処理したとき" + userCreated: "ユーザーが作成されたとき" deleteConfirm: "Webhookを削除しますか?" _abuseReport: @@ -2689,3 +2706,9 @@ _mediaControls: pip: "ピクチャインピクチャ" playbackRate: "再生速度" loop: "ループ再生" + +_contextMenu: + title: "コンテキストメニュー" + app: "アプリケーション" + appWithShift: "Shiftキーでアプリケーション" + native: "ブラウザのUI" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 7a33968e9e..774031f6f5 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -108,6 +108,7 @@ enterEmoji: "絵文字を入れてや" renote: "リノート" unrenote: "リノートやめる" renoted: "リノートしたで。" +renotedToX: "{name}にリノートしたで" cantRenote: "この投稿はリノートできへんっぽい。" cantReRenote: "リノート自体はリノートできへんで。" quote: "引用" @@ -313,6 +314,7 @@ selectFile: "ファイル選んでや" selectFiles: "ファイル選んでや" selectFolder: "フォルダ選んでや" selectFolders: "フォルダ選んでや" +fileNotSelected: "ファイルが選択されてへんで" renameFile: "ファイル名をいらう" folderName: "フォルダー名" createFolder: "フォルダー作る" @@ -468,6 +470,7 @@ retype: "もっかい入力" noteOf: "{user}はんのノート" quoteAttached: "引用付いとるで" quoteQuestion: "引用として添付してもええか?" +attachAsFileQuestion: "クリップボードのテキストが長すぎるからテキストファイルとして添付してもええか?" noMessagesYet: "まだチャットはあらへんで" newMessageExists: "新しいメッセージがきたで" onlyOneFileCanBeAttached: "ごめんな、メッセージに添付できるファイルはひとつだけなんよ。" @@ -832,6 +835,7 @@ administration: "管理" accounts: "アカウント" switch: "切り替え" noMaintainerInformationWarning: "管理者情報が設定されてへんで" +noInquiryUrlWarning: "問い合わせ先URLが設定されてへんで。" noBotProtectionWarning: "Botプロテクションが設定されてへんで。" configure: "設定する" postToGallery: "ギャラリーへ投稿" @@ -1923,8 +1927,6 @@ _sfx: note: "ノート" noteMy: "ノート(自分)" notification: "通知" - antenna: "アンテナ受信" - channel: "チャンネル通知" reaction: "ツッコミ選んどるとき" _soundSettings: driveFile: "ドライブん中の音使う" @@ -2399,6 +2401,12 @@ _webhookSettings: renote: "リノートされるとき~!" reaction: "ツッコまれたとき~!" mention: "メンションがあるとき~!" + deleteConfirm: "ほんまにWebhookをほかしてもええんか?" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "メール" + deleteConfirm: "通知先を削除してもええか?" _moderationLogTypes: createRole: "ロールを追加すんで" deleteRole: "ロールほかす" diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml index 22e24d3baa..d4aa36fa70 100644 --- a/locales/kab-KAB.yml +++ b/locales/kab-KAB.yml @@ -104,3 +104,7 @@ _deck: _columns: notifications: "Ilɣuyen" list: "Tibdarin" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Imayl" diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index 9466aff01f..9323ed2a26 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -805,6 +805,10 @@ _deck: mentions: "받언 멘션" _webhookSettings: name: "이럼" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "전자우펜" _moderationLogTypes: suspend: "얼우기" deleteNote: "노트 뭉캐기" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 294a5a1520..7fcb681c9a 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -52,14 +52,14 @@ deleteAndEditConfirm: "이 노트를 삭제한 뒤 다시 편집하시겠습니 addToList: "리스트에 추가" addToAntenna: "안테나에 추가" sendMessage: "메시지 보내기" -copyRSS: "RSS 복사" -copyUsername: "사용자 이름 복사" -copyUserId: "사용자 ID 복사" +copyRSS: "RSS 주소 복사" +copyUsername: "유저명 복사" +copyUserId: "유저 ID 복사" copyNoteId: "노트 ID 복사" copyFileId: "파일 ID 복사" copyFolderId: "폴더 ID 복사" copyProfileUrl: "프로필 URL 복사" -searchUser: "사용자 검색" +searchUser: "유저 검색" reply: "답글" loadMore: "더 보기" showMore: "더 보기" @@ -108,22 +108,25 @@ enterEmoji: "이모지 입력" renote: "리노트" unrenote: "리노트 취소" renoted: "리노트했습니다" +renotedToX: "{name}명이 리노트했습니다." cantRenote: "이 게시물은 리노트 할 수 없습니다." -cantReRenote: "리노트를 리노트할 수 없습니다." +cantReRenote: "리노트를 리노트 할 수 없습니다." quote: "인용" inChannelRenote: "채널 내 리노트" inChannelQuote: "채널 내 인용" +renoteToChannel: "채널에 리노트" +renoteToOtherChannel: "다른 채널에 리노트" pinnedNote: "고정된 노트" pinned: "고정하기" you: "나" clickToShow: "클릭하여 보기" sensitive: "열람 주의" add: "추가" -reaction: "반응" -reactions: "반응" +reaction: "리액션" +reactions: "리액션" emojiPicker: "이모지 선택기" -pinnedEmojisForReactionSettingDescription: "리액션을 할 때 프로필에 고정하여 표시할 이모지를 설정할 수 있습니다" -pinnedEmojisSettingDescription: "이모지를 입력할 때 프로필에 고정하여 표시할 이모지를 설정할 수 있습니다" +pinnedEmojisForReactionSettingDescription: "리액션을 할 때 이모지 선택기 상단에 표시할 이모지를 설정할 수 있습니다." +pinnedEmojisSettingDescription: "이모지를 입력할 때 이모지 선택기 상단에 표시할 이모지를 설정할 수 있습니다." emojiPickerDisplay: "선택기 표시" overwriteFromPinnedEmojisForReaction: "리액션 설정을 덮어쓰기" overwriteFromPinnedEmojis: "일반 설정을 덮어쓰기" @@ -136,7 +139,7 @@ unmarkAsSensitive: "열람주의 해제" enterFileName: "파일명을 입력" mute: "뮤트" unmute: "뮤트 해제" -renoteMute: "리노트 뮤트하기" +renoteMute: "리노트 뮤트" renoteUnmute: "리노트 뮤트 해제" block: "차단" unblock: "차단 해제" @@ -174,12 +177,16 @@ flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기" flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시합니다." autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" addAccount: "계정 추가" -reloadAccountsList: "계정 리스트 정보 갱신" +reloadAccountsList: "계정 목록 새로고침" loginFailed: "로그인에 실패했습니다" showOnRemote: "리모트에서 보기" +continueOnRemote: "리모트에서 계속" +chooseServerOnMisskeyHub: "Misskey Hub에서 서버 찾아보기" +specifyServerHost: "서버 도메인 직접 지정" +inputHostName: "도메인을 입력하세요" general: "일반" wallpaper: "배경" -setWallpaper: "배경화면 설정" +setWallpaper: "배경 설정" removeWallpaper: "배경 제거" searchWith: "검색: {q}" youHaveNoLists: "리스트가 없습니다" @@ -187,7 +194,7 @@ followConfirm: "{name}님을 팔로우 하시겠습니까?" proxyAccount: "프록시 계정" proxyAccountDescription: "프록시 계정은 특정 조건 하에서 유저의 리모트 팔로우를 대행하는 계정입니다. 예를 들면, 유저가 리모트 유저를 리스트에 넣었을 때, 리스트에 들어간 유저를 아무도 팔로우한 적이 없다면 액티비티가 서버로 배달되지 않기 때문에, 대신 프록시 계정이 해당 유저를 팔로우하도록 합니다." host: "호스트" -selectUser: "사용자 선택" +selectUser: "유저 선택" recipient: "수신인" annotation: "내용에 대한 주석" federation: "연합" @@ -230,7 +237,7 @@ noUsers: "아무도 없습니다" editProfile: "프로필 수정" noteDeleteConfirm: "이 노트를 삭제하시겠습니까?" pinLimitExceeded: "더 이상 고정할 수 없습니다." -intro: "Misskey의 설치를 완료했습니다! 관리자 계정을 만들어 주세요." +intro: "Misskey의 설치가 완료되었습니다! 관리자 계정을 생성해주세요." done: "완료" processing: "처리중" preview: "미리보기" @@ -247,7 +254,7 @@ publishing: "배포 중" notResponding: "응답 없음" instanceFollowing: "서버의 팔로잉" instanceFollowers: "서버의 팔로워" -instanceUsers: "서버의 유저" +instanceUsers: "서버의 사용자" changePassword: "비밀번호 변경" security: "보안" retypedNotMatch: "입력이 일치하지 않습니다." @@ -263,12 +270,12 @@ lookup: "찾아보기" announcements: "공지사항" imageUrl: "이미지 URL" remove: "삭제" -removed: "삭제하였습니다" +removed: "삭제했습니다" removeAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?" deleteAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?" resetAreYouSure: "초기화 하시겠습니까?" areYouSure: "계속 진행하시겠습니까?" -saved: "저장하였습니다" +saved: "저장했습니다" messaging: "대화" upload: "업로드" keepOriginalUploading: "원본 이미지를 유지" @@ -296,7 +303,7 @@ activity: "활동" images: "이미지" image: "이미지" birthday: "생일" -yearsOld: "만 {age} 세" +yearsOld: "{age}세" registeredDate: "등록일" location: "장소" theme: "테마" @@ -313,6 +320,7 @@ selectFile: "파일 선택" selectFiles: "파일 선택" selectFolder: "폴더 선택" selectFolders: "폴더 선택" +fileNotSelected: "파일을 선택하지 않았습니다" renameFile: "파일 이름 변경" folderName: "폴더 이름" createFolder: "폴더 만들기" @@ -370,7 +378,7 @@ inMb: "메가바이트 단위" bannerUrl: "배너 이미지 URL" backgroundImageUrl: "배경 이미지 URL" basicInfo: "기본 정보" -pinnedUsers: "고정된 유저" +pinnedUsers: "고정한 사용자" pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다." pinnedPages: "고정한 페이지" pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적습니다." @@ -437,13 +445,13 @@ moderationNote: "조정 기록" addModerationNote: "조정 기록 추가하기" moderationLogs: "모더레이션 로그" nUsersMentioned: "{n}명이 언급함" -securityKeyAndPasskey: "보안 키 또는 패스 키" +securityKeyAndPasskey: "보안 키 또는 패스키" securityKey: "보안 키" lastUsed: "마지막 사용" lastUsedAt: "마지막 사용: {t}" unregister: "등록 해제" passwordLessLogin: "비밀번호 없이 로그인" -passwordLessLoginDescription: "비밀번호를 사용하지 않고 보안 키 또는 패스 키 등으로만 로그인합니다." +passwordLessLoginDescription: "비밀번호 없이 보안 키 또는 패스키만 사용해서 로그인합니다." resetPassword: "비밀번호 재설정" newPasswordIs: "새로운 비밀번호는 \"{password}\" 입니다" reduceUiAnimation: "UI의 애니메이션을 줄이기" @@ -468,10 +476,12 @@ retype: "다시 입력" noteOf: "{user}의 노트" quoteAttached: "인용함" quoteQuestion: "인용해서 작성하시겠습니까?" +attachAsFileQuestion: "붙여넣으려는 글이 너무 깁니다. 텍스트 파일로 첨부하시겠습니까?" noMessagesYet: "아직 대화가 없습니다" newMessageExists: "새 메시지가 있습니다" onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하나까지입니다" signinRequired: "진행하기 전에 로그인을 해 주세요" +signinOrContinueOnRemote: "계속하려면 사용하는 서버로 이동하거나 이 서버에 로그인해야 합니다." invitations: "초대" invitationCode: "초대 코드" checking: "확인하는 중입니다" @@ -486,7 +496,7 @@ strongPassword: "강한 비밀번호" passwordMatched: "일치합니다" passwordNotMatched: "일치하지 않습니다" signinWith: "{x}로 로그인" -signinFailed: "로그인할 수 없습니다. 사용자명과 비밀번호를 확인하여 주십시오." +signinFailed: "로그인할 수 없습니다. 사용자 이름과 비밀번호를 확인해 주십시오." or: "혹은" language: "언어" uiLanguage: "UI 표시 언어" @@ -494,7 +504,7 @@ aboutX: "{x}에 대하여" emojiStyle: "이모지 스타일" native: "기본" disableDrawer: "드로어 메뉴를 사용하지 않기" -showNoteActionsOnlyHover: "노트 액션 버튼을 마우스를 올렸을 때에만 표시" +showNoteActionsOnlyHover: "마우스가 올라간 때에만 노트 동작 버튼을 표시하기" showReactionsCount: "노트의 반응 수를 표시하기" noHistory: "기록이 없습니다" signinHistory: "로그인 기록" @@ -559,7 +569,7 @@ popout: "새 창으로 열기" volume: "음량" masterVolume: "마스터 볼륨" notUseSound: "음소거 하기" -useSoundOnlyWhenActive: "Misskey가 활성화 되어져 있을 때만 소리 출력하기" +useSoundOnlyWhenActive: "Misskey를 활성화한 때에만 소리를 출력하기" details: "자세히" chooseEmoji: "이모지 선택" unableToProcess: "작업을 완료할 수 없습니다" @@ -588,7 +598,7 @@ deleteAllFiles: "모든 파일 삭제" deleteAllFilesConfirm: "모든 파일을 삭제하시겠습니까?" removeAllFollowing: "모든 팔로잉 해제" removeAllFollowingDescription: "{host} 서버의 모든 팔로잉을 해제합니다. 해당 서버가 더 이상 존재하지 않는 경우 등에 실행해 주세요." -userSuspended: "이 계정은 정지된 상태입니다." +userSuspended: "이 사용자는 정지되었습니다." userSilenced: "이 계정은 사일런스된 상태입니다." yourAccountSuspendedTitle: "계정이 정지되었습니다" yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반하거나, 기타 다른 이유로 인해 정지되었습니다. 자세한 사항은 관리자에게 문의해 주십시오. 계정을 새로 생성하지 마십시오." @@ -752,7 +762,7 @@ experimentalFeatures: "실험실" experimental: "실험실" thisIsExperimentalFeature: "이 기능은 실험적인 기능입니다. 사양이 변경되거나 정상적으로 동작하지 않을 가능성이 있습니다." developer: "개발자" -makeExplorable: "\"발견하기\"에 내 계정 보이기" +makeExplorable: "계정을 쉽게 발견하도록 하기" makeExplorableDescription: "비활성화하면 \"발견하기\"에 나의 계정을 표시하지 않습니다." showGapBetweenNotesInTimeline: "타임라인의 노트 사이를 띄워서 표시" duplicate: "복제" @@ -798,7 +808,7 @@ emailNotification: "메일 알림" publish: "게시" inChannelSearch: "채널에서 검색" useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기" -typingUsers: "{users} 님이 입력하고 있어요.." +typingUsers: "{users}님이 입력 중" jumpToSpecifiedDate: "특정 날짜로 이동" showingPastTimeline: "과거의 타임라인을 표시하고 있어요" clear: "지우기" @@ -832,6 +842,7 @@ administration: "관리" accounts: "계정" switch: "전환" noMaintainerInformationWarning: "관리자 정보가 설정되어 있지 않습니다." +noInquiryUrlWarning: "문의처 주소를 설정하지 않았습니다." noBotProtectionWarning: "Bot 방어가 설정되어 있지 않습니다." configure: "설정하기" postToGallery: "갤러리에 업로드" @@ -1021,6 +1032,7 @@ thisPostMayBeAnnoyingHome: "홈에 게시" thisPostMayBeAnnoyingCancel: "그만두기" thisPostMayBeAnnoyingIgnore: "이대로 게시" collapseRenotes: "이미 본 리노트를 간략화하기" +collapseRenotesDescription: "반응이나 리노트를 한 노트를 접어서 표시합니다." internalServerError: "내부 서버 오류" internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했습니다." copyErrorInfo: "오류 정보 복사" @@ -1090,7 +1102,7 @@ serverRules: "서버 규칙" pleaseConfirmBelowBeforeSignup: "이 서버에 가입하기 전에 아래 사항을 확인하여 주십시오." pleaseAgreeAllToContinue: "계속하시려면 모든 항목에 동의하십시오." continue: "계속" -preservedUsernames: "예약된 사용자명" +preservedUsernames: "예약한 사용자 이름" preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." createNoteFromTheFile: "이 파일로 노트를 작성" archive: "아카이브" @@ -1230,10 +1242,22 @@ useTotp: "일회용 비밀번호 사용" useBackupCode: "백업 코드 사용" launchApp: "앱 실행" useNativeUIForVideoAudioPlayer: "브라우저 UI에서 미디어 재생" +keepOriginalFilename: "원본 파일 이름을 유지" +keepOriginalFilenameDescription: "이 설정을 끄면 업로드를 할 때 파일 이름이 자동으로 무작위 문자열로 바뀝니다." +noDescription: "설명문이 없습니다" +alwaysConfirmFollow: "팔로우일 때 항상 확인하기" +inquiry: "문의하기" +tryAgain: "다시 시도해 주세요." +confirmWhenRevealingSensitiveMedia: "민감한 미디어를 열 때 두 번 확인" _delivery: + status: "전송 상태" stop: "정지됨" + resume: "전송 다시 시작" _type: none: "배포 중" + manuallySuspended: "수동 정지 중" + goneSuspended: "서버 삭제를 이유로 정지 중" + autoSuspendedForNotResponding: "서버 응답 없음을 이유로 정지 중" _bubbleGame: howToPlay: "설명" hold: "홀드" @@ -1359,6 +1383,8 @@ _serverSettings: fanoutTimelineDescription: "활성화하면 각종 타임라인을 가져올 때의 성능을 대폭 향상하며, 데이터베이스의 부하를 줄일 수 있습니다. 단, Redis의 메모리 사용량이 증가합니다. 서버의 메모리 용량이 작거나, 서비스가 불안정해지는 경우 비활성화할 수 있습니다." fanoutTimelineDbFallback: "데이터베이스를 예비로 사용하기" fanoutTimelineDbFallbackDescription: "활성화하면 타임라인의 캐시되어 있지 않은 부분에 대해 DB에 질의하여 정보를 가져옵니다. 비활성화하면 이를 실행하지 않음으로써 서버의 부하를 줄일 수 있지만, 타임라인에서 가져올 수 있는 게시물 범위가 한정됩니다." + inquiryUrl: "문의처 URL" + inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다." _accountMigration: moveFrom: "다른 계정에서 이 계정으로 이사" moveFromSub: "다른 계정에 대한 별칭을 생성" @@ -1675,10 +1701,11 @@ _role: canManageAvatarDecorations: "아바타 꾸미기 관리" driveCapacity: "드라이브 용량" alwaysMarkNsfw: "파일을 항상 NSFW로 지정" + canUpdateBioMedia: "아바타 및 배너 이미지 변경 허용" pinMax: "고정할 수 있는 노트 수" antennaMax: "만들 수 있는 안테나 수" wordMuteMax: "단어 뮤트할 수 있는 문자 수" - webhookMax: "만들 수 있는 웹후크 수" + webhookMax: "만들 수 있는 Webhook 수" clipMax: "만들 수 있는 클립 수" noteEachClipsMax: "클립에 넣을 수 있는 노트 수" userListMax: "만들 수 있는 사용자 리스트 수" @@ -1693,6 +1720,11 @@ _role: roleAssignedTo: "수동 역할에 이미 할당됨" isLocal: "로컬 사용자" isRemote: "리모트 사용자" + isCat: "고양이 사용자" + isBot: "봇 사용자" + isSuspended: "정지된 사용자" + isLocked: "잠금 계정 사용자" + isExplorable: "‘계정을 쉽게 발견하도록 하기’를 활성화한 사용자" createdLessThan: "가입한 지 다음 일수 이내인 유저" createdMoreThan: "가입한 지 다음 일수 이상인 유저" followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" @@ -1913,8 +1945,6 @@ _sfx: note: "새 노트" noteMy: "내 노트" notification: "알림" - antenna: "안테나 수신" - channel: "채널 알림" reaction: "리액션 선택" _soundSettings: driveFile: "드라이브에 있는 오디오를 사용" @@ -1975,6 +2005,7 @@ _2fa: backupCodesDescription: "인증 앱을 사용할 수 없게 된 경우 아래 백업 코드를 사용하여 계정에 액세스 할 수 있습니다.이 코드들은 반드시 안전한 장소에 보관하십시오.각 코드는 한 번만 사용할 수 있습니다." backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오." backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요." + moreDetailedGuideHere: "여기에 자세한 설명이 있습니다" _permissions: "read:account": "계정의 정보를 봅니다" "write:account": "계정의 정보를 변경합니다" @@ -2163,7 +2194,7 @@ _postForm: c: "무엇을 생각하고 있나요?" d: "말하고 싶은 게 있나요?" e: "여기에 적어 주세요" - f: "글 쓰기를 기다려요…" + f: "작성해주시길 기다리고 있어요..." _profile: name: "이름" username: "사용자 이름" @@ -2338,6 +2369,7 @@ _deck: alwaysShowMainColumn: "메인 칼럼 항상 표시" columnAlign: "칼럼 정렬" addColumn: "칼럼 추가" + newNoteNotificationSettings: "새 노트 알림 설정" configureColumn: "칼럼 설정" swapLeft: "왼쪽으로 이동" swapRight: "오른쪽으로 이동" @@ -2376,6 +2408,7 @@ _drivecleaner: orderByCreatedAtAsc: "등록일이 오래된 순" _webhookSettings: createWebhook: "Webhook 생성" + modifyWebhook: "Webhook 수정" name: "이름" secret: "시크릿" events: "Webhook을 실행할 타이밍" @@ -2388,6 +2421,26 @@ _webhookSettings: renote: "누군가 내 글을 리노트했을 때" reaction: "누군가 내 노트에 리액션했을 때" mention: "누군가 나를 멘션했을 때" + _systemEvents: + abuseReport: "유저로부터 신고를 받았을 때" + abuseReportResolved: "받은 신고를 처리했을 때" + userCreated: "유저가 생성되었을 때" + deleteConfirm: "Webhook을 삭제할까요?" +_abuseReport: + _notificationRecipient: + createRecipient: "신고 수신자 추가" + modifyRecipient: "신고 수신자 편집" + recipientType: "알림 수신 유형" + _recipientType: + mail: "이메일" + webhook: "Webhook" + _captions: + mail: "모더레이터 권한을 가진 사용자의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" + webhook: "지정한 SystemWebhook에 알림을 보냅니다 (신고를 받은 때와 해결했을 때에 송신)" + keywords: "키워드" + notifiedUser: "신고 알림을 보낼 유저" + notifiedWebhook: "사용할 Webhook" + deleteConfirm: "수신자를 삭제하시겠습니까?" _moderationLogTypes: createRole: "역할 생성" deleteRole: "역할 삭제" @@ -2403,7 +2456,7 @@ _moderationLogTypes: updateUserNote: "조정 기록 갱신" deleteDriveFile: "파일 삭제" deleteNote: "노트 삭제" - createGlobalAnnouncement: "모든 공지사항 만들기" + createGlobalAnnouncement: "전역 공지사항 생성" createUserAnnouncement: "사용자 공지사항 만들기" updateGlobalAnnouncement: "모든 공지사항 수정" updateUserAnnouncement: "사용자 공지사항 수정" @@ -2425,6 +2478,12 @@ _moderationLogTypes: deleteAvatarDecoration: "아바타 장식 삭제" unsetUserAvatar: "유저 아바타 제거" unsetUserBanner: "유저 배너 제거" + createSystemWebhook: "SystemWebhook을 생성" + updateSystemWebhook: "SystemWebhook을 수정" + deleteSystemWebhook: "SystemWebhook을 삭제" + createAbuseReportNotificationRecipient: "신고 알림 수신자 생성" + updateAbuseReportNotificationRecipient: "신고 알림 수신자 편집" + deleteAbuseReportNotificationRecipient: "신고 알림 수신자 삭제" _fileViewer: title: "파일 상세" type: "파일 유형" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 087bac3745..1bead5635d 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -18,15 +18,15 @@ enterUsername: "ປ້ອນຊື່ຜູ້ໃຊ້" renotedBy: "Renoted ໂດຍ {user}" noNotes: "ບໍ່ມີ note" noNotifications: "ບໍ່ມີການແຈ້ງເຕືອນ" -instance: "ອີນສະແຕນ" -settings: "ກຳນົດຄ່າ" +instance: "ເຊີຟເວີຣ໌" +settings: "ຕັ້ງຄ່າ" notificationSettings: "ຕັ້ງຄ່າການແຈ້ງເຕືອນ" basicSettings: "ການຕັ້ງຄ່າພື້ນຖານ" otherSettings: "ການຕັ້ງຄ່າອື່ນໆ" -openInWindow: "ເປີດໃນປ່ອງຢ້ຽມ" -profile: "ໂພຼຟາຍ" +openInWindow: "ເປີດໃນ window" +profile: "ໂປຣໄຟລ໌" timeline: "ໄທມ໌ໄລນ໌" -noAccountDescription: "ຜູ້ໃຊ້ນີ້ຍັງບໍ່ໄດ້ຂຽນໃນຊີວະປະຫວັດຂອງເຂົາເຈົ້າເທື່ອ" +noAccountDescription: "ຜູ້ໃຊ້ຄົນນີ້ຍັງບໍ່ໄດ້ຂຽນຄຳແນະນຳໂຕ" login: "ເຂົ້າ​ສູ່​ລະ​ບົບ" loggingIn: "ກຳລັງເຂົ້າສູ່ລະບົບ..." logout: "ອອກ​ຈາກ​ລະ​ບົບ" @@ -37,7 +37,7 @@ users: "ຜູ້ໃຊ້" addUser: "ເພີ່ມຜູ້ໃຊ້" favorite: "ເພີ່ມໃສ່ລາຍການທີ່ມັກ" favorites: "ລາຍການທີ່ມັກ" -unfavorite: "ລຶບອອກຈາກລາຍການທີ່ມັກ" +unfavorite: "ເອົາອອກຈາກລາຍການທີ່ມັກ" favorited: "ເພີ່ມໃສ່ລາຍການທີ່ມັກແລ້ວ" alreadyFavorited: "ເພີ່ມເຂົ້າໃນລາຍການທີ່ມັກແລ້ວ." cantFavorite: "ບໍ່ສາມາດເພີ່ມໃສ່ລາຍການທີ່ມັກໄດ້." @@ -48,41 +48,41 @@ copyLink: "ຄັດລອກລິ້ງ" copyLinkRenote: "ຄັດລອກລິ້ງຂອງ renote" delete: "ລຶບ" deleteAndEdit: "ລຶບ​ແລະ​ແກ້​ໄຂ​" -deleteAndEditConfirm: "ເຈົ້າ​ແນ່​ໃຈ​ບໍ່? ທີ່ທ່ານຕ້ອງການທີ່ຈະລຶບ note ນີ້ ແລະແກ້ໄຂມັນ ທ່ານອາດຈະສູນເສຍ reaction, renote, ແລະການຕອບກັບທັງໝົດ" +deleteAndEditConfirm: "ຕ້ອງການລຶບ note ນີ້ແລະແກ້ໄຂໃໝ່ແມ່ນບໍ່? reaction, renote ແລະການຕອບກັບຕໍ່ note ນີ້ ທັງເບິດຈະຖືກລຶບອອກ" addToList: "ເພີ່ມໃສ່ລາຍຊື່" addToAntenna: "ເພີ່ມໃສ່ເສົາອາກາດ" sendMessage: "ສົ່ງຂໍ້ຄວາມ" -copyRSS: "ສຳເນົາ RSS" -copyUsername: "ສຳເນົາຊື່ຜູ້ໃຊ້" -copyUserId: "ສຳເນົາ ID ຜູ້ໃຊ້" -copyNoteId: "ສຳເນົາ ID ບັນທຶກ" -copyFileId: "ສຳເນົາ ID ໄຟລ໌" -copyFolderId: "ສຳເນົາ ID ໂຟນເດີ" -copyProfileUrl: "ສຳເນົາ URL ໂປຣໄຟລ໌" +copyRSS: "ຄັດລອກ RSS" +copyUsername: "ຄັດລອກຊື່ຜູ້ໃຊ້" +copyUserId: "ຄັດລອກ ID ຜູ້ໃຊ້" +copyNoteId: "ຄັດລອກ ID ຂອງ note" +copyFileId: "ຄັດລອກ ID ໄຟລ໌" +copyFolderId: "ຄັດລອກ ID ໂຟລ໌ເດີຣ໌" +copyProfileUrl: "ຄັດລອກ URL ໂປຣໄຟລ໌" searchUser: "ຄົ້ນຫາຜູ້ໃຊ້" -reply: "ຕອບ​ໄປ​ທີ" +reply: "ຕອບ​ກັບ" loadMore: "ໂຫຼດເພີ່ມເຕີມ" showMore: "ໂຫຼດເພີ່ມເຕີມ" showLess: "ປິດ" -youGotNewFollower: "ໄດ້ຕິດຕາມທ່ານ" -receiveFollowRequest: "ປະຕິບັດຕາມຄໍາຮ້ອງຂໍທີ່ໄດ້ຮັບ" -followRequestAccepted: "ຜູ້ຕິດຕາມໄດ້ຍອມຮັບຄໍາຮ້ອງຂໍຂອງທ່ານ" -mention: "ກ່າວຖືງ" -mentions: "ກ່າວເຖິງ" +youGotNewFollower: "ໄດ້ຕິດຕາມເຈົ້າ" +receiveFollowRequest: "ມີຄຳຂໍຕິດຕາມສົ່ງມາ" +followRequestAccepted: "ການຕິດຕາມໄດ້ຮັບອນຸຍາດແລ້ວ" +mention: "ເວົ້າເຖີງ" +mentions: "ເວົ້າເຖີງເຈົ້າ" directNotes: "ໂພສ Direct note" importAndExport: "ນໍາເຂົ້າ / ສົ່ງອອກ" import: "ນຳເຂົ້າ" export: "ສົ່ງອອກ" files: "ໄຟລ໌" download: "ດາວໂຫລດ" -driveFileDeleteConfirm: "ທ່ານແນ່ໃຈບໍ່ວ່າຕ້ອງການລຶບໄຟລ໌ \"{name}\"? note ທີ່ມີໄຟລ໌ແນບນີ້ຈະຖືກລຶບຖິ້ມ" -unfollowConfirm: "ທ່ານແນ່ໃຈບໍ່ວ່າຕ້ອງການເຊົາຕິດຕາມ {name}?" -exportRequested: "ໃນເວລາທີ່ທ່ານໄດ້ຮ້ອງຂໍການສົ່ງອອກ ມັນອາດຈະໃຊ້ເວລາບາງເວລາ ແລະມັນຈະຖືກເພີ່ມໃສ່ drive ຂອງທ່ານເມື່ອມັນສຳເລັດແລ້ວ" -importRequested: "ໃນເວລາທີ່ທ່ານໄດ້ຮ້ອງຂໍການນໍາເຂົ້າ ມັນອາດຈະໃຊ້ເວລາບາງເວລາ" +driveFileDeleteConfirm: "ຕ້ອງການລຶບໄຟລ໌ “{name}” ແມ່ນບໍ່? Note ທີ່ແນບມາກັບໄຟລ໌ນີ້ຈະຖືກລຶບອອກ" +unfollowConfirm: "ຕ້ອງການເລີກຕິດຕາມ {name} ແມ່ນບໍ່?" +exportRequested: "ເຈົ້າໄດ້ຮ້ອງຂໍການສົ່ງອອກ ອາດໃຊ້ເວລາຈັກໜ່ອຍ ເມື່ອແລ້ວຈະຖືກເພີ່ມໃສ່ drive" +importRequested: "ເຈົ້າໄດ້ຮ້ອງຂໍການນຳເຂົ້າ ການດຳເນິນການນີ້ອາດໃຊ້ເວລາຈັກໜ່ອຍ" lists: "ລາຍການ" -noLists: "ທ່ານ​ບໍ່​ມີ​ລາຍ​ການ​ໃດໆ​" -note: "ບັນທຶກ" -notes: "ບັນທຶກ" +noLists: "ບໍ່​ມີ​ລາຍ​ການ​ໃດໆ​" +note: "Note" +notes: "Note" following: "ກຳລັງຕິດຕາມ" followers: "ຜູ້ຕິດຕາມ" followsYou: "ຕິດ​ຕາມ​ເຈົ້າ" @@ -124,11 +124,11 @@ reactions: "reaction" attachCancel: "ເອົາໄຟລ໌ແນບ" mute: "ປີດສຽງ" unmute: "ເປີດສຽງ" -block: "ບ໋ອກ" -unblock: "ຍົກເລີກກາຮົບລັອກ" +block: "ບລັອກ" +unblock: "ເລີກບລັອກ" suspend: "ລະງັບ" unsuspend: "ເຊົາ​ລະ​ງັບ" -selectList: "ເລືອກບັນຊີລາຍການ" +selectList: "ເລືອກລາຍຊື່" editList: "ແກ້ໄຂລາຍຊື່" selectChannel: "ເລືອກຊ່ອງ" selectAntenna: "ເລືອກເສົາອາກາດ" @@ -151,30 +151,30 @@ flagShowTimelineRepliesDescription: "ສະແດງການຕອບກັບ autoAcceptFollowed: "ອະນຸມັດອັດຕະໂນມັດຕາມຄຳຮ້ອງຂໍຈາກຜູ້ໃຊ້ທີ່ທ່ານກຳລັງຕິດຕາມຢູ່" addAccount: "ເພີ່ມບັນຊີ" loginFailed: "ການເຂົ້າສູ່ລະບົບບໍ່ສຳເລັດ" -showOnRemote: "ເບິ່ງຢູ່ໃນຕົວຢ່າງໄລຍະໄກ" +showOnRemote: "ເບິ່ງໃນເຊີຟເວີຣ໌ໄລຍະໄກ" general: "ທົ່ວໄປ" wallpaper: "ພາບພື້ນຫລັງ" setWallpaper: "ຕັ້ງເປັນພາບພື້ນຫຼັງ" removeWallpaper: "ລຶບຮູບວໍເປເປີອອກ" searchWith: "ຊອກຫາ: {q}" -youHaveNoLists: "ທ່ານ​ບໍ່​ມີ​ລາຍ​ການ​ໃດໆ​" +youHaveNoLists: "ເຈົ້າບໍ່ມີລາຍຊື່ໃດໆ" proxyAccount: "ບັນຊີພຣັອກຊີ" -host: "ໂຮດສ" +host: "ໂຮສຕ໌" selectUser: "ເລືອກຜູ້ໃຊ້" recipient: "ເຖິງ" annotation: "ຄຳເຫັນ" federation: "ສະຫະພັນ" -instances: "ອີນສະແຕນ" +instances: "ເຊີຟເວີຣ໌" registeredAt: "ລົງທະບຽນຢູ່" storageUsage: "ບ່ອນ​ຈັດ​ເກັບ​ຂໍ້​ມູນທີ່ໃຊ້" -charts: "ອັນດັບເພງ" +charts: "ແຜນພູມ" perHour: "ຕໍ່ຊົ່ວໂມງ" perDay: "ຕໍ່​ມື້" stopActivityDelivery: "ຢຸດເຊົາການສົ່ງກິດຈະກໍາ" blockThisInstance: "ຂັດຂວາງຕົວຢ່າງນີ້" operations: "ການດຳເນີນງານ" software: "ຊອບແວ" -version: "ສະບັບ" +version: "ເວີຣ໌ຊັນ" metadata: "Metadata" withNFiles: "{n} ໄຟລ໌(s)" monitor: "ຈໍພາບ" @@ -199,15 +199,15 @@ federating: "ສະຫະພັນ" blocked: "ບລັອກແລ້ວ " suspended: "ໂຈະ" all: "ທັງໝົດ" -subscribing: "ສະໝັກສະມາຊິກແລັວ" -publishing: "ການ​ພິມ​ເຜີຍ​ແຜ່" +subscribing: "ກຳລັງສະມັກສະມາຊິກ" +publishing: "ກຳລັງ​ເຜີຍ​ແພ່" notResponding: "ບໍ່ຕອບສະໜອງ" -instanceFollowing: "ກຳລັງຕິດຕາມສຸດຕົວຢ່າງ" -instanceFollowers: "ຜູ້ຕິດຕາມຕົວຢ່າງ" -instanceUsers: "ຜູ້​ຊົມ​ໃຊ້​ຂອງ​ຕົວ​ຢ່າງ​ນີ້​" +instanceFollowing: "ກຳລັງຕິດຕາມບົນເຊີຟເວີຣ໌" +instanceFollowers: "ຜູ້ຕິດຕາມຂອງເຊີຟເວີຣ໌" +instanceUsers: "ຜູ້​ໃຊ້​ຂອງ​ເຊີຟເວີຣ໌ນີ້" changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ" security: "ຄວາມປອດໄພ" -retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ" +retypedNotMatch: "ປ້ອນຂໍ້ມູນບໍ່ກົງກັນ" currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ" newPassword: "ລະຫັດຜ່ານໃໝ່" newPasswordRetype: "ໃສ່ລະຫັດຜ່ານໃໝ່ອີກເທື່ອໜຶ່ງ" @@ -223,14 +223,14 @@ remove: "ລຶບ" removed: "ລຶບແລ້ວ" resetAreYouSure: "ຣີ​ເຊັດບໍ?" saved: "ບັນທຶກແລ້ວ" -messaging: "ແຊ໋ດ" +messaging: "ແຊັຕ" upload: "ອັບໂຫຼດ" keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" fromDrive: "ຈາກ Drive" fromUrl: "ຈາກ URL" uploadFromUrl: "ອັບໂຫຼດຈາກ URL" uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ" -uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດ" +uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດແລ້ວ" explore: "ສຳຫຼວດ" messageRead: "ອ່ານແລ້ວ" startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" @@ -244,47 +244,47 @@ images: "ຮູບພາບ" image: "ຮູບພາບ" birthday: "ວັນເກີດ" yearsOld: "{age} ປີ" -registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" +registeredDate: "ວັນທີ່ລົງທະບຽນ" location: "ທີ່ຕັ້ງ" -theme: "ແທ໋ມ" -themeForLightMode: "ຮູບແບບສີສັນເພື່ອໃຊ້ໃນໂໝດແສງ" -themeForDarkMode: "ຮູບແບບສີສັນທີ່ຈະໃຊ້ຢູ່ໃນໂໝດມືດ" +theme: "Theme" +themeForLightMode: "Theme ໃຊ້ໃນໂໝດສະຫວ່າງ" +themeForDarkMode: "Theme ໃຊ້ໃນໂໝດມືດ" light: "ສະຫວ່າງ" dark: "ມືດ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" darkThemes: "ຮູບແບບສີສັນມືດ" syncDeviceDarkMode: "ຊິງຄ໌ໂໝດມືດກັບການຕັ້ງຄ່າທົ່ວອຸປະກອນ" -drive: "ຂັບ" +drive: "Drive" fileName: "ຊື່ໄຟລ໌" selectFile: "ເລືອກໄຟລ໌" selectFiles: "ເລືອກໄຟລ໌" selectFolder: "ເລືອກໂຟລເດີ" selectFolders: "ເລືອກໂຟລເດີ" renameFile: "ປ່ຽນຊື່ໄຟລ໌" -folderName: "ຊື່ໂຟນເດີ" +folderName: "ຊື່ໂຟລເດີຣ໌" createFolder: "​ສ້າງ​ໂຟ​ລ​ເດີ" renameFolder: "ປ່ຽນຊື່ໂຟນເດີນີ້" deleteFolder: "ລົບໂຟ​ລ​ເດີ​" addFile: "ເພີ່ມໄຟລ໌" emptyDrive: "Drive ຂອງທ່ານຫວ່າງເປົ່າ" -emptyFolder: "ໂຟນເດີນີ້ເປົ່າຫວ່າງ" +emptyFolder: "ໂຟລເດີຣ໌ນີ້ວ່າງເປົ່າ" unableToDelete: "ບໍ່​ສາ​ມາດລົບໄດ້" inputNewFileName: "ໃສ່ຊື່ໄຟລ໌ໃໝ່" inputNewDescription: "ໃສ່ຄຳບັນຍາຍໃໝ່" inputNewFolderName: "ໃສ່ຊື່ໂຟນເດີໃໝ່" circularReferenceFolder: "ໂຟນເດີປາຍທາງແມ່ນໂຟນເດີຍ່ອຍຂອງໂຟນເດີທີ່ທ່ານຕ້ອງການຍ້າຍ" rename: "ປ່ຽນຊື່" -doNothing: "ບໍ່ສົນໃຈ" -watch: "ເບິ່ງ" -unwatch: "ຢຸດເບິ່ງ" +doNothing: "ຢ່າມັນ" +watch: "ເພັ່ງເລັງ" +unwatch: "ຢຸດເພັ່ງເລັງ" accept: "ອະນຸຍາດ" reject: "ປະຕິເສດ" normal: "ປົກກະຕິ" instanceName: "ຊື່ເຊີເວີ້" -instanceDescription: "ຄໍາອະທິບາຍຕົວຢ່າງ" +instanceDescription: "ຄຳອະທິບາຍແນະນຳເຊີຟເວີຣ໌" maintainerName: "ຜູ້ດູແລ" -maintainerEmail: "ອີເມວ admin" -tosUrl: "ເງື່ອນໄຂການໃຫ້ບໍລິການ URL" +maintainerEmail: "ອີເມລຜູ້ດູແລ" +tosUrl: " URL ເງື່ອນໄຂການໃຫ້ບໍລິການ" thisYear: "ປີນີ້" thisMonth: "ເດືອນນີ້" today: "ມື້ນີ້" @@ -292,34 +292,34 @@ dayX: "ວັນ {day}" monthX: "ເດືອນ {month}" yearX: "ປີ {year}" pages: "ໜ້າ" -integration: "ຄວາມສຳພັນຂອງ" +integration: "ເຊື່ອມໂຍງ" connectService: "ເຊື່ອມຕໍ່" disconnectService: "ຕັດການເຊື່ອມຕໍ່" enableLocalTimeline: "ເປີດໃຊ້ທາມລາຍທ້ອງຖິ່ນ" enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ" -disablingTimelinesInfo: "ຜູ້ເບິ່ງແຍງລະບົບ ແລະຜູ້ຄວບຄຸມຈະມີການເຂົ້າເຖິງທຸກກຳນົດເວລາ, ເຖິງແມ່ນວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍຕາມ" +disablingTimelinesInfo: "ຜູ້ດູແລລະບບແລະຜູ້ຄວບຄຸມຈະສາມາດເຂົ້າເຖີງໄທມ໌ໄລນ໌ທັ້ງເບີດ ເຖີງວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍ່ຕາມ" registration: "ລົງທະບຽນ" enableRegistration: "ເປີດໃຊ້ການລົງທະບຽນຜູ້ໃຊ້ໃໝ່" invite: "ເຊີນ" -driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" -driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ" +driveCapacityPerLocalAccount: "ຄວາມຈຸຂອງ drive ຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" +driveCapacityPerRemoteAccount: "ຄວາມຈຸຂອງ drive ຕໍ່ຜູ້ໃຊ້ໄລຍະໄກ" basicInfo: "ຂໍ້ມຸນເບື້ອງຕົ້ນ" -pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" -hcaptchaSiteKey: "ກະແຈໄຊທ໌" -hcaptchaSecretKey: "ກະແຈລັບ" -mcaptchaSiteKey: "ກະແຈໄຊທ໌" -mcaptchaSecretKey: "ກະແຈລັບ" +pinnedNotes: "Note ທີ່ປັກໝຸດໄວ້" +hcaptchaSiteKey: "Site key" +hcaptchaSecretKey: "Secret key" +mcaptchaSiteKey: "Site key" +mcaptchaSecretKey: "Secret Key" recaptcha: "reCAPTCHA" -enableRecaptcha: "ເປີດໃຊ້ງານລີແຄ໋ບຈາ" -recaptchaSiteKey: "ກະແຈໄຊທ໌" -recaptchaSecretKey: "ກະແຈລັບ" -turnstileSiteKey: "ກະແຈໄຊທ໌" -turnstileSecretKey: "ກະແຈລັບ" +enableRecaptcha: "ເປີດໃຊ້ງານ reCAPTCHA" +recaptchaSiteKey: "Site key" +recaptchaSecretKey: "Secret key" +turnstileSiteKey: "Site key" +turnstileSecretKey: "Secret key" name: "ຊື່" userList: "ລາຍການ" about: "ກ່ຽວກັບ" aboutMisskey: "ກ່ຽວກັບ Misskey" -administrator: "ຜູ້ບໍລິຫານ" +administrator: "ຜູ້ດູແລ" token: "ໂທເຄັນ" share: "ແບ່ງປັນ" notFound: "ບໍ່ພົບ" @@ -332,27 +332,27 @@ title: "ຫົວຂໍ້" text: "ຂໍ້ຄວາມ" enable: "ເປີດໃຊ້" next: "ຕໍ່ໄປ" -retype: "ເຂົ້າໄປອີກຄັ້ງ" -quoteAttached: "ວົງຢືມ" +retype: "ລອງພິມລະຫັດອີກເທື່ອໜຶ່ງ" +quoteAttached: "ອ້າງອິງ" invitations: "ເຊີນ" unavailable: "ບໍ່​ສາ​ມາດ​ໃຊ້​ໄດ້" language: "ພາສາ" aboutX: "ກ່ຽວກັບ {x}" emojiStyle: "ຮູບແບບອີໂມຈິ" native: "ພາ​ສາ​ແມ່" -noHistory: "​ບໍ່​ມີ​ລາຍ​ການ​ຢູ່​ບ່ອນ​ນີ້" +noHistory: "​ບໍ່​ມີປະຫວັດ" doing: "ກຳລັງປະມວນຜົນ..." category: "ຫມວດຫມູ່" -tags: "ແທ໋ກ" +tags: "Aliases" createAccount: "ສ້າງບັນຊີ" -existingAccount: "ທີ່ມີຢູ່" -dashboard: "ໜ້າປັດ" +existingAccount: "ບັນຊີທີ່ມີຢູ່ແລ້ວ" +dashboard: "Dashboard" local: "ທ້ອງຖິ່ນ" numberOfDays: "ຈຳນວນມື້" objectStorageBucket: "Bucket" objectStoragePrefix: "Prefix" objectStorageEndpoint: "Endpoint" -objectStorageRegion: "ພາກ​ພື້ນ" +objectStorageRegion: "ພູມິພາກ" deleteAll: "ລຶບທັງໝົດ" sounds: "ສຽງ" sound: "ສຽງ" @@ -365,11 +365,11 @@ state: "ສະຖານະ" sort: "ຈັດຮຽງໂດຍ" ascendingOrder: "ນ້ອຍໄປຫາໃຫຍ່" descendingOrder: "ໃຫຍ່ຫານ້ອຍ" -output: "ຜົນຜະລິດ" -script: "ບົດ​ຄວາມ" +output: "Output" +script: "Script" menu: "ເມນູ" -rearrange: "ຈັດລຽງຄືນ" -poll: "ການພູນ" +rearrange: "ຈັດລຽງໃໝ່" +poll: "Poll" description: "ລາຍລະອຽດ" author: "ຜູ້ຂຽນ" manage: "ການຈັດການ" @@ -383,7 +383,7 @@ permission: "ການອະນຸຍາດ" notificationType: "​ປະເພດການ​ແຈ້ງ​ເຕືອນ" edit: "ແກ້ໄຂ" email: "ອີເມວ" -smtpHost: "ໂຮດສ" +smtpHost: "ໂຮສຕ໌" smtpUser: "ຊື່ຜູ້ໃຊ້" smtpPass: "ລະຫັດຜ່ານ" clearCache: "ລຶບລ້າງແຄສ" @@ -393,12 +393,12 @@ administration: "ການຈັດການ" middle: "ປານກາງ" searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" -replies: "ຕອບ​ໄປ​ທີ" +replies: "ຕອບ​ກັບ" renotes: "Renote" _delivery: stop: "ໂຈະ" _type: - none: "ການ​ພິມ​ເຜີຍ​ແຜ່" + none: "ກຳລັງ​ເຜີຍ​ແພ່" _role: _priority: middle: "ປານກາງ" @@ -416,8 +416,8 @@ _sfx: _2fa: renewTOTPCancel: "ບໍ່​ແມ່ນ​ຕອນ​ນີ້" _widgets: - profile: "ໂພຼຟາຍ" - instanceInfo: "ອີນສະແຕນ" + profile: "ໂປຣໄຟລ໌" + instanceInfo: "ຂໍ້ມູລເຊີຟເວີຣ໌" notifications: "ການແຈ້ງເຕືອນ" timeline: "​ເສັ້ນກຳ​ນົດ​ເວ​ລາ​" activity: "ກິດຈະກຳ" @@ -436,28 +436,28 @@ _profile: _exportOrImport: followingList: "ກຳລັງຕິດຕາມ" muteList: "ປີດສຽງ" - blockingList: "ບ໋ອກ" + blockingList: "ບລັອກ" userLists: "ລາຍການ" _charts: federation: "ສະຫະພັນ" _timelines: home: "ໜ້າຫຼັກ" _play: - script: "ບົດ​ຄວາມ" + script: "Script" summary: "ລາຍລະອຽດ" _pages: blocks: image: "ຮູບພາບ" _notification: - youWereFollowed: "ໄດ້ຕິດຕາມທ່ານ" + youWereFollowed: "ໄດ້ຕິດຕາມເຈົ້າ" _types: follow: "ກຳລັງຕິດຕາມ" - mention: "ໄດ້ກ່າວມາ" + mention: "ໄດ້ກ່າວເຖິງ" renote: "Renote" - quote: "ລວມຂໍ້ຄວາມອ້າງອີງ" - reaction: "ປະຕິກິລິຍາ" + quote: "ອ້າງອີງ" + reaction: "Reaction" _actions: - reply: "ຕອບ​ໄປ​ທີ" + reply: "ຕອບ​ກັບ" renote: "Renote" _deck: _columns: @@ -465,8 +465,12 @@ _deck: tl: "​ເສັ້ນກຳ​ນົດ​ເວ​ລາ​" list: "ລາຍການ" channel: "ຊ່ອງ" - mentions: "ກ່າວເຖິງ" + mentions: "ກ່າວເຖິງເຈົ້າ" _webhookSettings: name: "ຊື່" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "ອີເມວ" _moderationLogTypes: suspend: "ລະງັບ" diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 2b4c9b7776..cd00ecf9ab 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -721,5 +721,9 @@ _deck: direct: "Direkte" _webhookSettings: name: "Navn" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "E-post" _moderationLogTypes: suspend: "Suspender" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 9d75f7a9d7..7513d056b4 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1221,8 +1221,6 @@ _sfx: note: "Wpisy" noteMy: "Mój wpis" notification: "Powiadomienia" - antenna: "Anteny" - channel: "Powiadomienia kanału" _ago: future: "W przyszłości" justNow: "Przed chwilą" @@ -1556,6 +1554,10 @@ _webhookSettings: renote: "Po udostępnieniu wpisu" reaction: "Po otrzymaniu reakcji" mention: "Po zostaniu wspomnianym" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Adres e-mail" _moderationLogTypes: suspend: "Zawieś" resetPassword: "Zresetuj hasło" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index cfc576b6e1..2e26f6d12a 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -1500,6 +1500,10 @@ _webhookSettings: follow: "Quando seguindo um usuário" followed: "Quando sendo seguido" renote: "Quando repostado" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "E-mail" _moderationLogTypes: suspend: "Suspender" resetPassword: "Redefinir senha" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 328d34405e..b4c9b90de9 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -728,6 +728,10 @@ _deck: mentions: "Mențiuni" _webhookSettings: name: "Nume" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Email" _moderationLogTypes: suspend: "Suspendă" resetPassword: "Resetează parola" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 71f5cad601..88f59155d6 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -1612,8 +1612,6 @@ _sfx: note: "Заметки" noteMy: "Собственные заметки" notification: "Уведомления" - antenna: "Антенна" - channel: "Канал" _ago: future: "Из будущего" justNow: "Только что" @@ -1983,6 +1981,10 @@ _webhookSettings: createWebhook: "Создать вебхук" name: "Название" active: "Вкл." +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Электронная почта" _moderationLogTypes: suspend: "Заморозить" addCustomEmoji: "Добавлено эмодзи" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 52f6bf142c..41f8949196 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -1124,8 +1124,6 @@ _sfx: note: "Poznámky" noteMy: "Vlastná poznámka" notification: "Oznámenia" - antenna: "Antény" - channel: "Upozornenia kanála" _ago: future: "Budúcnosť" justNow: "Teraz" @@ -1447,6 +1445,10 @@ _deck: _webhookSettings: name: "Názov" active: "Zapnuté" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Email" _moderationLogTypes: suspend: "Zmraziť" resetPassword: "Resetovať heslo" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 089dc3949f..c1a998b8fb 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -512,7 +512,6 @@ _theme: _sfx: note: "Noter" notification: "Notifikationer" - antenna: "Antenner" _2fa: renewTOTPCancel: "Nej tack" _antennaSources: @@ -577,6 +576,10 @@ _deck: _webhookSettings: name: "Namn" active: "Aktiverad" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "E-post" _moderationLogTypes: suspend: "Suspendera" resetPassword: "Återställ Lösenord" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index ab09ac4d5a..eb7cdc2365 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -2,10 +2,10 @@ _lang_: "ภาษาไทย" headlineMisskey: "เชื่อมต่อเครือข่ายโดยโน้ต" introMisskey: "ยินดีต้อนรับทุกคนจ้า! Misskey คือ ซอฟต์แวร์โอเพนซอร์สสำหรับบริการไมโครบล็อกกิ้ง (MicroBlogging) แบบกระจายศูนย์อำนาจ (Decentralized) \n\nเขียน “โน้ต (Note)” เพื่อส่งต่อเรื่องราวของคุณให้ทั้งโลกได้รับรู้📡\nและอย่าลืมที่จะ “รีแอคชั่น” กับเรื่องราวของคนอื่น ๆ ด้วยนะ! 👍\n\nท่องสำรวจโลกใบใหม่กันเถอะ🚀" -poweredByMisskeyDescription: "{name} เป็นส่วนหนึ่งในบริการที่ถูกขับเคลื่อนโดยแพลตฟอร์มโอเพ่นซอร์ส Misskey (เรียกว่า \"อินสแตนซ์ Misskey\")" +poweredByMisskeyDescription: "{name} เป็นหนึ่งในเซิร์ฟเวอร์ของแพลตฟอร์มโอเพ่นซอร์ส Misskey" monthAndDay: "{month}/{day}" search: "ค้นหา" -notifications: "การเเจ้งเตือน" +notifications: "เเจ้งเตือน" username: "ชื่อผู้ใช้" password: "รหัสผ่าน" forgotPassword: "ลืมรหัสผ่าน" @@ -18,7 +18,7 @@ enterUsername: "กรอกชื่อผู้ใช้" renotedBy: "รีโน้ตโดย {user}" noNotes: "ไม่มีโน้ต" noNotifications: "ไม่มีการแจ้งเตือน" -instance: "อินสแตนซ์" +instance: "เซิร์ฟเวอร์" settings: "การตั้งค่า" notificationSettings: "ตั้งค่าการแจ้งเตือน" basicSettings: "การตั้งค่าพื้นฐาน" @@ -48,7 +48,7 @@ copyLink: "คัดลอกลิงก์" copyLinkRenote: "คัดลอกลิงก์รีโน้ต" delete: "ลบ" deleteAndEdit: "ลบและแก้ไข" -deleteAndEditConfirm: "คุณต้องการลบโน้ตนี้และแก้ไขใหม่ใช่ไหม? รีแอคชั่น รีโน้ต และการตอบกลับต่อโน้ตนี้ทั้งหมดจะถูกลบออกด้วย" +deleteAndEditConfirm: "ต้องการลบโน้ตนี้และแก้ไขใหม่ใช่ไหม? รีแอคชั่น รีโน้ต และการตอบกลับต่อโน้ตนี้ทั้งหมดจะถูกลบออกด้วย" addToList: "เพิ่มลงรายชื่อ" addToAntenna: "เพิ่มไปยังเสาอากาศ" sendMessage: "ส่งข้อความ" @@ -68,7 +68,7 @@ youGotNewFollower: "ได้ติดตามคุณ" receiveFollowRequest: "มีคำขอติดตามส่งมาหา" followRequestAccepted: "การติดตามได้รับการอนุมัติแล้ว" mention: "กล่าวถึง" -mentions: "พูดถึง" +mentions: "กล่าวถึงคุณ" directNotes: "โพสต์แบบไดเร็กต์" importAndExport: "นำเข้า / ส่งออก" import: "นำเข้า" @@ -92,7 +92,7 @@ error: "ผิดพลาด!" somethingHappened: "อุ๊ย ! มีอะไรบางอย่างผิดพลาด" retry: "ลองใหม่อีกครั้ง" pageLoadError: "เกิดข้อผิดพลาดในการโหลดหน้านี้" -pageLoadErrorDescription: "โดยปกติแล้วมักจะเกิดจากข้อผิดพลาดของเครือข่ายหรือแคชของเบราว์เซอร์ ลองล้างแคชแล้วลองใหม่อีกครั้งหลังจากรอสักครู่ " +pageLoadErrorDescription: "ปัญหานี้มักเกิดจากแคชของเครือข่ายหรือเบราว์เซอร์ ควรล้างแคช, รอสักครู่ แล้วลองใหม่อีกครั้ง" serverIsDead: "เซิร์ฟเวอร์นี้ไม่มีการตอบสนอง โปรดกรุณารอสักครู่แล้วลองใหม่อีกครั้ง" youShouldUpgradeClient: "หากต้องการดูหน้านี้ กรุณาโหลดหน้าใหม่เพื่ออัปเดตไคลเอ็นต์ของคุณ" enterListName: "ป้อนนามเรียกของรายชื่อชุดนี้" @@ -108,11 +108,14 @@ enterEmoji: "พิมพ์เอโมจิ" renote: "รีโน้ต" unrenote: "เลิกรีโน้ต" renoted: "รีโน้ตแล้ว" +renotedToX: "รีโน้ตให้ {name} แล้ว" cantRenote: "โพสต์นี้ไม่สามารถรีโน้ตใหม่ได้" cantReRenote: "รีโน้ตไม่สามารถรีโน้ตซ้ำได้" quote: "อ้างอิง" inChannelRenote: "รีโน้ตในช่องเท่านั้น" inChannelQuote: "อ้างอิงในช่องเท่านั้น" +renoteToChannel: "รีโน้ตไปที่ช่อง" +renoteToOtherChannel: "รีโน้ตไปยังช่องอื่น" pinnedNote: "โน้ตที่ปักหมุดไว้" pinned: "ปักหมุด" you: "คุณ" @@ -163,20 +166,24 @@ addEmoji: "แทรกเอโมจิ" settingGuide: "การตั้งค่าที่แนะนำ" cacheRemoteFiles: "แคชไฟล์ระยะไกล" cacheRemoteFilesDescription: "หากเปิดใช้งาน ไฟล์ระยะไกลจะถูกแคชไว้ ทำให้แสดงภาพเร็วขึ้น แต่ก็ใช้พื้นที่เก็บข้อมูลของเซิร์ฟเวอร์มากขึ้นเช่นกัน สำหรับขีดจำกัดที่ผู้ใช้ระยะไกลถูกแคชไว้จะขึ้นอยู่กับความจุไดรฟ์ตามบทบาทของเขา เมื่อเกินแล้วไฟล์เก่าจะถูกลบออกและเก็บเป็นลิงก์แทน หากปิดใช้งาน ไฟล์ระยะไกลจะถูกเก็บเป็นลิงก์ตั้งแต่ต้น เราแนะนำให้ตั้งค่า proxyRemoteFiles ใน default.yml เป็น true เพื่อสร้างธัมบ์เนลและปกป้องความเป็นส่วนตัวของผู้ใช้" -youCanCleanRemoteFilesCache: "คุณสามารถล้างแคชได้โดยคลิกที่ปุ่ม 🗑️ ในมุมมองการจัดการไฟล์" +youCanCleanRemoteFilesCache: "สามารถลบแคชทั้งหมดได้โดยใช้ปุ่ม 🗑️ ในหน้าการจัดการไฟล์" cacheRemoteSensitiveFiles: "แคชไฟล์ระยะไกลที่มีเนื้อหาละเอียดอ่อน" -cacheRemoteSensitiveFilesDescription: "เมื่อปิดการใช้งานการตั้งค่านี้ ไฟล์ระยะไกลที่มีเครื่องหมายว่ามีเนื้อหาละเอียดอ่อนนั้นจะถูกโหลดโดยตรงจากอินสแตนซ์ระยะไกลโดยที่ไม่มีการแคช" +cacheRemoteSensitiveFilesDescription: "เมื่อปิดการใช้งานการตั้งค่านี้ ไฟล์ระยะไกลที่มีเนื้อหาละเอียดอ่อนจะถูกโหลดโดยตรงจากเซิร์ฟเวอร์ระยะไกลโดยไม่มีการแคช" flagAsBot: "ทำเครื่องหมายบอกว่าบัญชีนี้เป็นบอต" -flagAsBotDescription: "การเปิดใช้งานตัวเลือกนี้หากบัญชีนี้ถูกควบคุมโดยนักเขียนโปรแกรม หรือ ถ้าหากเปิดใช้งาน มันจะทำหน้าที่เป็นแฟล็กสำหรับนักพัฒนารายอื่นๆ และเพื่อป้องกันการโต้ตอบแบบไม่มีที่สิ้นสุดกับบอทตัวอื่นๆ และยังสามารถปรับเปลี่ยนระบบภายในของ Misskey เพื่อปฏิบัติต่อบัญชีนี้เป็นบอท" +flagAsBotDescription: "เปิดใช้งานตัวเลือกนี้หากบัญชีนี้ถูกควบคุมโดยโปรแกรม เมื่อเปิดใช้งาน มันจะทำหน้าที่เป็นแฟล็กสำหรับนักพัฒนารายอื่นในการป้องกันการสร้างห่วงโซ่การโต้ตอบแบบอนันต์กับบอตตัวอื่น และปรับระบบภายในของ Misskey เพื่อจัดการบัญชีนี้ในฐานะบอต" flagAsCat: "เมี้ยววววววววววววววว!!!!!!!!!!!" flagAsCatDescription: "เหมียวเหมียวเมี้ยว??" -flagShowTimelineReplies: "แสดงตอบกลับ ในไทม์ไลน์" -flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้" -autoAcceptFollowed: "อนุมัติคำขอติดตามโดยอัตโนมัติทันที จากผู้ใช้งานที่คุณกำลังติดตาม" +flagShowTimelineReplies: "แสดงตอบกลับโน้ตลงไทม์ไลน์" +flagShowTimelineRepliesDescription: "เมื่อเปิดใช้งาน จะแสดงการตอบกลับของผู้ใช้คนนั้นต่อโน้ตอื่นๆ ในไทม์ไลน์ด้วย" +autoAcceptFollowed: "อนุมัติคำขอติดตามจากผู้ใช้ที่คุณติดตามอยู่โดยอัตโนมัติ" addAccount: "เพิ่มบัญชี" reloadAccountsList: "รีโหลดรายการบัญชีใหม่" loginFailed: "การเข้าสู่ระบบไม่สำเร็จ" -showOnRemote: "ดูบนอินสแตนซ์ระยะไกล" +showOnRemote: "ดูบนเซิร์ฟเวอร์ฝั่งระยะไกล" +continueOnRemote: "ดำเนินการต่อบนเซิร์ฟเวอร์ฝั่งระยะไกล" +chooseServerOnMisskeyHub: "เลือกเซิร์ฟเวอร์จาก Misskey Hub" +specifyServerHost: "ระบุโดเมนของเซิร์ฟเวอร์โดยตรง" +inputHostName: "โปรดป้อนโดเมน" general: "ทั่วไป" wallpaper: "ภาพพื้นหลัง" setWallpaper: "ตั้งค่าภาพพื้นหลัง" @@ -185,23 +192,23 @@ searchWith: "ค้นหา: {q}" youHaveNoLists: "คุณไม่มีรายชื่อใดๆ " followConfirm: "ต้องการติดตาม {name} ใช่ไหม?" proxyAccount: "บัญชีพร็อกซี่" -proxyAccountDescription: "บัญชีพร็อกซี่ คือ บัญชีที่จะทำหน้าที่เป็นผู้ติดตามระยะไกลสำหรับผู้ใช้งานที่อยู่ภายใต้ด้วยเงื่อนไขบางอย่าง ยกตัวอย่าง เช่น เมื่อมีผู้ใช้งานนั้นได้เพิ่มผู้ใช้งานจากระยะไกลลงในรายการ แต่กิจกรรมของผู้ใช้ในระยะไกลนั้นจะไม่ถูกส่งไปยังอินสแตนซ์หากไม่มีผู้ใช้งานในพื้นที่ติดตามผู้ใช้รายนั้น ดังนั้นบัญชีพร็อกซีนี้จะติดตามแทน" +proxyAccountDescription: "บัญชีพร็อกซี คือ บัญชีที่ทำหน้าที่ติดตาม(ผู้ใช้)ระยะไกลภายใต้เงื่อนไขบางประการ ตัวอย่างเช่น เมื่อผู้ใช้ท้องถิ่นเพิ่มผู้ใช้ระยะไกลลงรายชื่อ หากไม่มีใครติดตามผู้ใช้ระยะไกลในรายชื่อนั้น กิจกรรมก็จะไม่ถูกส่งมายังเซิร์ฟเวอร์ ดังนั้นจึงมีบัญชีพร็อกซีไว้ติดตามผู้ใช้ระยะไกลเหล่านั้น" host: "โฮสต์" selectUser: "เลือกผู้ใช้งาน" recipient: "ผู้รับ" annotation: "หมายเหตุประกอบ" federation: "สหพันธ์" -instances: "อินสแตนซ์" +instances: "เซิร์ฟเวอร์" registeredAt: "วันที่ลงทะเบียน" latestRequestReceivedAt: "คำขอล่าสุดที่ได้รับ" latestStatus: "สถานะล่าสุด" storageUsage: "พื้นที่จัดเก็บข้อมูลที่ใช้ไป" -charts: "โดดเด่น" -perHour: "ทุกชั่วโมง" +charts: "แผนภูมิ" +perHour: "ต่อชั่วโมง" perDay: "ต่อวัน" stopActivityDelivery: "หยุดส่งกิจกรรม" -blockThisInstance: "บล็อกอินสแตนซ์นี้" -silenceThisInstance: "ปิดปากอินสแตนซ์นี้" +blockThisInstance: "บล็อกเซิร์ฟเวอร์นี้" +silenceThisInstance: "ปิดปากเซิร์ฟเวอร์นี้" operations: "ดำเนินการ" software: "ซอฟต์แวร์" version: "เวอร์ชั่น" @@ -212,17 +219,17 @@ jobQueue: "คิวงาน" cpuAndMemory: "ซีพียู และ หน่วยความจำ" network: "เครือข่าย" disk: "ดิสก์" -instanceInfo: "ข้อมูลอินสแตนซ์" +instanceInfo: "ข้อมูลเซิร์ฟเวอร์" statistics: "สถิติการใช้งาน" clearQueue: "ล้างคิว" clearQueueConfirmTitle: "ต้องการล้างคิวใช่ไหม?" clearQueueConfirmText: "โพสต์ที่ยังค้างในคิวจะไม่ถูกจัดส่งอีกต่อไป โดยปกติแล้วการดำเนินการนี้ไม่จำเป็น" clearCachedFiles: "ล้างแคช" clearCachedFilesConfirm: "ต้องการลบไฟล์ระยะไกลที่แคชไว้ทั้งหมดใช่ไหม?" -blockedInstances: "อินสแตนซ์ที่ถูกบล็อก" -blockedInstancesDescription: "ระบุชื่อโฮสต์ของอินสแตนซ์ที่คุณต้องการบล็อก อินสแตนซ์ที่อยู่ในรายการนั้นจะไม่สามารถพูดคุยกับอินสแตนซ์นี้ได้อีกต่อไป" -silencedInstances: "ปิดปากอินสแตนซ์นี้แล้ว" -silencedInstancesDescription: "ตั้งค่ารายชื่อโฮสต์ของอินสแตนซ์ที่คุณต้องการปิดปาก บัญชีทั้งหมดของอินสแตนซ์ที่อยู่ในรายชื่อนั้นๆ จะถือว่าถูกปิดปากเช่นกัน ทำได้เฉพาะคำขอติดตามเท่านั้น และไม่สามารถกล่าวถึงบัญชีในพื้นที่ได้หากไม่ได้ติดตาม | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก" +blockedInstances: "เซิร์ฟเวอร์ที่ถูกบล็อก" +blockedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการบล็อก คั่นด้วยการขึ้นบรรทัดใหม่ เซิร์ฟเวอร์ที่ถูกบล็อกจะไม่สามารถติดต่อกับอินสแตนซ์นี้ได้" +silencedInstances: "ปิดปากเซิร์ฟเวอร์นี้แล้ว" +silencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปาก คั่นด้วยการขึ้นบรรทัดใหม่, บัญชีทั้งหมดของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปากเช่นกัน ทำได้เฉพาะคำขอติดตามเท่านั้น และไม่สามารถกล่าวถึงบัญชีในเซิร์ฟเวอร์นี้ได้หากไม่ได้ถูกติดตามกลับ | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก" muteAndBlock: "ปิดเสียงและบล็อก" mutedUsers: "ผู้ใช้ที่ถูกปิดเสียง" blockedUsers: "ผู้ใช้ที่ถูกบล็อก" @@ -240,14 +247,14 @@ noCustomEmojis: "ไม่มีเอโมจิ" noJobs: "ไม่มีงาน" federating: "สหพันธ์" blocked: "ถูกบล็อก" -suspended: "ถูกระงับ" +suspended: "ระงับการส่ง" all: "ทั้งหมด" -subscribing: "สมัครแล้ว" +subscribing: "กำลังสมัครสมาชิก" publishing: "กำลังเผยแพร่" notResponding: "ไม่มีการตอบสนอง" -instanceFollowing: "กำลังติดตามบนอินสแตนซ์" -instanceFollowers: "ผู้ติดตามของอินสแตนซ์" -instanceUsers: "ผู้ใช้งานของอินสแตนซ์นี้" +instanceFollowing: "กำลังติดตามบนเซิร์ฟเวอร์" +instanceFollowers: "ผู้ติดตามของเซิร์ฟเวอร์" +instanceUsers: "ผู้ใช้ของเซิร์ฟเวอร์นี้" changePassword: "เปลี่ยนรหัสผ่าน" security: "ความปลอดภัย" retypedNotMatch: "ทั้งสองป้อนข้อมูลไม่สอดคล้องกัน" @@ -284,20 +291,20 @@ messageRead: "อ่านแล้ว" noMoreHistory: "ไม่มีประวัติเพิ่มเติม" startMessaging: "เริ่มการสนทนา" nUsersRead: "อ่านโดย {n}" -agreeTo: "ฉันยอมรับที่จะ {0}" +agreeTo: "ฉันยอมรับ {0}" agree: "ยอมรับ" -agreeBelow: "ฉันยอมรับถึงด้านล่าง" +agreeBelow: "ยอมรับตามที่ระบุด้านล่าง" basicNotesBeforeCreateAccount: "หมายเหตุสำคัญ" termsOfService: "เงื่อนไขการให้บริการ" start: "เริ่ม" -home: "หน้าแรก" -remoteUserCaution: "ข้อมูลอาจไม่สมบูรณ์เนื่องจากผู้ใช้รายนี้มาจากอินสแตนซ์ระยะไกล" +home: "หน้าหลัก" +remoteUserCaution: "ข้อมูลอาจไม่สมบูรณ์เนื่องจากผู้ใช้รายนี้มาจากเซิร์ฟเวอร์ระยะไกล" activity: "กิจกรรม" images: "รูปภาพ" image: "รูปภาพ" birthday: "วันเกิด" yearsOld: "{age} ปี" -registeredDate: "วันที่สมัครสมาชิก" +registeredDate: "วันที่ลงทะเบียน" location: "ตำแหน่งที่ตั้ง" theme: "ธีม" themeForLightMode: "ธีมที่จะใช้ในโหมดสว่าง" @@ -313,6 +320,7 @@ selectFile: "เลือกไฟล์" selectFiles: "เลือกไฟล์" selectFolder: "เลือกโฟลเดอร์" selectFolders: "เลือกโฟลเดอร์" +fileNotSelected: "ยังไม่ได้เลือกไฟล์" renameFile: "เปลี่ยนชื่อไฟล์" folderName: "ชื่อโฟลเดอร์" createFolder: "สร้างโฟลเดอร์" @@ -336,15 +344,15 @@ displayOfSensitiveMedia: "แสดงสื่อที่มีเนื้อ whenServerDisconnected: "เมื่อสูญเสียการเชื่อมต่อกับเซิร์ฟเวอร์" disconnectedFromServer: "การเชื่อมต่อเซิร์ฟเวอร์ถูกตัด" reload: "รีโหลด" -doNothing: "เมิน" +doNothing: "ช่างมัน" reloadConfirm: "รีโหลดเลยไหม?" -watch: "ดู" -unwatch: "หยุดดู" +watch: "เพ่งเล็ง" +unwatch: "เลิกเพ่งเล็ง" accept: "ยอมรับ" reject: "ปฏิเสธ" normal: "ปกติ" -instanceName: "ชื่ออินสแตนซ์" -instanceDescription: "คำอธิบายอินสแตนซ์" +instanceName: "ชื่อเซิร์ฟเวอร์" +instanceDescription: "คำอธิบายแนะนำเซิร์ฟเวอร์" maintainerName: "ผู้ดูแล" maintainerEmail: "อีเมลผู้ดูแลระบบ" tosUrl: "URL เงื่อนไขการให้บริการ" @@ -355,16 +363,16 @@ dayX: "{day}" monthX: "เดือน {month}" yearX: "{year}" pages: "หน้าเพจ" -integration: "รวบรวม" +integration: "เชื่อมโยง" connectService: "เชื่อมต่อ" disconnectService: "ตัดการเชื่อมต่อ" -enableLocalTimeline: "เปิดใช้งานไทม์ไลน์ในพื้นที่" +enableLocalTimeline: "เปิดใช้งานไทม์ไลน์ท้องถิ่น" enableGlobalTimeline: "เปิดใช้งานไทม์ไลน์ทั่วโลก" disablingTimelinesInfo: "ผู้ดูแลระบบและผู้ควบคุมจะสามารถเข้าถึงไทม์ไลน์ทั้งหมด ถึงแม้ว่าจะไม่ได้เปิดใช้งานก็ตาม" registration: "ลงทะเบียน" enableRegistration: "เปิดใช้งานการลงทะเบียนผู้ใช้ใหม่" invite: "คำเชิญ" -driveCapacityPerLocalAccount: "ความจุของไดรฟ์ต่อผู้ใช้ภายในเครื่อง" +driveCapacityPerLocalAccount: "ความจุของไดรฟ์ต่อผู้ใช้ท้องถิ่น" driveCapacityPerRemoteAccount: "ความจุของไดรฟ์ต่อผู้ใช้ระยะไกล" inMb: "เป็นเมกะไบต์" bannerUrl: "URL รูปภาพแบนเนอร์" @@ -373,7 +381,7 @@ basicInfo: "ข้อมูลเบื้องต้น" pinnedUsers: "ผู้ใช้ที่ถูกปักหมุด" pinnedUsersDescription: "ป้อนชื่อผู้ใช้ที่คุณต้องการปักหมุดในหน้า “ค้นพบ” ฯลฯ คั่นด้วยการขึ้นบรรทัดใหม่" pinnedPages: "หน้าเพจที่ปักหมุด" -pinnedPagesDescription: "ป้อนเส้นทางของหน้าเพจที่คุณต้องการปักหมุดไว้ที่หน้าแรกของอินสแตนซ์นี้ คั่นด้วยขึ้นบรรทัดใหม่" +pinnedPagesDescription: "ป้อนเส้นทางของหน้าเพจที่คุณต้องการปักหมุดไว้ที่หน้าแรกของเซิร์ฟเวอร์นี้ คั่นด้วยการขึ้นบรรทัดใหม่" pinnedClipId: "ID ของคลิปที่จะปักหมุด" pinnedNotes: "โน้ตที่ปักหมุดไว้" hcaptcha: "hCaptcha" @@ -389,11 +397,11 @@ recaptcha: "reCAPTCHA" enableRecaptcha: "เปิดใช้ reCAPTCHA" recaptchaSiteKey: "คีย์ไซต์" recaptchaSecretKey: "คีย์ลับ" -turnstile: "เทิร์น'สไทล" -enableTurnstile: "เปิดใช้งาน เทิร์น'สไทล" +turnstile: "Turnstile" +enableTurnstile: "เปิดใช้งาน Turnstile" turnstileSiteKey: "คีย์ไซต์" turnstileSecretKey: "คีย์ลับ" -avoidMultiCaptchaConfirm: "การใช้ระบบ Captcha หลายระบบอาจทำให้เกิดการรบกวนหรืออาจจะเกิดข้อผิดพลาดได้ หากต้องการที่จะปิดการใช้งานระบบ Captcha อื่น ๆ แนะนำให้ปิดตัวอื่นๆก่อน ถ้าหากคุณต้องการให้เปิดใช้งานต่อไป ให้ กด ยกเลิก" +avoidMultiCaptchaConfirm: "การใช้ Captcha หลายตัวอาจทำให้เกิดการรบกวนหรือข้อผิดพลาดได้ ต้องการที่จะปิดการใช้งาน Captcha ตัวอื่นเลยไหม? หากต้องการให้เปิดใช้งานต่อไป ให้กดยกเลิก" antennas: "เสาอากาศ" manageAntennas: "จัดการเสาอากาศ" name: "ชื่อ" @@ -401,7 +409,7 @@ antennaSource: "แหล่งเสาอากาศ" antennaKeywords: "คีย์เวิร์ดที่ควรฟัง" antennaExcludeKeywords: "คีย์เวิร์ดที่จะยกเว้น" antennaExcludeBots: "ยกเว้นบัญชีบอต" -antennaKeywordsDescription: "คั่นด้วยช่องว่างสำหรับเงื่อนไข AND หรือด้วยการขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR" +antennaKeywordsDescription: "คั่นด้วยเว้นวรรคสำหรับเงื่อนไข AND, หรือขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR" notifyAntenna: "แจ้งเตือนเกี่ยวกับโน้ตใหม่" withFileAntenna: "เฉพาะโน้ตที่มีไฟล์" enableServiceworker: "เปิดใช้งานการแจ้งเตือนแบบพุชไปยังเบราว์เซอร์ของคุณ" @@ -418,7 +426,7 @@ unsilenceConfirm: "ต้องการเลิกปิดปากผู้ popularUsers: "ผู้ใช้ที่เป็นที่นิยม" recentlyUpdatedUsers: "ผู้ใช้ที่เพิ่งใช้งานล่าสุด" recentlyRegisteredUsers: "ผู้ใช้ที่เข้าร่วมใหม่" -recentlyDiscoveredUsers: "ผู้ใช้ที่เพิ่งค้นพบใหม่" +recentlyDiscoveredUsers: "ผู้ใช้ที่เพิ่งค้นพบล่าสุด" exploreUsersCount: "มีผู้ใช้ {count} ราย" exploreFediverse: "สำรวจสหพันธ์" popularTags: "แท็กยอดนิยม" @@ -435,7 +443,7 @@ moderator: "ผู้ควบคุม" moderation: "การกลั่นกรอง" moderationNote: "โน้ตการกลั่นกรอง" addModerationNote: "เพิ่มโน้ตการกลั่นกรอง" -moderationLogs: "ปูมการแก้ไข" +moderationLogs: "ปูมการควบคุมดูแล" nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} ราย" securityKeyAndPasskey: "ความปลอดภัยและรหัสผ่าน" securityKey: "กุญแจความปลอดภัย" @@ -468,44 +476,46 @@ retype: "พิมพ์รหัสอีกครั้ง" noteOf: "โน้ตของ {user}" quoteAttached: "อ้างอิง" quoteQuestion: "ต้องการที่จะแนบมันเพื่ออ้างอิงใช่ไหม?" +attachAsFileQuestion: "ข้อความในคลิปบอร์ดยาวเกินไป คุณต้องการแนบเป็นไฟล์ข้อความหรือไม่?" noMessagesYet: "ยังไม่มีข้อความ" newMessageExists: "คุณมีข้อความใหม่" onlyOneFileCanBeAttached: "สามารถแนบไฟล์ได้เพียงไฟล์เดียวต่อ 1 ข้อความ" -signinRequired: "กรุณาลงทะเบียนหรือลงชื่อเข้าใช้ก่อนดำเนินการต่อ" +signinRequired: "ก่อนดำเนินการต่อ กรุณาลงทะเบียนหรือเข้าสู่ระบบ" +signinOrContinueOnRemote: "เพื่อดำเนินการต่อได้ คุณต้องไปที่เซิร์ฟเวอร์ที่คุณใช้งานอยู่ หรือลงทะเบียน/เข้าสู่ระบบเซิร์ฟเวอร์นี้" invitations: "คำเชิญ" invitationCode: "รหัสเชิญ" checking: "Checking" available: "พร้อมใช้งาน" unavailable: "ไม่พร้อมใช้" -usernameInvalidFormat: "คุณสามารถใช้อักษรตัวพิมพ์ใหญ่และตัวพิมพ์เล็ก ตัวเลข และขีดล่างได้นะ ( a-z , A-Z , 0-9 , รวมไปถึงอักษรพิเศษเช่น + * / , . - อื่นๆเป็นต้น )" +usernameInvalidFormat: "สามารถใช้ a~z A~Z 0~9 และ _ ได้" tooShort: "สั้นเกินไปนะ" tooLong: "ยาวเกินไปนะ" -weakPassword: "รหัสผ่าน แย่มาก" +weakPassword: "รหัสผ่านแย่มาก" normalPassword: "รหัสผ่านปกติ" strongPassword: "รหัสผ่านรัดกุมมาก" passwordMatched: "ถูกต้อง!" passwordNotMatched: "ไม่ถูกต้อง" -signinWith: "ลงชื่อเข้าใช้ด้วย {x}" -signinFailed: "ไม่สามารถลงชื่อผู้เข้าใช้ได้ เนื่องจาก ชื่อผู้ใช้หรือรหัสผ่านที่คุณป้อนนั้นไม่ถูกต้องนะ" +signinWith: "เข้าสู่ระบบด้วย {x}" +signinFailed: "ไม่สามารถเข้าสู่ระบบได้ กรุณาตรวจสอบชื่อผู้ใช้และรหัสผ่าน" or: "หรือ" language: "ภาษา" uiLanguage: "ภาษาอินเทอร์เฟซผู้ใช้งาน" aboutX: "เกี่ยวกับ {x}" -emojiStyle: "สไตล์เอโมจิ" +emojiStyle: "สไตล์ของเอโมจิ" native: "ภาษาแม่" -disableDrawer: "อย่าใช้ลิ้นชักสไตล์เมนู" -showNoteActionsOnlyHover: "แสดงการดำเนินการเฉพาะโน้ตเมื่อโฮเวอร์" +disableDrawer: "ไม่แสดงเมนูในรูปแบบลิ้นชัก" +showNoteActionsOnlyHover: "แสดงการดำเนินการโน้ตเมื่อโฮเวอร์(วางเมาส์เหนือ)เท่านั้น" showReactionsCount: "แสดงจำนวนรีแอกชั่นในโน้ต" noHistory: "ไม่มีประวัติ" signinHistory: "ประวัติการเข้าสู่ระบบ" enableAdvancedMfm: "เปิดใช้งาน MFM ขั้นสูง" -enableAnimatedMfm: "เปิดการใช้งาน MFM ด้วยแอนิเมชั่น" +enableAnimatedMfm: "เปิดการใช้งาน MFM แบบเคลื่อนไหว" doing: "กำลังประมวลผล......" category: "หมวดหมู่" tags: "นามแฝง" docSource: "ที่มาของเอกสารนี้" createAccount: "สร้างบัญชี" -existingAccount: "บัญชีที่มีอยู่" +existingAccount: "บัญชีที่มีอยู่แล้ว" regenerate: "สร้างอีกครั้ง" fontSize: "ขนาดตัวอักษร" mediaListWithOneImageAppearance: "ความสูงของรายการสื่อที่มีเพียงรูปเดียว" @@ -513,11 +523,11 @@ limitTo: "จำกัดไว้ที่ {x}" noFollowRequests: "คุณไม่มีคำขอติดตามที่รอดำเนินการ" openImageInNewTab: "เปิดรูปภาพในแท็บใหม่" dashboard: "หน้ากระดานหลัก" -local: "ในพื้นที่" +local: "ท้องถิ่น" remote: "ระยะไกล" total: "รวมทั้งหมด" -weekOverWeekChanges: "เปลี่ยนแปลงไปเมื่อสัปดาห์ที่แล้ว" -dayOverDayChanges: "เปลี่ยนแปลงไปเมื่อวานนี้" +weekOverWeekChanges: "เทียบกับสัปดาห์ก่อน" +dayOverDayChanges: "เทียบกับเมื่อวาน" appearance: "ภาพลักษณ์" clientSettings: "การตั้งค่าไคลเอนต์" accountSettings: "ตั้งค่าบัญชี" @@ -531,19 +541,19 @@ useObjectStorage: "ใช้การจัดเก็บในรูปแบ objectStorageBaseUrl: "Base URL" objectStorageBaseUrlDesc: "URL ที่ใช้เป็นข้อมูลอ้างอิง ระบุ URL ของ CDN หรือ Proxy ถ้าหากคุณใช้อย่างใดอย่างหนึ่ง\n สำหรับการใช้งาน S3 'https://.s3.amazonaws.com' และสำหรับ GCS หรือบริการที่เทียบเท่าใช้ 'https://storage.googleapis.com/', เป็นต้น" objectStorageBucket: "Bucket" -objectStorageBucketDesc: "โปรดระบุชื่อที่เก็บข้อมูลที่ใช้กับผู้ให้บริการของคุณ" +objectStorageBucketDesc: "โปรดระบุชื่อบัคเก็ตของบริการที่ใช้อยู่" objectStoragePrefix: "คำนำหน้า" objectStoragePrefixDesc: "ไฟล์ทั้งหมดจะถูกเก็บไว้ภายใต้ไดเร็กทอรีที่มีคำนำหน้านี้" objectStorageEndpoint: "ปลายทาง" objectStorageEndpointDesc: "เว้นว่างไว้หากคุณใช้ AWS S3 หรือระบุปลายทางเป็น '' หรือ ':' ทั้งนี้ขึ้นอยู่กับผู้ให้บริการที่คุณใช้อยู่ด้วย" objectStorageRegion: "ภูมิภาค" -objectStorageRegionDesc: "ระบุภูมิภาค เช่น 'xx-east-1' ถ้าหากบริการของคุณไม่ได้แยกความแตกต่างระหว่างภูมิภาคก็ให้ เว้นว่างไว้หรือป้อน 'us-east-1'" +objectStorageRegionDesc: "ระบุภูมิภาค เช่น ‘xx-east-1’ หากบริการของคุณไม่แยกภูมิภาค ให้ระบุเป็น ‘us-east-1’ หรือเว้นวางไว้หากใช้ AWS configuration files / environment variables" objectStorageUseSSL: "ใช้ SSL" objectStorageUseSSLDesc: "ปิดการทำงานนี้ไว้ ถ้าหากคุณจะไม่ใช้ HTTPS สำหรับการเชื่อมต่อ API" objectStorageUseProxy: "เชื่อมต่อผ่านพร็อกซี" objectStorageUseProxyDesc: "ปิดสิ่งนี้ไว้ถ้าหากคุณจะไม่ใช้ Proxy สำหรับการเชื่อมต่อ API" objectStorageSetPublicRead: "ตั้งค่าเป็น “public-read” เมื่ออัปโหลด" -s3ForcePathStyleDesc: "ถ้าหากเปิดใช้งาน s3ForcePathStyle ชื่อบัคเก็ตนั้นอาจจะต้องรวมอยู่ในเส้นทางของ URL ซึ่งตรงข้ามกับชื่อโฮสต์ของ URL คุณอาจจะต้องเปิดใช้งานการตั้งค่านี้เมื่อใช้บริการต่างๆ เช่น อินสแตนซ์ Minio ที่โฮสต์เองนะ" +s3ForcePathStyleDesc: "เมื่อเปิดใช้งาน s3ForcePathStyle จะบังคับให้ ระบุชื่อบัคเก็ตเป็นส่วนหนึ่งของพาธ แทนที่จะเป็นชื่อโฮสต์ใน URL, อาจจำเป็นต้องเปิดใช้งานตัวเลือกนี้เมื่อใช้กับ Minio ที่โฮสต์เองหรือบริการที่คล้ายกัน" serverLogs: "ปูมของเซิร์ฟเวอร์" deleteAll: "ลบทั้งหมด" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" @@ -575,7 +585,7 @@ sort: "เรียงลำดับ" ascendingOrder: "เรียงลำดับขึ้น" descendingOrder: "เรียงลำดับลง" scratchpad: "Scratchpad" -scratchpadDescription: "Scratchpad เป็นการจัดเตรียมสภาพแวดล้อมสำหรับการทดลอง AiScript แต่คุณสามารถเขียน ดำเนินการ และตรวจสอบผลลัพธ์ของการโต้ตอบกับ Misskey มันได้ด้วยนะ" +scratchpadDescription: "Scratchpad ให้สภาพแวดล้อมสำหรับการทดลอง AiScript คุณสามารถเขียนโค้ด/สั่งดำเนินการ/ตรวจสอบผลลัพธ์ ของการโต้ตอบกับ Misskey ได้" output: "เอาท์พุต" script: "สคริปต์" disablePagesScript: "ปิดการใช้งาน AiScript บนเพจ" @@ -587,15 +597,15 @@ unsetUserBannerConfirm: "ต้องการเลิกตั้งแบน deleteAllFiles: "ลบไฟล์ทั้งหมด" deleteAllFilesConfirm: "ต้องการลบไฟล์ทั้งหมดใช่ไหม?" removeAllFollowing: "เลิกติดตามผู้ใช้ที่ติดตามทั้งหมด" -removeAllFollowingDescription: "เลิกติดตามทั้งหมดจาก {host} โปรดเรียกใช้สิ่งนี้เมื่ออินสแตนซ์ดังกล่าวได้สูญหายตายจากไปแล้ว" +removeAllFollowingDescription: "จะเลิกติดตามทั้งหมดจาก {host} โปรดดำเนินการสิ่งนี้เมื่อเซิร์ฟเวอร์ดังกล่าวได้สูญหายตายจากไปแล้ว" userSuspended: "ผู้ใช้รายนี้ถูกระงับการใช้งาน" userSilenced: "ผู้ใช้รายนี้ถูกปิดปากอยู่" yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" tokenRevoked: "โทเค็นไม่ถูกต้อง" -tokenRevokedDescription: "โทเค็นนี้หมดอายุแล้วนะค่ะกรุณาเข้าสู่ระบบอีกครั้งนะ" +tokenRevokedDescription: "โทเค็นการเข้าสู่ระบบหมดอายุ กรุณาเข้าสู่ระบบใหม่อีกครั้ง" accountDeleted: "ลบบัญชีแล้ว" -accountDeletedDescription: "บัญชีนี้ถูกลบไปแล้วนะ" +accountDeletedDescription: "บัญชีนี้ถูกลบแล้ว" menu: "เมนู" divider: "ตัวแบ่ง" addItem: "เพิ่มรายการ" @@ -615,14 +625,14 @@ enablePlayer: "เปิดเครื่องเล่นวิดีโอ" disablePlayer: "ปิดเครื่องเล่นวิดีโอ" expandTweet: "ขยายทวีต" themeEditor: "ตัวแก้ไขธีม" -description: "รายละเอียด" +description: "คำอธิบาย" describeFile: "เพิ่มแคปชั่น" enterFileDescription: "ใส่แคปชั่น" author: "ผู้เขียน" leaveConfirm: "มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก ต้องการละทิ้งมันใช่ไหม?" manage: "การจัดการ" plugins: "ปลั๊กอิน" -preferencesBackups: "ตั้งค่าการสำรองข้อมูล" +preferencesBackups: "สำรองการตั้งค่า" deck: "เด็ค" undeck: "ออกจากเด็ค" useBlurEffectForModal: "ใช้เอฟเฟกต์เบลอสำหรับโมดอล" @@ -632,21 +642,21 @@ height: "ความสูง" large: "ใหญ่" medium: "ปานกลาง" small: "เล็ก" -generateAccessToken: "สร้างการเข้าถึงโทเค็น" -permission: "การอนุญาต" +generateAccessToken: "สร้างโทเค็นการเข้าถึง" +permission: "สิทธิ์" adminPermission: "สิทธิ์ของผู้ดูแลระบบ" enableAll: "เปิดใช้งานทั้งหมด" disableAll: "ปิดการใช้งานทั้งหมด" tokenRequested: "ให้สิทธิ์การเข้าถึงบัญชี" -pluginTokenRequestedDescription: "ปลั๊กอินนี้จะสามารถใช้การอนุญาตที่ตั้งค่าไว้ที่นี่นะ" +pluginTokenRequestedDescription: "ปลั๊กอินนี้จะใช้สิทธิ์ตามที่ตั้งค่าไว้ที่นี่" notificationType: "ประเภทการแจ้งเตือน" edit: "แก้ไข" -emailServer: "อีเมลเซิร์ฟเวอร์" +emailServer: "เซิร์ฟเวอร์ของอีเมล" enableEmail: "เปิดใช้งานการกระจายอีเมล" -emailConfigInfo: "ใช้เพื่อยืนยันอีเมลของคุณระหว่างการสมัครหรือถ้าหากคุณลืมรหัสผ่าน" +emailConfigInfo: "ใช้สำหรับการยืนยันอีเมลหรือการรีเซ็ตรหัสผ่าน" email: "อีเมล" emailAddress: "ที่อยู่อีเมล" -smtpConfig: "กำหนดค่าเซิร์ฟเวอร์ SMTP" +smtpConfig: "ตั้งค่าเซิร์ฟเวอร์ SMTP" smtpHost: "โฮสต์" smtpPort: "พอร์ต" smtpUser: "ชื่อผู้ใช้" @@ -656,10 +666,10 @@ smtpSecure: "ใช้โดยนัย SSL/TLS สำหรับการเ smtpSecureInfo: "ปิดสิ่งนี้เมื่อใช้ STARTTLS" testEmail: "ทดสอบการส่งอีเมล" wordMute: "ปิดเสียงคำ" -hardWordMute: "ปิดเสียงคำยาก" -regexpError: "ข้อผิดพลาดของนิพจน์ทั่วไป" -regexpErrorDescription: "เกิดข้อผิดพลาดในนิพจน์ทั่วไปในบรรทัดที่ {line} ของการปิดเสียงคำ {tab} ของคุณ:" -instanceMute: "ปิดเสียง อินสแตนซ์" +hardWordMute: "ปิดเสียงคำแบบแข็งโป๊ก" +regexpError: "เกิดข้อผิดพลาดใน regular expression" +regexpErrorDescription: "เกิดข้อผิดพลาดใน regular expression บรรทัดที่ {line} ของการปิดเสียงคำ {tab} :" +instanceMute: "ปิดเสียงเซิร์ฟเวอร์" userSaysSomething: "{name} พูดอะไรบางอย่าง" makeActive: "เปิดใช้งาน" display: "แสดงผล" @@ -690,17 +700,17 @@ reportAbuseOf: "รายงาน {name}" fillAbuseReportDescription: "กรุณากรอกรายละเอียดเกี่ยวกับรายงานนี้ หากเป็นเรื่องเกี่ยวกับโน้ตโดยเฉพาะ ได้โปรดระบุ URL" abuseReported: "เราได้ส่งรายงานของคุณไปแล้ว ขอบคุณมากๆนะ" reporter: "ผู้รายงาน" -reporteeOrigin: "รายงานต้นทาง" +reporteeOrigin: "ปลายทางรายงาน" reporterOrigin: "แหล่งผู้รายงาน" -forwardReport: "ส่งต่อรายงานไปยังอินสแตนซ์ระยะไกล" -forwardReportIsAnonymous: "ข้อมูลของคุณจะไม่ปรากฏบนอินสแตนซ์ระยะไกลและปรากฏเป็นบัญชีระบบที่ไม่ระบุชื่อ" +forwardReport: "ส่งต่อรายงานไปยังเซิร์ฟเวอร์ระยะไกล" +forwardReportIsAnonymous: "ข้อมูลของคุณจะไม่ปรากฏบนเซิร์ฟเวอร์ระยะไกลและปรากฏเป็นบัญชีระบบที่ไม่ระบุชื่อ" send: "ส่ง" abuseMarkAsResolved: "ทำเครื่องหมายรายงานว่าแก้ไขแล้ว" openInNewTab: "เปิดในแท็บใหม่" openInSideView: "เปิดในมุมมองด้านข้าง" defaultNavigationBehaviour: "พฤติกรรมการนำทางที่เป็นค่าเริ่มต้น" editTheseSettingsMayBreakAccount: "การแก้ไขการตั้งค่าเหล่านี้อาจทำให้บัญชีของคุณเสียหายนะ" -instanceTicker: "ข้อมูลอินสแตนซ์ของโน้ต" +instanceTicker: "ข้อมูลเซิร์ฟเวอร์ของโน้ต" waitingFor: "กำลังรอ {x}" random: "สุ่มค่า" system: "ระบบ" @@ -739,7 +749,7 @@ alwaysMarkSensitive: "ทำเครื่องหมายว่ามีเ loadRawImages: "โหลดภาพต้นฉบับแทนการแสดงภาพขนาดย่อ" disableShowingAnimatedImages: "ไม่ต้องเล่นภาพเคลื่อนไหว" highlightSensitiveMedia: "ไฮไลท์สื่อที่มีเนื้อหาละเอียดอ่อน" -verificationEmailSent: "ส่งอีเมลยืนยันแล้วนะ ได้โปรดกรุณาไปที่ลิงก์ที่รวมไว้เพื่อทำการตรวจสอบให้เสร็จสิ้น" +verificationEmailSent: "ได้ส่งอีเมลยืนยันแล้ว กรุณาเข้าลิงก์ที่ระบุในอีเมลเพื่อทำการตั้งค่าให้เสร็จสิ้น" notSet: "ไม่ได้ตั้งค่า" emailVerified: "อีเมลได้รับการยืนยันแล้ว" noteFavoritesCount: "จำนวนโน้ตที่ชื่นชอบ" @@ -750,7 +760,7 @@ useSystemFont: "ใช้ฟอนต์เริ่มต้นของระ clips: "คลิป" experimentalFeatures: "ฟังก์ชั่นทดสอบ" experimental: "ทดลอง" -thisIsExperimentalFeature: "นี่คือฟีเจอร์ทดลองนะค่ะ ฟังก์ชันการทำงานบางอย่างอาจเปลี่ยนแปลงได้ และอาจไม่ทำงานหรือไม่เสถียรตามที่ตั้งใจไว้นะ" +thisIsExperimentalFeature: "นี่เป็นฟีเจอร์ทดลอง ซึ่งอาจมีการเปลี่ยนแปลงการทำงาน และอาจไม่ทำงานตามที่ตั้งใจไว้" developer: "สำหรับนักพัฒนา" makeExplorable: "ทำให้บัญชีมองเห็นใน “สำรวจ”" makeExplorableDescription: "ถ้าหากคุณปิดการทำงานนี้ บัญชีของคุณนั้นจะไม่แสดงในส่วน “สำรวจ”" @@ -761,14 +771,14 @@ center: "กึ่งกลาง" wide: "กว้าง" narrow: "ชิด" reloadToApplySetting: "การตั้งค่านี้จะมีผลหลังจากโหลดหน้าซ้ำเท่านั้น ต้องการที่จะโหลดใหม่เลยไหม?" -needReloadToApply: "จำเป็นต้องโหลดซ้ำถึงจะมีผลนะ" +needReloadToApply: "ต้องรีโหลดเพื่อให้การเปลี่ยนแปลงมีผล" showTitlebar: "แสดงแถบชื่อ" clearCache: "ล้างแคช" -onlineUsersCount: "{n} ผู้ใช้คนนี้กำลังออนไลน์" +onlineUsersCount: "{n} รายกำลังออนไลน์" nUsers: "{n} ผู้ใช้งาน" nNotes: "{n} โน้ต" -sendErrorReports: "ส่งรายงานว่าข้อผิดพลาด" -sendErrorReportsDescription: "เมื่อเปิดใช้งาน ข้อมูลข้อผิดพลาดโดยรายละเอียดนั้นจะถูกแชร์ให้กับ Misskey เมื่อเกิดปัญหา ซึ่งช่วยปรับปรุงคุณภาพของ Misskey\nซึ่งจะรวมถึงข้อมูล เช่น เวอร์ชั่นของระบบปฏิบัติการ เบราว์เซอร์ที่คุณใช้ กิจกรรมของคุณใน Misskey เป็นต้น" +sendErrorReports: "ส่งรายงานข้อผิดพลาด" +sendErrorReportsDescription: "เมื่อเปิดใช้งาน การแจ้งข้อผิดพลาดจะถูกแชร์กับ Misskey เมื่อเกิดปัญหา ซึ่งช่วยในการปรับปรุงคุณภาพของซอฟต์แวร์ ข้อมูลข้อผิดพลาดอาจรวมถึงเวอร์ชันของระบบปฏิบัติการ ประเภทของเบราว์เซอร์ และประวัติการใช้งาน ฯลฯ" myTheme: "ธีมของฉัน" backgroundColor: "สีพื้นหลัง" accentColor: "สีหลัก" @@ -780,7 +790,7 @@ value: "ค่า" createdAt: "สร้างเมื่อ" updatedAt: "อัปเดตล่าสุด" saveConfirm: "บันทึกเปลี่ยนแปลงมั้ย?" -deleteConfirm: "ลบจริงๆเหรอ?" +deleteConfirm: "ต้องการลบใช่ไหม?" invalidValue: "ค่านี้ไม่ถูกต้อง" registry: "ทะเบียน" closeAccount: "ปิด บัญชี" @@ -793,7 +803,7 @@ capacity: "ความจุ" inUse: "ใช้แล้ว" editCode: "แก้ไขโค้ด" apply: "นำไปใช้" -receiveAnnouncementFromInstance: "รับการแจ้งเตือนจากอินสแตนซ์นี้" +receiveAnnouncementFromInstance: "รับการแจ้งเตือนจากเซิร์ฟเวอร์นี้" emailNotification: "การแจ้งเตือนทางอีเมล" publish: "เผยแพร่" inChannelSearch: "ค้นหาในช่อง" @@ -821,7 +831,7 @@ active: "ใช้งานอยู่" offline: "ออฟไลน์" notRecommended: "ไม่แนะนำ" botProtection: "การป้องกัน Bot" -instanceBlocking: "อินสแตนซ์ที่ถูกบล็อก" +instanceBlocking: "เซิร์ฟเวอร์ที่ถูกบล็อก/ปิดปาก" selectAccount: "เลือกบัญชี" switchAccount: "สลับบัญชีผู้ใช้" enabled: "เปิดใช้งาน" @@ -831,9 +841,10 @@ user: "ผู้ใช้" administration: "การจัดการ" accounts: "บัญชีผู้ใช้" switch: "สลับ" -noMaintainerInformationWarning: "ข้อมูลผู้ดูแลไม่ได้รับการกำหนดค่านะ" -noBotProtectionWarning: "ไม่ได้กำหนดค่าการป้องกันบอทนะ" -configure: "กำหนดค่า" +noMaintainerInformationWarning: "ยังไม่ได้ตั้งค่าข้อมูลของผู้ดูแลระบบ" +noInquiryUrlWarning: "ยังไม่ได้ตั้งค่า URL สำหรับการติดต่อสอบถาม" +noBotProtectionWarning: "ยังไม่ได้ตั้งค่าการป้องกันบอต" +configure: "ตั้งค่า" postToGallery: "สร้างโพสต์แกลเลอรี่ใหม่" postToHashtag: "โพสต์ไปที่แฮชแท็กนี้" gallery: "แกลเลอรี่" @@ -909,8 +920,8 @@ themeColor: "สีธีม" size: "ขนาด" numberOfColumn: "จำนวนคอลัมน์" searchByGoogle: "ค้นหา" -instanceDefaultLightTheme: "ธีมสว่างตามค่าเริ่มต้นของอินสแตนซ์" -instanceDefaultDarkTheme: "ธีมมืดตามค่าเริ่มต้นของอินสแตนซ์" +instanceDefaultLightTheme: "ธีมสว่างตามค่าเริ่มต้นของเซิร์ฟเวอร์" +instanceDefaultDarkTheme: "ธีมมืดตามค่าเริ่มต้นของเซิร์ฟเวอร์" instanceDefaultThemeDescription: "ป้อนรหัสธีมในรูปแบบออบเจ็กต์" mutePeriod: "ระยะเวลาปิดเสียง" period: "ระยะเวลา" @@ -930,7 +941,7 @@ cropNo: "ใช้ตามที่เป็นอยู่" file: "ไฟล์" recentNHours: "ล่าสุด {n} ชั่วโมงที่แล้ว" recentNDays: "ล่าสุด {n} วันที่แล้ว" -noEmailServerWarning: "ไม่ได้กำหนดค่าเซิร์ฟเวอร์อีเมลนี้" +noEmailServerWarning: "ยังไม่ได้ตั้งค่าเซิร์ฟเวอร์ของอีเมล" thereIsUnresolvedAbuseReportWarning: "มีรายงานที่ยังไม่ได้แก้ไข" recommended: "แนะนำ" check: "ตรวจสอบ" @@ -965,7 +976,7 @@ cannotUploadBecauseExceedsFileSizeLimit: "ไม่สามารถอัป beta: "เบต้า" enableAutoSensitive: "ทำเครื่องหมายว่ามีเนื้อหาที่ละเอียดอ่อนโดยอัตโนมัติ" enableAutoSensitiveDescription: "อนุญาตให้ตรวจหาและทำเครื่องหมายสื่อว่ามีเนื้อหาโดยละเอียดอ่อนโดยอัตโนมัติ ผ่าน Machine Learning หากเป็นไปได้ แม้ว่าคุณจะปิดคุณสมบัตินี้ ก็อาจถูกตั้งค่าโดยอัตโนมัติ ทั้งนี้ขึ้นอยู่กับเซิร์ฟเวอร์" -activeEmailValidationDescription: "เปิดใช้งานการตรวจสอบที่อยู่อีเมลให้มีความเข้มงวดยิ่งขึ้น ซึ่งอาจจะรวมไปถึงการตรวจสอบที่อยู่อีเมล์ที่ใช้แล้วทิ้งและโดยให้พิจารณาว่าสามารถสื่อสารด้วยได้หรือไม่ เมื่อไม่เลือกระบบจะตรวจสอบเฉพาะรูปแบบของอีเมลเท่านั้น" +activeEmailValidationDescription: "การตรวจสอบอีเมลของผู้ใช้จะเข้มงวดมากขึ้น โดยพิจารณาว่าเป็นอีเมลชั่วคราวหรือไม่ และสามารถติดต่อได้จริงหรือไม่ หากปิดการตรวจสอบนี้ จะตรวจสอบเพียงว่ารูปแบบอีเมลที่ถูกต้องหรือไม่เท่านั้น" navbar: "แถบนำทาง" shuffle: "สลับ" account: "บัญชีผู้ใช้" @@ -974,7 +985,7 @@ pushNotification: "การแจ้งเตือนแบบพุช" subscribePushNotification: "เปิดการแจ้งเตือนแบบพุช" unsubscribePushNotification: "ปิดการแจ้งเตือนแบบพุช" pushNotificationAlreadySubscribed: "การแจ้งเตือนแบบพุชได้เปิดใช้งานแล้ว" -pushNotificationNotSupported: "เบราว์เซอร์หรืออินสแตนซ์ของคุณนั้นไม่รองรับการแจ้งเตือนแบบพุช" +pushNotificationNotSupported: "เบราว์เซอร์หรือเซิร์ฟเวอร์ไม่รองรับการแจ้งเตือนแบบพุช" sendPushNotificationReadMessage: "ลบการแจ้งเตือนแบบพุชเมื่ออ่านการแจ้งเตือนหรือข้อความที่เกี่ยวข้องแล้ว" sendPushNotificationReadMessageCaption: "อาจทำให้อุปกรณ์ของคุณใช้พลังงานมากขึ้น" windowMaximize: "ขยายใหญ่สุด" @@ -1017,36 +1028,37 @@ achievements: "ความสำเร็จ" gotInvalidResponseError: "การตอบสนองเซิร์ฟเวอร์ไม่ถูกต้อง" gotInvalidResponseErrorDescription: "เซิร์ฟเวอร์อาจไม่สามารถเข้าถึงได้หรืออาจจะกำลังอยู่ในระหว่างปรับปรุง กรุณาลองใหม่อีกครั้งในภายหลังนะคะ" thisPostMayBeAnnoying: "โน้ตนี้อาจจะเป็นการรบกวนผู้อื่นนะคะ" -thisPostMayBeAnnoyingHome: "โพสต์ไปยังไทม์ไลน์หน้าแรก" +thisPostMayBeAnnoyingHome: "โพสต์ไปยังไทม์ไลน์หลัก" thisPostMayBeAnnoyingCancel: "เลิก" thisPostMayBeAnnoyingIgnore: "โพสต์ยังไงก็แล้วแต่" collapseRenotes: "ยุบรีโน้ตที่คุณเคยเห็นแล้ว" +collapseRenotesDescription: "พับย่อโน้ตที่เคยตอบสนองหรือรีโน้ตแล้ว" internalServerError: "เซิร์ฟเวอร์ภายในเกิดข้อผิดพลาด" -internalServerErrorDescription: "เซิร์ฟเวอร์รันค้นพบข้อผิดพลาดที่ไม่คาดคิด" +internalServerErrorDescription: "เกิดข้อผิดพลาดที่ไม่คาดคิดภายในเซิร์ฟเวอร์" copyErrorInfo: "คัดลอกรายละเอียดข้อผิดพลาด" -joinThisServer: "ลงชื่อสมัครใช้ในอินสแตนซ์นี้" -exploreOtherServers: "มองหาอินสแตนซ์อื่น" +joinThisServer: "ลงทะเบียนบนเซิร์ฟเวอร์นี้" +exploreOtherServers: "มองหาเซิร์ฟเวอร์อื่น" letsLookAtTimeline: "มาดูไทม์ไลน์กัน" -disableFederationConfirm: "ปิดใช้งานสหพันธ์จริงๆหรอแน่ใจแล้วนะ?" +disableFederationConfirm: "ปิดใช้งานสหพันธ์เลยใช่ไหม?" disableFederationConfirmWarn: "โพสต์จะยังคงเป็นสาธารณะต่อไป เว้นแต่จะตั้งค่าเป็นอย่างอื่น" disableFederationOk: "ปิดการใช้งาน" -invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญ เฉพาะผู้ที่มีรหัสเชิญเท่านั้นที่สามารถลงทะเบียนได้" -emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมล" +invitationRequiredToRegister: "เซิร์ฟเวอร์นี้เป็นแบบรับเชิญ เฉพาะผู้มีรหัสเชิญเท่านั้นถึงสามารถลงทะเบียนได้" +emailNotSupported: "เซิร์ฟเวอร์นี้ไม่รองรับการส่งอีเมล" postToTheChannel: "โพสต์ลงช่อง" cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" reactionAcceptance: "การยอมรับรีแอคชั่น" likeOnly: "ที่ถูกใจเท่านั้น" -likeOnlyForRemote: "ทั้งหมด (เฉพาะการถูกใจจากอินสแตนซ์ระยะไกล)" +likeOnlyForRemote: "ทั้งหมด (เฉพาะการถูกใจจากเซิร์ฟเวอร์ระยะไกล)" nonSensitiveOnly: "เฉพาะไม่มีเนื้อหาละเอียดอ่อน" nonSensitiveOnlyForLocalLikeOnlyForRemote: "เฉพาะไม่มีเนื้อหาละเอียดอ่อน (เฉพาะการถูกใจจากระยะไกลเท่านั้น)" rolesAssignedToMe: "บทบาทที่ได้รับมอบหมายให้ฉัน" -resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?" +resetPasswordConfirm: "ต้องการรีเซ็ตรหัสผ่านใช่ไหม?" sensitiveWords: "คำที่มีเนื้อหาละเอียดอ่อน" -sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ" -sensitiveWordsDescription2: "การใช้ช่องว่างนั้นอาจจะสร้างนิพจน์ AND และคำหลักที่มีเครื่องหมายทับล้อมรอบจะเปลี่ยนเป็นนิพจน์ทั่วไปนะ" +sensitiveWordsDescription: "โน้ตที่มีคำที่ระบุไว้จะถูกตั้งค่าการมองเห็นของให้แสดงเฉพาะในหน้าหลักเท่านั้น คั่นคำด้วยการขึ้นบรรทัดใหม่" +sensitiveWordsDescription2: "ถ้าแยกด้วยเว้นวรรคจะเป็นการระบุ AND และถ้าล้อมคำด้วยสแลช (/) จะเป็นการใช้ regular expression" prohibitedWords: "คำต้องห้าม" prohibitedWordsDescription: "จะแจ้งเตือนว่าเกิดข้อผิดพลาดเมื่อพยายามโพสต์โน้ตที่มีคำที่กำหนดไว้ สามารถตั้งได้หลายคำด้วยการขึ้นบรรทัดใหม่" -prohibitedWordsDescription2: "การใช้ช่องว่างนั้นอาจจะสร้างนิพจน์ AND และคำหลักที่มีเครื่องหมายทับล้อมรอบจะเปลี่ยนเป็นนิพจน์ทั่วไปนะ" +prohibitedWordsDescription2: "ถ้าแยกด้วยเว้นวรรคจะเป็นการระบุ AND และถ้าล้อมคำด้วยสแลช (/) จะเป็นการใช้ regular expression" hiddenTags: "แฮชแท็กที่ซ่อนอยู่" hiddenTagsDescription: "เลือกแท็กที่จะไม่แสดงในรายการเทรนด์ สามารถลงทะเบียนหลายแท็กได้โดยขึ้นบรรทัดใหม่" notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งาน" @@ -1058,7 +1070,7 @@ retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหม retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?" retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล" -enableChartsForFederatedInstances: "สร้างแผนภูมิข้อมูลอินสแตนซ์ระยะไกล" +enableChartsForFederatedInstances: "สร้างแผนภูมิของเซิร์ฟเวอร์ระยะไกล" showClipButtonInNoteFooter: "เพิ่ม “คลิป” ไปยังเมนูสั่งการของโน้ต" reactionsDisplaySize: "ขนาดของรีแอคชั่น" limitWidthOfReaction: "จำกัดความกว้างสูงสุดของรีแอคชั่นและแสดงให้เล็กลง" @@ -1077,7 +1089,7 @@ addMemo: "เพิ่มเมโม" editMemo: "แก้ไขเมโม" reactionsList: "รายการรีแอคชั่น" renotesList: "รายการรีโน้ต" -notificationDisplay: "การแจ้งเตือน" +notificationDisplay: "การแสดงการแจ้งเตือน" leftTop: "บนซ้าย" rightTop: "บนขวา" leftBottom: "ล่างซ้าย" @@ -1087,7 +1099,7 @@ vertical: "แนวตั้ง" horizontal: "แนวนอน" position: "ตำแหน่ง" serverRules: "กฎของเซิร์ฟเวอร์" -pleaseConfirmBelowBeforeSignup: "โปรดยืนยันที่ด้านล่างก่อนสมัครใช้งาน" +pleaseConfirmBelowBeforeSignup: "หากต้องการลงทะเบียนบนเซิร์ฟเวอร์นี้ คุณต้องตรวจสอบและยอมรับสิ่งต่อไปนี้" pleaseAgreeAllToContinue: "คุณต้องยอมรับทุกช่องตรงด้านบนเพื่อดำเนินการต่อค่ะ" continue: "ดำเนินการต่อ" preservedUsernames: "ชื่อผู้ใช้ที่สงวนไว้" @@ -1157,7 +1169,7 @@ notifyNotes: "แจ้งเตือนเกี่ยวกับโพสต unnotifyNotes: "หยุดการแจ้งเตือนเกี่ยวกับโน้ตใหม่" authentication: "การตรวจสอบสิทธิ์" authenticationRequiredToContinue: "กรุณายืนยันตัวตนทางอิเล็กทรอนิกส์เพื่อดำเนินการต่อ" -dateAndTime: "เวลาประทับ" +dateAndTime: "วันเวลา" showRenotes: "แสดงรีโน้ต" edited: "แก้ไขแล้ว" notificationRecieveConfig: "การตั้งค่าการแจ้งเตือน" @@ -1168,11 +1180,11 @@ showRepliesToOthersInTimeline: "แสดงการตอบกลับผู hideRepliesToOthersInTimeline: "ไม่แสดงการตอบกลับผู้อื่นลงในไทม์ไลน์" showRepliesToOthersInTimelineAll: "รวมตอบกลับจากทุกคนที่คุณติดตามไว้ในไทม์ไลน์ของคุณ" hideRepliesToOthersInTimelineAll: "ซ่อนตอบกลับจากทุกคนที่คุณติดตามไปจากไทม์ไลน์ของคุณ" -confirmShowRepliesAll: "การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณต้องการแสดงการตอบกลับผู้อื่นจากผู้ใช้ทุกคนที่คุณติดตามอยู่ในไทม์ไลน์ของคุณหรือไม่?" -confirmHideRepliesAll: "การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณต้องการซ่อนการตอบกลับผู้อื่นจากผู้ใช้ทุกคนที่คุณติดตามอยู่ในไทม์ไลน์ของคุณหรือไม่?" +confirmShowRepliesAll: "การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณต้องการแสดงการตอบกลับผู้อื่นจากผู้ใช้ทุกคนที่คุณติดตามอยู่ ใส่ลงไทม์ไลน์ใช่ไหม?" +confirmHideRepliesAll: "การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณต้องการซ่อนการตอบกลับผู้อื่นจากผู้ใช้ทุกคนที่คุณติดตามอยู่ ไปจากไทม์ไลน์ใช่ไหม?" externalServices: "บริการภายนอก" sourceCode: "ซอร์สโค้ด" -sourceCodeIsNotYetProvided: "ซอร์สโค้ดยังไม่พร้อมใช้งาน โปรดติดต่อผู้ดูแลระบบของคุณเพื่อแก้ไขปัญหานี้" +sourceCodeIsNotYetProvided: "ซอร์สโค้ดยังไม่พร้อมใช้งาน โปรดติดต่อผู้ดูแลระบบเพื่อแก้ไขปัญหานี้" repositoryUrl: "URL ของ repository" repositoryUrlDescription: "หากมีที่เก็บซอร์สโค้ดที่เปิดเผยต่อสาธารณะ ให้ป้อน URL ที่เก็บซอร์สโค้ดนั้น แต่หากคุณใช้ Misskey ตามต้นฉบับ (ไม่มีการเปลี่ยนแปลงซอร์สโค้ด) ให้ป้อน https://github.com/misskey-dev/misskey" repositoryUrlOrTarballRequired: "หากคุณไม่มี repository สาธารณะ คุณจะต้องจัดเตรียม tarball แทน ดู .config/example.yml สำหรับรายละเอียด" @@ -1227,7 +1239,7 @@ surrender: "ยอมแพ้" gameRetry: "เริ่มเกมใหม่" notUsePleaseLeaveBlank: "หากไม่ได้ใช้กรุณาเว้นว่างไว้" useTotp: "ใช้รหัสผ่านแบบใช้ครั้งเดียว (TOTP)" -useBackupCode: "ใช้รหัสสำรอง" +useBackupCode: "ใช้รหัสแบ๊กอัป" launchApp: "เริ่มแอป" useNativeUIForVideoAudioPlayer: "ใช้ UI ของเบราว์เซอร์เพื่อเล่นวิดีโอ/เสียง" keepOriginalFilename: "คงชื่อไฟล์เดิมไว้" @@ -1235,13 +1247,21 @@ keepOriginalFilenameDescription: "หากปิดการตั้งค่ noDescription: "ไม่มีข้อความอธิบาย" alwaysConfirmFollow: "แสดงข้อความยืนยันเมื่อกดติดตาม" inquiry: "ติดต่อเรา" +tryAgain: "โปรดลองอีกครั้ง" +confirmWhenRevealingSensitiveMedia: "ตรวจสอบก่อนแสดงสื่อที่มีเนื้อหาละเอียดอ่อน" +sensitiveMediaRevealConfirm: "สื่อนี้มีเนื้อหาละเอียดอ่อน, ต้องการแสดงใช่ไหม?" _delivery: - stop: "ถูกระงับ" + status: "สถานะการจัดส่ง" + stop: "ระงับการส่ง" + resume: "จัดส่งต่อ" _type: none: "กำลังเผยแพร่" + manuallySuspended: "หยุดชั่วคราวด้วยตนเอง" + goneSuspended: "เซิร์ฟเวอร์ถูกระงับเนื่องจากมีการลบเซิร์ฟเวอร์นี้" + autoSuspendedForNotResponding: "เซิร์ฟเวอร์ถูกระงับเนื่องจากไม่ตอบสนอง" _bubbleGame: howToPlay: "วิธีเล่น" - hold: "หยุดชั่วคราว" + hold: "ถือไว้" _score: score: "คะแนน" scoreYen: "จำนวนเงินที่ได้รับ" @@ -1255,27 +1275,27 @@ _bubbleGame: section2: "เมื่อวัตถุประเภทเดียวกันมารวมกัน พวกมันจะกลายเป็นวัตถุใหม่และคุณจะได้รับคะแนน" section3: "หากวัตถุล้นออกมาจากกล่อง เกมก็จะจบลง ตั้งเป้าทำคะแนนให้สูงด้วยการหลอมวัตถุต่าง ๆ โดยไม่ทำให้ล้นกล่อง!" _announcement: - forExistingUsers: "ผู้ใช้งานที่มีอยู่เท่านั้น" - forExistingUsersDescription: "การประกาศนี้จะแสดงต่อผู้ใช้ที่มีอยู่ ณ จุดที่เผยแพร่นั้นๆถ้าหากเปิดใช้งาน ถ้าหากปิดใช้งานผู้ที่กำลังสมัครใหม่หลังจากโพสต์แล้วนั้นก็จะเห็นเช่นกัน" + forExistingUsers: "ผู้ใช้งานที่มีอยู่ตอนนี้เท่านั้น" + forExistingUsersDescription: "หากเปิดใช้งาน การประกาศนี้จะแสดงเฉพาะกับผู้ใช้ที่สร้างบัญชีก่อน/ที่มีอยู่ในขณะที่สร้างประกาศนี้เท่านั้น หากปิดใช้งาน การประกาศนี้จะแสดงกับผู้ใช้ที่สร้างบัญชีหลังจากสร้างประกาศนี้ด้วย" needConfirmationToRead: "จำเป็นต้องยืนยันว่าอ่านแล้ว" needConfirmationToReadDescription: "กล่องโต้ตอบการยืนยันจะปรากฏขึ้นเมื่อจะทำเครื่องหมายว่าอ่านแล้ว นอกจากนี้ยังทำให้ประกาศนี้ยังไม่ถูกอ่านเมื่อใช้ฟังก์ชั่น “ทำเครื่องหมายฯ ทั้งหมดว่าอ่านแล้ว”" end: "เก็บประกาศ" - tooManyActiveAnnouncementDescription: "การมีประกาศที่ใช้งานมากเกินไปนั้นอาจจะทำให้ประสบการณ์ของผู้ใช้งานนั้นดูแย่ลง โปรดกรุณาพิจารณาการเก็บประกาศที่ล้าสมัยด้วยนะค่ะ" + tooManyActiveAnnouncementDescription: "เนื่องจากมีการประกาศที่ยังใช้งานอยู่จำนวนมาก อาจทำให้ UX ลดลง แนะนำให้พิจารณาการเก็บประกาศที่สิ้นสุดไปแล้ว" readConfirmTitle: "ทำเครื่องหมายว่าอ่านแล้วเลยไหม?" readConfirmText: "จะทำเครื่องหมายใส่ “{title}” ว่าอ่านแล้ว" - shouldNotBeUsedToPresentPermanentInfo: "เราขอแนะนำให้ใช้ประกาศเพื่อโพสต์ข้อมูลแบบ flow มากกว่าข้อมูลแบบ stock เนื่องจากมีแนวโน้มที่จะส่งผลเสียต่อ UX โดยเฉพาะสำหรับผู้ใช้ใหม่" + shouldNotBeUsedToPresentPermanentInfo: "เนื่องจากมีความเป็นไปได้สูงที่จะส่งผลเสียต่อง UX ของผู้ใช้ใหม่ จึงขอแนะนำให้ใช้ประกาศสำหรับข้อมูลที่ต้องการการตอบสนองในทันที ไม่ใช่ข้อมูลที่ต้องการแสดงตลอดเวลา" dialogAnnouncementUxWarn: "เราขอแนะนำให้ใช้ด้วยความระมัดระวัง เนื่องจากการแจ้งเตือนแบบกล่องโต้ตอบตั้งแต่ 2 รายการขึ้นไปพร้อมกันอาจส่งผลเสียต่อ UX ได้อย่างมาก" silence: "ไม่มีการแจ้งเตือน" silenceDescription: "หากเปิดใช้งาน จะไม่มีการแจ้งเตือนประกาศนี้ และผู้ใช้จะไม่จำเป็นต้องทำเครื่องหมายว่าอ่านแล้ว" _initialAccountSetting: - accountCreated: "คุณได้สร้างบัญชีของคุณสำเร็จเรียบร้อยแล้ว!" + accountCreated: "สร้างบัญชีเสร็จสมบูรณ์!" letsStartAccountSetup: "สำหรับผู้เริ่มต้นมาตั้งค่าโปรไฟล์ของคุณกันเถอะ" letsFillYourProfile: "ก่อนอื่นมาตั้งค่าโปรไฟล์ของคุณ" profileSetting: "ตั้งค่าโปรไฟล์" privacySetting: "ตั้งค่าความเป็นส่วนตัว" theseSettingsCanEditLater: "คุณสามารถเปลี่ยนการตั้งค่าเหล่านี้ได้ในภายหลังได้ตลอดเวลานะ" - youCanEditMoreSettingsInSettingsPageLater: "ยังมีการตั้งค่าอื่นๆ อีกมากมายที่คุณนั้นสามารถกำหนดค่าได้จาก \"การตั้งค่า\" เพื่อให้แน่ใจว่าได้เยี่ยมชมมันได้ภายหลังนะ" - followUsers: "ลองติดตามผู้ใช้บางคนที่คุณอาจจะสนใจเพื่อสร้างไทม์ไลน์ของคุณสิ !" + youCanEditMoreSettingsInSettingsPageLater: "สามารถตั้งค่าเพิ่มเติมได้ที่หน้า “การตั้งค่า” อย่าลืมไปเยี่ยมชมภายหลังด้วย" + followUsers: "ลองติดตามผู้ใช้ที่สนใจเพื่อสร้างไทม์ไลน์ดูสิ" pushNotificationDescription: "กำลังเปิดใช้งานการแจ้งเตือนแบบพุชจะช่วยให้คุณได้รับการแจ้งเตือนจาก {name} โดยตรงบนอุปกรณ์ของคุณนะ" initialAccountSettingCompleted: "ตั้งค่าโปรไฟล์เสร็จสมบูรณ์แล้ว!" haveFun: "ขอให้สนุกกับ {name}!" @@ -1310,7 +1330,7 @@ _initialTutorial: description1: "Misskey มีหลายไทม์ไลน์ขึ้นอยู่กับวิธีการใช้งานของคุณ (บางไทม์ไลน์อาจไม่สามารถใช้ได้ขึ้นอยู่กับนโยบายของเซิร์ฟเวอร์)" home: "คุณสามารถดูโพสต์จากบัญชีที่คุณติดตามได้" local: "คุณสามารถดูโพสต์จากผู้ใช้ทั้งหมดบนเซิร์ฟเวอร์นี้" - social: "โพสต์จากทั้งไทม์ไลน์หน้าแรกและไทม์ไลน์ในพื้นที่ของคุณจะปรากฏขึ้น" + social: "จะแสดงโพสต์ทั้งจากไทม์ไลน์หลักและไทม์ไลน์ท้องถิ่น" global: "คุณสามารถดูโพสต์จากเซิร์ฟเวอร์ที่เชื่อมต่ออื่นๆ ทั้งหมดได้" description2: "คุณสามารถสลับระหว่างแต่ละไทม์ไลน์ได้ตลอดเวลาได้ที่บริเวณด้านบนของหน้าจอ" description3: "นอกจากนี้ยังมีรายการไทม์ไลน์ ไทม์ไลน์ของช่อง ฯลฯ โปรดดู {link} สำหรับรายละเอียดเพิ่มเติม" @@ -1320,7 +1340,7 @@ _initialTutorial: _visibility: description: "คุณสามารถจำกัดผู้ที่สามารถดูโน้ตของคุณได้นะ" public: "โน้ตของคุณนั้นจะปรากฏแก่ผู้ใช้งานทุกคน" - home: "เผยแพร่บนไทม์ไลน์หน้าแรกเท่านั้น ผู้คนที่เข้าชมโปรไฟล์ของคุณ ผ่านผู้ติดตาม และผ่านการรีโน้ตสามารถเห็นได้" + home: "เผยแพร่บนไทม์ไลน์หลักเท่านั้น แต่ผู้ติดตาม ผู้ที่เข้ามาดูโปรไฟล์ และผู้ที่เห็นจากรีโน้ตยังสามารถดูโพสต์นี้ได้" followers: "มองเห็นได้เฉพาะผู้ติดตามเท่านั้น ไม่มีใครอื่นนอกจากตัวคุณเองที่สามารถรีโน้ตได้ และมีเพียงผู้ติดตามของคุณเท่านั้นที่สามารถดูได้" direct: "เปิดให้เห็นเฉพาะผู้ใช้ที่ระบุเท่านั้น และพวกเขาจะได้รับแจ้งเตือนด้วย คุณสามารถใช้มันแทนข้อความโดยตรง (dm)" doNotSendConfidencialOnDirect1: "โปรดใช้ความระมัดระวังในการส่งข้อมูลที่ละเอียดอ่อน" @@ -1346,17 +1366,17 @@ _initialTutorial: title: "บทเรียนจบลงแล้วจ้า เย่เย่เย่ 🎉" description: "คุณสมบัติที่แนะนำในที่นี่เป็นเพียงบางส่วนเท่านั้น หากต้องการเรียนรู้เพิ่มเติมเกี่ยวกับวิธีใช้ Misskey โปรดไปที่ {link}" _timelineDescription: - home: "บนไทม์ไลน์หน้าแรก คุณสามารถดูโพสต์จากบัญชีที่คุณติดตามได้" - local: "ไทม์ไลน์ในพื้นที่ช่วยให้คุณเห็นโพสต์จากผู้ใช้ทั้งหมดบนเซิร์ฟเวอร์นี้" - social: "ไทม์ไลน์โซเชียลจะแสดงโพสต์จากทั้งไทม์ไลน์หน้าแรกและไทม์ไลน์ในพื้นที่" + home: "บนไทม์ไลน์หลัก คุณสามารถดูโพสต์จากบัญชีที่ติดตามอยู่ได้" + local: "ไทม์ไลน์ท้องถิ่นช่วยให้เห็นโพสต์จากผู้ใช้ทั้งหมดบนเซิร์ฟเวอร์นี้" + social: "ไทม์ไลน์โซเชียลจะแสดงโพสต์จากทั้งไทม์ไลน์หลักและไทม์ไลน์ท้องถิ่น" global: "ในไทม์ไลน์ทั่วโลก คุณสามารถดูโน้ตจากเซิร์ฟเวอร์ที่เชื่อมต่อทั้งหมดได้" _serverRules: description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ" _serverSettings: iconUrl: "URL ไอคอน" appIconDescription: "ระบุไอคอนที่จะใช้เมื่อ {host} แสดงเป็นแอป" - appIconUsageExample: "E.g. เป็น PWA หรือเมื่อแสดงผลเป็นบุ๊กมาร์กหน้าจอหลักบนโทรศัพท์" - appIconStyleRecommendation: "เนื่องจากไอคอนอาจถูกครอบตัดเป็นสี่เหลี่ยมจัตุรัสหรือวงกลม จึงแนะนำให้ใช้ไอคอนที่มีขอบสีรอบๆ เนื้อหา" + appIconUsageExample: "ตัวอย่างเช่น เมื่อถูกเพิ่มเป็น PWA หรือบุ๊กมาร์กบนหน้าจอหลักในสมาร์ทโฟน" + appIconStyleRecommendation: "เนื่องจากอาจถูกครอบตัดเป็นสี่เหลี่ยมหรือวงกลม จึงแนะนำให้ใช้ภาพที่เผื่อพื้นที่รอบๆ ตัวโลโก้ไอคอนไว้" appIconResolutionMustBe: "ความละเอียดขั้นต่ำไว้คือ {resolution}." manifestJsonOverride: "เขียนทับ manifest.json" shortName: "ชื่อย่อ" @@ -1364,27 +1384,29 @@ _serverSettings: fanoutTimelineDescription: "เพิ่มประสิทธิภาพการดึงข้อมูลไทม์ไลน์อย่างมาก และลดภาระในฐานข้อมูลเมื่อเปิดใช้งาน ในทางกลับกัน การใช้หน่วยความจำของ Redis จะเพิ่มขึ้น ลองปิดการใช้งานนี้ในกรณีที่หน่วยความจำเซิร์ฟเวอร์เหลือน้อยหรือเซิร์ฟเวอร์ไม่เสถียร" fanoutTimelineDbFallback: "ฟอลแบ๊กกลับฐานข้อมูล" fanoutTimelineDbFallbackDescription: "เมื่อเปิดใช้งาน หากไม่ได้แคชไทม์ไลน์ ไทม์ไลน์จะฟอลแบ๊กไปยังฐานข้อมูลสำหรับการ query เพิ่มเติม การปิดใช้งานจะช่วยลดภาระของเซิร์ฟเวอร์ด้วยการกำจัดกระบวนฟอลแบ๊ก แต่มันก็จะจำกัดช่วงเวลาไทม์ไลน์ที่สามารถดึงข้อมูลได้" + inquiryUrl: "URL สำหรับการติดต่อสอบถาม" + inquiryUrlDescription: "ระบุ URL ของหน้าเว็บที่มีแบบฟอร์มสำหรับติดต่อผู้ดูแลเซิร์ฟเวอร์ หรือข้อมูลการติดต่อของผู้ดูแลเซิร์ฟเวอร์" _accountMigration: - moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง" + moveFrom: "ย้ายจากบัญชีอื่นมาที่บัญชีนี้" moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น" moveFromLabel: "บัญชีที่จะย้ายจาก #{n}" - moveFromDescription: "ถ้าหากคุณต้องการโอนข้อมูล คุณจำเป็นต้องสร้างบัญชีสำรองสำหรับการย้ายบัญชี หลังจากนั้นป้อนบัญชีที่จะย้ายไปในรูปแบบต่อไปนี้: @person@instance.com" - moveTo: "ย้ายข้อมูลบัญชีนี้ไปยังบัญชีอีกหนึ่ง" + moveFromDescription: "หากต้องการโอนข้อมูลจากบัญชีอื่นมายังบัญชีนี้ จำเป็นต้องสร้างบัญชีนามแฝง (alias) ไว้ที่นี่ด้วย\nกรุณากรอกบัญชีเดิมในรูปแบบ: @username@server.example.com\nหากต้องการลบ alias, ให้เว้นว่างไว้แล้วบันทึก (ไม่แนะนำ)" + moveTo: "ย้ายบัญชีนี้ไปยังบัญชีใหม่" moveToLabel: "บัญชีที่จะย้ายไปที่:" moveCannotBeUndone: "ไม่สามารถยกเลิกการโอนย้ายบัญชีได้" moveAccountDescription: "การดำเนินการนี้จะย้ายบัญชีของคุณไปยังบัญชีอื่น\n・ผู้ที่กำลังติดตามคุณจากบัญชีนี้จะถูกย้ายไปยังบัญชีใหม่โดยอัตโนมัติ\n・บัญชีนี้จะเลิกติดตามผู้ใช้ทั้งหมดที่กำลังติดตามอยู่\n・คุณจะไม่สามารถสร้างโน้ต ฯลฯ ในบัญชีนี้ได้\n\nแม้ว่าการย้ายผู้ที่ติดตามคุณจะเป็นไปโดยอัตโนมัติ แต่คุณต้องเตรียมขั้นตอนบางอย่างด้วยตนเอง เพื่อย้ายรายชื่อผู้ใช้ที่คุณกำลังติดตาม โดยดำเนินการส่งออกรายชื่อแล้วค่อยนำเข้ามาภายหลังในเมนูการตั้งค่าของบัญชีใหม่ ใช้ขั้นตอนเดียวกันนี้ใช้รายชื่อผู้ใช้ที่ถูกปิดเสียงและถูกบล็อก\n\n(คำอธิบายนี้ใช้กับ Misskey v13.12.0 ขึ้นไป, ซอฟต์แวร์ ActivityPub อื่นๆ เช่น Mastodon อาจทำงานแตกต่างออกไป)" - moveAccountHowTo: "หากต้องการย้ายข้อมูลก่อนอื่นให้สร้างชื่อแทนสำหรับบัญชีนี้ ในบัญชีที่จะต้องการย้ายไป\nหลังจากที่คุณสร้างนามแฝงนั้นแล้ว ให้ป้อนบัญชีที่ต้องการจะย้ายไปในรูปแบบดังต่อไปนี้: @username@server.example.com" + moveAccountHowTo: "การย้ายบัญชีจะเริ่มต้นโดยการสร้างบัญชีนามแฝง (alias) ของบัญชีนี้ ณ บัญชีที่เป็นปลายทาง หลังจากสร้างนามแฝงแล้ว ให้ป้อนบัญชีปลายทางในรูปแบบดังนี้: @username@server.example.com" startMigration: "โอนย้าย" migrationConfirm: "ยืนยันการย้ายข้อมูลบัญชีนี้ไปที่ {account} เมื่อเริ่มแล้วจะไม่สามารถหยุดหรือนำกลับคืนมาได้ และคุณจะไม่สามารถใช้บัญชีนี้ในสถานะดั้งเดิมได้อีกต่อไป\n\nนอกจากนี้ คุณจำเป็นต้องสร้างบัญชีสำรองสำหรับการย้ายบัญชี" - movedAndCannotBeUndone: "\nบัญชีนี้ถูกโอนย้ายไปแล้ว\nไม่สามารถย้อนกลับโอนย้ายข้อมูลได้" - postMigrationNote: "บัญชีนี้จะถูกเลิกติดตามบัญชีทั้งหมดที่กำลังติดตามภายใน 24 ชั่วโมงหลังจากการย้ายข้อมูลนั้นเสร็จสิ้น ทั้งจำนวนผู้ติดตามและผู้ติดตามนั้นจะกลายเป็นศูนย์ เพื่อหลีกเลี่ยงป้องกันไม่ให้ผู้ติดตามของคุณนั้นไม่สามารถเห็นโพสต์เฉพาะผู้ติดตามของบัญชีนี้ได้ แต่อย่างไรก็ตามแล้วพวกเขาจะยังคงติดตามบัญชีนี้ต่อไป" - movedTo: "บัญชีที่จะย้ายไปที่:" + movedAndCannotBeUndone: "\nบัญชีนี้ถูกโอนย้ายไปแล้ว\nไม่สามารถยกเลิกการโอนย้ายได้" + postMigrationNote: "บัญชีนี้จะดำเนินการยกเลิกการติดตามทั้งหมดหลังจากการย้ายข้อมูลไปแล้ว 24 ชั่วโมง จำนวนกำลังติดตามและจำนวนผู้ติดตามของบัญชีนี้จะเป็น 0 และเพื่อหลีกเลี่ยงไม่ให้ผู้ติดตามคุณนั้นไม่สามารถเห็นโพสต์เฉพาะผู้ติดตามฯได้ การยกเลิกการติดตามจะไม่กระทบกับผู้ติดตามคุณ ดังนั้นผู้ติดตามคุณยังคงสามารถดูโพสต์ของบัญชีนี้ได้" + movedTo: "บัญชีที่จะย้ายไป:" _achievements: earnedAt: "ได้รับเมื่อ" _types: _notes1: title: "just setting up my msky" - description: "โพสต์โน้ตแรกของคุณ" + description: "โพสต์โน้ตเป็นครั้งแรก" flavor: "ขอให้มีช่วงเวลาที่ดีกับ Misskey นะคะ!" _notes10: title: "โน้ตไม่กี่ชิ้น" @@ -1484,19 +1506,19 @@ _achievements: flavor: "ขอบคุณที่ใช้ Misskey นะ !" _noteClipped1: title: "อดไม่ได้ที่จะต้องคลิปมันเอาไว้" - description: "คลิปโน้ตตัวแรกของคุณ" + description: "คลิปโน้ตเป็นครั้งแรก" _noteFavorited1: title: "สตาร์เกเซอร์" - description: "ชื่นชอบโน้ตแรกของคุณ" + description: "ใส่โน้ตเป็นรายการโปรดเป็นครั้งแรก" _myNoteFavorited1: title: "แสวงหาดวงดาว" - description: "มีคนอื่นๆที่ชื่นชอบหนึ่งในโน้ตของคุณ" + description: "โน้ตตัวเองถูกคนอื่นเพิ่มลงรายการโปรดของเขา" _profileFilled: title: "เตรียมตัวอย่างดี" - description: "ตั้งค่าโปรไฟล์ของคุณ" + description: "ตั้งค่าโปรไฟล์" _markedAsCat: title: "ฉันเป็นแมว" - description: "ทำเครื่องหมายบัญชีของคุณว่าเป็นแมว" + description: "ตั้งค่าบัญชีเป็นแมวเมี้ยวเมี้ยว" flavor: "แมวน้อยไร้ชื่อ" _following1: title: "ก้าวแรกสู่...กดติดตาม" @@ -1539,7 +1561,7 @@ _achievements: description: "ได้รับความสำเร็จ 30 ครั้ง" _viewAchievements3min: title: "ชอบบรรลุความสําเร็จ" - description: "มองดูรายการความสำเร็จของคุณเป็นเวลาอย่างน้อย 3 นาที" + description: "มองดูรายการความสำเร็จเป็นเวลานานกว่า 3 นาที" _iLoveMisskey: title: "ฉันรัก Misskey" description: "โพสต์ “I ❤ #Misskey”" @@ -1566,13 +1588,13 @@ _achievements: flavor: "โป๊ะ โป๊ะ โป๊ะ ปิ้งงงงง" _selfQuote: title: "อ้างอิงตนเอง" - description: "อ้างโน้ตของคุณเอง" + description: "อ้างอิงโน้ตตัวเอง" _htl20npm: title: "ไทม์ไลน์ไหล" - description: "มีการทำความเร็วของไทม์ไลน์หน้าแรกเกิน 20 npm (โน้ตต่อนาที)" + description: "มีการทำความเร็วของไทม์ไลน์หลักเกิน 20 npm (โน้ตต่อนาที)" _viewInstanceChart: title: "วิเคราะห์" - description: "ดูแผนภูมิอินสแตนซ์ของคุณ" + description: "ดูแผนภูมิของเซิร์ฟเวอร์" _outputHelloWorldOnScratchpad: title: "หวัดดีชาวโลก!" description: "เอาพุต \"hello world\" ใน Scratchpad" @@ -1593,16 +1615,16 @@ _achievements: description: "มีโอกาสที่จะได้รับด้วยความน่าจะเป็นไปได้ 0.005% ทุก ๆ 10 วินาที" _setNameToSyuilo: title: "คอมเพล็กซ์ของพระเจ้า" - description: "ตั้งชื่อของคุณเป็น “syuilo”" + description: "ตั้งชื่อเป็น “syuilo”" _passedSinceAccountCreated1: title: "ครบรอบหนึ่งปี" - description: "ผ่านไปหนึ่งปีแล้วนะตั้งแต่บัญชีของคุณถูกสร้างขึ้นมาน่ะ" + description: "ผ่านไป 1 ปีนับตั้งแต่สร้างบัญชี" _passedSinceAccountCreated2: title: "ครบรอบสองปี" - description: "ผ่านไปสองปีแล้วนะตั้งแต่บัญชีของคุณถูกสร้างขึ้นมาน่ะ" + description: "ผ่านไป 2 ปีนับตั้งแต่สร้างบัญชี" _passedSinceAccountCreated3: title: "ครบรอบสามปี" - description: "ผ่านไปสามปีแล้วนะตั้งแต่บัญชีของคุณถูกสร้างขึ้นมาน่ะ" + description: "ผ่านไป 3 ปีนับตั้งแต่สร้างบัญชี" _loggedInOnBirthday: title: "สุขสันต์วันเกิด" description: "เข้าสู่ระบบในวันเกิดของคุณ" @@ -1637,7 +1659,7 @@ _role: name: "ชื่อบทบาท" description: "คำอธิบายบทบาท" permission: "สิทธิ์ตามบทบาท" - descriptionOfPermission: "ผู้ควบคุม สามารถดำเนินการดูแลขั้นพื้นฐานได้\nผู้ดูแลระบบ สามารถเปลี่ยนการตั้งค่าทั้งหมดของอินสแตนซ์ได้" + descriptionOfPermission: "ผู้ควบคุม สามารถดำเนินการดูแลขั้นพื้นฐานได้\nผู้ดูแลระบบ สามารถเปลี่ยนการตั้งค่าทั้งหมดของเซิร์ฟเวอร์ได้" assignTarget: "มอบหมาย" descriptionOfAssignTarget: "แบบปรับเอง เพิ่มถอนบทบาทนี้แก่ผู้ใช้ด้วยตัวเอง\nแบบมีเงื่อนไข เพิ่มถอนบทบาทนี้แก่ผู้ใช้โดยอัตโนมัติหากเข้าเงื่อนไขใดต่อไปนี้" manual: "ปรับเอง" @@ -1650,8 +1672,8 @@ _role: descriptionOfIsPublic: "บทบาทจะปรากฏบนโปรไฟล์ของผู้ใช้และเปิดเผยต่อสาธารณะ (ทุกคนสามารถเห็นได้ว่าผู้ใช้รายนี้มีบทบาทนี้)" options: "ตัวเลือกบทบาท" policies: "นโยบาย" - baseRole: "เทมเพลตบทบาท" - useBaseValue: "ใช้ตามเทมเพลตบทบาท" + baseRole: "แม่แบบบทบาท" + useBaseValue: "ใช้ตามแม่แบบบทบาท" chooseRoleToAssign: "เลือกบทบาทที่ต้องการกำหนด" iconUrl: "URL ไอคอน" asBadge: "แสดงเป็นตรา" @@ -1668,11 +1690,11 @@ _role: middle: "ปานกลาง" high: "สูง" _options: - gtlAvailable: "การดูไทม์ไลน์ทั่วโลก" - ltlAvailable: "การดูไทม์ไลน์ในท้องถิ่น" + gtlAvailable: "สามารถดูไทม์ไลน์ทั่วโลกได้" + ltlAvailable: "สามารถดูไทม์ไลน์ท้องถิ่นได้" canPublicNote: "สามารถโพสต์แบบสาธารณะ" mentionMax: "จำนวนการกล่าวถึงสูงสุดต่อโน้ต" - canInvite: "สร้างรหัสเชิญอินสแตนซ์" + canInvite: "สร้างรหัสเชิญเข้าเซิร์ฟเวอร์" inviteLimit: "จำกัดการเชิญ" inviteLimitCycle: "คูลดาวน์ในการเชิญ" inviteExpirationTime: "วันหมดอายุของรหัสการเชิญ" @@ -1680,6 +1702,7 @@ _role: canManageAvatarDecorations: "จัดการตกแต่งอวตาร" driveCapacity: "ความจุของไดรฟ์" alwaysMarkNsfw: "ทำเครื่องหมายไฟล์ว่าเป็น NSFW เสมอ" + canUpdateBioMedia: "อนุญาตให้ปรับปรุงไอคอนและแบนเนอร์" pinMax: "จํานวนสูงสุดของโน้ตที่ปักหมุดไว้" antennaMax: "จำนวนสูงสุดของเสาอากาศ" wordMuteMax: "จำนวนอักขระสูงสุดที่อนุญาตในการปิดเสียงคำ" @@ -1696,7 +1719,7 @@ _role: avatarDecorationLimit: "จำนวนการตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้" _condition: roleAssignedTo: "มอบหมายให้มีบทบาทแบบทำมือ" - isLocal: "ผู้ใช้ในพื้นที่" + isLocal: "ผู้ใช้ท้องถิ่น" isRemote: "ผู้ใช้ระยะไกล" isCat: "ผู้ใช้ที่เป็นแมว" isBot: "ผู้ใช้ที่เป็นบอต" @@ -1755,8 +1778,8 @@ _ad: adsTooClose: "เนื่องจากช่วงเวลาการแสดงโฆษณาสั้นมาก ประสบการณ์ผู้ใช้จึงอาจลดลงอย่างมาก" _forgotPassword: enterEmail: "ป้อนที่อยู่อีเมลที่คุณเคยใช้ในการลงทะเบียนไว้ ลิงก์ที่คุณสามารถรีเซ็ตรหัสผ่านได้นั้นจะถูกส่งไปนะ" - ifNoEmail: "ถ้าหากคุณไม่ได้ใช้อีเมลระหว่างการลงทะเบียน กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์แทนนะ" - contactAdmin: "อินสแตนซ์นี้ไม่รองรับการใช้งานที่อยู่อีเมลนี้ กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์เพื่อรีเซ็ตรหัสผ่านของคุณแทน" + ifNoEmail: "หากลงทะเบียนแบบไม่ใช้อีเมล โปรดติดต่อผู้ดูแลระบบ" + contactAdmin: "เนื่องจากเซิร์ฟเวอร์นี้ไม่รองรับการส่งอีเมล หากต้องการรีเซ็ตรหัสผ่าน กรุณาติดต่อผู้ดูแลระบบ" _gallery: my: "แกลลอรี่ของฉัน" liked: "โพสต์ที่ถูกใจ" @@ -1774,23 +1797,23 @@ _plugin: viewSource: "ดูต้นฉบับ" viewLog: "แสดงปูม" _preferencesBackups: - list: "สร้างการสำรองข้อมูล" - saveNew: "บันทึกข้อมูลสำรองใหม่" + list: "การตั้งค่าที่สำรองไว้" + saveNew: "บันทึกการตั้งค่าสำรองใหม่" loadFile: "โหลดจากไฟล์" apply: "นำไปใช้กับอุปกรณ์นี้" save: "บันทึก" - inputName: "กรุณาป้อนชื่อสำหรับข้อมูลสำรองนี้" + inputName: "กรุณาป้อนชื่อการตั้งค่าสำรองนี้" cannotSave: "การบันทึกล้มเหลว" - nameAlreadyExists: "มีข้อมูลสำรองชื่อ \"{name}\" นี้อยู่แล้ว กรุณาป้อนชื่ออื่นนะ" - applyConfirm: "คุณต้องการใช้ข้อมูลสำรอง \"{name}\" กับอุปกรณ์นี้อย่างงั้นจริงหรอ การตั้งค่าที่มีอยู่ของอุปกรณ์นี้จะถูกเขียนทับนะ" - saveConfirm: "บันทึกข้อมูลสำรองเป็น {name} มั้ย?" - deleteConfirm: "ลบข้อมูลสำรอง {name} มั้ย?" - renameConfirm: "ต้องการเปลี่ยนชื่อข้อมูลสำรองจาก “{old}” เป็น “{new}” ใช่ไหม?" - noBackups: "ไม่มีข้อมูลสำรอง สามารถบันทึกการตั้งค่าไคลเอนต์ปัจจุบันไปยังเซิร์ฟเวอร์ด้วย “บันทึกข้อมูลสำรองใหม่”" + nameAlreadyExists: "มีการตั้งค่าสำรองชื่อ “{name}” อยู่แล้ว กรุณาป้อนชื่ออื่น" + applyConfirm: "ต้องการใช้การตั้งค่าสำรอง “{name}” กับอุปกรณ์นี้ใช่ไหม? การตั้งค่าที่มีอยู่บนอุปกรณ์นี้จะถูกเขียนทับ" + saveConfirm: "บันทึกการตั้งค่าสำรองเป็น {name} ใช่ไหม?" + deleteConfirm: "ต้องการลบ {name} ใช่ไหม?" + renameConfirm: "ต้องการเปลี่ยนชื่อจาก “{old}” เป็น “{new}” ใช่ไหม?" + noBackups: "ไม่มีการตั้งค่าสำรอง สามารถบันทึกการตั้งค่าไคลเอนต์ปัจจุบันไปยังเซิร์ฟเวอร์ด้วย “บันทึกการตั้งค่าสำรองใหม่”" createdAt: "สร้างเมื่อ: {date} {time}" updatedAt: "อัปเดตเมื่อ: {date} {time}" cannotLoad: "การโหลดล้มเหลว" - invalidFile: "รูปแบบไฟล์ไม่ถูกต้องนะ" + invalidFile: "รูปแบบไฟล์ไม่ถูกต้อง" _registry: scope: "สโคป" key: "คีย์" @@ -1841,13 +1864,13 @@ _menuDisplay: hide: "ซ่อน" _wordMute: muteWords: "ปิดเสียงคำ" - muteWordsDescription: "คั่นด้วยช่องว่างสำหรับเงื่อนไข AND หรือด้วยการขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR นะ" + muteWordsDescription: "คั่นด้วยเว้นวรรคสำหรับเงื่อนไข AND, หรือขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR" muteWordsDescription2: "ล้อมรอบคีย์เวิร์ดด้วยเครื่องหมายทับเพื่อใช้นิพจน์ทั่วไป" _instanceMute: - instanceMuteDescription: "การดำเนินการนี้จะปิดเสียง\"โน้ต/รีโน้ต\"จากอินสแตนซ์ที่อยู่ในรายการ รวมถึงบันทึกของผู้ใช้ที่ตอบกลับผู้ใช้จากอินสแตนซ์ที่ปิดเสียง" + instanceMuteDescription: "ปิดเสียง “โน้ต/รีโน้ต” ทั้งหมดจากเซิร์ฟเวอร์ที่ระบุไว้ รวมถึงโน้ตของผู้ใช้ที่ตอบกลับผู้ใช้จากเซิร์ฟเวอร์ที่ถูกปิดเสียง" instanceMuteDescription2: "คั่นด้วยการขึ้นบรรทัดใหม่" - title: "ซ่อนโน้ตจากอินสแตนซ์ที่มีอยู่ในรายชื่อ" - heading: "รายชื่ออินสแตนซ์ที่ถูกปิดเสียง" + title: "ซ่อนโน้ตจากเซิร์ฟเวอร์ที่มีระบุไว้" + heading: "เซิร์ฟเวอร์ที่ถูกปิดเสียง" _theme: explore: "สำรวจธีม" install: "ติดตั้งธีม" @@ -1923,8 +1946,6 @@ _sfx: note: "โน้ต" noteMy: "โน้ตของตัวเอง" notification: "การเเจ้งเตือน" - antenna: "เสาอากาศ" - channel: "การแจ้งเตือนช่อง" reaction: "เมื่อเลือกรีแอคชั่น" _soundSettings: driveFile: "ใช้เสียงจากไดรฟ์" @@ -1932,7 +1953,7 @@ _soundSettings: driveFileTypeWarn: "ไม่รองรับไฟล์นี้" driveFileTypeWarnDescription: "กรุณาเลือกไฟล์เสียง" driveFileDurationWarn: "เสียงยาวเกินไป" - driveFileDurationWarnDescription: "การใช้เสียงที่ยาวอาจรบกวนการใช้งาน Misskey, ต้องการดำเนินการต่อหรือไม่?" + driveFileDurationWarnDescription: "การใช้เสียงที่ยาว อาจรบกวนการใช้งาน Misskey, ต้องการดำเนินการต่อใช่ไหม?" _ago: future: "อนาคต" justNow: "เมื่อกี๊นี้" @@ -1976,49 +1997,49 @@ _2fa: removeKey: "ลบคีย์ความปลอดภัยออก" removeKeyConfirm: "ลบข้อมูลสำรอง {name} มั้ย?" whyTOTPOnlyRenew: "ไม่สามารถลบแอปตัวรับรองความถูกต้องได้ตราบใดที่มีการลงทะเบียนคีย์ความปลอดภัยไว้แล้ว" - renewTOTP: "กำหนดค่าแอพตัวตรวจสอบสิทธิ์ใหม่" + renewTOTP: "ตั้งค่าแอปยืนยันตัวตน" renewTOTPConfirm: "วิธีการแบบนี้จะทําให้รหัสยืนยันจากแอพก่อนหน้าของคุณหยุดทํางานเลยนะ" renewTOTPOk: "ตั้งค่าคอนฟิกใหม่" renewTOTPCancel: "ไม่เป็นไร" - checkBackupCodesBeforeCloseThisWizard: "โปรดตรวจสอบรหัสสำรองด้านล่างก่อนที่จะปิดวิซาร์ดนี้" - backupCodes: "รหัสสำรองข้อมูล" + checkBackupCodesBeforeCloseThisWizard: "โปรดตรวจสอบรหัสแบ๊กอัปด้านล่างก่อนที่จะปิดวิซาร์ดนี้" + backupCodes: "รหัสแบ๊กอัป" backupCodesDescription: "หากแอปยืนยันตัวตนของคุณไม่พร้อมใช้งาน คุณสามารถใช้รหัสสำรองด้านล่างเพื่อเข้าถึงบัญชีของคุณได้ อย่าลืมเก็บรหัสเหล่านี้ไว้ในที่ปลอดภัย แต่ละรหัสสามารถใช้ได้เพียงครั้งเดียวเท่านั้น" - backupCodeUsedWarning: "มีการใช้รหัสสำรองแล้ว โปรดกรุณากำหนดค่าการตรวจสอบสิทธิ์แบบสองปัจจัยโดยเร็วที่สุดถ้าหากคุณยังไม่สามารถใช้งานได้อีก" - backupCodesExhaustedWarning: "รหัสสำรองทั้งหมดถูกใช้แล้ว ถ้าหากคุณยังสูญเสียการเข้าถึงแอปการตรวจสอบสิทธิ์แบบสองปัจจัยคุณจะยังไม่สามารถเข้าถึงบัญชีนี้ได้ กรุณากำหนดค่าการรับรองความถูกต้องด้วยการยืนยันสองชั้น" + backupCodeUsedWarning: "รหัสแบ๊กอัปถูกใช้งานแล้ว หากแอปพลิเคชันการยืนยันตัวตนไม่สามารถใช้งานได้ ให้รีบทำการตั้งค่าแอปฯใหม่โดยเร็วที่สุด" + backupCodesExhaustedWarning: "รหัสแบ๊กอัปทั้งหมดถูกใช้งานแล้ว หากยังไม่สามารถใช้แอปพลิเคชันการยืนยันตัวตนได้ก็จะไม่สามารถเข้าถึงบัญชีนี้ได้อีกต่อไป กรุณาลงทะเบียนแอปพลิเคชันการยืนยันตัวตนใหม่" moreDetailedGuideHere: "คลิกที่นี่เพื่อดูคำแนะนำโดยละเอียด" _permissions: - "read:account": "ดูข้อมูลบัญชีของคุณ" - "write:account": "แก้ไขข้อมูลบัญชีของคุณ" - "read:blocks": "ดูรายชื่อผู้ใช้ที่ถูกบล็อกของคุณ" - "write:blocks": "แก้ไขรายชื่อผู้ใช้ที่ถูกบล็อกของคุณ" - "read:drive": "เข้าถึงไฟล์และโฟลเดอร์ในไดรฟ์ของคุณ" - "write:drive": "แก้ไขหรือลบไฟล์และโฟลเดอร์ในไดรฟ์ของคุณ" + "read:account": "ดูข้อมูลบัญชี" + "write:account": "แก้ไขข้อมูลบัญชี" + "read:blocks": "ดูรายชื่อผู้ใช้ที่ถูกบล็อก" + "write:blocks": "แก้ไขรายชื่อผู้ใช้ที่ถูกบล็อก" + "read:drive": "เข้าถึงไดรฟ์" + "write:drive": "จัดการไดรฟ์" "read:favorites": "ดูรายการโปรด" "write:favorites": "แก้ไขรายการโปรด" "read:following": "ดูข้อมูลว่าใครที่คุณติดตาม" "write:following": "ติดตามหรือเลิกติดตามบัญชีอื่น" - "read:messaging": "ดูแชทของคุณ" + "read:messaging": "ดูแชท" "write:messaging": "เขียนหรือลบข้อความแชท" - "read:mutes": "ดูรายชื่อผู้ใช้ที่ปิดเสียงของคุณ" + "read:mutes": "ดูรายชื่อผู้ใช้ที่ถูกปิดเสียง" "write:mutes": "แก้ไขรายชื่อผู้ใช้ที่ถูกปิดเสียง" "write:notes": "เขียนหรือลบโน้ต" - "read:notifications": "ดูการแจ้งเตือนของคุณ" - "write:notifications": "จัดการแจ้งเตือนของคุณ" - "read:reactions": "ดูรีแอคชั่นของคุณ" - "write:reactions": "แก้ไขรีแอคชั่นของคุณ" + "read:notifications": "ดูการแจ้งเตือน" + "write:notifications": "จัดการแจ้งเตือน" + "read:reactions": "ดูรีแอคชั่น" + "write:reactions": "แก้ไขรีแอคชั่น" "write:votes": "โหวตบนสำรวจความคิดเห็น" "read:pages": "ดูหน้าเพจ" - "write:pages": "แก้ไขหรือลบเพจของคุณ" + "write:pages": "แก้ไขหรือลบเพจ" "read:page-likes": "ดูรายการเพจที่ถูกใจไว้" "write:page-likes": "แก้ไขรายการเพจที่ถูกใจ" - "read:user-groups": "ดูกลุ่มผู้ใช้ของคุณ" - "write:user-groups": "แก้ไขหรือลบกลุ่มผู้ใช้ของคุณ" - "read:channels": "ดูแชนแนลของคุณ" - "write:channels": "แก้ไขแชนแนลของคุณ" + "read:user-groups": "ดูกลุ่มผู้ใช้" + "write:user-groups": "แก้ไขหรือลบกลุ่มผู้ใช้" + "read:channels": "ดูช่อง" + "write:channels": "แก้ไขช่อง" "read:gallery": "ดูแกลเลอรี่" - "write:gallery": "แก้ไขแกลเลอรี่ของคุณ" - "read:gallery-likes": "ดูรายการโพสต์แกลเลอรีที่ถูกใจไว้" - "write:gallery-likes": "แก้ไขรายการโพสต์แกลเลอรีที่ถูกใจไว้" + "write:gallery": "แก้ไขแกลเลอรี" + "read:gallery-likes": "ดูแกลเลอรีที่ถูกใจไว้" + "write:gallery-likes": "จัดการแกลเลอรีที่ถูกใจไว้" "read:flash": "ดู Play" "write:flash": "แก้ไข Play" "read:flash-likes": "ดูรายการ play ที่ถูกใจไว้" @@ -2027,20 +2048,20 @@ _permissions: "write:admin:delete-account": "ลบบัญชีผู้ใช้" "write:admin:delete-all-files-of-a-user": "ลบไฟล์ทั้งหมดของผู้ใช้" "read:admin:index-stats": "ดูข้อมูลเกี่ยวกับดัชนีฐานข้อมูล" - "read:admin:table-stats": "ดูข้อมูลเกี่ยวกับตารางฐานข้อมูล" + "read:admin:table-stats": "ดูข้อมูลเกี่ยวกับตารางในฐานข้อมูล" "read:admin:user-ips": "ดูที่อยู่ IP ของผู้ใช้" - "read:admin:meta": "ดูข้อมูลเมตาของอินสแตนซ์" + "read:admin:meta": "ดูข้อมูลอภิพันธุ์ของอินสแตนซ์" "write:admin:reset-password": "รีเซ็ตรหัสผ่านของผู้ใช้" "write:admin:resolve-abuse-user-report": "แก้ไขรายงานจากผู้ใช้" "write:admin:send-email": "ส่งอีเมล" "read:admin:server-info": "ดูข้อมูลเซิร์ฟเวอร์" - "read:admin:show-moderation-log": "ดูปูมการแก้ไข" + "read:admin:show-moderation-log": "ดูปูมการควบคุมดูแล" "read:admin:show-user": "ดูข้อมูลส่วนตัวของผู้ใช้" "write:admin:suspend-user": "ระงับผู้ใช้" "write:admin:unset-user-avatar": "ลบอวตารผู้ใช้" "write:admin:unset-user-banner": "ลบแบนเนอร์ผู้ใช้" "write:admin:unsuspend-user": "ยกเลิกการระงับผู้ใช้" - "write:admin:meta": "จัดการข้อมูลเมตาของอินสแตนซ์" + "write:admin:meta": "จัดการข้อมูลอภิพันธุ์ของอินสแตนซ์" "write:admin:user-note": "จัดการโน้ตการกลั่นกรอง" "write:admin:roles": "จัดการบทบาท" "read:admin:roles": "ดูบทบาท" @@ -2067,14 +2088,14 @@ _permissions: "read:admin:ad": "ดูโฆษณา" "write:invite-codes": "สร้างรหัสเชิญ" "read:invite-codes": "รับรหัสเชิญ" - "write:clip-favorite": "ควบคุมการถูกใจของคลิป" - "read:clip-favorite": "ดูการถูกใจของคลิป" + "write:clip-favorite": "จัดการคลิปที่ถูกใจ" + "read:clip-favorite": "ดูคลิปที่ถูกใจ" "read:federation": "รับข้อมูลเกี่ยวกับสหพันธ์" "write:report-abuse": "รายงานการละเมิด" _auth: shareAccessTitle: "การให้สิทธิ์แอปพลิเคชัน" shareAccess: "คุณต้องการอนุญาตให้ \"{name}\" เข้าถึงบัญชีนี้เลยมั้ย?" - shareAccessAsk: "ต้องการอนุญาตให้แอปพลิเคชันนี้เข้าถึงบัญชีของคุณหรือไม่?" + shareAccessAsk: "ต้องการอนุญาตให้แอปพลิเคชันนี้เข้าถึงบัญชีของคุณใช่ไหม?" permission: "{name} ได้ขอสิทธิ์การเข้าถึงดังต่อไปนี้" permissionAsk: "แอปพลิเคชันนี้ขอสิทธิ์ดังต่อไปนี้" pleaseGoBack: "กรุณากลับไปที่แอปพลิเคชัน" @@ -2097,7 +2118,7 @@ _weekday: saturday: "วันเสาร์" _widgets: profile: "โปรไฟล์" - instanceInfo: "ข้อมูล อินสแตนซ์" + instanceInfo: "ข้อมูลเซิร์ฟเวอร์" memo: "โน้ตแปะ" notifications: "การเเจ้งเตือน" timeline: "ไทม์ไลน์" @@ -2111,7 +2132,7 @@ _widgets: digitalClock: "นาฬิกาดิจิตอล" unixClock: "นาฬิกา UNIX" federation: "สหพันธ์" - instanceCloud: "อินสแตนซ์คลาวด์" + instanceCloud: "กลุ่มเมฆเซิร์ฟเวอร์" postForm: "แบบฟอร์มการโพสต์" slideshow: "แสดงภาพนิ่ง" button: "ปุ่ม" @@ -2144,7 +2165,7 @@ _poll: deadlineTime: "เวลา" duration: "ระยะเวลา" votesCount: "{n} คะแนนเสียง" - totalVotes: "{n} คะแนนเสียงทั้งหมด" + totalVotes: "ทั้งหมด {n} คะแนนเสียง" vote: "โหวต" showResult: "ดูผลลัพธ์" voted: "โหวตแล้ว" @@ -2156,14 +2177,14 @@ _poll: _visibility: public: "สาธารณะ" publicDescription: "โน้ตของคุณจะปรากฏแก่ผู้ใช้ทุกคน" - home: "หน้าแรก" - homeDescription: "โพสลงไทม์ไลน์ที่บ้านเท่านั้น" + home: "หน้าหลัก" + homeDescription: "โพสต์ลงไทม์ไลน์หลักเท่านั้น" followers: "ผู้ติดตาม" followersDescription: "เฉพาะผู้ติดตามเท่านั้นที่มองเห็นได้" specified: "ไดเร็ค" specifiedDescription: "ทำให้มองเห็นได้เฉพาะผู้ใช้ที่ระบุเท่านั้น" disableFederation: "ไม่มีสหพันธ์" - disableFederationDescription: "อย่าส่งไปยังอินสแตนซ์อื่น" + disableFederationDescription: "อย่าส่งข้อมูลไปยังเซิร์ฟเวอร์อื่น" _postForm: replyPlaceholder: "ตอบกลับโน้ตนี้..." quotePlaceholder: "อ้างโน้ตนี้..." @@ -2199,37 +2220,37 @@ _exportOrImport: userLists: "รายชื่อ" excludeMutingUsers: "ยกเว้นผู้ใช้ที่ปิดเสียง" excludeInactiveUsers: "ยกเว้นผู้ใช้ที่ไม่ได้ใช้งาน" - withReplies: "รวมการตอบกลับจากผู้ใช้ที่นำเข้าไว้ในไทม์ไลน์" + withReplies: "รวมการตอบกลับจากผู้ใช้ที่ถูกนำเข้า ลงไทม์ไลน์" _charts: federation: "สหพันธ์" apRequest: "คำขอ" - usersIncDec: "ความแตกต่างของจำนวนผู้ใช้งาน" + usersIncDec: "การเพิ่มลดของจำนวนผู้ใช้" usersTotal: "จำนวนผู้ใช้งานทั้งหมด" activeUsers: "จำนวนผู้ใช้งานที่ยังมีความเคลื่อนไหวอยู่" - notesIncDec: "ความแตกต่างของจำนวนโน้ต" - localNotesIncDec: "ความแตกต่างของจำนวนโน้ตท้องถิ่น" - remoteNotesIncDec: "ความแตกต่างของจำนวนโน้ตระยะไกล" + notesIncDec: "การเพิ่มลดของจำนวนโน้ต" + localNotesIncDec: "การเพิ่มลดของจำนวนโน้ตท้องถิ่น" + remoteNotesIncDec: "การเพิ่มลดของจำนวนโน้ตระยะไกล" notesTotal: "จำนวนโน้ตทั้งหมด" - filesIncDec: "ความแตกต่างของจำนวนไฟล์" + filesIncDec: "การเพิ่มลดของจำนวนไฟล์" filesTotal: "จำนวนไฟล์ทั้งหมด" - storageUsageIncDec: "ความแตกต่างในการใช้พื้นที่เก็บข้อมูล" + storageUsageIncDec: "การเพิ่มลดในการใช้พื้นที่เก็บข้อมูล" storageUsageTotal: "การใช้พื้นที่เก็บข้อมูลทั้งหมด" _instanceCharts: requests: "คำขอ" - users: "ความแตกต่างของจำนวนผู้ใช้งาน" + users: "การเพิ่มลดของจำนวนผู้ใช้งาน" usersTotal: "จำนวนผู้ใช้งานสะสม" - notes: "ความแตกต่างของจำนวนโน้ต" + notes: "การเพิ่มลดของจำนวนโน้ต" notesTotal: "จำนวนโน้ตสะสม" - ff: "ความแตกต่างของจำนวนผู้ใช้ที่ติดตาม / ผู้ติดตาม" - ffTotal: "จำนวนผู้ใช้งานที่ติดตามสะสม / ผู้ติดตาม" - cacheSize: "ความแตกต่างในขนาดของแคช" - cacheSizeTotal: "ขนาดแคชรวมที่สะสม" - files: "ความแตกต่างของจำนวนไฟล์" + ff: "การเพิ่มลดของการติดตาม/ผู้ติดตาม" + ffTotal: "จำนวนสะสมของการติดตาม/ผู้ติดตาม" + cacheSize: "การเพิ่มลดขนาดของแคช" + cacheSizeTotal: "ขนาดแคชสะสม" + files: "การเพิ่มลดของจำนวนไฟล์" filesTotal: "จำนวนไฟล์สะสม" _timelines: - home: "หน้าแรก" - local: "ในพื้นที่" - social: "โซเชี่ยล" + home: "หน้าหลัก" + local: "ท้องถิ่น" + social: "โซเชียล" global: "ทั่วโลก" _play: new: "สร้าง Play" @@ -2245,7 +2266,7 @@ _play: featured: "เป็นที่นิยม" title: "หัวข้อ" script: "สคริปต์" - summary: "รายละเอียด" + summary: "คำอธิบาย" visibilityDescription: "หากตั้งค่าเป็นส่วนตัว มันจะไม่ปรากฏในโปรไฟล์อีกต่อไป แต่ผู้ที่ทราบ URL ของมันจะยังสามารถเข้าถึงได้" _pages: newPage: "สร้างหน้าเพจใหม่" @@ -2333,7 +2354,7 @@ _notification: mention: "กล่าวถึง" reply: "ตอบกลับ" renote: "รีโน้ต" - quote: "อ้างคำพูด" + quote: "อ้างอิง" reaction: "รีแอคชั่น" pollEnded: "โพลสิ้นสุดแล้ว" receiveFollowRequest: "ได้รับคำร้องขอติดตาม" @@ -2349,6 +2370,7 @@ _deck: alwaysShowMainColumn: "แสดงคอลัมน์หลักเสมอ" columnAlign: "จัดแนวคอลัมน์" addColumn: "เพิ่มคอลัมน์" + newNoteNotificationSettings: "ตั้งค่าการแจ้งเตือนเมื่อมีโน้ตใหม่" configureColumn: "ตั้งค่าคอลัมน์" swapLeft: "ขยับไปทางซ้าย" swapRight: "ขยับไปทางขวา" @@ -2373,7 +2395,7 @@ _deck: antenna: "เสาอากาศ" list: "รายการ" channel: "ช่อง" - mentions: "พูดถึง" + mentions: "กล่าวถึงคุณ" direct: "ไดเร็กต์" roleTimeline: "บทบาทไทม์ไลน์" _dialog: @@ -2387,6 +2409,7 @@ _drivecleaner: orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก" _webhookSettings: createWebhook: "สร้าง Webhook" + modifyWebhook: "แก้ไข Webhook" name: "ชื่อ" secret: "ความลับ" events: "อีเว้นท์ Webhook" @@ -2399,6 +2422,26 @@ _webhookSettings: renote: "รีโน้ตแล้วเมื่อ" reaction: "เมื่อได้รับรีแอคชั่น" mention: "เมื่อกำลังถูกกล่าวถึง" + _systemEvents: + abuseReport: "เมื่อมีการรายงานจากผู้ใช้" + abuseReportResolved: "เมื่อมีการจัดการกับการรายงานจากผู้ใช้" + userCreated: "เมื่อผู้ใช้ถูกสร้างขึ้น" + deleteConfirm: "ต้องการลบ Webhook ใช่ไหม?" +_abuseReport: + _notificationRecipient: + createRecipient: "เพิ่มปลายทางการแจ้งเตือนการรายงาน" + modifyRecipient: "แก้ไขปลายทางการแจ้งเตือนการรายงาน" + recipientType: "ประเภทของปลายทางการแจ้งเตือน\n" + _recipientType: + mail: "อีเมล" + webhook: "Webhook" + _captions: + mail: "ส่งการแจ้งเตือนไปยังที่อยู่อีเมลของผู้ควบคุม (เฉพาะเมื่อได้รับการรายงาน)" + webhook: "ส่งการแจ้งเตือนไปยัง SystemWebhook ที่กำหนด (จะส่งเมื่อได้รับการรายงานและเมื่อการรายงานได้รับการแก้ไข)" + keywords: "คีย์เวิร์ด" + notifiedUser: "ผู้ใช้ที่ได้รับการแจ้งเตือน" + notifiedWebhook: "Webhook ที่ใช้" + deleteConfirm: "ต้องการลบปลายทางการแจ้งเตือนใช่ไหม?" _moderationLogTypes: createRole: "สร้างบทบาทแล้ว" deleteRole: "ลบบทบาทแล้ว" @@ -2421,9 +2464,9 @@ _moderationLogTypes: deleteGlobalAnnouncement: "ลบประกาศทั่วโลกออกแล้ว" deleteUserAnnouncement: "ลบประกาศผู้ใช้ออกแล้ว" resetPassword: "รีเซ็ตรหัสผ่าน" - suspendRemoteInstance: "ระงับอินสแตนซ์ระยะไกล" - unsuspendRemoteInstance: "เลิกระงับอินสแตนซ์ระยะไกล" - updateRemoteInstanceNote: "อัปเดตโน้ตการกลั่นกรองของอินสแตนซ์ระยะไกลแล้ว" + suspendRemoteInstance: "ระงับเซิร์ฟเวอร์ระยะไกล" + unsuspendRemoteInstance: "เลิกระงับเซิร์ฟเวอร์ระยะไกล" + updateRemoteInstanceNote: "อัปเดตโน้ตการกลั่นกรองสำหรับเซิร์ฟเวอร์ระยะไกลแล้ว" markSensitiveDriveFile: "ทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน" unmarkSensitiveDriveFile: "ยกเลิกทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน" resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว" @@ -2436,6 +2479,12 @@ _moderationLogTypes: deleteAvatarDecoration: "ลบการตกแต่งไอคอนแล้ว" unsetUserAvatar: "ลบไอคอนผู้ใช้" unsetUserBanner: "ลบแบนเนอร์ผู้ใช้" + createSystemWebhook: "สร้าง SystemWebhook" + updateSystemWebhook: "อัปเดต SystemWebhook" + deleteSystemWebhook: "ลบ SystemWebhook" + createAbuseReportNotificationRecipient: "สร้างปลายทางการแจ้งเตือนการรายงาน" + updateAbuseReportNotificationRecipient: "อัปเดตปลายทางการแจ้งเตือนการรายงาน" + deleteAbuseReportNotificationRecipient: "ลบปลายทางการแจ้งเตือนการรายงาน" _fileViewer: title: "รายละเอียดไฟล์" type: "ประเภทไฟล์" @@ -2448,10 +2497,10 @@ _externalResourceInstaller: title: "ติดตั้งจากไซต์ภายนอก" checkVendorBeforeInstall: "โปรดตรวจสอบให้แน่ใจว่าแหล่งแจกหน่ายมีความน่าเชื่อถือก่อนทำการติดตั้ง" _plugin: - title: "ต้องการติดตั้งปลั๊กอินนี้หรือไม่?" + title: "ต้องการติดตั้งปลั๊กอินนี้ใช่ไหม?" metaTitle: "ข้อมูลส่วนเสริม" _theme: - title: "ต้องการติดตั้งธีมนี้หรือไม่?" + title: "ต้องการติดตั้งธีมนี้ใช่ไหม?" metaTitle: "ข้อมูลธีม" _meta: base: "โทนสีพื้นฐาน" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 661ecf19d7..36d741d30e 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -1318,8 +1318,6 @@ _sfx: note: "Нотатки" noteMy: "Мої нотатки" notification: "Сповіщення" - antenna: "Прийом антени" - channel: "Повідомлення каналу" _ago: future: "Майбутнє" justNow: "Щойно" @@ -1622,6 +1620,10 @@ _deck: _webhookSettings: name: "Ім'я" active: "Увімкнено" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "E-mail" _moderationLogTypes: suspend: "Призупинити" resetPassword: "Скинути пароль" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index 4a930626f4..ee4ab83ce7 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -1089,6 +1089,10 @@ _webhookSettings: _events: renote: "Qayta qayd qilinganda" mention: "Eslanganda" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Email" _moderationLogTypes: suspend: "To'xtatish" resetPassword: "Parolni tiklash" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index acc2e0c6a9..317dea5150 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -376,6 +376,7 @@ mcaptcha: "mCaptcha" enableMcaptcha: "Bật mCaptcha" mcaptchaSiteKey: "Khóa của trang" mcaptchaSecretKey: "Khóa bí mật" +mcaptchaInstanceUrl: "URL mCaptcha máy chủ" recaptcha: "reCAPTCHA" enableRecaptcha: "Bật reCAPTCHA" recaptchaSiteKey: "Khóa của trang" @@ -426,6 +427,7 @@ moderator: "Kiểm duyệt viên" moderation: "Kiểm duyệt" moderationNote: "Ghi chú kiểm duyệt" addModerationNote: "Thêm ghi chú kiểm duyệt" +moderationLogs: "Nhật kí quản trị" nUsersMentioned: "Dùng bởi {n} người" securityKeyAndPasskey: "Mã bảo mật・Passkey" securityKey: "Khóa bảo mật" @@ -458,6 +460,7 @@ retype: "Nhập lại" noteOf: "Tút của {user}" quoteAttached: "Trích dẫn" quoteQuestion: "Trích dẫn lại?" +attachAsFileQuestion: "Văn bản ở trong bộ nhớ tạm rất dài. Bạn có muốn đăng nó dưới dạng một tệp văn bản không?" noMessagesYet: "Chưa có tin nhắn" newMessageExists: "Bạn có tin nhắn mới" onlyOneFileCanBeAttached: "Bạn chỉ có thể đính kèm một tập tin" @@ -1559,8 +1562,6 @@ _sfx: note: "Tút" noteMy: "Tút của tôi" notification: "Thông báo" - antenna: "Trạm phát sóng" - channel: "Kênh" _ago: future: "Tương lai" justNow: "Vừa xong" @@ -1922,6 +1923,10 @@ _webhookSettings: _events: reaction: "Khi nhận được sự kiện" mention: "Khi có người nhắc tới bạn" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Email" _moderationLogTypes: suspend: "Vô hiệu hóa" resetPassword: "Đặt lại mật khẩu" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index f92d997b5a..8eae32ca32 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -60,6 +60,7 @@ copyFileId: "复制文件ID" copyFolderId: "复制文件夹ID" copyProfileUrl: "复制个人资料URL" searchUser: "搜索用户" +searchThisUsersNotes: "搜索用户帖子" reply: "回复" loadMore: "查看更多" showMore: "查看更多" @@ -154,6 +155,7 @@ editList: "编辑列表" selectChannel: "选择频道" selectAntenna: "选择天线" editAntenna: "编辑天线" +createAntenna: "创建天线" selectWidget: "选择小工具" editWidgets: "编辑部件" editWidgetsExit: "完成编辑" @@ -180,6 +182,10 @@ addAccount: "添加账户" reloadAccountsList: "更新账户列表" loginFailed: "登录失败" showOnRemote: "转到所在服务器显示" +continueOnRemote: "转到所在服务器继续" +chooseServerOnMisskeyHub: "从 Misskey Hub 选择服务器" +specifyServerHost: "直接输入服务器域名" +inputHostName: "请输入域名" general: "常规设置" wallpaper: "壁纸" setWallpaper: "设置壁纸" @@ -190,6 +196,7 @@ followConfirm: "你确定要关注 {name} 吗?" proxyAccount: "代理账户" proxyAccountDescription: "代理账户是在某些情况下替代用户进行远程关注用的账户。 例如说,当用户将一位远程用户放入一个列表中时,如果本地服务器上没有任何人关注这位远程用户,则这位远程用户的账户活动将不会被送到本地服务器上。作为替代,此时将使用代理账户进行关注。" host: "主机名" +selectSelf: "选择自己" selectUser: "选择用户" recipient: "收件人" annotation: "注解" @@ -351,7 +358,7 @@ instanceName: "服务器名称" instanceDescription: "服务器简介" maintainerName: "管理员名称" maintainerEmail: "管理员电子邮箱" -tosUrl: "服务条款 URL" +tosUrl: "服务条款地址" thisYear: "今年" thisMonth: "本月" today: "今天" @@ -433,8 +440,8 @@ administrator: "管理员" token: "Token (令牌)" 2fa: "双因素认证" setupOf2fa: "设置双因素认证" -totp: "身份验证应用" -totpDescription: "使用认证应用输入一次性密码。" +totp: "验证器" +totpDescription: "使用验证器输入一次性密码" moderator: "监察员" moderation: "管理" moderationNote: "管理笔记" @@ -477,6 +484,7 @@ noMessagesYet: "现在没有新的聊天" newMessageExists: "新信息" onlyOneFileCanBeAttached: "只能添加一个附件" signinRequired: "请先登录" +signinOrContinueOnRemote: "若要继续,需要转到您所使用的实例,或者在此服务器上注册或登录。" invitations: "邀请" invitationCode: "邀请码" checking: "正在确认" @@ -837,6 +845,7 @@ administration: "管理" accounts: "账户" switch: "切换" noMaintainerInformationWarning: "管理人员信息未设置。" +noInquiryUrlWarning: "尚未设置联络地址。" noBotProtectionWarning: "Bot 防御未设置。" configure: "设置" postToGallery: "发送到图库" @@ -848,7 +857,7 @@ shareWithNote: "在帖子中分享" ads: "广告" expiration: "截止时间" startingperiod: "开始时间" -memo: "便笺" +memo: "备注" priority: "优先级" high: "高" middle: "中" @@ -1100,6 +1109,8 @@ preservedUsernames: "保留的用户名" preservedUsernamesDescription: "列出需要保留的用户名,使用换行来作为分割。被指定的用户名在建立账户时无法使用,但由管理员所创建的账户不受该限制。此外,现有的账户也不会受到影响。" createNoteFromTheFile: "从文件创建帖子" archive: "归档" +archived: "已归档" +unarchive: "取消归档" channelArchiveConfirmTitle: "要将 {name} 归档吗?" channelArchiveConfirmDescription: "归档后,在频道列表与搜索结果中不会显示,也无法发布新的贴文。" thisChannelArchived: "该频道已被归档。" @@ -1110,6 +1121,7 @@ preventAiLearning: "拒绝接受生成式 AI 的学习" preventAiLearningDescription: "要求文章生成 AI 或图像生成 AI 不能够以发布的帖子和图像等内容作为学习对象。这是通过在 HTML 响应中包含 noai 标志来实现的,这不能完全阻止 AI 学习你的发布内容,并不是所有 AI 都会遵守这类请求。" options: "选项" specifyUser: "用户指定" +specifyHost: "指定主机名" failedToPreviewUrl: "无法预览" update: "更新" rolesThatCanBeUsedThisEmojiAsReaction: "可以使用表情作为回应的角色" @@ -1180,7 +1192,7 @@ externalServices: "外部服务" sourceCode: "源代码" sourceCodeIsNotYetProvided: "还未提供源代码。要解决此问题请联系管理员。" repositoryUrl: "仓库地址" -repositoryUrlDescription: "若源代码所在的仓库是公开的,请填入对应的 URL。若是按原样使用 Misskey(并未追加或者修改代码)的情况请填入 https://github.com/misskey-dev/misskey。" +repositoryUrlDescription: "若源代码所在的仓库是公开的,请填入对应的 URL。若并未追加或者修改 Misskey 的代码,请填入 https://github.com/misskey-dev/misskey。" repositoryUrlOrTarballRequired: "若仓库并未公开,则需要提供 tarball 作为替代。详情请看 .config/example.yml。" feedback: "反馈" feedbackUrl: "反馈地址" @@ -1241,6 +1253,11 @@ keepOriginalFilenameDescription: "若关闭此设置,上传文件时文件名 noDescription: "没有描述" alwaysConfirmFollow: "总是确认关注" inquiry: "联系我们" +tryAgain: "请再试一次" +confirmWhenRevealingSensitiveMedia: "显示敏感内容前需要确认" +sensitiveMediaRevealConfirm: "这是敏感内容。是否显示?" +createdLists: "已创建的列表" +createdAntennas: "已创建的天线" _delivery: status: "投递状态" stop: "停止投递" @@ -1375,6 +1392,8 @@ _serverSettings: fanoutTimelineDescription: "当启用时,可显著提高获取各种时间线时的性能,并减轻数据库的负荷。但是相对的 Redis 的内存使用量将会增加。如果服务器的内存不是很大,又或者运行不稳定的话可以把它关掉。" fanoutTimelineDbFallback: "回退到数据库" fanoutTimelineDbFallbackDescription: "当启用时,若时间线未被缓存,则将额外查询数据库。禁用该功能可通过不执行回退处理进一步减少服务器负载,但会限制可检索的时间线范围。" + inquiryUrl: "联络地址" + inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。" _accountMigration: moveFrom: "从别的账号迁移到此账户" moveFromSub: "为另一个账户建立别名" @@ -1670,8 +1689,8 @@ _role: descriptionOfIsExplorable: "打开后将公开角色时间线。如果角色不是公开的,就无法公开时间线。" displayOrder: "显示顺序" descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" - canEditMembersByModerator: "允许监察者编辑成员" - descriptionOfCanEditMembersByModerator: "如果选中,监察者和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" + canEditMembersByModerator: "允许监察员编辑成员" + descriptionOfCanEditMembersByModerator: "如果选中,监察员和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" _priority: low: "低" @@ -1690,6 +1709,7 @@ _role: canManageAvatarDecorations: "管理头像挂件" driveCapacity: "网盘容量" alwaysMarkNsfw: "总是将文件标记为 NSFW" + canUpdateBioMedia: "可以更新头像和横幅" pinMax: "帖子置顶数量限制" antennaMax: "可创建的最大天线数量" wordMuteMax: "屏蔽词的字数限制" @@ -1933,8 +1953,6 @@ _sfx: note: "帖子" noteMy: "我的帖子" notification: "通知" - antenna: "天线接收" - channel: "频道通知" reaction: "选择回应时" _soundSettings: driveFile: "使用网盘内的音频" @@ -1969,7 +1987,7 @@ _time: day: "日" _2fa: alreadyRegistered: "此设备已被注册" - registerTOTP: "开始设置认证应用" + registerTOTP: "开始设置验证器" step1: "首先,在您的设备上安装验证应用,例如 {a} 或 {b}。" step2: "然后,扫描屏幕上显示的二维码。" step2Uri: "如果使用桌面应用程序的话,请输入下面的 URI" @@ -1978,23 +1996,23 @@ _2fa: setupCompleted: "设置完成" step4: "从现在开始,任何登录操作都将要求您提供动态口令。" securityKeyNotSupported: "您的浏览器不支持安全密钥。" - registerTOTPBeforeKey: "要注册安全密钥或 Passkey,请先设置验证器应用程序。" + registerTOTPBeforeKey: "要注册安全密钥或 Passkey,请先设置验证器。" securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 码以及 Passkey 等。" registerSecurityKey: "注册安全密钥或 Passkey" securityKeyName: "输入密钥名称" tapSecurityKey: "请按照浏览器说明操作来注册安全密钥或 Passkey。" removeKey: "删除安全密钥" removeKeyConfirm: "您确定要删除 {name} 吗?" - whyTOTPOnlyRenew: "如果注册了安全密钥,则无法取消验证器应用程序上的设置。" - renewTOTP: "重置验证器应用程序" - renewTOTPConfirm: "当前验证器应用程序的验证码将不再有效" + whyTOTPOnlyRenew: "当注册了安全密钥时,无法取消使用验证器。" + renewTOTP: "重置验证器" + renewTOTPConfirm: "当前验证器的验证码及备用代码已失效" renewTOTPOk: "重新配置" renewTOTPCancel: "不用,谢谢" checkBackupCodesBeforeCloseThisWizard: "在关闭此窗口前,请确认下面的备用代码" backupCodes: "备用代码" - backupCodesDescription: "如果无法使用认证应用,可以使用以下的备用代码来访问账户。请务必将这些代码保存在安全的地方。每个代码仅可使用一次。" - backupCodeUsedWarning: "已使用备用代码。如果无法使用认证应用,请尽快重新设定。" - backupCodesExhaustedWarning: "已使用完所有的备用代码。如果无法使用认证应用,将无法再访问您的账户。请再次设定认证应用。" + backupCodesDescription: "如果无法使用验证器,可以使用以下的备用代码来访问账户。请务必将这些代码保存在安全的地方。每个代码仅可使用一次。" + backupCodeUsedWarning: "已使用备用代码。若验证器无法使用,请尽快重置验证器。" + backupCodesExhaustedWarning: "已使用完所有的备用代码。若验证器无法使用,则无法再访问您的账户。请重置验证器。" moreDetailedGuideHere: "此处为详细指南" _permissions: "read:account": "查看账户信息" @@ -2398,6 +2416,7 @@ _drivecleaner: orderByCreatedAtAsc: "按添加日期降序排列" _webhookSettings: createWebhook: "创建 Webhook" + modifyWebhook: "编辑 webhook" name: "名称" secret: "密钥" events: "何时运行 Webhook" @@ -2410,6 +2429,26 @@ _webhookSettings: renote: "被转发时" reaction: "被回应时" mention: "被提及时" + _systemEvents: + abuseReport: "当收到举报时" + abuseReportResolved: "当举报被处理时" + userCreated: "当用户被创建时" + deleteConfirm: "要删除 webhook 吗?" +_abuseReport: + _notificationRecipient: + createRecipient: "新建举报通知" + modifyRecipient: "编辑举报通知" + recipientType: "通知类型" + _recipientType: + mail: "邮箱" + webhook: "Webhook" + _captions: + mail: "当收到新举报时,向持有监察员权限的用户发送通知邮件" + webhook: "当收到新举报及举报被处理时,使用指定的 SystemWebhook 发送通知" + keywords: "关键字" + notifiedUser: "通知的用户" + notifiedWebhook: "使用的 webhook" + deleteConfirm: "要删除通知吗?" _moderationLogTypes: createRole: "创建角色" deleteRole: "删除角色" @@ -2447,6 +2486,12 @@ _moderationLogTypes: deleteAvatarDecoration: "删除头像挂件" unsetUserAvatar: "清除用户头像" unsetUserBanner: "清除用户横幅" + createSystemWebhook: "新建了 SystemWebhook" + updateSystemWebhook: "更新了 SystemWebhook" + deleteSystemWebhook: "删除了 SystemWebhook" + createAbuseReportNotificationRecipient: "新建了举报通知" + updateAbuseReportNotificationRecipient: "更新了举报通知" + deleteAbuseReportNotificationRecipient: "删除了举报通知" _fileViewer: title: "文件信息" type: "文件类型" @@ -2578,3 +2623,8 @@ _mediaControls: pip: "画中画" playbackRate: "播放速度" loop: "循环播放" +_contextMenu: + title: "上下文菜单" + app: "应用" + appWithShift: "Shift 键应用" + native: "浏览器的用户界面" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index aac3f7662c..8b53273c3b 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -60,6 +60,7 @@ copyFileId: "複製檔案 ID" copyFolderId: "複製資料夾ID" copyProfileUrl: "複製個人資料網址" searchUser: "搜尋使用者" +searchThisUsersNotes: "搜尋這個使用者的貼文" reply: "回覆" loadMore: "載入更多" showMore: "載入更多" @@ -154,6 +155,7 @@ editList: "編輯清單" selectChannel: "選擇頻道" selectAntenna: "選擇天線" editAntenna: "編輯天線" +createAntenna: "建立天線" selectWidget: "選擇小工具" editWidgets: "編輯小工具" editWidgetsExit: "完成" @@ -180,6 +182,10 @@ addAccount: "新增帳戶" reloadAccountsList: "更新帳戶清單的資訊" loginFailed: "登入失敗" showOnRemote: "轉到所在實例顯示" +continueOnRemote: "在遠端伺服器繼續" +chooseServerOnMisskeyHub: "從 Misskey Hub 選擇伺服器" +specifyServerHost: "直接指定伺服器網域" +inputHostName: "請輸入域名" general: "一般" wallpaper: "桌布" setWallpaper: "設定桌布" @@ -190,6 +196,7 @@ followConfirm: "你真的要追隨{name}嗎?" proxyAccount: "代理帳戶" proxyAccountDescription: "代理帳戶是在特定條件下充當遠端追隨者的帳戶。例如,當使用者新增遠端使用者至其列表時,若沒有本地使用者追隨該遠端使用者,則其活動將不會傳送至伺服器,此時便會由代理帳戶代為追隨以解決問題。" host: "主機" +selectSelf: "選擇自己" selectUser: "選取使用者" recipient: "收件人" annotation: "註解" @@ -243,10 +250,10 @@ noCustomEmojis: "沒有自訂的表情符號" noJobs: "沒有任務" federating: "聯邦運作中" blocked: "已封鎖" -suspended: "已凍結" +suspended: "停止發送" all: "全部" subscribing: "訂閱中" -publishing: "直播中" +publishing: "發送中" notResponding: "沒有回應" instanceFollowing: "追隨的伺服器" instanceFollowers: "伺服器的追隨者" @@ -343,7 +350,7 @@ reload: "重新整理" doNothing: "無視" reloadConfirm: "確定要重新整理嗎?" watch: "關注" -unwatch: "取消追隨" +unwatch: "取消關注" accept: "接受" reject: "拒絕" normal: "正常" @@ -477,6 +484,7 @@ noMessagesYet: "沒有訊息" newMessageExists: "有新的訊息" onlyOneFileCanBeAttached: "只能加入一個附件" signinRequired: "請先登入" +signinOrContinueOnRemote: "若要繼續,需前往您所在的伺服器,或者註冊並登入此伺服器" invitations: "邀請" invitationCode: "邀請碼" checking: "確認中" @@ -837,6 +845,7 @@ administration: "管理" accounts: "帳戶" switch: "切換" noMaintainerInformationWarning: "尚未設定管理員訊息。" +noInquiryUrlWarning: "尚未設定聯絡表單網址。" noBotProtectionWarning: "尚未設定 Bot 防護。" configure: "設定" postToGallery: "發佈到相簿" @@ -1100,6 +1109,8 @@ preservedUsernames: "保留的使用者名稱" preservedUsernamesDescription: "換行列舉要保留的使用者名稱。此處出現的名稱將在註冊時禁用,但由管理者建立帳戶則不受此限。此外,既有的帳戶也不受影響。" createNoteFromTheFile: "由此檔案建立貼文" archive: "封存" +archived: "已封存" +unarchive: "取消封存" channelArchiveConfirmTitle: "要封存{name}嗎?" channelArchiveConfirmDescription: "封存後,將不會在頻道列表與搜尋結果中顯示,也無法發佈新貼文。" thisChannelArchived: "這個頻道已被封存。" @@ -1110,6 +1121,7 @@ preventAiLearning: "拒絕接受生成式AI的訓練" preventAiLearningDescription: "要求站外生成式 AI 不使用您發佈的內容訓練模型。此功能會使伺服器於 HTML 回應新增「noai」標籤,而因為要視乎 AI 會否遵守該標籤,所以此功能無法完全阻止所有 AI 使用您的內容。" options: "選項" specifyUser: "指定使用者" +specifyHost: "指定主機" failedToPreviewUrl: "無法預覽" update: "更新" rolesThatCanBeUsedThisEmojiAsReaction: "可以使用此表情符號為反應的角色" @@ -1241,12 +1253,17 @@ keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱 noDescription: "沒有說明文字" alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息" inquiry: "聯絡我們" +tryAgain: "請再試一次。" +confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認" +sensitiveMediaRevealConfirm: "這是敏感媒體。確定要顯示嗎?" +createdLists: "已建立的清單" +createdAntennas: "已建立的天線" _delivery: status: "傳送狀態" - stop: "停止傳送" - resume: "恢復傳送" + stop: "停止發送" + resume: "恢復發送" _type: - none: "直播中" + none: "發送中" manuallySuspended: "手動暫停中" goneSuspended: "因為伺服器刪除所以暫停中" autoSuspendedForNotResponding: "因為伺服器沒有回應所以暫停中" @@ -1376,7 +1393,7 @@ _serverSettings: fanoutTimelineDbFallback: "資料庫的回退" fanoutTimelineDbFallbackDescription: "若啟用,在時間軸沒有快取的情況下將執行回退處理以額外查詢資料庫。若停用,可以透過不執行回退處理來進一步減少伺服器的負荷,但會限制可取得的時間軸範圍。" inquiryUrl: "聯絡表單網址" - inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址或包含運營者聯絡資訊網頁的網址。" + inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" @@ -1693,6 +1710,7 @@ _role: canManageAvatarDecorations: "管理頭像裝飾" driveCapacity: "雲端硬碟容量" alwaysMarkNsfw: "總是將檔案標記為NSFW" + canUpdateBioMedia: "允許更新大頭貼和橫幅" pinMax: "置頂貼文的最大數量" antennaMax: "可建立的天線數量" wordMuteMax: "靜音文字的最大字數" @@ -1936,8 +1954,6 @@ _sfx: note: "貼文" noteMy: "我的貼文" notification: "通知" - antenna: "天線接收" - channel: "頻道通知" reaction: "選擇反應時" _soundSettings: driveFile: "使用雲端硬碟的音效檔案" @@ -2217,7 +2233,7 @@ _charts: federation: "聯邦宇宙" apRequest: "請求" usersIncDec: "使用者增減" - usersTotal: "使用者總數" + usersTotal: "使用者合計" activeUsers: "活躍使用者" notesIncDec: "貼文増減" localNotesIncDec: "本地貼文増減" @@ -2401,6 +2417,7 @@ _drivecleaner: orderByCreatedAtAsc: "按新增日期降序排列" _webhookSettings: createWebhook: "建立 Webhook" + modifyWebhook: "編輯 Webhook" name: "名字" secret: "密鑰" events: "何時運行 Webhook" @@ -2413,6 +2430,25 @@ _webhookSettings: renote: "當被轉發時" reaction: "當獲得反應時" mention: "當被提到時" + _systemEvents: + abuseReport: "當使用者檢舉時" + abuseReportResolved: "當處理了使用者的檢舉時" + deleteConfirm: "請問是否要刪除 Webhook?" +_abuseReport: + _notificationRecipient: + createRecipient: "新增接收檢舉的通知對象" + modifyRecipient: "編輯接收檢舉的通知對象" + recipientType: "通知對象的種類" + _recipientType: + mail: "電子郵件" + webhook: "Webhook" + _captions: + mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)" + webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)" + keywords: "關鍵字" + notifiedUser: "被通知的使用者" + notifiedWebhook: "使用的 Webhook" + deleteConfirm: "確定要刪除通知對象嗎?" _moderationLogTypes: createRole: "新增角色" deleteRole: "刪除角色 " @@ -2450,6 +2486,12 @@ _moderationLogTypes: deleteAvatarDecoration: "刪除頭像裝飾" unsetUserAvatar: "移除使用者的大頭貼" unsetUserBanner: "移除使用者的橫幅圖像" + createSystemWebhook: "建立 SystemWebhook" + updateSystemWebhook: "更新 SystemWebhook" + deleteSystemWebhook: "刪除 SystemWebhook" + createAbuseReportNotificationRecipient: "建立接收檢舉的通知對象" + updateAbuseReportNotificationRecipient: "更新接收檢舉的通知對象" + deleteAbuseReportNotificationRecipient: "刪除接收檢舉的通知對象" _fileViewer: title: "檔案詳細資訊" type: "檔案類型 " @@ -2581,3 +2623,7 @@ _mediaControls: pip: "畫中畫" playbackRate: "播放速度" loop: "循環播放" +_contextMenu: + title: "內容功能表" + app: "應用程式" + native: "瀏覽器的使用者介面" diff --git a/misskey-assets b/misskey-assets deleted file mode 160000 index 0179793ec8..0000000000 --- a/misskey-assets +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0179793ec891856d6f37a3be16ba4c22f67a81b5 diff --git a/package.json b/package.json index 0dd7afb9e9..42751be196 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "2024.7.0-beta.0", + "version": "2024.7.0-rc.8", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@9.5.0", + "packageManager": "pnpm@9.6.0", "workspaces": [ "packages/frontend", "packages/backend", @@ -21,7 +21,7 @@ "build-assets": "node ./scripts/build-assets.mjs", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", - "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", + "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", @@ -51,24 +51,24 @@ "cssnano": "6.1.2", "execa": "8.0.1", "fast-glob": "3.3.2", - "ignore-walk": "6.0.4", + "ignore-walk": "6.0.5", "js-yaml": "4.1.0", - "postcss": "8.4.38", + "postcss": "8.4.40", "tar": "6.2.1", - "terser": "5.31.1", - "typescript": "5.5.3", - "esbuild": "0.22.0", - "glob": "10.3.12" + "terser": "5.31.3", + "typescript": "5.5.4", + "esbuild": "0.23.0", + "glob": "11.0.0" }, "devDependencies": { "@misskey-dev/eslint-plugin": "2.0.2", - "@types/node": "20.14.9", - "@typescript-eslint/eslint-plugin": "7.15.0", - "@typescript-eslint/parser": "7.15.0", + "@types/node": "20.14.12", + "@typescript-eslint/eslint-plugin": "7.17.0", + "@typescript-eslint/parser": "7.17.0", "cross-env": "7.0.3", - "cypress": "13.13.0", - "eslint": "9.6.0", - "globals": "15.7.0", + "cypress": "13.13.1", + "eslint": "9.8.0", + "globals": "15.8.0", "ncp": "2.0.0", "start-server-and-test": "2.0.4" }, diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index 318b7fd340..4fd9f0cd51 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -4,7 +4,7 @@ import sharedConfig from '../shared/eslint.config.js'; export default [ ...sharedConfig, { - ignores: ['**/node_modules', 'built', '@types/**/*'], + ignores: ['**/node_modules', 'built', '@types/**/*', 'migration'], }, { files: ['**/*.ts', '**/*.tsx'], diff --git a/packages/backend/migration/1708980134301-APMultipleKeys.js b/packages/backend/migration/1708980134301-APMultipleKeys.js deleted file mode 100644 index ca55526c6e..0000000000 --- a/packages/backend/migration/1708980134301-APMultipleKeys.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export class APMultipleKeys1708980134301 { - name = 'APMultipleKeys1708980134301' - - async up(queryRunner) { - await queryRunner.query(`DROP INDEX "public"."IDX_171e64971c780ebd23fae140bb"`); - await queryRunner.query(`ALTER TABLE "user_keypair" ADD "ed25519PublicKey" character varying(128)`); - await queryRunner.query(`ALTER TABLE "user_keypair" ADD "ed25519PrivateKey" character varying(128)`); - await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`); - await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_10c146e4b39b443ede016f6736d"`); - await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_0db6a5fdb992323449edc8ee421" PRIMARY KEY ("userId", "keyId")`); - await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_0db6a5fdb992323449edc8ee421"`); - await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_171e64971c780ebd23fae140bba" PRIMARY KEY ("keyId")`); - await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`); - await queryRunner.query(`CREATE INDEX "IDX_10c146e4b39b443ede016f6736" ON "user_publickey" ("userId") `); - await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); - } - - async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`); - await queryRunner.query(`DROP INDEX "public"."IDX_10c146e4b39b443ede016f6736"`); - await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "UQ_10c146e4b39b443ede016f6736d"`); - await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_171e64971c780ebd23fae140bba"`); - await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_0db6a5fdb992323449edc8ee421" PRIMARY KEY ("userId", "keyId")`); - await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_0db6a5fdb992323449edc8ee421"`); - await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_10c146e4b39b443ede016f6736d" PRIMARY KEY ("userId")`); - await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" DROP DEFAULT`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" TYPE "public"."user_profile_followersVisibility_enum_old" USING "followersVisibility"::"text"::"public"."user_profile_followersVisibility_enum_old"`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" SET DEFAULT 'public'`); - await queryRunner.query(`ALTER TABLE "user_keypair" DROP COLUMN "ed25519PrivateKey"`); - await queryRunner.query(`ALTER TABLE "user_keypair" DROP COLUMN "ed25519PublicKey"`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_171e64971c780ebd23fae140bb" ON "user_publickey" ("keyId") `); - } -} diff --git a/packages/backend/migration/1709242519122-HttpSignImplLv.js b/packages/backend/migration/1709242519122-HttpSignImplLv.js deleted file mode 100644 index 7748bae006..0000000000 --- a/packages/backend/migration/1709242519122-HttpSignImplLv.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export class HttpSignImplLv1709242519122 { - name = 'HttpSignImplLv1709242519122' - - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "instance" ADD "httpMessageSignaturesImplementationLevel" character varying(16) NOT NULL DEFAULT '00'`); - } - - async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "httpMessageSignaturesImplementationLevel"`); - } -} diff --git a/packages/backend/migration/1709269211718-APMultipleKeysFix1.js b/packages/backend/migration/1709269211718-APMultipleKeysFix1.js deleted file mode 100644 index d2011802f2..0000000000 --- a/packages/backend/migration/1709269211718-APMultipleKeysFix1.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export class APMultipleKeys1709269211718 { - name = 'APMultipleKeys1709269211718' - - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "UQ_10c146e4b39b443ede016f6736d"`); - } - - async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`); - } -} diff --git a/packages/backend/migration/1716197366117-MediaSilenceForHosts.js b/packages/backend/migration/1716197366117-MediaSilenceForHosts.js new file mode 100644 index 0000000000..10bb7f0255 --- /dev/null +++ b/packages/backend/migration/1716197366117-MediaSilenceForHosts.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class MediaSilenceForHosts1716197366117 { + name = 'MediaSilenceForHosts1716197366117' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "mediaSilencedHosts" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "mediaSilencedHosts"`); + } +} diff --git a/packages/backend/migration/1721666053703-fixDriveUrl.js b/packages/backend/migration/1721666053703-fixDriveUrl.js new file mode 100644 index 0000000000..d8512fb835 --- /dev/null +++ b/packages/backend/migration/1721666053703-fixDriveUrl.js @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixDriveUrl1721666053703 { + name = 'FixDriveUrl1721666053703' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "url" TYPE character varying(1024), ALTER COLUMN "url" SET NOT NULL`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."url" IS 'The URL of the DriveFile.'`); + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "uri" TYPE character varying(1024)`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."uri" IS 'The URI of the DriveFile. it will be null when the DriveFile is local.'`); + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "src" TYPE character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "src" TYPE character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."uri" IS 'The URI of the DriveFile. it will be null when the DriveFile is local.'`); + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "uri" TYPE character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."url" IS 'The URL of the DriveFile.'`); + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "url" TYPE character varying(512), ALTER COLUMN "url" SET NOT NULL`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 893171ebd6..f497610af9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -31,7 +31,7 @@ "test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e", "test-and-coverage": "pnpm jest-and-coverage", "test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e", - "generate-api-json": "pnpm build && node ./scripts/generate_api_json.js" + "generate-api-json": "node ./scripts/generate_api_json.js" }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", @@ -65,11 +65,11 @@ "utf-8-validate": "6.0.3" }, "dependencies": { - "@aws-sdk/client-s3": "3.600.0", - "@aws-sdk/lib-storage": "3.600.0", - "@bull-board/api": "5.20.5", - "@bull-board/fastify": "5.20.5", - "@bull-board/ui": "5.20.5", + "@aws-sdk/client-s3": "3.620.0", + "@aws-sdk/lib-storage": "3.620.0", + "@bull-board/api": "5.21.1", + "@bull-board/fastify": "5.21.1", + "@bull-board/ui": "5.21.1", "@discordapp/twemoji": "15.0.3", "@fastify/accepts": "4.3.0", "@fastify/cookie": "9.3.1", @@ -79,29 +79,29 @@ "@fastify/multipart": "8.3.0", "@fastify/static": "7.0.4", "@fastify/view": "9.1.0", - "@misskey-dev/node-http-message-signatures": "0.0.10", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.1.0", "@napi-rs/canvas": "^0.1.53", "@nestjs/common": "10.3.10", "@nestjs/core": "10.3.10", "@nestjs/testing": "10.3.10", - "@sentry/node": "8.13.0", - "@sentry/profiling-node": "8.13.0", - "@simplewebauthn/server": "10.0.0", + "@peertube/http-signature": "1.7.0", + "@sentry/node": "8.20.0", + "@sentry/profiling-node": "8.20.0", + "@simplewebauthn/server": "10.0.1", "@sinonjs/fake-timers": "11.2.2", "@smithy/node-http-handler": "2.5.0", "@swc/cli": "0.3.12", "@swc/core": "1.6.6", "@twemoji/parser": "15.1.1", "accepts": "1.3.8", - "ajv": "8.16.0", + "ajv": "8.17.1", "archiver": "7.0.1", "async-mutex": "0.5.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.2", - "bullmq": "5.8.3", + "bullmq": "5.10.4", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", "chalk": "5.3.0", @@ -115,10 +115,10 @@ "fastify": "4.28.1", "fastify-raw-body": "4.3.0", "feed": "4.2.2", - "file-type": "19.0.0", + "file-type": "19.3.0", "fluent-ffmpeg": "2.1.3", "form-data": "4.0.0", - "got": "14.4.1", + "got": "14.4.2", "happy-dom": "10.0.3", "hpagent": "1.2.0", "htmlescape": "1.1.1", @@ -128,7 +128,7 @@ "ipaddr.js": "2.2.0", "is-svg": "5.0.1", "js-yaml": "4.1.0", - "jsdom": "24.1.0", + "jsdom": "24.1.1", "json5": "2.2.3", "jsonld": "8.3.2", "jsrsasign": "11.1.0", @@ -177,11 +177,11 @@ "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", "typeorm": "0.3.20", - "typescript": "5.5.3", + "typescript": "5.5.4", "ulid": "2.3.0", "vary": "1.1.2", "web-push": "3.6.7", - "ws": "8.17.1", + "ws": "8.18.0", "xev": "3.0.2" }, "devDependencies": { @@ -201,11 +201,11 @@ "@types/jest": "29.5.12", "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", - "@types/jsonld": "1.5.14", + "@types/jsonld": "1.5.15", "@types/jsrsasign": "10.5.14", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "20.14.9", + "@types/node": "20.14.12", "@types/nodemailer": "6.4.15", "@types/oauth": "0.9.5", "@types/oauth2orize": "1.11.5", @@ -225,18 +225,18 @@ "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.3", - "@types/ws": "8.5.10", - "@typescript-eslint/eslint-plugin": "7.15.0", - "@typescript-eslint/parser": "7.15.0", + "@types/ws": "8.5.11", + "@typescript-eslint/eslint-plugin": "7.17.0", + "@typescript-eslint/parser": "7.17.0", "aws-sdk-client-mock": "4.0.1", "cross-env": "7.0.3", "eslint-plugin-import": "2.29.1", - "execa": "9.2.0", + "execa": "9.3.0", "fkill": "9.0.0", "jest": "29.7.0", "jest-mock": "29.7.0", "nodemon": "3.1.4", "pid-port": "1.0.0", - "simple-oauth2": "5.0.1" + "simple-oauth2": "5.1.0" } } diff --git a/packages/backend/scripts/generate_api_json.js b/packages/backend/scripts/generate_api_json.js index b4769ef801..798e243004 100644 --- a/packages/backend/scripts/generate_api_json.js +++ b/packages/backend/scripts/generate_api_json.js @@ -3,11 +3,34 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { loadConfig } from '../built/config.js' -import { genOpenapiSpec } from '../built/server/api/openapi/gen-spec.js' -import { writeFileSync } from "node:fs"; +import { execa } from 'execa'; +import { writeFileSync, existsSync } from "node:fs"; -const config = loadConfig(); -const spec = genOpenapiSpec(config, true); +async function main() { + if (!process.argv.includes('--no-build')) { + await execa('pnpm', ['run', 'build'], { + stdout: process.stdout, + stderr: process.stderr, + }); + } -writeFileSync('./built/api.json', JSON.stringify(spec), 'utf-8'); + if (!existsSync('./built')) { + throw new Error('`built` directory does not exist.'); + } + + /** @type {import('../src/config.js')} */ + const { loadConfig } = await import('../built/config.js'); + + /** @type {import('../src/server/api/openapi/gen-spec.js')} */ + const { genOpenapiSpec } = await import('../built/server/api/openapi/gen-spec.js'); + + const config = loadConfig(); + const spec = genOpenapiSpec(config, true); + + writeFileSync('./built/api.json', JSON.stringify(spec), 'utf-8'); +} + +main().catch(e => { + console.error(e); + process.exit(1); +}); diff --git a/packages/backend/src/@types/http-signature.d.ts b/packages/backend/src/@types/http-signature.d.ts new file mode 100644 index 0000000000..75b62e55f0 --- /dev/null +++ b/packages/backend/src/@types/http-signature.d.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare module '@peertube/http-signature' { + import type { IncomingMessage, ClientRequest } from 'node:http'; + + interface ISignature { + keyId: string; + algorithm: string; + headers: string[]; + signature: string; + } + + interface IOptions { + headers?: string[]; + algorithm?: string; + strict?: boolean; + authorizationHeaderName?: string; + } + + interface IParseRequestOptions extends IOptions { + clockSkew?: number; + } + + interface IParsedSignature { + scheme: string; + params: ISignature; + signingString: string; + algorithm: string; + keyId: string; + } + + type RequestSignerConstructorOptions = + IRequestSignerConstructorOptionsFromProperties | + IRequestSignerConstructorOptionsFromFunction; + + interface IRequestSignerConstructorOptionsFromProperties { + keyId: string; + key: string | Buffer; + algorithm?: string; + } + + interface IRequestSignerConstructorOptionsFromFunction { + sign?: (data: string, cb: (err: any, sig: ISignature) => void) => void; + } + + class RequestSigner { + constructor(options: RequestSignerConstructorOptions); + + public writeHeader(header: string, value: string): string; + + public writeDateHeader(): string; + + public writeTarget(method: string, path: string): void; + + public sign(cb: (err: any, authz: string) => void): void; + } + + interface ISignRequestOptions extends IOptions { + keyId: string; + key: string; + httpVersion?: string; + } + + export function parse(request: IncomingMessage, options?: IParseRequestOptions): IParsedSignature; + export function parseRequest(request: IncomingMessage, options?: IParseRequestOptions): IParsedSignature; + + export function sign(request: ClientRequest, options: ISignRequestOptions): boolean; + export function signRequest(request: ClientRequest, options: ISignRequestOptions): boolean; + export function createSigner(): RequestSigner; + export function isSigner(obj: any): obj is RequestSigner; + + export function sshKeyToPEM(key: string): string; + export function sshKeyFingerprint(key: string): string; + export function pemToRsaSSHKey(pem: string, comment: string): string; + + export function verify(parsedSignature: IParsedSignature, pubkey: string | Buffer): boolean; + export function verifySignature(parsedSignature: IParsedSignature, pubkey: string | Buffer): boolean; + export function verifyHMAC(parsedSignature: IParsedSignature, secret: string): boolean; +} diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index c132cc7e7b..a238f4973a 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -3,17 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// dummy export const MAX_NOTE_TEXT_LENGTH = 3000; export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days -export const REMOTE_USER_CACHE_TTL = 1000 * 60 * 60 * 3; // 3hours -export const REMOTE_USER_MOVE_COOLDOWN = 1000 * 60 * 60 * 24 * 14; // 14days - -export const REMOTE_SERVER_CACHE_TTL = 1000 * 60 * 60 * 3; // 3hours - //#region hard limits // If you change DB_* values, you must also change the DB schema. diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index 42e5931212..7be5335885 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -44,7 +44,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { /** * 管理者用Redisイベントを用いて{@link abuseReports}の内容を管理者各位に通知する. - * 通知先ユーザは{@link RoleService.getModeratorIds}の取得結果に依る. + * 通知先ユーザは{@link getModeratorIds}の取得結果に依る. * * @see RoleService.getModeratorIds * @see GlobalEventService.publishAdminStream diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index ca0864f679..69a57b4854 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -3,8 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; +import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; @@ -13,44 +12,30 @@ import { RelayService } from '@/core/RelayService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; @Injectable() -export class AccountUpdateService implements OnModuleInit { - private apDeliverManagerService: ApDeliverManagerService; +export class AccountUpdateService { constructor( - private moduleRef: ModuleRef, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, private userEntityService: UserEntityService, private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, private relayService: RelayService, ) { } - async onModuleInit() { - this.apDeliverManagerService = this.moduleRef.get(ApDeliverManagerService.name); - } - @bindThis - /** - * Deliver account update to followers - * @param userId user id - * @param deliverKey optional. Private key to sign the deliver. - */ - public async publishToFollowers(userId: MiUser['id'], deliverKey?: PrivateKeyWithPem) { + public async publishToFollowers(userId: MiUser['id']) { const user = await this.usersRepository.findOneBy({ id: userId }); if (user == null) throw new Error('user not found'); // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); - await Promise.allSettled([ - this.apDeliverManagerService.deliverToFollowers(user, content, deliverKey), - this.relayService.deliverToRelays(user, content, deliverKey), - ]); + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); } } } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 0208540afa..c9427bbeb7 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -61,6 +61,7 @@ import { UserFollowingService } from './UserFollowingService.js'; import { UserKeypairService } from './UserKeypairService.js'; import { UserListService } from './UserListService.js'; import { UserMutingService } from './UserMutingService.js'; +import { UserRenoteMutingService } from './UserRenoteMutingService.js'; import { UserSuspendService } from './UserSuspendService.js'; import { UserAuthService } from './UserAuthService.js'; import { VideoProcessingService } from './VideoProcessingService.js'; @@ -203,6 +204,7 @@ const $UserFollowingService: Provider = { provide: 'UserFollowingService', useEx const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; +const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService', useExisting: UserRenoteMutingService }; const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; @@ -350,6 +352,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserRenoteMutingService, UserSearchService, UserSuspendService, UserAuthService, @@ -493,6 +496,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserRenoteMutingService, $UserSearchService, $UserSuspendService, $UserAuthService, @@ -637,6 +641,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserRenoteMutingService, UserSearchService, UserSuspendService, UserAuthService, @@ -779,6 +784,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserRenoteMutingService, $UserSearchService, $UserSuspendService, $UserAuthService, diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts index 60ddc9cde2..6c5b0f6a36 100644 --- a/packages/backend/src/core/CreateSystemUserService.ts +++ b/packages/backend/src/core/CreateSystemUserService.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { IsNull, DataSource } from 'typeorm'; -import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js'; +import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { MiUser } from '@/models/User.js'; import { MiUserProfile } from '@/models/UserProfile.js'; import { IdService } from '@/core/IdService.js'; @@ -38,7 +38,7 @@ export class CreateSystemUserService { // Generate secret const secret = generateNativeUserToken(); - const keyPair = await genRSAAndEd25519KeyPair(); + const keyPair = await genRsaKeyPair(); let account!: MiUser; @@ -64,8 +64,9 @@ export class CreateSystemUserService { }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); await transactionalEntityManager.insert(MiUserKeypair, { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, userId: account.id, - ...keyPair, }); await transactionalEntityManager.insert(MiUserProfile, { diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 37c5d1adf7..8aa04b4da7 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -43,6 +43,7 @@ import { RoleService } from '@/core/RoleService.js'; import { correctFilename } from '@/misc/correct-filename.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UtilityService } from '@/core/UtilityService.js'; type AddFileArgs = { /** User who wish to add file */ @@ -127,6 +128,7 @@ export class DriveService { private driveChart: DriveChart, private perUserDriveChart: PerUserDriveChart, private instanceChart: InstanceChart, + private utilityService: UtilityService, ) { const logger = new Logger('drive', 'blue'); this.registerLogger = logger.createSubLogger('register', 'yellow'); @@ -587,6 +589,7 @@ export class DriveService { sensitive ?? false : false; + if (user && this.utilityService.isMediaSilencedHost(instance.mediaSilencedHosts, user.host)) file.isSensitive = true; if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; if (userRoleNSFW) file.isSensitive = true; diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index dc53c8711d..aa16468ecb 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -15,7 +15,6 @@ import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { REMOTE_SERVER_CACHE_TTL } from '@/const.js'; import type { DOMWindow } from 'jsdom'; type NodeInfo = { @@ -25,7 +24,6 @@ type NodeInfo = { version?: unknown; }; metadata?: { - httpMessageSignaturesImplementationLevel?: unknown, name?: unknown; nodeName?: unknown; nodeDescription?: unknown; @@ -41,7 +39,6 @@ type NodeInfo = { @Injectable() export class FetchInstanceMetadataService { private logger: Logger; - private httpColon = 'https://'; constructor( private httpRequestService: HttpRequestService, @@ -51,7 +48,6 @@ export class FetchInstanceMetadataService { private redisClient: Redis.Redis, ) { this.logger = this.loggerService.getLogger('metadata', 'cyan'); - this.httpColon = process.env.MISSKEY_USE_HTTP?.toLowerCase() === 'true' ? 'http://' : 'https://'; } @bindThis @@ -63,7 +59,7 @@ export class FetchInstanceMetadataService { return await this.redisClient.set( `fetchInstanceMetadata:mutex:v2:${host}`, '1', 'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 - 'GET', // 古い値を返す(なかったらnull) + 'GET' // 古い値を返す(なかったらnull) ); } @@ -77,24 +73,23 @@ export class FetchInstanceMetadataService { public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise { const host = instance.host; - if (!force) { - // キャッシュ有効チェックはロック取得前に行う - const _instance = await this.federatedInstanceService.fetch(host); - const now = Date.now(); - if (_instance && _instance.infoUpdatedAt != null && (now - _instance.infoUpdatedAt.getTime() < REMOTE_SERVER_CACHE_TTL)) { - this.logger.debug(`Skip because updated recently ${_instance.infoUpdatedAt.toJSON()}`); - return; - } - - // finallyでunlockされてしまうのでtry内でロックチェックをしない - // (returnであってもfinallyは実行される) - if (await this.tryLock(host) === '1') { - // 1が返ってきていたら他にロックされているという意味なので、何もしない - return; - } + // finallyでunlockされてしまうのでtry内でロックチェックをしない + // (returnであってもfinallyは実行される) + if (!force && await this.tryLock(host) === '1') { + // 1が返ってきていたらロックされているという意味なので、何もしない + return; } try { + if (!force) { + const _instance = await this.federatedInstanceService.fetch(host); + const now = Date.now(); + if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { + // unlock at the finally caluse + return; + } + } + this.logger.info(`Fetching metadata of ${instance.host} ...`); const [info, dom, manifest] = await Promise.all([ @@ -123,14 +118,6 @@ export class FetchInstanceMetadataService { updates.openRegistrations = info.openRegistrations; updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null; - if (info.metadata && info.metadata.httpMessageSignaturesImplementationLevel && ( - info.metadata.httpMessageSignaturesImplementationLevel === '01' || - info.metadata.httpMessageSignaturesImplementationLevel === '11' - )) { - updates.httpMessageSignaturesImplementationLevel = info.metadata.httpMessageSignaturesImplementationLevel; - } else { - updates.httpMessageSignaturesImplementationLevel = '00'; - } } if (name) updates.name = name; @@ -142,12 +129,6 @@ export class FetchInstanceMetadataService { await this.federatedInstanceService.update(instance.id, updates); this.logger.succ(`Successfuly updated metadata of ${instance.host}`); - this.logger.debug('Updated metadata:', { - info: !!info, - dom: !!dom, - manifest: !!manifest, - updates, - }); } catch (e) { this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); } finally { @@ -160,7 +141,7 @@ export class FetchInstanceMetadataService { this.logger.info(`Fetching nodeinfo of ${instance.host} ...`); try { - const wellknown = await this.httpRequestService.getJson(this.httpColon + instance.host + '/.well-known/nodeinfo') + const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') .catch(err => { if (err.statusCode === 404) { throw new Error('No nodeinfo provided'); @@ -203,7 +184,7 @@ export class FetchInstanceMetadataService { private async fetchDom(instance: MiInstance): Promise { this.logger.info(`Fetching HTML of ${instance.host} ...`); - const url = this.httpColon + instance.host; + const url = 'https://' + instance.host; const html = await this.httpRequestService.getHtml(url); @@ -215,7 +196,7 @@ export class FetchInstanceMetadataService { @bindThis private async fetchManifest(instance: MiInstance): Promise | null> { - const url = this.httpColon + instance.host; + const url = 'https://' + instance.host; const manifestUrl = url + '/manifest.json'; @@ -226,7 +207,7 @@ export class FetchInstanceMetadataService { @bindThis private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise { - const url = this.httpColon + instance.host; + const url = 'https://' + instance.host; if (doc) { // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 @@ -253,12 +234,12 @@ export class FetchInstanceMetadataService { @bindThis private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { - const url = this.httpColon + instance.host; + const url = 'https://' + instance.host; return (new URL(manifest.icons[0].src, url)).href; } if (doc) { - const url = this.httpColon + instance.host; + const url = 'https://' + instance.host; // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 const links = Array.from(doc.getElementsByTagName('link')).reverse(); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 2a7d8d4bbe..87aa70713e 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -209,6 +209,10 @@ type SerializedAll = { [K in keyof T]: Serialized; }; +type UndefinedAsNullAll = { + [K in keyof T]: T[K] extends undefined ? null : T[K]; +} + export interface InternalEventTypes { userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; }; @@ -245,46 +249,47 @@ export interface InternalEventTypes { unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; - userKeypairUpdated: { userId: MiUser['id']; }; } +type EventTypesToEventPayload = EventUnionFromDictionary>>; + // name/messages(spec) pairs dictionary export type GlobalEvents = { internal: { name: 'internal'; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; broadcast: { name: 'broadcast'; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; main: { name: `mainStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; drive: { name: `driveStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; note: { name: `noteStream:${MiNote['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; userList: { name: `userListStream:${MiUserList['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; roleTimeline: { name: `roleTimelineStream:${MiRole['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; antenna: { name: `antennaStream:${MiAntenna['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; admin: { name: `adminStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; notes: { name: 'notesStream'; @@ -292,11 +297,11 @@ export type GlobalEvents = { }; reversi: { name: `reversiStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; reversiGame: { name: `reversiGameStream:${MiReversiGame['id']}`; - payload: EventUnionFromDictionary>; + payload: EventTypesToEventPayload; }; }; diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 4249c158d7..7f3cac7c58 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -70,7 +70,7 @@ export class HttpRequestService { localAddress: config.outgoingAddress, }); - const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 16); + const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); this.httpAgent = config.proxy ? new HttpProxyAgent({ diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index a2c3aaa701..32cf3f3e26 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -364,6 +364,9 @@ export class NoteCreateService implements OnApplicationShutdown { mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); } + // if the host is media-silenced, custom emojis are not allowed + if (this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, user.host)) emojis = []; + tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { @@ -933,10 +936,13 @@ export class NoteCreateService implements OnApplicationShutdown { } } - if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL - this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + // 自分自身のHTL + if (note.userHost == null) { + if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { + this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + } } } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index dd3f2182b4..80827a500b 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -13,6 +13,7 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; +import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; import type { DbJobData, DeliverJobData, @@ -32,7 +33,7 @@ import type { UserWebhookDeliverQueue, SystemWebhookDeliverQueue, } from './QueueModule.js'; -import { genRFC3230DigestHeader, type PrivateKeyWithPem, type ParsedSignature } from '@misskey-dev/node-http-message-signatures'; +import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; @Injectable() @@ -89,21 +90,21 @@ export class QueueService { } @bindThis - public async deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean, privateKey?: PrivateKeyWithPem) { + public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { if (content == null) return null; if (to == null) return null; const contentBody = JSON.stringify(content); + const digest = ApRequestCreator.createDigest(contentBody); const data: DeliverJobData = { user: { id: user.id, }, content: contentBody, - digest: await genRFC3230DigestHeader(contentBody, 'SHA-256'), + digest, to, isSharedInbox, - privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem }, }; return this.deliverQueue.add(to, data, { @@ -121,13 +122,13 @@ export class QueueService { * @param user `{ id: string; }` この関数ではThinUserに変換しないので前もって変換してください * @param content IActivity | null * @param inboxes `Map` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox) - * @param forceMainKey boolean | undefined, force to use main (rsa) key * @returns void */ @bindThis - public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map, privateKey?: PrivateKeyWithPem) { + public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map) { if (content == null) return null; const contentBody = JSON.stringify(content); + const digest = ApRequestCreator.createDigest(contentBody); const opts = { attempts: this.config.deliverJobMaxAttempts ?? 12, @@ -143,9 +144,9 @@ export class QueueService { data: { user, content: contentBody, + digest, to: d[0], isSharedInbox: d[1], - privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem }, } as DeliverJobData, opts, }))); @@ -154,7 +155,7 @@ export class QueueService { } @bindThis - public inbox(activity: IActivity, signature: ParsedSignature | null) { + public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { const data = { activity: activity, signature, diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 64c7b2ed03..371207c33a 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -105,6 +105,8 @@ export class ReactionService { @bindThis public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) { + const meta = await this.metaService.fetch(); + // Check blocking if (note.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); @@ -148,6 +150,11 @@ export class ReactionService { if ((note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && emoji.isSensitive) { reaction = FALLBACK; } + + // for media silenced host, custom emoji reactions are not allowed + if (reacterHost != null && this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, reacterHost)) { + reaction = FALLBACK; + } } else { // リアクションとして使う権限がない reaction = FALLBACK; @@ -220,8 +227,6 @@ export class ReactionService { } } - const meta = await this.metaService.fetch(); - if (meta.enableChartsForRemoteUser || (user.host == null)) { this.perUserReactionsChart.update(user, note); } diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index ad01f98902..8dd3d64f5b 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -16,8 +16,6 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { DI } from '@/di-symbols.js'; import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; -import { UserKeypairService } from './UserKeypairService.js'; -import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; const ACTOR_USERNAME = 'relay.actor' as const; @@ -36,7 +34,6 @@ export class RelayService { private queueService: QueueService, private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, - private userKeypairService: UserKeypairService, ) { this.relaysCache = new MemorySingleCache(1000 * 60 * 10); } @@ -114,7 +111,7 @@ export class RelayService { } @bindThis - public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any, privateKey?: PrivateKeyWithPem): Promise { + public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any): Promise { if (activity == null) return; const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ @@ -124,9 +121,11 @@ export class RelayService { const copy = deepClone(activity); if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; - privateKey = privateKey ?? await this.userKeypairService.getLocalUserPrivateKeyPem(user.id); - const signed = await this.apRendererService.attachLdSignature(copy, privateKey); - this.queueService.deliverMany(user, signed, new Map(relays.map(({ inbox }) => [inbox, false])), privateKey); + const signed = await this.apRendererService.attachLdSignature(copy, user); + + for (const relay of relays) { + this.queueService.deliver(user, signed, relay.inbox, false); + } } } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 94026fd503..7966774673 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -505,14 +505,15 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { this.globalEventService.publishInternalEvent('userRoleAssigned', created); - if (role.isPublic) { + const user = await this.usersRepository.findOneByOrFail({ id: userId }); + + if (role.isPublic && user.host === null) { this.notificationService.createNotification(userId, 'roleAssigned', { roleId: roleId, }); } if (moderator) { - const user = await this.usersRepository.findOneByOrFail({ id: userId }); this.moderationLogService.log(moderator, 'assignRole', { roleId: roleId, roleName: role.name, diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 54c6170062..de45898328 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { generateKeyPair } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { DataSource, IsNull } from 'typeorm'; @@ -20,7 +21,7 @@ import { bindThis } from '@/decorators.js'; import UsersChart from '@/core/chart/charts/users.js'; import { UtilityService } from '@/core/UtilityService.js'; import { MetaService } from '@/core/MetaService.js'; -import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js'; +import { UserService } from '@/core/UserService.js'; @Injectable() export class SignupService { @@ -35,6 +36,7 @@ export class SignupService { private usedUsernamesRepository: UsedUsernamesRepository, private utilityService: UtilityService, + private userService: UserService, private userEntityService: UserEntityService, private idService: IdService, private metaService: MetaService, @@ -93,7 +95,22 @@ export class SignupService { } } - const keyPair = await genRSAAndEd25519KeyPair(); + const keyPair = await new Promise((res, rej) => + generateKeyPair('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined, + }, + }, (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]), + )); let account!: MiUser; @@ -116,8 +133,9 @@ export class SignupService { })); await transactionalEntityManager.save(new MiUserKeypair({ + publicKey: keyPair[0], + privateKey: keyPair[1], userId: account.id, - ...keyPair, })); await transactionalEntityManager.save(new MiUserProfile({ @@ -132,7 +150,8 @@ export class SignupService { })); }); - this.usersChart.update(account, true); + this.usersChart.update(account, true).then(); + this.userService.notifySystemWebhook(account, 'userCreated').then(); return { account, secret }; } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 267a6a3f1b..6aab8fde70 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -279,8 +279,10 @@ export class UserFollowingService implements OnModuleInit { }); // 通知を作成 - this.notificationService.createNotification(follower.id, 'followRequestAccepted', { - }, followee.id); + if (follower.host === null) { + this.notificationService.createNotification(follower.id, 'followRequestAccepted', { + }, followee.id); + } } if (alreadyFollowed) return; diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index aa90f1e209..51ac99179a 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -5,184 +5,41 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; -import { genEd25519KeyPair, importPrivateKey, PrivateKey, PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; import type { MiUser } from '@/models/User.js'; import type { UserKeypairsRepository } from '@/models/_.js'; -import { RedisKVCache, MemoryKVCache } from '@/misc/cache.js'; +import { RedisKVCache } from '@/misc/cache.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { GlobalEventService, GlobalEvents } from '@/core/GlobalEventService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { webcrypto } from 'node:crypto'; @Injectable() export class UserKeypairService implements OnApplicationShutdown { - private keypairEntityCache: RedisKVCache; - private privateKeyObjectCache: MemoryKVCache; + private cache: RedisKVCache; constructor( @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, + @Inject(DI.userKeypairsRepository) private userKeypairsRepository: UserKeypairsRepository, - - private globalEventService: GlobalEventService, - private userEntityService: UserEntityService, ) { - this.keypairEntityCache = new RedisKVCache(this.redisClient, 'userKeypair', { + this.cache = new RedisKVCache(this.redisClient, 'userKeypair', { lifetime: 1000 * 60 * 60 * 24, // 24h memoryCacheLifetime: Infinity, fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), toRedisConverter: (value) => JSON.stringify(value), fromRedisConverter: (value) => JSON.parse(value), }); - this.privateKeyObjectCache = new MemoryKVCache(1000 * 60 * 60 * 1); - - this.redisForSub.on('message', this.onMessage); } @bindThis public async getUserKeypair(userId: MiUser['id']): Promise { - return await this.keypairEntityCache.fetch(userId); + return await this.cache.fetch(userId); } - /** - * Get private key [Only PrivateKeyWithPem for queue data etc.] - * @param userIdOrHint user id or MiUserKeypair - * @param preferType - * If ed25519-like(`ed25519`, `01`, `11`) is specified, ed25519 keypair will be returned if exists. - * Otherwise, main keypair will be returned. - * @returns - */ - @bindThis - public async getLocalUserPrivateKeyPem( - userIdOrHint: MiUser['id'] | MiUserKeypair, - preferType?: string, - ): Promise { - const keypair = typeof userIdOrHint === 'string' ? await this.getUserKeypair(userIdOrHint) : userIdOrHint; - if ( - preferType && ['01', '11', 'ed25519'].includes(preferType.toLowerCase()) && - keypair.ed25519PublicKey != null && keypair.ed25519PrivateKey != null - ) { - return { - keyId: `${this.userEntityService.genLocalUserUri(keypair.userId)}#ed25519-key`, - privateKeyPem: keypair.ed25519PrivateKey, - }; - } - return { - keyId: `${this.userEntityService.genLocalUserUri(keypair.userId)}#main-key`, - privateKeyPem: keypair.privateKey, - }; - } - - /** - * Get private key [Only PrivateKey for ap request] - * Using cache due to performance reasons of `crypto.subtle.importKey` - * @param userIdOrHint user id, MiUserKeypair, or PrivateKeyWithPem - * @param preferType - * If ed25519-like(`ed25519`, `01`, `11`) is specified, ed25519 keypair will be returned if exists. - * Otherwise, main keypair will be returned. (ignored if userIdOrHint is PrivateKeyWithPem) - * @returns - */ - @bindThis - public async getLocalUserPrivateKey( - userIdOrHint: MiUser['id'] | MiUserKeypair | PrivateKeyWithPem, - preferType?: string, - ): Promise { - if (typeof userIdOrHint === 'object' && 'privateKeyPem' in userIdOrHint) { - // userIdOrHint is PrivateKeyWithPem - return { - keyId: userIdOrHint.keyId, - privateKey: await this.privateKeyObjectCache.fetch(userIdOrHint.keyId, async () => { - return await importPrivateKey(userIdOrHint.privateKeyPem); - }), - }; - } - - const userId = typeof userIdOrHint === 'string' ? userIdOrHint : userIdOrHint.userId; - const getKeypair = () => typeof userIdOrHint === 'string' ? this.getUserKeypair(userId) : userIdOrHint; - - if (preferType && ['01', '11', 'ed25519'].includes(preferType.toLowerCase())) { - const keyId = `${this.userEntityService.genLocalUserUri(userId)}#ed25519-key`; - const fetched = await this.privateKeyObjectCache.fetchMaybe(keyId, async () => { - const keypair = await getKeypair(); - if (keypair.ed25519PublicKey != null && keypair.ed25519PrivateKey != null) { - return await importPrivateKey(keypair.ed25519PrivateKey); - } - return; - }); - if (fetched) { - return { - keyId, - privateKey: fetched, - }; - } - } - - const keyId = `${this.userEntityService.genLocalUserUri(userId)}#main-key`; - return { - keyId, - privateKey: await this.privateKeyObjectCache.fetch(keyId, async () => { - const keypair = await getKeypair(); - return await importPrivateKey(keypair.privateKey); - }), - }; - } - - @bindThis - public async refresh(userId: MiUser['id']): Promise { - return await this.keypairEntityCache.refresh(userId); - } - - /** - * If DB has ed25519 keypair, refresh cache and return it. - * If not, create, save and return ed25519 keypair. - * @param userId user id - * @returns MiUserKeypair if keypair is created, void if keypair is already exists - */ - @bindThis - public async refreshAndPrepareEd25519KeyPair(userId: MiUser['id']): Promise { - await this.refresh(userId); - const keypair = await this.keypairEntityCache.fetch(userId); - if (keypair.ed25519PublicKey != null) { - return; - } - - const ed25519 = await genEd25519KeyPair(); - await this.userKeypairsRepository.update({ userId }, { - ed25519PublicKey: ed25519.publicKey, - ed25519PrivateKey: ed25519.privateKey, - }); - this.globalEventService.publishInternalEvent('userKeypairUpdated', { userId }); - const result = { - ...keypair, - ed25519PublicKey: ed25519.publicKey, - ed25519PrivateKey: ed25519.privateKey, - }; - this.keypairEntityCache.set(userId, result); - return result; - } - - @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; - switch (type) { - case 'userKeypairUpdated': { - this.refresh(body.userId); - break; - } - } - } - } @bindThis public dispose(): void { - this.keypairEntityCache.dispose(); + this.cache.dispose(); } @bindThis diff --git a/packages/backend/src/core/UserRenoteMutingService.ts b/packages/backend/src/core/UserRenoteMutingService.ts new file mode 100644 index 0000000000..bdc5e23f4b --- /dev/null +++ b/packages/backend/src/core/UserRenoteMutingService.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import type { RenoteMutingsRepository } from '@/models/_.js'; +import type { MiRenoteMuting } from '@/models/RenoteMuting.js'; + +import { IdService } from '@/core/IdService.js'; +import type { MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; + +@Injectable() +export class UserRenoteMutingService { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private idService: IdService, + private cacheService: CacheService, + ) { + } + + @bindThis + public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise { + await this.renoteMutingsRepository.insert({ + id: this.idService.gen(), + muterId: user.id, + muteeId: target.id, + }); + + await this.cacheService.renoteMutingsCache.refresh(user.id); + } + + @bindThis + public async unmute(mutings: MiRenoteMuting[]): Promise { + if (mutings.length === 0) return; + + await this.renoteMutingsRepository.delete({ + id: In(mutings.map(m => m.id)), + }); + + const muterIds = [...new Set(mutings.map(m => m.muterId))]; + for (const muterId of muterIds) { + await this.cacheService.renoteMutingsCache.refresh(muterId); + } + } +} diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts index 72fa4d928d..9b1961c631 100644 --- a/packages/backend/src/core/UserService.ts +++ b/packages/backend/src/core/UserService.ts @@ -8,15 +8,18 @@ import type { FollowingsRepository, UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; @Injectable() export class UserService { constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + private systemWebhookService: SystemWebhookService, + private userEntityService: UserEntityService, ) { } @@ -50,4 +53,23 @@ export class UserService { }); } } + + /** + * SystemWebhookを用いてユーザに関する操作内容を管理者各位に通知する. + * ここではJobQueueへのエンキューのみを行うため、即時実行されない. + * + * @see SystemWebhookService.enqueueSystemWebhook + */ + @bindThis + public async notifySystemWebhook(user: MiUser, type: 'userCreated') { + const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' }); + const recipientWebhookIds = await this.systemWebhookService.fetchSystemWebhooks({ isActive: true, on: [type] }); + for (const webhookId of recipientWebhookIds) { + await this.systemWebhookService.enqueueSystemWebhook( + webhookId, + type, + packedUser, + ); + } + } } diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index fc5a68c72e..d594a223f4 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -3,23 +3,27 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { Not, IsNull } from 'typeorm'; +import type { FollowingsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; +import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import { UserKeypairService } from './UserKeypairService.js'; -import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js'; @Injectable() export class UserSuspendService { constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + private userEntityService: UserEntityService, + private queueService: QueueService, private globalEventService: GlobalEventService, private apRendererService: ApRendererService, - private userKeypairService: UserKeypairService, - private apDeliverManagerService: ApDeliverManagerService, ) { } @@ -28,12 +32,28 @@ export class UserSuspendService { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); if (this.userEntityService.isLocalUser(user)) { + // 知り得る全SharedInboxにDelete配信 const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - const manager = this.apDeliverManagerService.createDeliverManager(user, content); - manager.addAllKnowingSharedInboxRecipe(); - // process deliver時にはキーペアが消去されているはずなので、ここで挿入する - const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main'); - manager.execute({ privateKey }); + + const queue: string[] = []; + + const followings = await this.followingsRepository.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + this.queueService.deliver(user, content, inbox, true); + } } } @@ -42,12 +62,28 @@ export class UserSuspendService { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); if (this.userEntityService.isLocalUser(user)) { + // 知り得る全SharedInboxにUndo Delete配信 const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); - const manager = this.apDeliverManagerService.createDeliverManager(user, content); - manager.addAllKnowingSharedInboxRecipe(); - // process deliver時にはキーペアが消去されているはずなので、ここで挿入する - const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main'); - manager.execute({ privateKey }); + + const queue: string[] = []; + + const followings = await this.followingsRepository.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + this.queueService.deliver(user as any, content, inbox, true); + } } } } diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 652e8f7449..94729250a6 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -42,6 +42,12 @@ export class UtilityService { return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); } + @bindThis + public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { + if (!silencedHosts || host == null) return false; + return silencedHosts.some(x => host.toLowerCase() === x); + } + @bindThis public concatNoteContentsForKeyWordCheck(content: { cw?: string | null; diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index aa1144778c..374536a741 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -46,7 +46,7 @@ export class WebfingerService { const m = query.match(mRegex); if (m) { const hostname = m[2]; - const useHttp = process.env.MISSKEY_USE_HTTP && process.env.MISSKEY_USE_HTTP.toLowerCase() === 'true'; + const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`; } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index 973394683f..f6b70ead44 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { MiUser, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; +import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import { MemoryKVCache } from '@/misc/cache.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js'; @@ -13,12 +13,9 @@ import { CacheService } from '@/core/CacheService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import Logger from '@/logger.js'; import { getApId } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; -import { ApLoggerService } from './ApLoggerService.js'; import type { IObject } from './type.js'; -import { UtilityService } from '../UtilityService.js'; export type UriParseResult = { /** wether the URI was generated by us */ @@ -38,8 +35,8 @@ export type UriParseResult = { @Injectable() export class ApDbResolverService implements OnApplicationShutdown { - private publicKeyByUserIdCache: MemoryKVCache; - private logger: Logger; + private publicKeyCache: MemoryKVCache; + private publicKeyByUserIdCache: MemoryKVCache; constructor( @Inject(DI.config) @@ -56,17 +53,9 @@ export class ApDbResolverService implements OnApplicationShutdown { private cacheService: CacheService, private apPersonService: ApPersonService, - private apLoggerService: ApLoggerService, - private utilityService: UtilityService, ) { - this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); - this.logger = this.apLoggerService.logger.createSubLogger('db-resolver'); - } - - private punyHost(url: string): string { - const urlObj = new URL(url); - const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`; - return host; + this.publicKeyCache = new MemoryKVCache(Infinity); + this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); } @bindThis @@ -127,141 +116,62 @@ export class ApDbResolverService implements OnApplicationShutdown { } } + /** + * AP KeyId => Misskey User and Key + */ @bindThis - private async refreshAndFindKey(userId: MiUser['id'], keyId: string): Promise { - this.refreshCacheByUserId(userId); - const keys = await this.getPublicKeyByUserId(userId); - if (keys == null || !Array.isArray(keys) || keys.length === 0) { - this.logger.warn(`No key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`); - return null; - } - const exactKey = keys.find(x => x.keyId === keyId); - if (exactKey) return exactKey; - this.logger.warn(`No exact key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`); - return null; + public async getAuthUserFromKeyId(keyId: string): Promise<{ + user: MiRemoteUser; + key: MiUserPublickey; + } | null> { + const key = await this.publicKeyCache.fetch(keyId, async () => { + const key = await this.userPublickeysRepository.findOneBy({ + keyId, + }); + + if (key == null) return null; + + return key; + }, key => key != null); + + if (key == null) return null; + + const user = await this.cacheService.findUserById(key.userId).catch(() => null) as MiRemoteUser | null; + if (user == null) return null; + if (user.isDeleted) return null; + + return { + user, + key, + }; } /** * AP Actor id => Misskey User and Key - * @param uri AP Actor id - * @param keyId Key id to find. If not specified, main key will be selected. - * @returns - * 1. `null` if the user and key host do not match - * 2. `{ user: null, key: null }` if the user is not found - * 3. `{ user: MiRemoteUser, key: null }` if key is not found - * 4. `{ user: MiRemoteUser, key: MiUserPublickey }` if both are found */ @bindThis - public async getAuthUserFromApId(uri: string, keyId?: string): Promise<{ + public async getAuthUserFromApId(uri: string): Promise<{ user: MiRemoteUser; key: MiUserPublickey | null; - } | { - user: null; - key: null; - } | - null> { - if (keyId) { - if (this.punyHost(uri) !== this.punyHost(keyId)) { - /** - * keyIdはURL形式かつkeyIdのホストはuriのホストと一致するはず - * (ApPersonService.validateActorに由来) - * - * ただ、Mastodonはリプライ関連で他人のトゥートをHTTP Signature署名して送ってくることがある - * そのような署名は有効性に疑問があるので無視することにする - * ここではuriとkeyIdのホストが一致しない場合は無視する - * ハッシュをなくしたkeyIdとuriの同一性を比べてみてもいいが、`uri#*-key`というkeyIdを設定するのが - * 決まりごとというわけでもないため幅を持たせることにする - * - * - * The keyId should be in URL format and its host should match the host of the uri - * (derived from ApPersonService.validateActor) - * - * However, Mastodon sometimes sends toots from other users with HTTP Signature signing for reply-related purposes - * Such signatures are of questionable validity, so we choose to ignore them - * Here, we ignore cases where the hosts of uri and keyId do not match - * We could also compare the equality of keyId without the hash and uri, but since setting a keyId like `uri#*-key` - * is not a strict rule, we decide to allow for some flexibility - */ - this.logger.warn(`actor uri and keyId are not matched uri=${uri} keyId=${keyId}`); - return null; - } - } + } | null> { + const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser; + if (user.isDeleted) return null; - const user = await this.apPersonService.resolvePerson(uri, undefined, true) as MiRemoteUser; - if (user.isDeleted) return { user: null, key: null }; - - const keys = await this.getPublicKeyByUserId(user.id); - - if (keys == null || !Array.isArray(keys) || keys.length === 0) { - this.logger.warn(`No key found uri=${uri} userId=${user.id} keys=${JSON.stringify(keys)}`); - return { user, key: null }; - } - - if (!keyId) { - // Choose the main-like - const mainKey = keys.find(x => { - try { - const url = new URL(x.keyId); - const path = url.pathname.split('/').pop()?.toLowerCase(); - if (url.hash) { - if (url.hash.toLowerCase().includes('main')) { - return true; - } - } else if (path?.includes('main') || path === 'publickey') { - return true; - } - } catch { /* noop */ } - - return false; - }); - return { user, key: mainKey ?? keys[0] }; - } - - const exactKey = keys.find(x => x.keyId === keyId); - if (exactKey) return { user, key: exactKey }; - - /** - * keyIdで見つからない場合、まずはキャッシュを更新して再取得 - * If not found with keyId, update cache and reacquire - */ - const cacheRaw = this.publicKeyByUserIdCache.cache.get(user.id); - if (cacheRaw && cacheRaw.date > Date.now() - 1000 * 60 * 12) { - const exactKey = await this.refreshAndFindKey(user.id, keyId); - if (exactKey) return { user, key: exactKey }; - } - - /** - * lastFetchedAtでの更新制限を弱めて再取得 - * Reacquisition with weakened update limit at lastFetchedAt - */ - if (user.lastFetchedAt == null || user.lastFetchedAt < new Date(Date.now() - 1000 * 60 * 12)) { - this.logger.info(`Fetching user to find public key uri=${uri} userId=${user.id} keyId=${keyId}`); - const renewed = await this.apPersonService.fetchPersonWithRenewal(uri, 0); - if (renewed == null || renewed.isDeleted) return null; - - return { user, key: await this.refreshAndFindKey(user.id, keyId) }; - } - - this.logger.warn(`No key found uri=${uri} userId=${user.id} keyId=${keyId}`); - return { user, key: null }; - } - - @bindThis - public async getPublicKeyByUserId(userId: MiUser['id']): Promise { - return await this.publicKeyByUserIdCache.fetch( - userId, - () => this.userPublickeysRepository.find({ where: { userId } }), + const key = await this.publicKeyByUserIdCache.fetch( + user.id, + () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null, ); - } - @bindThis - public refreshCacheByUserId(userId: MiUser['id']): void { - this.publicKeyByUserIdCache.delete(userId); + return { + user, + key, + }; } @bindThis public dispose(): void { + this.publicKeyCache.dispose(); this.publicKeyByUserIdCache.dispose(); } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index db3302e6ff..5d07cd8e8f 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -9,14 +9,10 @@ import { DI } from '@/di-symbols.js'; import type { FollowingsRepository } from '@/models/_.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { QueueService } from '@/core/QueueService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import type { IActivity } from '@/core/activitypub/type.js'; import { ThinUser } from '@/queue/types.js'; -import { AccountUpdateService } from '@/core/AccountUpdateService.js'; -import type Logger from '@/logger.js'; -import { UserKeypairService } from '../UserKeypairService.js'; -import { ApLoggerService } from './ApLoggerService.js'; -import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; interface IRecipe { type: string; @@ -31,19 +27,12 @@ interface IDirectRecipe extends IRecipe { to: MiRemoteUser; } -interface IAllKnowingSharedInboxRecipe extends IRecipe { - type: 'AllKnowingSharedInbox'; -} - const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe => recipe.type === 'Followers'; const isDirect = (recipe: IRecipe): recipe is IDirectRecipe => recipe.type === 'Direct'; -const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe => - recipe.type === 'AllKnowingSharedInbox'; - class DeliverManager { private actor: ThinUser; private activity: IActivity | null; @@ -51,18 +40,16 @@ class DeliverManager { /** * Constructor - * @param userKeypairService + * @param userEntityService * @param followingsRepository * @param queueService * @param actor Actor * @param activity Activity to deliver */ constructor( - private userKeypairService: UserKeypairService, + private userEntityService: UserEntityService, private followingsRepository: FollowingsRepository, private queueService: QueueService, - private accountUpdateService: AccountUpdateService, - private logger: Logger, actor: { id: MiUser['id']; host: null; }, activity: IActivity | null, @@ -104,18 +91,6 @@ class DeliverManager { this.addRecipe(recipe); } - /** - * Add recipe for all-knowing shared inbox deliver - */ - @bindThis - public addAllKnowingSharedInboxRecipe(): void { - const deliver: IAllKnowingSharedInboxRecipe = { - type: 'AllKnowingSharedInbox', - }; - - this.addRecipe(deliver); - } - /** * Add recipe * @param recipe Recipe @@ -129,44 +104,11 @@ class DeliverManager { * Execute delivers */ @bindThis - public async execute(opts?: { privateKey?: PrivateKeyWithPem }): Promise { - //#region MIGRATION - if (!opts?.privateKey) { - /** - * ed25519の署名がなければ追加する - */ - const created = await this.userKeypairService.refreshAndPrepareEd25519KeyPair(this.actor.id); - if (created) { - // createdが存在するということは新規作成されたということなので、フォロワーに配信する - this.logger.info(`ed25519 key pair created for user ${this.actor.id} and publishing to followers`); - // リモートに配信 - const keyPair = await this.userKeypairService.getLocalUserPrivateKeyPem(created, 'main'); - await this.accountUpdateService.publishToFollowers(this.actor.id, keyPair); - } - } - //#endregion - - //#region collect inboxes by recipes + public async execute(): Promise { // The value flags whether it is shared or not. // key: inbox URL, value: whether it is sharedInbox const inboxes = new Map(); - if (this.recipes.some(r => isAllKnowingSharedInbox(r))) { - // all-knowing shared inbox - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - for (const following of followings) { - if (following.followeeSharedInbox) inboxes.set(following.followeeSharedInbox, true); - if (following.followerSharedInbox) inboxes.set(following.followerSharedInbox, true); - } - } - // build inbox list // Process follower recipes first to avoid duplication when processing direct recipes later. if (this.recipes.some(r => isFollowers(r))) { @@ -200,49 +142,39 @@ class DeliverManager { inboxes.set(recipe.to.inbox, false); } - //#endregion // deliver - await this.queueService.deliverMany(this.actor, this.activity, inboxes, opts?.privateKey); - this.logger.info(`Deliver queues dispatched: inboxes=${inboxes.size} actorId=${this.actor.id} activityId=${this.activity?.id}`); + await this.queueService.deliverMany(this.actor, this.activity, inboxes); } } @Injectable() export class ApDeliverManagerService { - private logger: Logger; - constructor( @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - private userKeypairService: UserKeypairService, + private userEntityService: UserEntityService, private queueService: QueueService, - private accountUpdateService: AccountUpdateService, - private apLoggerService: ApLoggerService, ) { - this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager'); } /** * Deliver activity to followers * @param actor * @param activity Activity - * @param forceMainKey Force to use main (rsa) key */ @bindThis - public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, privateKey?: PrivateKeyWithPem): Promise { + public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise { const manager = new DeliverManager( - this.userKeypairService, + this.userEntityService, this.followingsRepository, this.queueService, - this.accountUpdateService, - this.logger, actor, activity, ); manager.addFollowersRecipe(); - await manager.execute({ privateKey }); + await manager.execute(); } /** @@ -254,11 +186,9 @@ export class ApDeliverManagerService { @bindThis public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise { const manager = new DeliverManager( - this.userKeypairService, + this.userEntityService, this.followingsRepository, this.queueService, - this.accountUpdateService, - this.logger, actor, activity, ); @@ -269,11 +199,10 @@ export class ApDeliverManagerService { @bindThis public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { return new DeliverManager( - this.userKeypairService, + this.userEntityService, this.followingsRepository, this.queueService, - this.accountUpdateService, - this.logger, + actor, activity, ); diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 1bef9fe071..e2164fec1d 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -114,8 +114,15 @@ export class ApInboxService { result = await this.performOneActivity(actor, activity); } - // ついでにリモートユーザーの情報が古かったら更新しておく? - // → No, この関数が呼び出される前に署名検証で更新されているはず + // ついでにリモートユーザーの情報が古かったら更新しておく + if (actor.uri) { + if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + setImmediate(() => { + this.apPersonService.updatePerson(actor.uri); + }); + } + } + return result; } @bindThis diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 5d7419f934..98e944f347 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -22,6 +22,7 @@ import { UserKeypairService } from '@/core/UserKeypairService.js'; import { MfmService } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import type { MiUserKeypair } from '@/models/UserKeypair.js'; import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; @@ -30,7 +31,6 @@ import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; -import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; @Injectable() export class ApRendererService { @@ -251,15 +251,15 @@ export class ApRendererService { } @bindThis - public renderKey(user: MiLocalUser, publicKey: string, postfix?: string): IKey { + public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey { return { - id: `${this.userEntityService.genLocalUserUri(user.id)}${postfix ?? '/publickey'}`, + id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, type: 'Key', owner: this.userEntityService.genLocalUserUri(user.id), - publicKeyPem: createPublicKey(publicKey).export({ + publicKeyPem: createPublicKey(key.publicKey).export({ type: 'spki', format: 'pem', - }) as string, + }), }; } @@ -499,10 +499,7 @@ export class ApRendererService { tag, manuallyApprovesFollowers: user.isLocked, discoverable: user.isExplorable, - publicKey: this.renderKey(user, keypair.publicKey, '#main-key'), - additionalPublicKeys: [ - ...(keypair.ed25519PublicKey ? [this.renderKey(user, keypair.ed25519PublicKey, '#ed25519-key')] : []), - ], + publicKey: this.renderKey(user, keypair, '#main-key'), isCat: user.isCat, attachment: attachment.length ? attachment : undefined, }; @@ -625,10 +622,12 @@ export class ApRendererService { } @bindThis - public async attachLdSignature(activity: any, key: PrivateKeyWithPem): Promise { + public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise { + const keypair = await this.userKeypairService.getUserKeypair(user.id); + const jsonLd = this.jsonLdService.use(); jsonLd.debug = false; - activity = await jsonLd.signRsaSignature2017(activity, key.privateKeyPem, key.keyId); + activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); return activity; } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 0cae91316b..93ac8ce9a7 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import * as crypto from 'node:crypto'; import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { genRFC3230DigestHeader, signAsDraftToRequest } from '@misskey-dev/node-http-message-signatures'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; @@ -15,61 +15,122 @@ import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; -import type { PrivateKeyWithPem, PrivateKey } from '@misskey-dev/node-http-message-signatures'; -export async function createSignedPost(args: { level: string; key: PrivateKey; url: string; body: string; digest?: string, additionalHeaders: Record }) { - const u = new URL(args.url); - const request = { - url: u.href, - method: 'POST', - headers: { - 'Date': new Date().toUTCString(), - 'Host': u.host, - 'Content-Type': 'application/activity+json', - ...args.additionalHeaders, - } as Record, - }; +type Request = { + url: string; + method: string; + headers: Record; +}; - // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする - const digestHeader = args.digest ?? await genRFC3230DigestHeader(args.body, 'SHA-256'); - request.headers['Digest'] = digestHeader; +type Signed = { + request: Request; + signingString: string; + signature: string; + signatureHeader: string; +}; - const result = await signAsDraftToRequest( - request, - args.key, - ['(request-target)', 'date', 'host', 'digest'], - ); +type PrivateKey = { + privateKeyPem: string; + keyId: string; +}; - return { - request, - ...result, - }; -} +export class ApRequestCreator { + static createSignedPost(args: { key: PrivateKey, url: string, body: string, digest?: string, additionalHeaders: Record }): Signed { + const u = new URL(args.url); + const digestHeader = args.digest ?? this.createDigest(args.body); -export async function createSignedGet(args: { level: string; key: PrivateKey; url: string; additionalHeaders: Record }) { - const u = new URL(args.url); - const request = { - url: u.href, - method: 'GET', - headers: { - 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', - 'Date': new Date().toUTCString(), - 'Host': new URL(args.url).host, - ...args.additionalHeaders, - } as Record, - }; + const request: Request = { + url: u.href, + method: 'POST', + headers: this.#objectAssignWithLcKey({ + 'Date': new Date().toUTCString(), + 'Host': u.host, + 'Content-Type': 'application/activity+json', + 'Digest': digestHeader, + }, args.additionalHeaders), + }; - // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする - const result = await signAsDraftToRequest( - request, - args.key, - ['(request-target)', 'date', 'host', 'accept'], - ); + const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); - return { - request, - ...result, - }; + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; + } + + static createDigest(body: string) { + return `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}`; + } + + static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }): Signed { + const u = new URL(args.url); + + const request: Request = { + url: u.href, + method: 'GET', + headers: this.#objectAssignWithLcKey({ + 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'Date': new Date().toUTCString(), + 'Host': new URL(args.url).host, + }, args.additionalHeaders), + }; + + const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; + } + + static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { + const signingString = this.#genSigningString(request, includeHeaders); + const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); + const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; + + request.headers = this.#objectAssignWithLcKey(request.headers, { + Signature: signatureHeader, + }); + // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! + delete request.headers['host']; + + return { + request, + signingString, + signature, + signatureHeader, + }; + } + + static #genSigningString(request: Request, includeHeaders: string[]): string { + request.headers = this.#lcObjectKey(request.headers); + + const results: string[] = []; + + for (const key of includeHeaders.map(x => x.toLowerCase())) { + if (key === '(request-target)') { + results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); + } else { + results.push(`${key}: ${request.headers[key]}`); + } + } + + return results.join('\n'); + } + + static #lcObjectKey(src: Record): Record { + const dst: Record = {}; + for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; + return dst; + } + + static #objectAssignWithLcKey(a: Record, b: Record): Record { + return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b)); + } } @Injectable() @@ -89,28 +150,21 @@ export class ApRequestService { } @bindThis - public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, level: string, digest?: string, key?: PrivateKeyWithPem): Promise { + public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise { const body = typeof object === 'string' ? object : JSON.stringify(object); - const keyFetched = await this.userKeypairService.getLocalUserPrivateKey(key ?? user.id, level); - const req = await createSignedPost({ - level, - key: keyFetched, + + const keypair = await this.userKeypairService.getUserKeypair(user.id); + + const req = ApRequestCreator.createSignedPost({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${this.config.url}/users/${user.id}#main-key`, + }, url, body, - additionalHeaders: { - 'User-Agent': this.config.userAgent, - }, digest, - }); - - // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! - delete req.request.headers['Host']; - - this.logger.debug('create signed post', { - version: 'draft', - level, - url, - keyId: keyFetched.keyId, + additionalHeaders: { + }, }); await this.httpRequestService.send(url, { @@ -126,27 +180,19 @@ export class ApRequestService { * @param url URL to fetch */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }, level: string): Promise { - const key = await this.userKeypairService.getLocalUserPrivateKey(user.id, level); - const req = await createSignedGet({ - level, - key, + public async signedGet(url: string, user: { id: MiUser['id'] }): Promise { + const keypair = await this.userKeypairService.getUserKeypair(user.id); + + const req = ApRequestCreator.createSignedGet({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${this.config.url}/users/${user.id}#main-key`, + }, url, additionalHeaders: { - 'User-Agent': this.config.userAgent, }, }); - // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! - delete req.request.headers['Host']; - - this.logger.debug('create signed get', { - version: 'draft', - level, - url, - keyId: key.keyId, - }); - const res = await this.httpRequestService.send(url, { method: req.request.method, headers: req.request.headers, diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 727ff6f956..bb3c40f093 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -16,7 +16,6 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; @@ -42,7 +41,6 @@ export class Resolver { private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, - private federatedInstanceService: FederatedInstanceService, private loggerService: LoggerService, private recursionLimit = 100, ) { @@ -105,10 +103,8 @@ export class Resolver { this.user = await this.instanceActorService.getInstanceActor(); } - const server = await this.federatedInstanceService.fetch(host); - const object = (this.user - ? await this.apRequestService.signedGet(value, this.user, server.httpMessageSignaturesImplementationLevel) as IObject + ? await this.apRequestService.signedGet(value, this.user) as IObject : await this.httpRequestService.getActivityJson(value)) as IObject; if ( @@ -204,7 +200,6 @@ export class ApResolverService { private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, - private federatedInstanceService: FederatedInstanceService, private loggerService: LoggerService, ) { } @@ -225,7 +220,6 @@ export class ApResolverService { this.httpRequestService, this.apRendererService, this.apDbResolverService, - this.federatedInstanceService, this.loggerService, ); } diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index fc4e3e3bef..feb8c42c56 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -134,7 +134,6 @@ const security_v1 = { 'privateKey': { '@id': 'sec:privateKey', '@type': '@id' }, 'privateKeyPem': 'sec:privateKeyPem', 'publicKey': { '@id': 'sec:publicKey', '@type': '@id' }, - 'additionalPublicKeys': { '@id': 'sec:publicKey', '@type': '@id' }, 'publicKeyBase58': 'sec:publicKeyBase58', 'publicKeyPem': 'sec:publicKeyPem', 'publicKeyWif': 'sec:publicKeyWif', diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index c41fc713d5..457205e023 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,10 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { verify } from 'crypto'; import { Inject, Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; -import { DataSource, In, Not } from 'typeorm'; +import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; @@ -40,7 +39,6 @@ import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; -import { REMOTE_USER_CACHE_TTL, REMOTE_USER_MOVE_COOLDOWN } from '@/const.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -50,7 +48,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js'; import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; -import type { IActor, IKey, IObject } from '../type.js'; +import type { IActor, IObject } from '../type.js'; const nameLength = 128; const summaryLength = 2048; @@ -187,38 +185,13 @@ export class ApPersonService implements OnModuleInit { } if (x.publicKey) { - const publicKeys = Array.isArray(x.publicKey) ? x.publicKey : [x.publicKey]; - - for (const publicKey of publicKeys) { - if (typeof publicKey.id !== 'string') { - throw new Error('invalid Actor: publicKey.id is not a string'); - } - - const publicKeyIdHost = this.punyHost(publicKey.id); - if (publicKeyIdHost !== expectHost) { - throw new Error('invalid Actor: publicKey.id has different host'); - } - } - } - - if (x.additionalPublicKeys) { - if (!x.publicKey) { - throw new Error('invalid Actor: additionalPublicKeys is set but publicKey is not'); + if (typeof x.publicKey.id !== 'string') { + throw new Error('invalid Actor: publicKey.id is not a string'); } - if (!Array.isArray(x.additionalPublicKeys)) { - throw new Error('invalid Actor: additionalPublicKeys is not an array'); - } - - for (const key of x.additionalPublicKeys) { - if (typeof key.id !== 'string') { - throw new Error('invalid Actor: additionalPublicKeys.id is not a string'); - } - - const keyIdHost = this.punyHost(key.id); - if (keyIdHost !== expectHost) { - throw new Error('invalid Actor: additionalPublicKeys.id has different host'); - } + const publicKeyIdHost = this.punyHost(x.publicKey.id); + if (publicKeyIdHost !== expectHost) { + throw new Error('invalid Actor: publicKey.id has different host'); } } @@ -255,33 +228,6 @@ export class ApPersonService implements OnModuleInit { return null; } - /** - * uriからUser(Person)をフェッチします。 - * - * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。 - * また、TTLが0でない場合、TTLを過ぎていた場合はupdatePersonを実行します。 - */ - @bindThis - async fetchPersonWithRenewal(uri: string, TTL = REMOTE_USER_CACHE_TTL): Promise { - const exist = await this.fetchPerson(uri); - if (exist == null) return null; - - if (this.userEntityService.isRemoteUser(exist)) { - if (TTL === 0 || exist.lastFetchedAt == null || Date.now() - exist.lastFetchedAt.getTime() > TTL) { - this.logger.debug('fetchPersonWithRenewal: renew', { uri, TTL, lastFetchedAt: exist.lastFetchedAt }); - try { - await this.updatePerson(exist.uri); - return await this.fetchPerson(uri); - } catch (err) { - this.logger.error('error occurred while renewing user', { err }); - } - } - this.logger.debug('fetchPersonWithRenewal: use cache', { uri, TTL, lastFetchedAt: exist.lastFetchedAt }); - } - - return exist; - } - private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any): Promise>> { if (user == null) throw new Error('failed to create user: user is null'); @@ -417,15 +363,11 @@ export class ApPersonService implements OnModuleInit { })); if (person.publicKey) { - const publicKeys = new Map(); - (person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key)); - (Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key)); - - await transactionalEntityManager.save(Array.from(publicKeys.values(), key => new MiUserPublickey({ - keyId: key.id, - userId: user!.id, - keyPem: key.publicKeyPem, - }))); + await transactionalEntityManager.save(new MiUserPublickey({ + userId: user.id, + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + })); } }); } catch (e) { @@ -571,29 +513,11 @@ export class ApPersonService implements OnModuleInit { // Update user await this.usersRepository.update(exist.id, updates); - try { - // Deleteアクティビティ受信時にもここが走ってsaveがuserforeign key制約エラーを吐くことがある - // とりあえずtry-catchで囲っておく - const publicKeys = new Map(); - if (person.publicKey) { - (person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key)); - (Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key)); - - await this.userPublickeysRepository.save(Array.from(publicKeys.values(), key => ({ - keyId: key.id, - userId: exist.id, - keyPem: key.publicKeyPem, - }))); - } - - this.userPublickeysRepository.delete({ - keyId: Not(In(Array.from(publicKeys.keys()))), - userId: exist.id, - }).catch(err => { - this.logger.error('something happened while deleting remote user public keys:', { userId: exist.id, err }); + if (person.publicKey) { + await this.userPublickeysRepository.update({ userId: exist.id }, { + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, }); - } catch (err) { - this.logger.error('something happened while updating remote user public keys:', { userId: exist.id, err }); } let _description: string | null = null; @@ -635,7 +559,7 @@ export class ApPersonService implements OnModuleInit { exist.movedAt == null || // 以前のmovingから14日以上経過した場合のみ移行処理を許可 // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) - exist.movedAt.getTime() + REMOTE_USER_MOVE_COOLDOWN < updated.movedAt.getTime() + exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() )) { this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); return this.processRemoteMove(updated, movePreventUris) @@ -658,9 +582,9 @@ export class ApPersonService implements OnModuleInit { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolvePerson(uri: string, resolver?: Resolver, withRenewal = false): Promise { + public async resolvePerson(uri: string, resolver?: Resolver): Promise { //#region このサーバーに既に登録されていたらそれを返す - const exist = withRenewal ? await this.fetchPersonWithRenewal(uri) : await this.fetchPerson(uri); + const exist = await this.fetchPerson(uri); if (exist) return exist; //#endregion diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 1d55971660..5b6c6c8ca6 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -55,7 +55,7 @@ export function getOneApId(value: ApObject): string { export function getApId(value: string | IObject): string { if (typeof value === 'string') return value; if (typeof value.id === 'string') return value.id; - throw new Error('cannot determine id'); + throw new Error('cannot detemine id'); } /** @@ -169,8 +169,10 @@ export interface IActor extends IObject { discoverable?: boolean; inbox: string; sharedInbox?: string; // 後方互換性のため - publicKey?: IKey | IKey[]; - additionalPublicKeys?: IKey[]; + publicKey?: { + id: string; + publicKeyPem: string; + }; followers?: string | ICollection | IOrderedCollection; following?: string | ICollection | IOrderedCollection; featured?: string | IOrderedCollection; @@ -234,9 +236,8 @@ export const isEmoji = (object: IObject): object is IApEmoji => export interface IKey extends IObject { type: 'Key'; - id: string; owner: string; - publicKeyPem: string; + publicKeyPem: string | Buffer; } export interface IApDocument extends IObject { diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index fd0f55c6ab..4c45c13167 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -50,13 +50,13 @@ export class InstanceEntityService { maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, isSilenced: this.utilityService.isSilencedHost(meta.silencedHosts, instance.host), + isMediaSilenced: this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, instance.host), iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, moderationNote: iAmModerator ? instance.moderationNote : null, - httpMessageSignaturesImplementationLevel: instance.httpMessageSignaturesImplementationLevel, }; } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 09641ce485..44ec0d6a7b 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -128,6 +128,7 @@ export class MetaEntityService { mediaProxy: this.config.mediaProxy, enableUrlPreview: instance.urlPreviewEnabled, + noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', }; return packed; diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f498c110bf..bba64a06ef 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -195,9 +195,6 @@ export class MemoryKVCache { private lifetime: number; private gcIntervalHandle: NodeJS.Timeout; - /** - * @param lifetime キャッシュの生存期間 (ms) - */ constructor(lifetime: MemoryKVCache['lifetime']) { this.cache = new Map(); this.lifetime = lifetime; diff --git a/packages/backend/src/misc/gen-key-pair.ts b/packages/backend/src/misc/gen-key-pair.ts index 0b033ec33e..02a303dc0a 100644 --- a/packages/backend/src/misc/gen-key-pair.ts +++ b/packages/backend/src/misc/gen-key-pair.ts @@ -3,14 +3,39 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { genEd25519KeyPair, genRsaKeyPair } from '@misskey-dev/node-http-message-signatures'; +import * as crypto from 'node:crypto'; +import * as util from 'node:util'; -export async function genRSAAndEd25519KeyPair(rsaModulusLength = 4096) { - const [rsa, ed25519] = await Promise.all([genRsaKeyPair(rsaModulusLength), genEd25519KeyPair()]); - return { - publicKey: rsa.publicKey, - privateKey: rsa.privateKey, - ed25519PublicKey: ed25519.publicKey, - ed25519PrivateKey: ed25519.privateKey, - }; +const generateKeyPair = util.promisify(crypto.generateKeyPair); + +export async function genRsaKeyPair(modulusLength = 2048) { + return await generateKeyPair('rsa', { + modulusLength, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined, + }, + }); +} + +export async function genEcKeyPair(namedCurve: 'prime256v1' | 'secp384r1' | 'secp521r1' | 'curve25519' = 'prime256v1') { + return await generateKeyPair('ec', { + namedCurve, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined, + }, + }); } diff --git a/packages/backend/src/misc/json-value.ts b/packages/backend/src/misc/json-value.ts new file mode 100644 index 0000000000..7994441791 --- /dev/null +++ b/packages/backend/src/misc/json-value.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type JsonValue = JsonArray | JsonObject | string | number | boolean | null; +export type JsonObject = {[K in string]?: JsonValue}; +export type JsonArray = JsonValue[]; diff --git a/packages/backend/src/models/DriveFile.ts b/packages/backend/src/models/DriveFile.ts index 438b32f79a..7b03e3e494 100644 --- a/packages/backend/src/models/DriveFile.ts +++ b/packages/backend/src/models/DriveFile.ts @@ -82,7 +82,7 @@ export class MiDriveFile { public storedInternal: boolean; @Column('varchar', { - length: 512, + length: 1024, comment: 'The URL of the DriveFile.', }) public url: string; @@ -124,13 +124,13 @@ export class MiDriveFile { @Index() @Column('varchar', { - length: 512, nullable: true, + length: 1024, nullable: true, comment: 'The URI of the DriveFile. it will be null when the DriveFile is local.', }) public uri: string | null; @Column('varchar', { - length: 512, nullable: true, + length: 1024, nullable: true, }) public src: string | null; diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index f2f2831cf1..17cd5c6665 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -158,9 +158,4 @@ export class MiInstance { length: 16384, default: '', }) public moderationNote: string; - - @Column('varchar', { - length: 16, default: '00', nullable: false, - }) - public httpMessageSignaturesImplementationLevel: string; } diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index ad306fcad6..70d41801b5 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -86,6 +86,11 @@ export class MiMeta { }) public silencedHosts: string[]; + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public mediaSilencedHosts: string[]; + @Column('varchar', { length: 1024, nullable: true, diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts index 86fb323d1d..d6c27eae51 100644 --- a/packages/backend/src/models/SystemWebhook.ts +++ b/packages/backend/src/models/SystemWebhook.ts @@ -12,6 +12,8 @@ export const systemWebhookEventTypes = [ 'abuseReport', // 通報を処理したとき 'abuseReportResolved', + // ユーザが作成された時 + 'userCreated', ] as const; export type SystemWebhookEventType = typeof systemWebhookEventTypes[number]; diff --git a/packages/backend/src/models/UserKeypair.ts b/packages/backend/src/models/UserKeypair.ts index afa74ef11a..f5252d126c 100644 --- a/packages/backend/src/models/UserKeypair.ts +++ b/packages/backend/src/models/UserKeypair.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { PrimaryColumn, Entity, JoinColumn, Column, OneToOne } from 'typeorm'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -12,42 +12,22 @@ export class MiUserKeypair { @PrimaryColumn(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @OneToOne(type => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() public user: MiUser | null; - /** - * RSA public key - */ @Column('varchar', { length: 4096, }) public publicKey: string; - /** - * RSA private key - */ @Column('varchar', { length: 4096, }) public privateKey: string; - @Column('varchar', { - length: 128, - nullable: true, - default: null, - }) - public ed25519PublicKey: string | null; - - @Column('varchar', { - length: 128, - nullable: true, - default: null, - }) - public ed25519PrivateKey: string | null; - constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/UserPublickey.ts b/packages/backend/src/models/UserPublickey.ts index 0ecff2bcbe..6bcd785304 100644 --- a/packages/backend/src/models/UserPublickey.ts +++ b/packages/backend/src/models/UserPublickey.ts @@ -9,13 +9,7 @@ import { MiUser } from './User.js'; @Entity('user_publickey') export class MiUserPublickey { - @PrimaryColumn('varchar', { - length: 256, - }) - public keyId: string; - - @Index() - @Column(id()) + @PrimaryColumn(id()) public userId: MiUser['id']; @OneToOne(type => MiUser, { @@ -24,6 +18,12 @@ export class MiUserPublickey { @JoinColumn() public user: MiUser | null; + @Index({ unique: true }) + @Column('varchar', { + length: 256, + }) + public keyId: string; + @Column('varchar', { length: 4096, }) diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index c02e7f557a..912a0399d8 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -88,6 +88,10 @@ export const packedFederationInstanceSchema = { type: 'boolean', optional: false, nullable: false, }, + isMediaSilenced: { + type: 'boolean', + optional: false, nullable: false, + }, iconUrl: { type: 'string', optional: false, nullable: true, @@ -116,9 +120,5 @@ export const packedFederationInstanceSchema = { type: 'string', optional: true, nullable: true, }, - httpMessageSignaturesImplementationLevel: { - type: 'string', - optional: false, nullable: false, - }, }, } as const; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index e7bc6356e5..3bcf9cac92 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -247,6 +247,12 @@ export const packedMetaLiteSchema = { optional: false, nullable: false, ref: 'RolePolicies', }, + noteSearchableScope: { + type: 'string', + enum: ['local', 'global'], + optional: false, nullable: false, + default: 'local', + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 2641161c8b..432c096e48 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -204,6 +204,7 @@ export const packedNoteSchema = { reactionAcceptance: { type: 'string', optional: false, nullable: true, + enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null], }, reactionEmojis: { type: 'object', diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 169b22c3f5..7bd74f3210 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -250,9 +250,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, { ...baseQueueOptions(this.config, QUEUE.DELIVER), autorun: false, - concurrency: this.config.deliverJobConcurrency ?? 16, + concurrency: this.config.deliverJobConcurrency ?? 128, limiter: { - max: this.config.deliverJobPerSec ?? 1024, + max: this.config.deliverJobPerSec ?? 128, duration: 1000, }, settings: { @@ -290,9 +290,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, { ...baseQueueOptions(this.config, QUEUE.INBOX), autorun: false, - concurrency: this.config.inboxJobConcurrency ?? 4, + concurrency: this.config.inboxJobConcurrency ?? 16, limiter: { - max: this.config.inboxJobPerSec ?? 64, + max: this.config.inboxJobPerSec ?? 32, duration: 1000, }, settings: { diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 3bd9187e8b..d665945861 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -73,33 +73,25 @@ export class DeliverProcessorService { } try { - const _server = await this.federatedInstanceService.fetch(host); - await this.fetchInstanceMetadataService.fetchInstanceMetadata(_server).then(() => {}); - const server = await this.federatedInstanceService.fetch(host); - - await this.apRequestService.signedPost( - job.data.user, - job.data.to, - job.data.content, - server.httpMessageSignaturesImplementationLevel, - job.data.digest, - job.data.privateKey, - ); + await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest); // Update stats - if (server.isNotResponding) { - this.federatedInstanceService.update(server.id, { - isNotResponding: false, - notRespondingSince: null, - }); - } + this.federatedInstanceService.fetch(host).then(i => { + if (i.isNotResponding) { + this.federatedInstanceService.update(i.id, { + isNotResponding: false, + notRespondingSince: null, + }); + } - this.apRequestChart.deliverSucc(); - this.federationChart.deliverd(server.host, true); + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + this.apRequestChart.deliverSucc(); + this.federationChart.deliverd(i.host, true); - if (meta.enableChartsForFederatedInstances) { - this.instanceChart.requestSent(server.host, true); - } + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestSent(i.host, true); + } + }); return 'Success'; } catch (res) { diff --git a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts index 29c1f27bb1..34180e5f2b 100644 --- a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts +++ b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { PollVotesRepository, NotesRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; +import { CacheService } from '@/core/CacheService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; @@ -24,6 +25,7 @@ export class EndedPollNotificationProcessorService { @Inject(DI.pollVotesRepository) private pollVotesRepository: PollVotesRepository, + private cacheService: CacheService, private notificationService: NotificationService, private queueLoggerService: QueueLoggerService, ) { @@ -47,9 +49,12 @@ export class EndedPollNotificationProcessorService { const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])]; for (const userId of userIds) { - this.notificationService.createNotification(userId, 'pollEnded', { - noteId: note.id, - }); + const profile = await this.cacheService.userProfileCache.fetch(userId); + if (profile.userHost === null) { + this.notificationService.createNotification(userId, 'pollEnded', { + noteId: note.id, + }); + } } } } diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 935c623df1..fa7009f8f5 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -5,8 +5,8 @@ import { URL } from 'node:url'; import { Injectable } from '@nestjs/common'; +import httpSignature from '@peertube/http-signature'; import * as Bull from 'bullmq'; -import { verifyDraftSignature } from '@misskey-dev/node-http-message-signatures'; import type Logger from '@/logger.js'; import { MetaService } from '@/core/MetaService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -20,7 +20,6 @@ import type { MiRemoteUser } from '@/models/User.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { StatusError } from '@/misc/status-error.js'; -import * as Acct from '@/misc/acct.js'; import { UtilityService } from '@/core/UtilityService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; @@ -53,15 +52,8 @@ export class InboxProcessorService { @bindThis public async process(job: Bull.Job): Promise { - const signature = job.data.signature ? - 'version' in job.data.signature ? job.data.signature.value : job.data.signature - : null; - if (Array.isArray(signature)) { - // RFC 9401はsignatureが配列になるが、とりあえずエラーにする - throw new Error('signature is array'); - } + const signature = job.data.signature; // HTTP-signature let activity = job.data.activity; - let actorUri = getApId(activity.actor); //#region Log const info = Object.assign({}, activity); @@ -69,7 +61,7 @@ export class InboxProcessorService { this.logger.debug(JSON.stringify(info, null, 2)); //#endregion - const host = this.utilityService.toPuny(new URL(actorUri).hostname); + const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); // ブロックしてたら中断 const meta = await this.metaService.fetch(); @@ -77,76 +69,69 @@ export class InboxProcessorService { return `Blocked request: ${host}`; } - // HTTP-Signature keyIdを元にDBから取得 - let authUser: Awaited> = null; - let httpSignatureIsValid = null as boolean | null; + const keyIdLower = signature.keyId.toLowerCase(); + if (keyIdLower.startsWith('acct:')) { + return `Old keyId is no longer supported. ${keyIdLower}`; + } - try { - authUser = await this.apDbResolverService.getAuthUserFromApId(actorUri, signature?.keyId); - } catch (err) { - // 対象が4xxならスキップ - if (err instanceof StatusError) { - if (!err.isRetryable) { - throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`); + // HTTP-Signature keyIdを元にDBから取得 + let authUser: { + user: MiRemoteUser; + key: MiUserPublickey | null; + } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); + + // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 + if (authUser == null) { + try { + authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); + } catch (err) { + // 対象が4xxならスキップ + if (err instanceof StatusError) { + if (!err.isRetryable) { + throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`); + } + throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`); } - throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`); } } - // authUser.userがnullならスキップ - if (authUser != null && authUser.user == null) { + // それでもわからなければ終了 + if (authUser == null) { throw new Bull.UnrecoverableError('skip: failed to resolve user'); } - if (signature != null && authUser != null) { - if (signature.keyId.toLowerCase().startsWith('acct:')) { - this.logger.warn(`Old keyId is no longer supported. lowerKeyId=${signature.keyId.toLowerCase()}`); - } else if (authUser.key != null) { - // keyがなかったらLD Signatureで検証するべき - // HTTP-Signatureの検証 - const errorLogger = (ms: any) => this.logger.error(ms); - httpSignatureIsValid = await verifyDraftSignature(signature, authUser.key.keyPem, errorLogger); - this.logger.debug('Inbox message validation: ', { - userId: authUser.user.id, - userAcct: Acct.toString(authUser.user), - parsedKeyId: signature.keyId, - foundKeyId: authUser.key.keyId, - httpSignatureValid: httpSignatureIsValid, - }); - } + // publicKey がなくても終了 + if (authUser.key == null) { + throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey'); } - if ( - authUser == null || - httpSignatureIsValid !== true || - authUser.user.uri !== actorUri // 一応チェック - ) { + // HTTP-Signatureの検証 + const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + + // また、signatureのsignerは、activity.actorと一致する必要がある + if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { // 一致しなくても、でもLD-Signatureがありそうならそっちも見る const ldSignature = activity.signature; - - if (ldSignature && ldSignature.creator) { + if (ldSignature) { if (ldSignature.type !== 'RsaSignature2017') { throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`); } - if (ldSignature.creator.toLowerCase().startsWith('acct:')) { - throw new Bull.UnrecoverableError(`old key not supported ${ldSignature.creator}`); + // ldSignature.creator: https://example.oom/users/user#main-key + // みたいになっててUserを引っ張れば公開キーも入ることを期待する + if (ldSignature.creator) { + const candicate = ldSignature.creator.replace(/#.*/, ''); + await this.apPersonService.resolvePerson(candicate).catch(() => null); } - authUser = await this.apDbResolverService.getAuthUserFromApId(actorUri, ldSignature.creator); - + // keyIdからLD-Signatureのユーザーを取得 + authUser = await this.apDbResolverService.getAuthUserFromKeyId(ldSignature.creator); if (authUser == null) { - throw new Bull.UnrecoverableError(`skip: LD-Signatureのactorとcreatorが一致しませんでした uri=${actorUri} creator=${ldSignature.creator}`); - } - if (authUser.user == null) { - throw new Bull.UnrecoverableError(`skip: LD-Signatureのユーザーが取得できませんでした uri=${actorUri} creator=${ldSignature.creator}`); - } - // 一応actorチェック - if (authUser.user.uri !== actorUri) { - throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${actorUri})`); + throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした'); } + if (authUser.key == null) { - throw new Bull.UnrecoverableError(`skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした uri=${actorUri} creator=${ldSignature.creator}`); + throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'); } const jsonLd = this.jsonLdService.use(); @@ -157,27 +142,13 @@ export class InboxProcessorService { throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); } - // ブロックしてたら中断 - const ldHost = this.utilityService.extractDbHost(authUser.user.uri); - if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { - throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); - } - // アクティビティを正規化 - // GHSA-2vxv-pv3m-3wvj delete activity.signature; try { activity = await jsonLd.compact(activity) as IActivity; } catch (e) { throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${e}`); } - - // actorが正規化前後で一致しているか確認 - actorUri = getApId(activity.actor); - if (authUser.user.uri !== actorUri) { - throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity(after normalization).actor(${actorUri})`); - } - // TODO: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする // https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29 activity.signature = ldSignature; @@ -187,8 +158,19 @@ export class InboxProcessorService { delete compactedInfo['@context']; this.logger.debug(`compacted: ${JSON.stringify(compactedInfo, null, 2)}`); //#endregion + + // もう一度actorチェック + if (authUser.user.uri !== activity.actor) { + throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); + } + + // ブロックしてたら中断 + const ldHost = this.utilityService.extractDbHost(authUser.user.uri); + if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { + throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); + } } else { - throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. http_signature_keyId=${signature?.keyId}`); + throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`); } } diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index f2466f2e3d..a4077a0547 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -9,24 +9,7 @@ import type { MiNote } from '@/models/Note.js'; import type { MiUser } from '@/models/User.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { IActivity } from '@/core/activitypub/type.js'; -import type { ParsedSignature, PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; - -/** - * @peertube/http-signature 時代の古いデータにも対応しておく - * TODO: 2026年ぐらいには消す - */ -export interface OldParsedSignature { - scheme: 'Signature'; - params: { - keyId: string; - algorithm: string; - headers: string[]; - signature: string; - }; - signingString: string; - algorithm: string; - keyId: string; -} +import type httpSignature from '@peertube/http-signature'; export type DeliverJobData = { /** Actor */ @@ -39,13 +22,11 @@ export type DeliverJobData = { to: string; /** whether it is sharedInbox */ isSharedInbox: boolean; - /** force to use main (rsa) key */ - privateKey?: PrivateKeyWithPem; }; export type InboxJobData = { activity: IActivity; - signature: ParsedSignature | OldParsedSignature | null; + signature: httpSignature.IParsedSignature; }; export type RelationshipJobData = { diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 753eaad047..3255d64621 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import * as crypto from 'node:crypto'; import { IncomingMessage } from 'node:http'; import { Inject, Injectable } from '@nestjs/common'; import fastifyAccepts from '@fastify/accepts'; -import { verifyDigestHeader, parseRequestSignature } from '@misskey-dev/node-http-message-signatures'; +import httpSignature from '@peertube/http-signature'; import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; import accepts from 'accepts'; import vary from 'vary'; @@ -30,17 +31,12 @@ import { IActivity } from '@/core/activitypub/type.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; -import { LoggerService } from '@/core/LoggerService.js'; -import Logger from '@/logger.js'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; @Injectable() export class ActivityPubServerService { - private logger: Logger; - private inboxLogger: Logger; - constructor( @Inject(DI.config) private config: Config, @@ -75,11 +71,8 @@ export class ActivityPubServerService { private queueService: QueueService, private userKeypairService: UserKeypairService, private queryService: QueryService, - private loggerService: LoggerService, ) { //this.createServer = this.createServer.bind(this); - this.logger = this.loggerService.getLogger('server-ap', 'gray'); - this.inboxLogger = this.logger.createSubLogger('inbox', 'gray'); } @bindThis @@ -107,44 +100,70 @@ export class ActivityPubServerService { } @bindThis - private async inbox(request: FastifyRequest, reply: FastifyReply) { - if (request.body == null) { - this.inboxLogger.warn('request body is empty'); - reply.code(400); - return; - } + private inbox(request: FastifyRequest, reply: FastifyReply) { + let signature; - let signature: ReturnType; - - const verifyDigest = await verifyDigestHeader(request.raw, request.rawBody || '', true); - if (verifyDigest !== true) { - this.inboxLogger.warn('digest verification failed'); + try { + signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); + } catch (e) { reply.code(401); return; } - try { - signature = parseRequestSignature(request.raw, { - requiredInputs: { - draft: ['(request-target)', 'digest', 'host', 'date'], - }, - }); - } catch (err) { - this.inboxLogger.warn('signature header parsing failed', { err }); + if (signature.params.headers.indexOf('host') === -1 + || request.headers.host !== this.config.host) { + // Host not specified or not match. + reply.code(401); + return; + } - if (typeof request.body === 'object' && 'signature' in request.body) { - // LD SignatureがあればOK - this.queueService.inbox(request.body as IActivity, null); - reply.code(202); + if (signature.params.headers.indexOf('digest') === -1) { + // Digest not found. + reply.code(401); + } else { + const digest = request.headers.digest; + + if (typeof digest !== 'string') { + // Huh? + reply.code(401); return; } - this.inboxLogger.warn('signature header parsing failed and LD signature not found'); - reply.code(401); - return; + const re = /^([a-zA-Z0-9\-]+)=(.+)$/; + const match = digest.match(re); + + if (match == null) { + // Invalid digest + reply.code(401); + return; + } + + const algo = match[1].toUpperCase(); + const digestValue = match[2]; + + if (algo !== 'SHA-256') { + // Unsupported digest algorithm + reply.code(401); + return; + } + + if (request.rawBody == null) { + // Bad request + reply.code(400); + return; + } + + const hash = crypto.createHash('sha256').update(request.rawBody).digest('base64'); + + if (hash !== digestValue) { + // Invalid digest + reply.code(401); + return; + } } this.queueService.inbox(request.body as IActivity, signature); + reply.code(202); } @@ -621,7 +640,7 @@ export class ActivityPubServerService { if (this.userEntityService.isLocalUser(user)) { reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); - return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair.publicKey))); + return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); } else { reply.code(400); return; diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index c0f8084768..cc18997fdc 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -94,13 +94,6 @@ export class NodeinfoServerService { localComments: 0, }, metadata: { - /** - * '00': Draft, RSA only - * '01': Draft, Ed25519 suported - * '11': RFC 9421, Ed25519 supported - */ - httpMessageSignaturesImplementationLevel: '01', - nodeName: meta.name, nodeDescription: meta.description, nodeAdmins: [{ diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 87eaad31a3..7596bf44e3 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -69,6 +69,7 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id', nullable: true }, + status: { type: 'string', enum: ['all', 'active', 'archived'], default: 'active' }, }, required: [], } as const; @@ -87,7 +88,13 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - query.andWhere('announcement.isActive = true'); + + if (ps.status === 'archived') { + query.andWhere('announcement.isActive = false'); + } else if (ps.status === 'active') { + query.andWhere('announcement.isActive = true'); + } + if (ps.userId) { query.andWhere('announcement.userId = :userId', { userId: ps.userId }); } else { diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index eee02a7123..2e7f73da73 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -128,6 +128,16 @@ export const meta = { nullable: false, }, }, + mediaSilencedHosts: { + type: 'array', + optional: false, + nullable: false, + items: { + type: 'string', + optional: false, + nullable: false, + }, + }, pinnedUsers: { type: 'array', optional: false, nullable: false, @@ -552,6 +562,7 @@ export default class extends Endpoint { // eslint- hiddenTags: instance.hiddenTags, blockedHosts: instance.blockedHosts, silencedHosts: instance.silencedHosts, + mediaSilencedHosts: instance.mediaSilencedHosts, sensitiveWords: instance.sensitiveWords, prohibitedWords: instance.prohibitedWords, preservedUsernames: instance.preservedUsernames, diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts index bfe230da8d..305ae1af1d 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -56,8 +56,7 @@ export default class extends Endpoint { // eslint- const res = [] as [string, number][]; for (const job of jobs) { - const signature = job.data.signature ? 'version' in job.data.signature ? job.data.signature.value : job.data.signature : null; - const host = signature ? Array.isArray(signature) ? 'TODO' : new URL(signature.keyId).host : new URL(job.data.activity.actor).host; + const host = new URL(job.data.signature.keyId).host; if (res.find(x => x[0] === host)) { res.find(x => x[0] === host)![1]++; } else { 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 4e28ee6877..5efdc9d8c4 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -150,6 +150,13 @@ export const paramDef = { type: 'string', }, }, + mediaSilencedHosts: { + type: 'array', + nullable: true, + items: { + type: 'string', + }, + }, summalyProxy: { type: 'string', nullable: true, description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.', @@ -203,6 +210,14 @@ export default class extends Endpoint { // eslint- return h !== '' && h !== lv && !set.blockedHosts?.includes(h); }); } + if (Array.isArray(ps.mediaSilencedHosts)) { + let lastValue = ''; + set.mediaSilencedHosts = ps.mediaSilencedHosts.sort().filter((h) => { + const lv = lastValue; + lastValue = h; + return h !== '' && h !== lv && !set.blockedHosts?.includes(h); + }); + } if (ps.themeColor !== undefined) { set.themeColor = ps.themeColor; } 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 5acc9706d3..2a2c659942 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -139,9 +139,16 @@ export default class extends Endpoint { // eslint- timelineConfig = [ `homeTimeline:${me.id}`, 'localTimeline', + `localTimelineWithReplyTo:${me.id}`, ]; } + const [ + followings, + ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + ]); + const redisTimeline = await this.fanoutTimelineEndpointService.timeline({ untilId, sinceId, @@ -152,6 +159,13 @@ export default class extends Endpoint { // eslint- useDbFallback: serverSettings.enableFanoutTimelineDbFallback, alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + noteFilter: note => { + if (note.reply && note.reply.visibility === 'followers') { + if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; + } + + return true; + }, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ untilId, sinceId, diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 8b87908bd3..c9b43b5359 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -114,7 +114,7 @@ export default class extends Endpoint { // eslint- excludePureRenotes: !ps.withRenotes, noteFilter: note => { if (note.reply && note.reply.visibility === 'followers') { - if (!Object.hasOwn(followings, note.reply.userId)) return false; + if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; } return true; diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index 39bf0cc428..84a1f010d4 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -6,12 +6,11 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { RenoteMutingsRepository } from '@/models/_.js'; -import type { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApiError } from '../../error.js'; +import { UserRenoteMutingService } from "@/core/UserRenoteMutingService.js"; +import type { RenoteMutingsRepository } from '@/models/_.js'; export const meta = { tags: ['account'], @@ -62,7 +61,7 @@ export default class extends Endpoint { // eslint- private renoteMutingsRepository: RenoteMutingsRepository, private getterService: GetterService, - private idService: IdService, + private userRenoteMutingService: UserRenoteMutingService, ) { super(meta, paramDef, async (ps, me) => { const muter = me; @@ -79,21 +78,19 @@ export default class extends Endpoint { // eslint- }); // Check if already muting - const exist = await this.renoteMutingsRepository.findOneBy({ - muterId: muter.id, - muteeId: mutee.id, + const exist = await this.renoteMutingsRepository.exists({ + where: { + muterId: muter.id, + muteeId: mutee.id, + }, }); - if (exist != null) { + if (exist === true) { throw new ApiError(meta.errors.alreadyMuting); } // Create mute - await this.renoteMutingsRepository.insert({ - id: this.idService.gen(), - muterId: muter.id, - muteeId: mutee.id, - } as MiRenoteMuting); + await this.userRenoteMutingService.mute(muter, mutee); }); } } diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts index 6e037cc07e..1a584b8404 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -5,10 +5,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RenoteMutingsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApiError } from '../../error.js'; +import { UserRenoteMutingService } from "@/core/UserRenoteMutingService.js"; +import type { RenoteMutingsRepository } from '@/models/_.js'; export const meta = { tags: ['account'], @@ -53,6 +54,7 @@ export default class extends Endpoint { // eslint- private renoteMutingsRepository: RenoteMutingsRepository, private getterService: GetterService, + private userRenoteMutingService: UserRenoteMutingService, ) { super(meta, paramDef, async (ps, me) => { const muter = me; @@ -79,9 +81,7 @@ export default class extends Endpoint { // eslint- } // Delete mute - await this.renoteMutingsRepository.delete({ - id: exist.id, - }); + await this.userRenoteMutingService.unmute([exist]); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index df9d9f6312..0b0136066d 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -57,88 +57,66 @@ export default class extends Endpoint { // eslint- const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 ps.query = ps.query.trim(); - const isUsername = ps.query.startsWith('@'); + const isUsername = ps.query.startsWith('@') && !ps.query.includes(' ') && ps.query.indexOf('@', 1) === -1; let users: MiUser[] = []; - if (isUsername) { - const usernameQuery = this.usersRepository.createQueryBuilder('user') - .where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE'); + const nameQuery = this.usersRepository.createQueryBuilder('user') + .where(new Brackets(qb => { + qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - if (ps.origin === 'local') { - usernameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - usernameQuery.andWhere('user.host IS NOT NULL'); - } - - users = await usernameQuery - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(); - } else { - const nameQuery = this.usersRepository.createQueryBuilder('user') - .where(new Brackets(qb => { - qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - - // Also search username if it qualifies as username - if (this.userEntityService.validateLocalUsername(ps.query)) { - qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' }); - } - })) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE'); - - if (ps.origin === 'local') { - nameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - nameQuery.andWhere('user.host IS NOT NULL'); - } - - users = await nameQuery - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(); - - if (users.length < ps.limit) { - const profQuery = this.userProfilesRepository.createQueryBuilder('prof') - .select('prof.userId') - .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - - if (ps.origin === 'local') { - profQuery.andWhere('prof.userHost IS NULL'); - } else if (ps.origin === 'remote') { - profQuery.andWhere('prof.userHost IS NOT NULL'); + if (isUsername) { + qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }); + } else if (this.userEntityService.validateLocalUsername(ps.query)) { // Also search username if it qualifies as username + qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' }); } + })) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); - const query = this.usersRepository.createQueryBuilder('user') - .where(`user.id IN (${ profQuery.getQuery() })`) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE') - .setParameters(profQuery.getParameters()); + if (ps.origin === 'local') { + nameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + nameQuery.andWhere('user.host IS NOT NULL'); + } - users = users.concat(await query - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(), - ); + users = await nameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .limit(ps.limit) + .offset(ps.offset) + .getMany(); + + if (users.length < ps.limit) { + const profQuery = this.userProfilesRepository.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); + + if (ps.origin === 'local') { + profQuery.andWhere('prof.userHost IS NULL'); + } else if (ps.origin === 'remote') { + profQuery.andWhere('prof.userHost IS NOT NULL'); } + + const query = this.usersRepository.createQueryBuilder('user') + .where(`user.id IN (${ profQuery.getQuery() })`) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE') + .setParameters(profQuery.getParameters()); + + users = users.concat(await query + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .limit(ps.limit) + .offset(ps.offset) + .getMany(), + ); } return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 41c0feccc7..96082827f8 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -14,6 +14,7 @@ import { CacheService } from '@/core/CacheService.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import type { JsonObject } from '@/misc/json-value.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; @@ -28,7 +29,7 @@ export default class Connection { private wsConnection: WebSocket.WebSocket; public subscriber: StreamEventEmitter; private channels: Channel[] = []; - private subscribingNotes: any = {}; + private subscribingNotes: Partial> = {}; private cachedNotes: Packed<'Note'>[] = []; public userProfile: MiUserProfile | null = null; public following: Record | undefined> = {}; @@ -101,7 +102,7 @@ export default class Connection { */ @bindThis private async onWsConnectionMessage(data: WebSocket.RawData) { - let obj: Record; + let obj: JsonObject; try { obj = JSON.parse(data.toString()); @@ -111,6 +112,8 @@ export default class Connection { const { type, body } = obj; + if (typeof body !== 'object' || body === null || Array.isArray(body)) return; + switch (type) { case 'readNotification': this.onReadNotification(body); break; case 'subNote': this.onSubscribeNote(body); break; @@ -151,7 +154,7 @@ export default class Connection { } @bindThis - private readNote(body: any) { + private readNote(body: JsonObject) { const id = body.id; const note = this.cachedNotes.find(n => n.id === id); @@ -163,7 +166,7 @@ export default class Connection { } @bindThis - private onReadNotification(payload: any) { + private onReadNotification(payload: JsonObject) { this.notificationService.readAllNotification(this.user!.id); } @@ -171,16 +174,14 @@ export default class Connection { * 投稿購読要求時 */ @bindThis - private onSubscribeNote(payload: any) { - if (!payload.id) return; + private onSubscribeNote(payload: JsonObject) { + if (!payload.id || typeof payload.id !== 'string') return; - if (this.subscribingNotes[payload.id] == null) { - this.subscribingNotes[payload.id] = 0; - } + const current = this.subscribingNotes[payload.id] ?? 0; + const updated = current + 1; + this.subscribingNotes[payload.id] = updated; - this.subscribingNotes[payload.id]++; - - if (this.subscribingNotes[payload.id] === 1) { + if (updated === 1) { this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); } } @@ -189,11 +190,14 @@ export default class Connection { * 投稿購読解除要求時 */ @bindThis - private onUnsubscribeNote(payload: any) { - if (!payload.id) return; + private onUnsubscribeNote(payload: JsonObject) { + if (!payload.id || typeof payload.id !== 'string') return; - this.subscribingNotes[payload.id]--; - if (this.subscribingNotes[payload.id] <= 0) { + const current = this.subscribingNotes[payload.id]; + if (current == null) return; + const updated = current - 1; + this.subscribingNotes[payload.id] = updated; + if (updated <= 0) { delete this.subscribingNotes[payload.id]; this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage); } @@ -212,17 +216,22 @@ export default class Connection { * チャンネル接続要求時 */ @bindThis - private onChannelConnectRequested(payload: any) { + private onChannelConnectRequested(payload: JsonObject) { const { channel, id, params, pong } = payload; - this.connectChannel(id, params, channel, pong); + if (typeof id !== 'string') return; + if (typeof channel !== 'string') return; + if (typeof pong !== 'boolean' && typeof pong !== 'undefined' && pong !== null) return; + if (typeof params !== 'undefined' && (typeof params !== 'object' || params === null || Array.isArray(params))) return; + this.connectChannel(id, params, channel, pong ?? undefined); } /** * チャンネル切断要求時 */ @bindThis - private onChannelDisconnectRequested(payload: any) { + private onChannelDisconnectRequested(payload: JsonObject) { const { id } = payload; + if (typeof id !== 'string') return; this.disconnectChannel(id); } @@ -230,7 +239,7 @@ export default class Connection { * クライアントにメッセージ送信 */ @bindThis - public sendMessageToWs(type: string, payload: any) { + public sendMessageToWs(type: string, payload: JsonObject) { this.wsConnection.send(JSON.stringify({ type: type, body: payload, @@ -241,7 +250,7 @@ export default class Connection { * チャンネルに接続 */ @bindThis - public connectChannel(id: string, params: any, channel: string, pong = false) { + public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { const channelService = this.channelsService.getChannelService(channel); if (channelService.requireCredential && this.user == null) { @@ -288,7 +297,11 @@ export default class Connection { * @param data メッセージ */ @bindThis - private onChannelMessageRequested(data: any) { + private onChannelMessageRequested(data: JsonObject) { + if (typeof data.id !== 'string') return; + if (typeof data.type !== 'string') return; + if (typeof data.body === 'undefined') return; + const channel = this.channels.find(c => c.id === data.id); if (channel != null && channel.onMessage != null) { channel.onMessage(data.type, data.body); diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index a267d27fba..84cb552369 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -8,6 +8,7 @@ import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { Packed } from '@/misc/json-schema.js'; +import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import type Connection from './Connection.js'; /** @@ -81,10 +82,12 @@ export default abstract class Channel { this.connection = connection; } + public send(payload: { type: string, body: JsonValue }): void + public send(type: string, payload: JsonValue): void @bindThis - public send(typeOrPayload: any, payload?: any) { - const type = payload === undefined ? typeOrPayload.type : typeOrPayload; - const body = payload === undefined ? typeOrPayload.body : payload; + public send(typeOrPayload: { type: string, body: JsonValue } | string, payload?: JsonValue) { + const type = payload === undefined ? (typeOrPayload as { type: string, body: JsonValue }).type : (typeOrPayload as string); + const body = payload === undefined ? (typeOrPayload as { type: string, body: JsonValue }).body : payload; this.connection.sendMessageToWs('channel', { id: this.id, @@ -93,11 +96,11 @@ export default abstract class Channel { }); } - public abstract init(params: any): void; + public abstract init(params: JsonObject): void; public dispose?(): void; - public onMessage?(type: string, body: any): void; + public onMessage?(type: string, body: JsonValue): void; } export type MiChannelService = { diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts index 92b6d2ac04..355d5dba21 100644 --- a/packages/backend/src/server/api/stream/channels/admin.ts +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class AdminChannel extends Channel { @@ -14,7 +15,7 @@ class AdminChannel extends Channel { public static kind = 'read:admin:stream'; @bindThis - public async init(params: any) { + public async init(params: JsonObject) { // Subscribe admin stream this.subscriber.on(`adminStream:${this.user!.id}`, data => { this.send(data); diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 4a1d2dd109..53dc7f18b6 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class AntennaChannel extends Channel { @@ -27,8 +28,9 @@ class AntennaChannel extends Channel { } @bindThis - public async init(params: any) { - this.antennaId = params.antennaId as string; + public async init(params: JsonObject) { + if (typeof params.antennaId !== 'string') return; + this.antennaId = params.antennaId; // Subscribe stream this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 140dd3dd9b..7108e0cd6e 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -8,6 +8,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class ChannelChannel extends Channel { @@ -27,8 +28,9 @@ class ChannelChannel extends Channel { } @bindThis - public async init(params: any) { - this.channelId = params.channelId as string; + public async init(params: JsonObject) { + if (typeof params.channelId !== 'string') return; + this.channelId = params.channelId; // Subscribe stream this.subscriber.on('notesStream', this.onNote); diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts index 0d9b486305..03768f3d23 100644 --- a/packages/backend/src/server/api/stream/channels/drive.ts +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class DriveChannel extends Channel { @@ -14,7 +15,7 @@ class DriveChannel extends Channel { public static kind = 'read:account'; @bindThis - public async init(params: any) { + public async init(params: JsonObject) { // Subscribe drive stream this.subscriber.on(`driveStream:${this.user!.id}`, data => { this.send(data); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 17116258d8..ed56fe0d40 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -10,6 +10,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class GlobalTimelineChannel extends Channel { @@ -32,12 +33,12 @@ class GlobalTimelineChannel extends Channel { } @bindThis - public async init(params: any) { + public async init(params: JsonObject) { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.gtlAvailable) return; - this.withRenotes = params.withRenotes ?? true; - this.withFiles = params.withFiles ?? false; + this.withRenotes = !!(params.withRenotes ?? true); + this.withFiles = !!(params.withFiles ?? false); // Subscribe events this.subscriber.on('notesStream', this.onNote); diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 57bada5d9c..8105f15cb1 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -9,6 +9,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class HashtagChannel extends Channel { @@ -28,11 +29,11 @@ class HashtagChannel extends Channel { } @bindThis - public async init(params: any) { + public async init(params: JsonObject) { + if (!Array.isArray(params.q)) return; + if (!params.q.every(x => Array.isArray(x) && x.every(y => typeof y === 'string'))) return; this.q = params.q; - if (this.q == null) return; - // Subscribe stream this.subscriber.on('notesStream', this.onNote); } diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 878a3180cb..66644ed58c 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -8,6 +8,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class HomeTimelineChannel extends Channel { @@ -29,9 +30,9 @@ class HomeTimelineChannel extends Channel { } @bindThis - public async init(params: any) { - this.withRenotes = params.withRenotes ?? true; - this.withFiles = params.withFiles ?? false; + public async init(params: JsonObject) { + this.withRenotes = !!(params.withRenotes ?? true); + this.withFiles = !!(params.withFiles ?? false); this.subscriber.on('notesStream', this.onNote); } @@ -59,7 +60,7 @@ class HomeTimelineChannel extends Channel { const reply = note.reply; if (this.following[note.userId]?.withReplies) { // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; } else { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; @@ -72,7 +73,7 @@ class HomeTimelineChannel extends Channel { if (note.renote.reply) { const reply = note.renote.reply; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; } } diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 575d23d53c..75bd13221f 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -10,6 +10,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class HybridTimelineChannel extends Channel { @@ -34,13 +35,13 @@ class HybridTimelineChannel extends Channel { } @bindThis - public async init(params: any): Promise { + public async init(params: JsonObject): Promise { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; - this.withRenotes = params.withRenotes ?? true; - this.withReplies = params.withReplies ?? false; - this.withFiles = params.withFiles ?? false; + this.withRenotes = !!(params.withRenotes ?? true); + this.withReplies = !!(params.withReplies ?? false); + this.withFiles = !!(params.withFiles ?? false); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -75,14 +76,22 @@ class HybridTimelineChannel extends Channel { const reply = note.reply; if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; } else { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + } + } if (this.user && note.renoteId && !note.text) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 442d08ae51..491029f5de 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -10,6 +10,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class LocalTimelineChannel extends Channel { @@ -33,13 +34,13 @@ class LocalTimelineChannel extends Channel { } @bindThis - public async init(params: any) { + public async init(params: JsonObject) { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; - this.withRenotes = params.withRenotes ?? true; - this.withReplies = params.withReplies ?? false; - this.withFiles = params.withFiles ?? false; + this.withRenotes = !!(params.withRenotes ?? true); + this.withReplies = !!(params.withReplies ?? false); + this.withFiles = !!(params.withFiles ?? false); // Subscribe events this.subscriber.on('notesStream', this.onNote); diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index a12976d69d..863d7f4c4e 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common'; import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class MainChannel extends Channel { @@ -25,7 +26,7 @@ class MainChannel extends Channel { } @bindThis - public async init(params: any) { + public async init(params: JsonObject) { // Subscribe main stream channel this.subscriber.on(`mainStream:${this.user!.id}`, async data => { switch (data.type) { diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index 061aa76904..ff7e740226 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -6,6 +6,7 @@ import Xev from 'xev'; import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; const ev = new Xev(); @@ -22,19 +23,22 @@ class QueueStatsChannel extends Channel { } @bindThis - public async init(params: any) { + public async init(params: JsonObject) { ev.addListener('queueStats', this.onStats); } @bindThis - private onStats(stats: any) { + private onStats(stats: JsonObject) { this.send('stats', stats); } @bindThis - public onMessage(type: string, body: any) { + public onMessage(type: string, body: JsonValue) { switch (type) { case 'requestLog': + if (typeof body !== 'object' || body === null || Array.isArray(body)) return; + if (typeof body.id !== 'string') return; + if (typeof body.length !== 'number') return; ev.once(`queueStatsLog:${body.id}`, statsLog => { this.send('statsLog', statsLog); }); diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index f4a3a09367..17823a164a 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { ReversiService } from '@/core/ReversiService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; +import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class ReversiGameChannel extends Channel { @@ -28,25 +29,41 @@ class ReversiGameChannel extends Channel { } @bindThis - public async init(params: any) { - this.gameId = params.gameId as string; + public async init(params: JsonObject) { + if (typeof params.gameId !== 'string') return; + this.gameId = params.gameId; this.subscriber.on(`reversiGameStream:${this.gameId}`, this.send); } @bindThis - public onMessage(type: string, body: any) { + public onMessage(type: string, body: JsonValue) { switch (type) { - case 'ready': this.ready(body); break; - case 'updateSettings': this.updateSettings(body.key, body.value); break; - case 'cancel': this.cancelGame(); break; - case 'putStone': this.putStone(body.pos, body.id); break; + case 'ready': + if (typeof body !== 'boolean') return; + this.ready(body); + break; + case 'updateSettings': + if (typeof body !== 'object' || body === null || Array.isArray(body)) return; + if (typeof body.key !== 'string') return; + if (typeof body.value !== 'object' || body.value === null || Array.isArray(body.value)) return; + this.updateSettings(body.key, body.value); + break; + case 'cancel': + this.cancelGame(); + break; + case 'putStone': + if (typeof body !== 'object' || body === null || Array.isArray(body)) return; + if (typeof body.pos !== 'number') return; + if (typeof body.id !== 'string') return; + this.putStone(body.pos, body.id); + break; case 'claimTimeIsUp': this.claimTimeIsUp(); break; } } @bindThis - private async updateSettings(key: string, value: any) { + private async updateSettings(key: string, value: JsonObject) { if (this.user == null) return; this.reversiService.updateSettings(this.gameId!, this.user, key, value); diff --git a/packages/backend/src/server/api/stream/channels/reversi.ts b/packages/backend/src/server/api/stream/channels/reversi.ts index 3998a0fd36..6e88939724 100644 --- a/packages/backend/src/server/api/stream/channels/reversi.ts +++ b/packages/backend/src/server/api/stream/channels/reversi.ts @@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class ReversiChannel extends Channel { @@ -21,7 +22,7 @@ class ReversiChannel extends Channel { } @bindThis - public async init(params: any) { + public async init(params: JsonObject) { this.subscriber.on(`reversiStream:${this.user!.id}`, this.send); } diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 6a4ad22460..fcfa26c38b 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -8,6 +8,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class RoleTimelineChannel extends Channel { @@ -28,8 +29,9 @@ class RoleTimelineChannel extends Channel { } @bindThis - public async init(params: any) { - this.roleId = params.roleId as string; + public async init(params: JsonObject) { + if (typeof params.roleId !== 'string') return; + this.roleId = params.roleId; this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); } diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index eb4d8c9992..6258afba35 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -6,6 +6,7 @@ import Xev from 'xev'; import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; const ev = new Xev(); @@ -22,19 +23,20 @@ class ServerStatsChannel extends Channel { } @bindThis - public async init(params: any) { + public async init(params: JsonObject) { ev.addListener('serverStats', this.onStats); } @bindThis - private onStats(stats: any) { + private onStats(stats: JsonObject) { this.send('stats', stats); } @bindThis - public onMessage(type: string, body: any) { + public onMessage(type: string, body: JsonValue) { switch (type) { case 'requestLog': + if (typeof body !== 'object' || body === null || Array.isArray(body)) return; ev.once(`serverStatsLog:${body.id}`, statsLog => { this.send('statsLog', statsLog); }); diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 14b30a157c..4f38351e94 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -10,6 +10,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; class UserListChannel extends Channel { @@ -36,10 +37,11 @@ class UserListChannel extends Channel { } @bindThis - public async init(params: any) { - this.listId = params.listId as string; - this.withFiles = params.withFiles ?? false; - this.withRenotes = params.withRenotes ?? true; + public async init(params: JsonObject) { + if (typeof params.listId !== 'string') return; + this.listId = params.listId; + this.withFiles = !!(params.withFiles ?? false); + this.withRenotes = !!(params.withRenotes ?? true); // Check existence and owner const listExist = await this.userListsRepository.exists({ diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index b0a70074c6..72f26a38e0 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -34,6 +34,7 @@ describe('Streaming', () => { let kyoko: misskey.entities.SignupResponse; let chitose: misskey.entities.SignupResponse; let kanako: misskey.entities.SignupResponse; + let erin: misskey.entities.SignupResponse; // Remote users let akari: misskey.entities.SignupResponse; @@ -53,6 +54,7 @@ describe('Streaming', () => { kyoko = await signup({ username: 'kyoko' }); chitose = await signup({ username: 'chitose' }); kanako = await signup({ username: 'kanako' }); + erin = await signup({ username: 'erin' }); // erin: A generic fifth participant akari = await signup({ username: 'akari', host: 'example.com' }); chinatsu = await signup({ username: 'chinatsu', host: 'example.com' }); @@ -71,6 +73,12 @@ describe('Streaming', () => { // Follow: kyoko => chitose await api('following/create', { userId: chitose.id }, kyoko); + // Follow: erin <=> ayano each other. + // erin => ayano: withReplies: true + await api('following/create', { userId: ayano.id, withReplies: true }, erin); + // ayano => erin: withReplies: false + await api('following/create', { userId: erin.id, withReplies: false }, ayano); + // Mute: chitose => kanako await api('mute/create', { userId: kanako.id }, chitose); @@ -297,6 +305,28 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + + test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => { + const erinNote = await post(erin, { text: 'hi', visibility: 'followers' }); + const fired = await waitFire( + erin, 'homeTimeline', // erin:home + () => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post + msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano + ); + + assert.strictEqual(fired, true); + }); + + test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => { + const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' }); + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post + msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin + ); + + assert.strictEqual(fired, true); + }); }); // Home describe('Local Timeline', () => { @@ -475,6 +505,38 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + + test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => { + const erinNote = await post(erin, { text: 'hi', visibility: 'followers' }); + const fired = await waitFire( + erin, 'homeTimeline', // erin:home + () => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post + msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano + ); + + assert.strictEqual(fired, true); + }); + + test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => { + const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' }); + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post + msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin + ); + + assert.strictEqual(fired, true); + }); + + test('withReplies: true のフォローしていない人のfollowersノートに対するリプライが流れない', async () => { + const fired = await waitFire( + erin, 'homeTimeline', // erin:home + () => api('notes/create', { text: 'hello', replyId: chitose.id }, ayano), // ayano reply to chitose's post + msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano + ); + + assert.strictEqual(fired, false); + }); }); describe('Global Timeline', () => { diff --git a/packages/backend/test/e2e/synalio/abuse-report.ts b/packages/backend/test/e2e/synalio/abuse-report.ts index b0cc3d13ec..6ce6e47781 100644 --- a/packages/backend/test/e2e/synalio/abuse-report.ts +++ b/packages/backend/test/e2e/synalio/abuse-report.ts @@ -5,65 +5,24 @@ import { entities } from 'misskey-js'; import { beforeEach, describe, test } from '@jest/globals'; -import Fastify from 'fastify'; -import { api, randomString, role, signup, startJobQueue, UserToken } from '../../utils.js'; +import { + api, + captureWebhook, + randomString, + role, + signup, + startJobQueue, + UserToken, + WEBHOOK_HOST, +} from '../../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; -const WEBHOOK_HOST = 'http://localhost:15080'; -const WEBHOOK_PORT = 15080; -process.env.NODE_ENV = 'test'; - describe('[シナリオ] ユーザ通報', () => { let queue: INestApplicationContext; let admin: entities.SignupResponse; let alice: entities.SignupResponse; let bob: entities.SignupResponse; - type SystemWebhookPayload = { - server: string; - hookId: string; - eventId: string; - createdAt: string; - type: string; - body: any; - } - - // ------------------------------------------------------------------------------------------- - - async function captureWebhook(postAction: () => Promise): Promise { - const fastify = Fastify(); - - let timeoutHandle: NodeJS.Timeout | null = null; - const result = await new Promise(async (resolve, reject) => { - fastify.all('/', async (req, res) => { - timeoutHandle && clearTimeout(timeoutHandle); - - const body = JSON.stringify(req.body); - res.status(200).send('ok'); - await fastify.close(); - resolve(body); - }); - - await fastify.listen({ port: WEBHOOK_PORT }); - - timeoutHandle = setTimeout(async () => { - await fastify.close(); - reject(new Error('timeout')); - }, 3000); - - try { - await postAction(); - } catch (e) { - await fastify.close(); - reject(e); - } - }); - - await fastify.close(); - - return JSON.parse(result) as T; - } - async function createSystemWebhook(args?: Partial, credential?: UserToken): Promise { const res = await api( 'admin/system-webhook/create', diff --git a/packages/backend/test/e2e/synalio/user-create.ts b/packages/backend/test/e2e/synalio/user-create.ts new file mode 100644 index 0000000000..cb0f68dfea --- /dev/null +++ b/packages/backend/test/e2e/synalio/user-create.ts @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setTimeout } from 'node:timers/promises'; +import { entities } from 'misskey-js'; +import { beforeEach, describe, test } from '@jest/globals'; +import { + api, + captureWebhook, + randomString, + role, + signup, + startJobQueue, + UserToken, + WEBHOOK_HOST, +} from '../../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('[シナリオ] ユーザ作成', () => { + let queue: INestApplicationContext; + let admin: entities.SignupResponse; + + async function createSystemWebhook(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'admin/system-webhook/create', + { + isActive: true, + name: randomString(), + on: ['userCreated'], + url: WEBHOOK_HOST, + secret: randomString(), + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + // ------------------------------------------------------------------------------------------- + + beforeAll(async () => { + queue = await startJobQueue(); + admin = await signup({ username: 'admin' }); + + await role(admin, { isAdministrator: true }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await queue.close(); + }); + + // ------------------------------------------------------------------------------------------- + + describe('SystemWebhook', () => { + beforeEach(async () => { + const webhooks = await api('admin/system-webhook/list', {}, admin); + for (const webhook of webhooks.body) { + await api('admin/system-webhook/delete', { id: webhook.id }, admin); + } + }); + + test('ユーザが作成された -> userCreatedが送出される', async () => { + const webhook = await createSystemWebhook({ + on: ['userCreated'], + isActive: true, + }); + + let alice: any = null; + const webhookBody = await captureWebhook(async () => { + alice = await signup({ username: 'alice' }); + }); + + // webhookの送出後にいろいろやってるのでちょっと待つ必要がある + await setTimeout(2000); + + console.log(alice); + console.log(JSON.stringify(webhookBody, null, 2)); + + expect(webhookBody.hookId).toBe(webhook.id); + expect(webhookBody.type).toBe('userCreated'); + + const body = webhookBody.body as entities.UserLite; + expect(alice.id).toBe(body.id); + expect(alice.name).toBe(body.name); + expect(alice.username).toBe(body.username); + expect(alice.host).toBe(body.host); + expect(alice.avatarUrl).toBe(body.avatarUrl); + expect(alice.avatarBlurhash).toBe(body.avatarBlurhash); + expect(alice.avatarDecorations).toEqual(body.avatarDecorations); + expect(alice.isBot).toBe(body.isBot); + expect(alice.isCat).toBe(body.isCat); + expect(alice.instance).toEqual(body.instance); + expect(alice.emojis).toEqual(body.emojis); + expect(alice.onlineStatus).toBe(body.onlineStatus); + expect(alice.badgeRoles).toEqual(body.badgeRoles); + }); + + test('ユーザ作成 -> userCreatedが未許可の場合は送出されない', async () => { + await createSystemWebhook({ + on: [], + isActive: true, + }); + + let alice: any = null; + const webhookBody = await captureWebhook(async () => { + alice = await signup({ username: 'alice' }); + }).catch(e => e.message); + + expect(webhookBody).toBe('timeout'); + expect(alice.id).not.toBeNull(); + }); + + test('ユーザ作成 -> Webhookが無効の場合は送出されない', async () => { + await createSystemWebhook({ + on: ['userCreated'], + isActive: false, + }); + + let alice: any = null; + const webhookBody = await captureWebhook(async () => { + alice = await signup({ username: 'alice' }); + }).catch(e => e.message); + + expect(webhookBody).toBe('timeout'); + expect(alice.id).not.toBeNull(); + }); + }); +}); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index fce1eacf00..b7069dd82c 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -9,8 +9,8 @@ import * as assert from 'assert'; import { setTimeout } from 'node:timers/promises'; import { Redis } from 'ioredis'; -import { loadConfig } from '@/config.js'; import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl } from '../utils.js'; +import { loadConfig } from '@/config.js'; function genHost() { return randomString() + '.example.com'; @@ -127,6 +127,7 @@ describe('Timelines', () => { test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + await api('following/create', { userId: carol.id }, bob); await api('following/create', { userId: bob.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); await setTimeout(1000); @@ -161,6 +162,24 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'hi'); }); + test.concurrent('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: alice.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(1000); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); @@ -378,7 +397,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); - }); + }, 1000 * 10); test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -492,6 +511,44 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); + + test.concurrent('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { + userId: alice.id, + }, bob); + + const aliceNote = await post(alice, { text: 'I\'m Alice.' }); + const bobNote = await post(bob, { text: 'I\'m Bob.' }); + const carolNote = await post(carol, { text: 'I\'m Carol.' }); + + await waitForPushToTl(); + + // NOTE: notes/timeline だと DB へのフォールバックが効くので Redis を直接見て確かめる + assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 1); + + const bobHTL = await redisForTimelines.lrange(`list:homeTimeline:${bob.id}`, 0, -1); + assert.strictEqual(bobHTL.includes(aliceNote.id), true); + assert.strictEqual(bobHTL.includes(bobNote.id), true); + assert.strictEqual(bobHTL.includes(carolNote.id), false); + }); + + test.concurrent('FTT: リモートユーザーの HTL にはプッシュされない', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + + await api('following/create', { + userId: alice.id, + }, bob); + + await post(alice, { text: 'I\'m Alice.' }); + await post(bob, { text: 'I\'m Bob.' }); + + await waitForPushToTl(); + + // NOTE: notes/timeline だと DB へのフォールバックが効くので Redis を直接見て確かめる + assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 0); + }); }); describe('Local TL', () => { @@ -672,7 +729,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }); + }, 1000 * 10); }); describe('Social TL', () => { @@ -730,6 +787,62 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); + test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: carol.id }, bob); + await api('following/create', { userId: bob.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(1000); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + await api('following/create', { userId: carol.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(1000); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id)?.text, 'hi'); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: alice.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(1000); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + test.concurrent('他人の他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); @@ -812,7 +925,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }); + }, 1000 * 10); }); describe('User List TL', () => { @@ -1025,7 +1138,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }); + }, 1000 * 10); test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1184,7 +1297,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }); + }, 1000 * 10); test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index 485506ee64..3c7e796700 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -14,7 +14,6 @@ import type { InstanceActorService } from '@/core/InstanceActorService.js'; import type { LoggerService } from '@/core/LoggerService.js'; import type { MetaService } from '@/core/MetaService.js'; import type { UtilityService } from '@/core/UtilityService.js'; -import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { bindThis } from '@/decorators.js'; import type { FollowRequestsRepository, @@ -48,7 +47,6 @@ export class MockResolver extends Resolver { {} as HttpRequestService, {} as ApRendererService, {} as ApDbResolverService, - {} as FederatedInstanceService, loggerService, ); } diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts index 2e66b81fcd..bf8f3ab0e3 100644 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -75,61 +75,62 @@ describe('FetchInstanceMetadataService', () => { test('Lock and update', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); + federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); expect(httpRequestService.getJson).toHaveBeenCalled(); }); - test('Don\'t lock and update if recently updated', async () => { + test('Lock and don\'t update', async () => { redisClient.set = mockRedis(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date() } as any); + const now = Date.now(); + federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); + expect(tryLockSpy).toHaveBeenCalledTimes(1); + expect(unlockSpy).toHaveBeenCalledTimes(1); expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); - expect(tryLockSpy).toHaveBeenCalledTimes(0); - expect(unlockSpy).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); }); test('Do nothing when lock not acquired', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); + federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(0); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); }); - test('Do when forced', async () => { + test('Do when lock not acquired but forced', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); + federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); expect(tryLockSpy).toHaveBeenCalledTimes(0); expect(unlockSpy).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalled(); }); }); diff --git a/packages/backend/test/unit/ap-request.ts b/packages/backend/test/unit/ap-request.ts index 50894c8b81..d3d39240dc 100644 --- a/packages/backend/test/unit/ap-request.ts +++ b/packages/backend/test/unit/ap-request.ts @@ -4,8 +4,10 @@ */ import * as assert from 'assert'; -import { verifyDraftSignature, parseRequestSignature, genEd25519KeyPair, genRsaKeyPair, importPrivateKey } from '@misskey-dev/node-http-message-signatures'; -import { createSignedGet, createSignedPost } from '@/core/activitypub/ApRequestService.js'; +import httpSignature from '@peertube/http-signature'; + +import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; +import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { return { @@ -22,68 +24,38 @@ export const buildParsedSignature = (signingString: string, signature: string, a }; }; -async function getKeyPair(level: string) { - if (level === '00') { - return await genRsaKeyPair(); - } else if (level === '01') { - return await genEd25519KeyPair(); - } - throw new Error('Invalid level'); -} +describe('ap-request', () => { + test('createSignedPost with verify', async () => { + const keypair = await genRsaKeyPair(); + const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; + const url = 'https://example.com/inbox'; + const activity = { a: 1 }; + const body = JSON.stringify(activity); + const headers = { + 'User-Agent': 'UA', + }; -describe('ap-request post', () => { - const url = 'https://example.com/inbox'; - const activity = { a: 1 }; - const body = JSON.stringify(activity); - const headers = { - 'User-Agent': 'UA', - }; + const req = ApRequestCreator.createSignedPost({ key, url, body, additionalHeaders: headers }); - describe.each(['00', '01'])('createSignedPost with verify', (level) => { - test('pem', async () => { - const keypair = await getKeyPair(level); - const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; + const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); - const req = await createSignedPost({ level, key, url, body, additionalHeaders: headers }); + const result = httpSignature.verifySignature(parsed, keypair.publicKey); + assert.deepStrictEqual(result, true); + }); - const parsed = parseRequestSignature(req.request); - expect(parsed.version).toBe('draft'); - expect(Array.isArray(parsed.value)).toBe(false); - const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey); - assert.deepStrictEqual(verify, true); - }); - test('imported', async () => { - const keypair = await getKeyPair(level); - const key = { keyId: 'x', 'privateKey': await importPrivateKey(keypair.privateKey) }; + test('createSignedGet with verify', async () => { + const keypair = await genRsaKeyPair(); + const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; + const url = 'https://example.com/outbox'; + const headers = { + 'User-Agent': 'UA', + }; - const req = await createSignedPost({ level, key, url, body, additionalHeaders: headers }); + const req = ApRequestCreator.createSignedGet({ key, url, additionalHeaders: headers }); - const parsed = parseRequestSignature(req.request); - expect(parsed.version).toBe('draft'); - expect(Array.isArray(parsed.value)).toBe(false); - const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey); - assert.deepStrictEqual(verify, true); - }); - }); -}); - -describe('ap-request get', () => { - describe.each(['00', '01'])('createSignedGet with verify', (level) => { - test('pass', async () => { - const keypair = await getKeyPair(level); - const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; - const url = 'https://example.com/outbox'; - const headers = { - 'User-Agent': 'UA', - }; - - const req = await createSignedGet({ level, key, url, additionalHeaders: headers }); - - const parsed = parseRequestSignature(req.request); - expect(parsed.version).toBe('draft'); - expect(Array.isArray(parsed.value)).toBe(false); - const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey); - assert.deepStrictEqual(verify, true); - }); + const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); + + const result = httpSignature.verifySignature(parsed, keypair.publicKey); + assert.deepStrictEqual(result, true); }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index e70befeebe..26de19eaf1 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -12,13 +12,14 @@ import WebSocket, { ClientOptions } from 'ws'; import fetch, { File, RequestInit, type Headers } from 'node-fetch'; import { DataSource } from 'typeorm'; import { JSDOM } from 'jsdom'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; +import { type Response } from 'node-fetch'; +import Fastify from 'fastify'; import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; -import { type Response } from 'node-fetch'; -import { ApiError } from "@/server/api/error.js"; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; +import { ApiError } from '@/server/api/error.js'; export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js'; @@ -27,11 +28,23 @@ export interface UserToken { bearer?: boolean; } +export type SystemWebhookPayload = { + server: string; + hookId: string; + eventId: string; + createdAt: string; + type: string; + body: any; +} + const config = loadConfig(); export const port = config.port; export const origin = config.url; export const host = new URL(config.url).host; +export const WEBHOOK_HOST = 'http://localhost:15080'; +export const WEBHOOK_PORT = 15080; + export const cookie = (me: UserToken): string => { return `token=${me.token};`; }; @@ -645,3 +658,37 @@ export async function sendEnvResetRequest() { export function castAsError(obj: Record): { error: ApiError } { return obj as { error: ApiError }; } + +export async function captureWebhook(postAction: () => Promise, port = WEBHOOK_PORT): Promise { + const fastify = Fastify(); + + let timeoutHandle: NodeJS.Timeout | null = null; + const result = await new Promise(async (resolve, reject) => { + fastify.all('/', async (req, res) => { + timeoutHandle && clearTimeout(timeoutHandle); + + const body = JSON.stringify(req.body); + res.status(200).send('ok'); + await fastify.close(); + resolve(body); + }); + + await fastify.listen({ port }); + + timeoutHandle = setTimeout(async () => { + await fastify.close(); + reject(new Error('timeout')); + }, 3000); + + try { + await postAction(); + } catch (e) { + await fastify.close(); + reject(e); + } + }); + + await fastify.close(); + + return JSON.parse(result) as T; +} diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts index bc7601441c..1299910499 100644 --- a/packages/frontend/.storybook/changes.ts +++ b/packages/frontend/.storybook/changes.ts @@ -53,7 +53,6 @@ await fs.readFile( '../../assets/**', '../../fluent-emojis/**', '../../locales/ja-JP.yml', - '../../misskey-assets/**', 'assets/**', 'public/**', '../../pnpm-lock.yaml', diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 9d789a34ff..ab04d3e60c 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -47,18 +47,7 @@ export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip { createdAt: '2016-12-28T22:49:51.000Z', lastClippedAt: null, userId: 'someuserid', - user: { - id: 'someuserid', - name: 'Misskey User', - username: 'miskist', - host: 'misskey-hub.net', - avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', - avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', - avatarDecorations: [], - emojis: {}, - badgeRoles: [], - onlineStatus: 'unknown', - }, + user: userLite(), notesCount: undefined, name, description: 'Some clip description', @@ -125,6 +114,15 @@ export function file(isSensitive = false) { }; } +export function folder(id = 'somefolderid', name = 'Some Folder', parentId: string | null = null): entities.DriveFolder { + return { + id, + createdAt: '2016-12-28T22:49:51.000Z', + name, + parentId, + }; +} + export function federationInstance(): entities.FederationInstance { return { id: 'someinstanceid', @@ -154,7 +152,27 @@ export function federationInstance(): entities.FederationInstance { }; } -export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed { +export function note(id = 'somenoteid'): entities.Note { + return { + id, + createdAt: '2016-12-28T22:49:51.000Z', + deletedAt: null, + text: 'some note', + cw: null, + userId: 'someuserid', + user: userLite(), + visibility: 'public', + reactionAcceptance: 'nonSensitiveOnly', + reactionEmojis: {}, + reactions: {}, + myReaction: null, + reactionCount: 0, + renoteCount: 0, + repliesCount: 0, + }; +} + +export function userLite(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserLite { return { id, username, @@ -165,6 +183,12 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', avatarDecorations: [], emojis: {}, + }; +} + +export function userDetailed(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserDetailed { + return { + ...userLite(id, username, host, name), bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', birthday: '2014-06-20', @@ -215,7 +239,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi movedTo: null, alsoKnownAs: null, notify: 'none', - memo: null + memo: null, }; } diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 7b6c86447e..52c01aaf70 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -397,14 +397,14 @@ function toStories(component: string): Promise { const globs = await Promise.all([ glob('src/components/global/Mk*.vue'), glob('src/components/global/RouterView.vue'), - glob('src/components/Mk[A-C]*.vue'), - glob('src/components/MkDigitalClock.vue'), + glob('src/components/Mk[A-E]*.vue'), glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkUserSetupDialog.vue'), glob('src/components/MkUserSetupDialog.*.vue'), glob('src/components/MkInstanceCardMini.vue'), glob('src/components/MkInviteCode.vue'), + glob('src/pages/search.vue'), glob('src/pages/user/home.vue'), ]); const components = globs.flat(); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index fbeae08aa0..244d117de2 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -27,8 +27,8 @@ "@syuilo/aiscript": "0.19.0", "@tabler/icons-webfont": "3.3.0", "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.0.5", - "@vue/compiler-sfc": "3.4.31", + "@vitejs/plugin-vue": "5.1.0", + "@vue/compiler-sfc": "3.4.34", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11", "astring": "1.8.6", "broadcast-channel": "7.0.0", @@ -39,9 +39,9 @@ "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "11.5.4", - "compare-versions": "6.1.0", - "cropperjs": "2.0.0-beta.5", + "chromatic": "11.5.6", + "compare-versions": "6.1.1", + "cropperjs": "2.0.0-rc.1", "date-fns": "2.30.0", "escape-regexp": "0.0.1", "estree-walker": "3.0.3", @@ -57,85 +57,85 @@ "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", "punycode": "2.3.1", - "rollup": "4.18.0", + "rollup": "4.19.1", "sanitize-html": "2.13.0", - "sass": "1.77.6", - "shiki": "1.10.0", + "sass": "1.77.8", + "shiki": "1.12.0", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.165.0", + "three": "0.167.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", - "typescript": "5.5.3", + "typescript": "5.5.4", "uuid": "10.0.0", "v-code-diff": "1.12.0", - "vite": "5.3.2", - "vue": "3.4.31", + "vite": "5.3.5", + "vue": "3.4.34", "vuedraggable": "next" }, "devDependencies": { "@misskey-dev/summaly": "5.1.0", - "@storybook/addon-actions": "8.1.11", - "@storybook/addon-essentials": "8.1.11", - "@storybook/addon-interactions": "8.1.11", - "@storybook/addon-links": "8.1.11", - "@storybook/addon-mdx-gfm": "8.1.11", - "@storybook/addon-storysource": "8.1.11", - "@storybook/blocks": "8.1.11", - "@storybook/components": "8.1.11", - "@storybook/core-events": "8.1.11", - "@storybook/manager-api": "8.1.11", - "@storybook/preview-api": "8.1.11", - "@storybook/react": "8.1.11", - "@storybook/react-vite": "8.1.11", - "@storybook/test": "8.1.11", - "@storybook/theming": "8.1.11", - "@storybook/types": "8.1.11", - "@storybook/vue3": "8.1.11", + "@storybook/addon-actions": "8.2.6", + "@storybook/addon-essentials": "8.2.6", + "@storybook/addon-interactions": "8.2.6", + "@storybook/addon-links": "8.2.6", + "@storybook/addon-mdx-gfm": "8.2.6", + "@storybook/addon-storysource": "8.2.6", + "@storybook/blocks": "8.2.6", + "@storybook/components": "8.2.6", + "@storybook/core-events": "8.2.6", + "@storybook/manager-api": "8.2.6", + "@storybook/preview-api": "8.2.6", + "@storybook/react": "8.2.6", + "@storybook/react-vite": "8.2.6", + "@storybook/test": "8.2.6", + "@storybook/theming": "8.2.6", + "@storybook/types": "8.2.6", + "@storybook/vue3": "8.2.6", "@storybook/vue3-vite": "8.1.11", "@testing-library/vue": "8.1.0", "@types/escape-regexp": "0.0.3", "@types/estree": "1.0.5", - "@types/matter-js": "0.19.6", + "@types/matter-js": "0.19.7", "@types/micromatch": "4.0.9", - "@types/node": "20.14.9", + "@types/node": "20.14.12", "@types/punycode": "2.1.4", "@types/sanitize-html": "2.11.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/uuid": "10.0.0", - "@types/ws": "8.5.10", - "@typescript-eslint/eslint-plugin": "7.15.0", - "@typescript-eslint/parser": "7.15.0", + "@types/ws": "8.5.11", + "@typescript-eslint/eslint-plugin": "7.17.0", + "@typescript-eslint/parser": "7.17.0", "@vitest/coverage-v8": "1.6.0", - "@vue/runtime-core": "3.4.31", - "acorn": "8.12.0", + "@vue/runtime-core": "3.4.34", + "acorn": "8.12.1", "cross-env": "7.0.3", - "cypress": "13.13.0", + "cypress": "13.13.1", "eslint-plugin-import": "2.29.1", - "eslint-plugin-vue": "9.26.0", + "eslint-plugin-vue": "9.27.0", "fast-glob": "3.3.2", "happy-dom": "10.0.3", "intersection-observer": "0.12.2", "micromatch": "4.0.7", - "msw": "2.3.1", - "msw-storybook-addon": "2.0.2", + "msw": "2.3.4", + "msw-storybook-addon": "2.0.3", "nodemon": "3.1.4", - "prettier": "3.3.2", + "prettier": "3.3.3", "react": "18.3.1", "react-dom": "18.3.1", "seedrandom": "3.0.5", "start-server-and-test": "2.0.4", - "storybook": "8.1.11", + "storybook": "8.2.6", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", "vitest": "1.6.0", "vitest-fetch-mock": "0.2.2", - "vue-component-type-helpers": "2.0.24", + "vue-component-type-helpers": "2.0.29", "vue-eslint-parser": "9.4.3", - "vue-tsc": "2.0.24" + "vue-tsc": "2.0.29" } } diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index d327016317..2a549d1e8b 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -5,6 +5,7 @@ import { createApp, defineAsyncComponent, markRaw } from 'vue'; import { common } from './common.js'; +import type * as Misskey from 'misskey-js'; import { ui } from '@/config.js'; import { i18n } from '@/i18n.js'; import { alert, confirm, popup, post, toast } from '@/os.js'; @@ -113,7 +114,7 @@ export async function mainBoot() { }); } - stream.on('announcementCreated', (ev) => { + function onAnnouncementCreated (ev: { announcement: Misskey.entities.Announcement }) { const announcement = ev.announcement; if (announcement.display === 'dialog') { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { @@ -122,7 +123,9 @@ export async function mainBoot() { closed: () => dispose(), }); } - }); + } + + stream.on('announcementCreated', onAnnouncementCreated); if ($i.isDeleted) { alert({ @@ -315,6 +318,9 @@ export async function mainBoot() { updateAccount({ hasUnreadAnnouncement: false }); }); + // 個人宛てお知らせが発行されたとき + main.on('announcementCreated', onAnnouncementCreated); + // トークンが再生成されたとき // このままではMisskeyが利用できないので強制的にサインアウトさせる main.on('myTokenRegenerated', () => { diff --git a/packages/frontend/src/boot/sub-boot.ts b/packages/frontend/src/boot/sub-boot.ts index 017457822b..35c84d5568 100644 --- a/packages/frontend/src/boot/sub-boot.ts +++ b/packages/frontend/src/boot/sub-boot.ts @@ -5,9 +5,12 @@ import { createApp, defineAsyncComponent } from 'vue'; import { common } from './common.js'; +import { emojiPicker } from '@/scripts/emoji-picker.js'; export async function subBoot() { const { isClientUpdated } = await common(() => createApp( defineAsyncComponent(() => import('@/ui/minimum.vue')), )); + + emojiPicker.init(); } diff --git a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts new file mode 100644 index 0000000000..1749e07a4e --- /dev/null +++ b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkAntennaEditor from './MkAntennaEditor.vue'; +export const Default = { + render(args) { + return { + components: { + MkAntennaEditor, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + created: action('created'), + updated: action('updated'), + deleted: action('deleted'), + }; + }, + }, + template: '', + }; + }, + args: { + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/antennas/create', async ({ request }) => { + action('POST /api/antennas/create')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/antennas/update', async ({ request }) => { + action('POST /api/antennas/update')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/antennas/delete', async ({ request }) => { + action('POST /api/antennas/delete')(await request.json()); + return HttpResponse.json(); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/components/MkAntennaEditor.vue similarity index 66% rename from packages/frontend/src/pages/my-antennas/editor.vue rename to packages/frontend/src/components/MkAntennaEditor.vue index 02e8f98265..cb7ee3d6ca 100644 --- a/packages/frontend/src/pages/my-antennas/editor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.save }} - {{ i18n.ts.delete }} + {{ i18n.ts.delete }}
@@ -61,28 +61,53 @@ import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { deepMerge } from '@/scripts/merge.js'; +import type { DeepPartial } from '@/scripts/merge.js'; + +type PartialAllowedAntenna = Omit & { + id?: string; + createdAt?: string; + updatedAt?: string; +}; const props = defineProps<{ - antenna: Misskey.entities.Antenna + antenna?: DeepPartial; }>(); +const initialAntenna = deepMerge(props.antenna ?? {}, { + name: '', + src: 'all', + userListId: null, + users: [], + keywords: [], + excludeKeywords: [], + excludeBots: false, + withReplies: false, + caseSensitive: false, + localOnly: false, + withFile: false, + isActive: true, + hasUnreadNote: false, + notify: false, +}); + const emit = defineEmits<{ - (ev: 'created'): void, - (ev: 'updated'): void, + (ev: 'created', newAntenna: Misskey.entities.Antenna): void, + (ev: 'updated', editedAntenna: Misskey.entities.Antenna): void, (ev: 'deleted'): void, }>(); -const name = ref(props.antenna.name); -const src = ref(props.antenna.src); -const userListId = ref(props.antenna.userListId); -const users = ref(props.antenna.users.join('\n')); -const keywords = ref(props.antenna.keywords.map(x => x.join(' ')).join('\n')); -const excludeKeywords = ref(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n')); -const caseSensitive = ref(props.antenna.caseSensitive); -const localOnly = ref(props.antenna.localOnly); -const excludeBots = ref(props.antenna.excludeBots); -const withReplies = ref(props.antenna.withReplies); -const withFile = ref(props.antenna.withFile); +const name = ref(initialAntenna.name); +const src = ref(initialAntenna.src); +const userListId = ref(initialAntenna.userListId); +const users = ref(initialAntenna.users.join('\n')); +const keywords = ref(initialAntenna.keywords.map(x => x.join(' ')).join('\n')); +const excludeKeywords = ref(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n')); +const caseSensitive = ref(initialAntenna.caseSensitive); +const localOnly = ref(initialAntenna.localOnly); +const excludeBots = ref(initialAntenna.excludeBots); +const withReplies = ref(initialAntenna.withReplies); +const withFile = ref(initialAntenna.withFile); const userLists = ref(null); watch(() => src.value, async () => { @@ -106,24 +131,26 @@ async function saveAntenna() { excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')), }; - if (props.antenna.id == null) { - await os.apiWithDialog('antennas/create', antennaData); - emit('created'); + if (initialAntenna.id == null) { + const res = await os.apiWithDialog('antennas/create', antennaData); + emit('created', res); } else { - await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: props.antenna.id }); - emit('updated'); + const res = await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: initialAntenna.id }); + emit('updated', res); } } async function deleteAntenna() { + if (initialAntenna.id == null) return; + const { canceled } = await os.confirm({ type: 'warning', - text: i18n.tsx.removeAreYouSure({ x: props.antenna.name }), + text: i18n.tsx.removeAreYouSure({ x: initialAntenna.name }), }); if (canceled) return; await misskeyApi('antennas/delete', { - antennaId: props.antenna.id, + antennaId: initialAntenna.id, }); os.success(); diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts new file mode 100644 index 0000000000..1c6ca83b47 --- /dev/null +++ b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkAntennaEditorDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + created: action('created'), + updated: action('updated'), + deleted: action('deleted'), + closed: action('closed'), + }; + }, + }, + template: '', + }; + }, + args: { + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/antennas/create', async ({ request }) => { + action('POST /api/antennas/create')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/antennas/update', async ({ request }) => { + action('POST /api/antennas/update')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/antennas/delete', async ({ request }) => { + action('POST /api/antennas/delete')(await request.json()); + return HttpResponse.json(); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.vue b/packages/frontend/src/components/MkAntennaEditorDialog.vue new file mode 100644 index 0000000000..6d815d29f3 --- /dev/null +++ b/packages/frontend/src/components/MkAntennaEditorDialog.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/packages/frontend/src/components/MkClipPreview.stories.impl.ts b/packages/frontend/src/components/MkClipPreview.stories.impl.ts index 1011254e7a..62503fb98a 100644 --- a/packages/frontend/src/components/MkClipPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkClipPreview.stories.impl.ts @@ -31,6 +31,7 @@ export const Default = { }, args: { clip: clip(), + noUserInfo: false, }, parameters: { layout: 'fullscreen', diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 2e9a172c23..dd550733cb 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -12,10 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.updatedAt }}:
{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})
-
-
- -
+ @@ -27,9 +29,12 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import number from '@/filters/number.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ clip: Misskey.entities.Clip; -}>(); + noUserInfo?: boolean; +}>(), { + noUserInfo: false, +}); const remaining = computed(() => { return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown; diff --git a/packages/frontend/src/components/MkDateSeparatedList.stories.impl.ts b/packages/frontend/src/components/MkDateSeparatedList.stories.impl.ts new file mode 100644 index 0000000000..0e5635754c --- /dev/null +++ b/packages/frontend/src/components/MkDateSeparatedList.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDateSeparatedList from './MkDateSeparatedList.vue'; +void MkDateSeparatedList; diff --git a/packages/frontend/src/components/MkDialog.stories.impl.ts b/packages/frontend/src/components/MkDialog.stories.impl.ts new file mode 100644 index 0000000000..2d8d3661f2 --- /dev/null +++ b/packages/frontend/src/components/MkDialog.stories.impl.ts @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { StoryObj } from '@storybook/vue3'; +import { i18n } from '@/i18n.js'; +import MkDialog from './MkDialog.vue'; +const Base = { + render(args) { + return { + components: { + MkDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + done: action('done'), + closed: action('closed'), + }; + }, + }, + template: '', + }; + }, + args: { + text: 'Hello, world!', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; +export const Success = { + ...Base, + args: { + ...Base.args, + type: 'success', + }, +} satisfies StoryObj; +export const Error = { + ...Base, + args: { + ...Base.args, + type: 'error', + }, +} satisfies StoryObj; +export const Warning = { + ...Base, + args: { + ...Base.args, + type: 'warning', + }, +} satisfies StoryObj; +export const Info = { + ...Base, + args: { + ...Base.args, + type: 'info', + }, +} satisfies StoryObj; +export const Question = { + ...Base, + args: { + ...Base.args, + type: 'question', + }, +} satisfies StoryObj; +export const Waiting = { + ...Base, + args: { + ...Base.args, + type: 'waiting', + }, +} satisfies StoryObj; +export const DialogWithActions = { + ...Question, + args: { + ...Question.args, + text: i18n.ts.areYouSure, + actions: [ + { + text: i18n.ts.yes, + primary: true, + callback() { + action('YES')(); + }, + }, + { + text: i18n.ts.no, + callback() { + action('NO')(); + }, + }, + ], + }, +} satisfies StoryObj; +export const DialogWithDangerActions = { + ...Warning, + args: { + ...Warning.args, + text: i18n.ts.resetAreYouSure, + actions: [ + { + text: i18n.ts.yes, + danger: true, + primary: true, + callback() { + action('YES')(); + }, + }, + { + text: i18n.ts.no, + callback() { + action('NO')(); + }, + }, + ], + }, +} satisfies StoryObj; +export const DialogWithInput = { + ...Question, + args: { + ...Question.args, + title: 'Hello, world!', + text: undefined, + input: { + placeholder: i18n.ts.inputMessageHere, + type: 'text', + default: null, + minLength: 2, + maxLength: 3, + }, + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + await expect(canvasElement).toHaveTextContent(i18n.tsx._dialog.charactersBelow({ current: 0, min: 2 })); + const okButton = canvas.getByRole('button', { name: i18n.ts.ok }); + await expect(okButton).toBeDisabled(); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(() => userEvent.type(input, 'M')); + await expect(canvasElement).toHaveTextContent(i18n.tsx._dialog.charactersBelow({ current: 1, min: 2 })); + await waitFor(() => userEvent.type(input, 'i')); + await expect(okButton).toBeEnabled(); + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 5c3c6aa51d..16cf5b1b75 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -36,7 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -67,11 +72,16 @@ type Input = { maxLength?: number; }; +type SelectItem = { + value: any; + text: string; +}; + type Select = { - items: { - value: any; - text: string; - }[]; + items: (SelectItem | { + sectionTitle: string; + items: SelectItem[]; + })[]; default: string | null; }; diff --git a/packages/frontend/src/components/MkDivider.stories.impl.ts b/packages/frontend/src/components/MkDivider.stories.impl.ts new file mode 100644 index 0000000000..a593111987 --- /dev/null +++ b/packages/frontend/src/components/MkDivider.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDivider from './MkDivider.vue'; +void MkDivider; diff --git a/packages/frontend/src/components/MkDonation.stories.impl.ts b/packages/frontend/src/components/MkDonation.stories.impl.ts new file mode 100644 index 0000000000..27d6b7df6c --- /dev/null +++ b/packages/frontend/src/components/MkDonation.stories.impl.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { onBeforeUnmount } from 'vue'; +import MkDonation from './MkDonation.vue'; +import { instance } from '@/instance.js'; +export const Default = { + render(args) { + return { + components: { + MkDonation, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + closed: action('closed'), + }; + }, + }, + template: '', + }; + }, + args: { + // @ts-expect-error name is used for mocking instance + name: 'Misskey Hub', + }, + decorators: [ + (_, { args }) => ({ + setup() { + // @ts-expect-error name is used for mocking instance + instance.name = args.name; + onBeforeUnmount(() => instance.name = null); + }, + template: '', + }), + ], + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkDrive.file.stories.impl.ts b/packages/frontend/src/components/MkDrive.file.stories.impl.ts new file mode 100644 index 0000000000..5f6e6a0667 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.file.stories.impl.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import MkDrive_file from './MkDrive.file.vue'; +import { file } from '../../.storybook/fakes.js'; +export const Default = { + render(args) { + return { + components: { + MkDrive_file, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + chosen: action('chosen'), + dragstart: action('dragstart'), + dragend: action('dragend'), + }; + }, + }, + template: '', + }; + }, + args: { + file: file(), + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts new file mode 100644 index 0000000000..5f8ef48520 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { http, HttpResponse } from 'msw'; +import * as Misskey from 'misskey-js'; +import MkDrive_folder from './MkDrive.folder.vue'; +import { folder } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +export const Default = { + render(args) { + return { + components: { + MkDrive_folder, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + chosen: action('chosen'), + move: action('move'), + upload: action('upload'), + removeFile: action('removeFile'), + removeFolder: action('removeFolder'), + dragstart: action('dragstart'), + dragend: action('dragend'), + }; + }, + }, + template: '', + }; + }, + args: { + folder: folder(), + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/drive/folders/delete', async ({ request }) => { + action('POST /api/drive/folders/delete')(await request.json()); + return HttpResponse.json(undefined, { status: 204 }); + }), + http.post('/api/drive/folders/update', async ({ request }) => { + const req = await request.json() as Misskey.entities.DriveFoldersUpdateRequest; + action('POST /api/drive/folders/update')(req); + return HttpResponse.json({ + ...folder(), + id: req.folderId, + name: req.name ?? folder().name, + parentId: req.parentId ?? folder().parentId, + }); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index c940596cde..d6dfaf34e5 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -27,7 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only

{{ i18n.ts.uploadFolder }}

- +
@@ -53,6 +55,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: Misskey.entities.DriveFolder): void; + (ev: 'unchose', v: Misskey.entities.DriveFolder): void; (ev: 'move', v: Misskey.entities.DriveFolder): void; (ev: 'upload', file: File, folder: Misskey.entities.DriveFolder); (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; @@ -68,7 +71,11 @@ const isDragging = ref(false); const title = computed(() => props.folder.name); function checkboxClicked() { - emit('chosen', props.folder); + if (props.isSelected) { + emit('unchose', props.folder); + } else { + emit('chosen', props.folder); + } } function onClick() { @@ -222,6 +229,17 @@ function rename() { }); } +function move() { + os.selectDriveFolder(false).then(folder => { + if (folder[0] && folder[0].id === props.folder.id) return; + + misskeyApi('drive/folders/update', { + folderId: props.folder.id, + parentId: folder[0] ? folder[0].id : null, + }); + }); +} + function deleteFolder() { misskeyApi('drive/folders/delete', { folderId: props.folder.id, @@ -267,6 +285,10 @@ function onContextmenu(ev: MouseEvent) { text: i18n.ts.rename, icon: 'ti ti-forms', action: rename, + }, { + text: i18n.ts.move, + icon: 'ti ti ti-folder-symlink', + action: move, }, { type: 'divider' }, { text: i18n.ts.delete, icon: 'ti ti-trash', @@ -310,17 +332,43 @@ function onContextmenu(ev: MouseEvent) { } } -.checkbox { +.checkboxWrapper { position: absolute; - bottom: 8px; - right: 8px; - width: 16px; - height: 16px; - background: #fff; - border: solid 1px #000; + border-radius: 50%; + bottom: 2px; + right: 2px; + padding: 8px; + box-sizing: border-box; - &.checked { - background: var(--accent); + > .checkbox { + position: relative; + width: 18px; + height: 18px; + background: #fff; + border: solid 2px var(--divider); + border-radius: 4px; + box-sizing: border-box; + + &.checked { + border-color: var(--accent); + background: var(--accent); + + &::after { + content: "\ea5e"; + font-family: 'tabler-icons'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #fff; + font-size: 12px; + line-height: 22px; + } + } + } + + &:hover { + background: var(--accentedBg); } } diff --git a/packages/frontend/src/components/MkDrive.navFolder.stories.impl.ts b/packages/frontend/src/components/MkDrive.navFolder.stories.impl.ts new file mode 100644 index 0000000000..9d49f24fa4 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.navFolder.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDrive_navFolder from './MkDrive.navFolder.vue'; +void MkDrive_navFolder; diff --git a/packages/frontend/src/components/MkDrive.stories.impl.ts b/packages/frontend/src/components/MkDrive.stories.impl.ts new file mode 100644 index 0000000000..fe20e61415 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.stories.impl.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { http, HttpResponse } from 'msw'; +import * as Misskey from 'misskey-js'; +import MkDrive from './MkDrive.vue'; +import { file, folder } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +export const Default = { + render(args) { + return { + components: { + MkDrive, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + selected: action('selected'), + 'change-selection': action('change-selection'), + 'move-root': action('move-root'), + cd: action('cd'), + 'open-folder': action('open-folder'), + }; + }, + }, + template: '', + }; + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/drive/files', async ({ request }) => { + action('POST /api/drive/files')(await request.json()); + return HttpResponse.json([file()]); + }), + http.post('/api/drive/folders', async ({ request }) => { + action('POST /api/drive/folders')(await request.json()); + return HttpResponse.json([folder(crypto.randomUUID())]); + }), + http.post('/api/drive/folders/create', async ({ request }) => { + const req = await request.json() as Misskey.entities.DriveFoldersCreateRequest; + action('POST /api/drive/folders/create')(req); + return HttpResponse.json(folder(crypto.randomUUID(), req.name, req.parentId)); + }), + http.post('/api/drive/folders/delete', async ({ request }) => { + action('POST /api/drive/folders/delete')(await request.json()); + return HttpResponse.json(undefined, { status: 204 }); + }), + http.post('/api/drive/folders/update', async ({ request }) => { + const req = await request.json() as Misskey.entities.DriveFoldersUpdateRequest; + action('POST /api/drive/folders/update')(req); + return HttpResponse.json({ + ...folder(), + id: req.folderId, + name: req.name ?? folder().name, + parentId: req.parentId ?? folder().parentId, + }); + }), + ] + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index a9717b4fb7..dbb4917069 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -52,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only :selectMode="select === 'folder'" :isSelected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" + @unchose="unchoseFolder" @move="move" @upload="upload" @removeFile="removeFile" @@ -428,6 +429,11 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { } } +function unchoseFolder(folderToUnchose: Misskey.entities.DriveFolder) { + selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToUnchose.id); + emit('change-selection', selectedFolders.value); +} + function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { if (!target) { goRoot(); diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts b/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts new file mode 100644 index 0000000000..3fa24d7edb --- /dev/null +++ b/packages/frontend/src/components/MkDriveFileThumbnail.stories.impl.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StoryObj } from '@storybook/vue3'; +import MkDriveFileThumbnail from './MkDriveFileThumbnail.vue'; +import { file } from '../../.storybook/fakes.js'; +export const Default = { + render(args) { + return { + components: { + MkDriveFileThumbnail, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + file: file(), + fit: 'contain', + }, + parameters: { + chromatic: { + // NOTE: ロードが終わるまで待つ + delay: 3000, + }, + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 706c9a55c7..2c47a70970 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -26,7 +26,7 @@ import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; const props = defineProps<{ file: Misskey.entities.DriveFile; - fit: string; + fit: 'cover' | 'contain'; }>(); const is = computed(() => { diff --git a/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts new file mode 100644 index 0000000000..fe8f705165 --- /dev/null +++ b/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDriveSelectDialog from './MkDriveSelectDialog.vue'; +void MkDriveSelectDialog; diff --git a/packages/frontend/src/components/MkDriveWindow.stories.impl.ts b/packages/frontend/src/components/MkDriveWindow.stories.impl.ts new file mode 100644 index 0000000000..faa1f7fd5f --- /dev/null +++ b/packages/frontend/src/components/MkDriveWindow.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDriveWindow from './MkDriveWindow.vue'; +void MkDriveWindow; diff --git a/packages/frontend/src/components/MkEmojiPicker.section.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.section.stories.impl.ts new file mode 100644 index 0000000000..69aef577de --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPicker.section.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkEmojiPicker_section from './MkEmojiPicker.section.vue'; +void MkEmojiPicker_section; diff --git a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts new file mode 100644 index 0000000000..d38d8de808 --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { action } from '@storybook/addon-actions'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { StoryObj } from '@storybook/vue3'; +import { i18n } from '@/i18n.js'; +import MkEmojiPicker from './MkEmojiPicker.vue'; +export const Default = { + render(args) { + return { + components: { + MkEmojiPicker, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + chosen: action('chosen'), + }; + }, + }, + template: '', + }; + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const faceSection = canvas.getByText(/face/i); + await waitFor(() => userEvent.click(faceSection)); + const grinning = canvasElement.querySelector('[data-emoji="😀"]'); + await expect(grinning).toBeInTheDocument(); + if (grinning == null) throw new Error(); // NOTE: not called + await waitFor(() => userEvent.click(grinning)); + const recentUsedSection = canvas.getByText(new RegExp(i18n.ts.recentUsed)).parentElement; + await expect(recentUsedSection).toBeInTheDocument(); + if (recentUsedSection == null) throw new Error(); // NOTE: not called + await expect(within(recentUsedSection).getByAltText('😀')).toBeInTheDocument(); + await expect(within(recentUsedSection).queryByAltText('😬')).toEqual(null); + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.stories.impl.ts b/packages/frontend/src/components/MkEmojiPickerDialog.stories.impl.ts new file mode 100644 index 0000000000..131087ad45 --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPickerDialog.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkEmojiPickerDialog from './MkEmojiPickerDialog.vue'; +void MkEmojiPickerDialog; diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 617404f5c4..8d301f16bd 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -151,22 +151,26 @@ function drawImage(bitmap: CanvasImageSource) { } function drawAvg() { - if (!canvas.value || !props.hash) return; + if (!canvas.value) return; + + const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888'; const ctx = canvas.value.getContext('2d'); if (!ctx) return; // avgColorでお茶をにごす ctx.beginPath(); - ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888'; + ctx.fillStyle = color; ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value); } async function draw() { - if (props.hash == null) return; + if (import.meta.env.MODE === 'test' && props.hash == null) return; drawAvg(); + if (props.hash == null) return; + if (props.onlyAvgColor) return; const work = await canvasPromise; diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 88ef4635e6..e695564f92 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -79,7 +79,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'change', _ev: KeyboardEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; - (ev: 'enter'): void; + (ev: 'enter', _ev: KeyboardEvent): void; (ev: 'update:modelValue', value: string | number): void; }>(); @@ -111,7 +111,7 @@ const onKeydown = (ev: KeyboardEvent) => { emit('keydown', ev); if (ev.code === 'Enter') { - emit('enter'); + emit('enter', ev); } }; diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 582cf238c0..a080550ddf 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only @contextmenu.stop @keydown.stop > -