diff --git a/.config/docker_example.yml b/.config/docker_example.yml index a9871a1106..344f11f9d0 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -95,15 +95,14 @@ redis: # #prefix: example-prefix # #db: 1 -# ┌─────────────────────────────┐ -#───┘ Elasticsearch configuration └───────────────────────────── +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── -#elasticsearch: -# host: localhost -# port: 9200 -# ssl: false -# user: -# pass: +#meilisearch: +# host: meilisearch +# port: 7700 +# apiKey: '' +# ssl: true # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index 53d465c974..95ac53e7ed 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -95,15 +95,14 @@ redis: # #prefix: example-prefix # #db: 1 -# ┌─────────────────────────────┐ -#───┘ Elasticsearch configuration └───────────────────────────── +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── -#elasticsearch: +#meilisearch: # host: localhost -# port: 9200 -# ssl: false -# user: -# pass: +# port: 7700 +# apiKey: '' +# ssl: true # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── @@ -133,16 +132,20 @@ id: 'aid' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 +#deliverJobConcurrency: 128 +#inboxJobConcurrency: 16 +#relashionshipJobConcurrency: 16 +# What's relashionshipJob?: +# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 16 +#deliverJobPerSec: 128 +#inboxJobPerSec: 16 +#relashionshipJobPerSec: 64 # Job attempts -# deliverJobMaxAttempts: 12 -# inboxJobMaxAttempts: 8 +#deliverJobMaxAttempts: 12 +#inboxJobMaxAttempts: 8 # IP address family used for outgoing request (ipv4, ipv6 or dual) #outgoingAddressFamily: ipv4 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6accd43476..a47804ab07 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,10 @@ "service": "app", "workspaceFolder": "/workspace", "features": { - "ghcr.io/devcontainers-contrib/features/pnpm:2": {} + "ghcr.io/devcontainers-contrib/features/pnpm:2": {}, + "ghcr.io/devcontainers/features/node:1": { + "version": "18.16.0" + } }, "forwardPorts": [3000], "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 2af306e3da..1d761ae75e 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -95,15 +95,14 @@ redis: # #prefix: example-prefix # #db: 1 -# ┌─────────────────────────────┐ -#───┘ Elasticsearch configuration └───────────────────────────── +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── -#elasticsearch: -# host: localhost -# port: 9200 -# ssl: false -# user: -# pass: +#meilisearch: +# host: meilisearch +# port: 7700 +# apiKey: '' +# ssl: true # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/.dockerignore b/.dockerignore index 151ede038e..1de0c7982b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,6 @@ build/ built/ db/ docker-compose.yml -elasticsearch/ node_modules/ packages/*/node_modules redis/ diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.md b/.github/ISSUE_TEMPLATE/01_bug-report.md index b4a2f69879..f6fd593c85 100644 --- a/.github/ISSUE_TEMPLATE/01_bug-report.md +++ b/.github/ISSUE_TEMPLATE/01_bug-report.md @@ -37,7 +37,10 @@ Please include errors from the developer console and/or server log files if you ## 📌 Environment + Misskey version: +PostgreSQL version: +Redis version: Your OS: Your browser: diff --git a/.github/labeler.yml b/.github/labeler.yml index b4fd0dd5df..137be487c0 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,12 +1,21 @@ -'⚙️Server': +'packages/backend': - packages/backend/**/* -'🖥️Client': -- packages/frontend/**/* - -'🧪Test': -- cypress/**/* +'packages/backend:test': - packages/backend/test/**/* -'‼️ wrong locales': -- any: ['locales/*.yml', '!locales/ja-JP.yml'] +'packages/frontend': +- packages/frontend/**/* + +'packages/frontend:test': +- cypress/**/* + +'packages/sw': +- packages/sw/**/* + +'packages/misskey-js': +- packages/misskey-js/**/* + +'packages/misskey-js:test': +- packages/misskey-js/test/**/* +- packages/misskey-js/test-d/**/* diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0739fee709..e78b82c47c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,5 +19,6 @@ https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md ## Checklist - [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) - [ ] Test working in a local environment +- [ ] (If needed) Add story of storybook - [ ] (If needed) Update CHANGELOG.md - [ ] (If possible) Add tests diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 6411d63bd5..ed004c78dc 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - name: Install dependencies diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1c6615e17f..0f3702f958 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,11 +17,11 @@ jobs: submodules: true - uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile @@ -48,7 +48,7 @@ jobs: run_install: false - uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile @@ -74,7 +74,7 @@ jobs: run_install: false - uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index f1e414dbbc..d7be15bd4f 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -35,7 +35,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3.6.0 diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index a5505d30d8..4ea4ba4628 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -22,7 +22,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3.6.0 @@ -106,7 +106,7 @@ jobs: install: false start: pnpm start:test wait-on: 'http://localhost:61812' - headless: false + headed: true browser: ${{ matrix.browser }} - uses: actions/upload-artifact@v2 if: failure() diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml new file mode 100644 index 0000000000..5243a83777 --- /dev/null +++ b/.github/workflows/test-production.yml @@ -0,0 +1,42 @@ +name: Test (production install and build) + +on: + push: + branches: + - master + - develop + pull_request: + +env: + NODE_ENV: production + +jobs: + production: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + steps: + - uses: actions/checkout@v3.3.0 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .github/misskey/test.yml .config/default.yml + - name: Build + run: pnpm build diff --git a/.gitignore b/.gitignore index fbe2245502..537232d37f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,7 @@ built /data /.cache-loader /db -/elasticsearch +/meili_data npm-debug.log *.pem run.bat diff --git a/.node-version b/.node-version index 0e9dc6b586..6d80269a4f 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v18.13.0 +18.16.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index baffbe18ec..71fb02a59d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,6 @@ "files.associations": { "*.test.ts": "typescript" }, + "jest.jestCommandLine": "pnpm run jest", "jest.autoRun": "off" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 011e1cbccf..50e097c683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,76 @@ --> +## 13.12.1 + +### Client +- プロフィール画面におけるモデレーションノートの表示を調整 +- Fix: 一部ダイアログが表示されない問題を修正 +- Fix: MkUserInfoのフォローボタンが変な位置にある問題を修正 + +### Server +- Fix: リモートサーバーの情報が更新できない問題を修正 +- Fix: 13.11を経験しない状態で13.12にアップデートした場合ユーザープロフィール関連の画像が消失する問題を修正 + +## 13.12.0 + +### NOTE +- Node.js 18.6.0以上が必要になりました + +### General +- アカウントの引っ越し(フォロワー引き継ぎ)に対応 +- Meilisearchを全文検索に使用できるようになりました +- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加 +- ユーザーへの自分用メモ機能 + * ユーザーに対して、自分だけが見られるメモを追加できるようになりました。 + (自分自身に対してもメモを追加できます。) + * ユーザーメニューから追加できます。 + (デスクトップ表示ではusernameの右側のボタンからも追加可能) +- チャンネルに色を設定できるようになりました。各ノートに設定した色のインジケーターが表示されます。 +- チャンネルをアーカイブできるようになりました。 + * アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。 +- アンテナのエクスポート・インポートができるようになりました +- ロールタイムラインをロールごとに表示するかどうかの選択できるようになりました。 + * デフォルトがオフになるので、ロールタイムラインを表示する場合はオンにしてください。 +- ロールに強制的にNSFWを付与するポリシーを追加 + * アップロード済みのファイルはNSFWにならない為注意してください。 +- モデレーションノートがユーザーのプロフィールページからも閲覧および編集できるようになりました。 +- カスタム絵文字のライセンスを複数でセットできるようになりました。 +- 管理者が予約ユーザー名を設定できるようになりました。 +- Fix: フォローリクエストの通知が残る問題を修正 + +### Client +- アカウント作成時に初期設定ウィザードを表示するように +- チャンネル内検索ができるように +- チャンネル検索ですべてのチャンネルの取得/表示ができるように +- 通知の表示をカスタマイズできるように +- ドライブのファイル一覧から直接ノートを作成できるように +- ノートメニューからRenoteしたユーザーの一覧を見れるように +- コントロールパネルのカスタム絵文字ページおよびaboutのカスタム絵文字の検索インプットで、`:emojiname1::emojiname2:`のように検索して絵文字を検索できるように + * 絵文字ピッカーから入力可能になります +- データセーバーモードを追加 + * 画像が全て隠れた状態で表示されるようになります +- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように +- モデレーターはノートに添付された画像上から直接NSFW設定できるように +- プロフィール設定「追加情報」の項目の削除と並び替えができるように +- 新しい実績を追加 +- AiScriptを0.13.2に更新 +- Fix: AiScript APIのMk:dialogで何も返していなかったのをNULLを返すように修正 +- Fix: 1:1ではない画像のリアクション通知バッジが左や上に寄ってしまっていたのを中央に来るように修正 +- Fix: リアクションをホバーした時のユーザーリストで猫耳が切れてしまっていた問題を修正 +- Fix: NSFWメディアの上に表示された「もっと見る」ボタンが押しづらい問題を修正 + +### Server +- channel/searchのqueryが空の場合に全てのチャンネルを返すように変更 +- 環境変数MISSKEY_CONFIG_YMLで設定ファイルをdefault.ymlから変更可能に +- Fix: 他のサーバーの情報が取得できないことがある問題を修正 +- Fix: エクスポートデータの拡張子がunknownになる問題を修正 +- Fix: Content-Dispositionのパースでエラーが発生した場合にダウンロードが完了しない問題を修正 +- Fix: API: i/update avatarIdとbannerIdにnullを渡した時、画像がリセットされない問題を修正 +- Fix: .wav, .flacが再生できない問題を修正(新しくアップロードされたファイルのみ修正が適用されます) +- Fix: 凍結されたユーザーが一部APIのレスポンスに含まれる問題を修正 +- Fix: メモリの使用量を`used - buffers - cached`ではなく`total - available`で求めるように(環境によって正常に計測できていなかったため) + ## 13.11.3 ### General @@ -107,6 +177,8 @@ - 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように - 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります - Add Minimizing ("folding") of windows +- 「データセーバー」モードを追加 +- 非NSFWメディアが隠れている際にも「閲覧注意」が出てしまう問題を修正 ### Server - PostgreSQLのレプリケーション対応 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fece05d7a9..f6b3804f84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,6 +165,11 @@ pnpm jest -- foo.ts ### e2e tests TODO +## Environment Variable + +- `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`). +- `MISSKEY_WEBFINGER_USE_HTTP`: If it's set true, WebFinger requests will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. + ## Continuous integration Misskey uses GitHub Actions for executing automated tests. Configuration files are located in [`/.github/workflows`](/.github/workflows). @@ -245,7 +250,6 @@ You can override the default story by creating a impl story file (`MyComponent.s ```ts /* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-duplicates */ import { StoryObj } from '@storybook/vue3'; import MyComponent from './MyComponent.vue'; export const Default = { diff --git a/Dockerfile b/Dockerfile index 8db7400c9f..fb389659bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.4 -ARG NODE_VERSION=18.13.0-bullseye +ARG NODE_VERSION=18.16.0-bullseye # build assets & compile TypeScript diff --git a/chart/files/default.yml b/chart/files/default.yml index 1888669245..342c6091f0 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -116,15 +116,14 @@ redis: # #prefix: example-prefix # #db: 1 -# ┌─────────────────────────────┐ -#───┘ Elasticsearch configuration └───────────────────────────── +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── -#elasticsearch: +#meilisearch: # host: localhost -# port: 9200 -# ssl: false -# user: -# pass: +# port: 7700 +# apiKey: '' +# ssl: true # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/cypress/e2e/basic.cy.js b/cypress/e2e/basic.cy.js index 8dc07c1800..73f6e7a0f0 100644 --- a/cypress/e2e/basic.cy.js +++ b/cypress/e2e/basic.cy.js @@ -52,6 +52,11 @@ describe('After setup instance', () => { cy.intercept('POST', '/api/signup').as('signup'); cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); + cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); + cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); + cy.get('[data-cy-signup-rules-continue]').click(); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-username] input').type('alice'); cy.get('[data-cy-signup-submit]').should('be.disabled'); @@ -71,6 +76,11 @@ describe('After setup instance', () => { // ユーザー名が重複している場合の挙動確認 cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); + cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); + cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); + cy.get('[data-cy-signup-rules-continue]').click(); + cy.get('[data-cy-signup-username] input').type('alice'); cy.get('[data-cy-signup-password] input').type('alice1234'); cy.get('[data-cy-signup-password-retype] input').type('alice1234'); @@ -149,10 +159,55 @@ describe('After user signed in', () => { }); it('successfully loads', () => { - cy.get('[data-cy-open-post-form]').should('be.visible'); + cy.get('[data-cy-user-setup-continue]').should('be.visible'); }); + it('account setup wizard', () => { + cy.get('[data-cy-user-setup-continue]').click(); + + cy.get('[data-cy-user-setup-user-name] input').type('ありす'); + cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ'); + // TODO: アイコン設定テスト + + cy.get('[data-cy-user-setup-continue]').click(); + + // フォローはスキップ + + cy.get('[data-cy-user-setup-continue]').click(); + + // プッシュ通知設定はスキップ + + cy.get('[data-cy-user-setup-continue]').click(); + + cy.get('[data-cy-user-setup-continue]').click(); + }); +}); + +describe('After user setup', () => { + beforeEach(() => { + cy.resetState(); + + // インスタンス初期セットアップ + cy.registerUser('admin', 'pass', true); + + // ユーザー作成 + cy.registerUser('alice', 'alice1234'); + + cy.login('alice', 'alice1234'); + + // アカウント初期設定ウィザード + cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click(); + cy.get('[data-cy-modal-dialog-ok]').click(); + }); + + afterEach(() => { + // テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。 + // waitを入れることでそれを防止できる + cy.wait(1000); + }); + it('note', () => { + cy.get('[data-cy-open-post-form]').should('be.visible'); cy.get('[data-cy-open-post-form]').click(); cy.get('[data-cy-post-form-text]').type('Hello, Misskey!'); cy.get('[data-cy-open-post-form-submit]').click(); diff --git a/cypress/e2e/widgets.cy.js b/cypress/e2e/widgets.cy.js index a39ea85e1d..f5a982eb0a 100644 --- a/cypress/e2e/widgets.cy.js +++ b/cypress/e2e/widgets.cy.js @@ -10,6 +10,10 @@ describe('After user signed in', () => { cy.registerUser('alice', 'alice1234'); cy.login('alice', 'alice1234'); + + // アカウント初期設定ウィザード + cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click(); + cy.get('[data-cy-modal-dialog-ok]').click(); }); afterEach(() => { @@ -19,26 +23,26 @@ describe('After user signed in', () => { }); it('widget edit toggle is visible', () => { - cy.get('.mk-widget-edit').should('be.visible'); + cy.get('[data-cy-widget-edit]').should('be.visible'); }); it('widget select should be visible in edit mode', () => { - cy.get('.mk-widget-edit').click(); - cy.get('.mk-widget-select').should('be.visible'); + cy.get('[data-cy-widget-edit]').click(); + cy.get('[data-cy-widget-select]').should('be.visible'); }); it('first widget should be removed', () => { - cy.get('.mk-widget-edit').click(); + cy.get('[data-cy-widget-edit]').click(); cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click(); cy.get('[data-cy-customize-container]').should('have.length', 2); }); function buildWidgetTest(widgetName) { it(`${widgetName} widget should get added`, () => { - cy.get('.mk-widget-edit').click(); - cy.get('.mk-widget-select select').select(widgetName, { force: true }); + cy.get('[data-cy-widget-edit]').click(); + cy.get('[data-cy-widget-select] select').select(widgetName, { force: true }); cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true }); - cy.get('.mk-widget-add').click({ force: true }); + cy.get('[data-cy-widget-add]').click({ force: true }); cy.get(`[data-cy-mkw-${widgetName}]`).should('exist'); }); } diff --git a/docker-compose.yml.example b/docker-compose.yml.example index b0c4a914d5..a0061c5c20 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -7,7 +7,7 @@ services: links: - db - redis -# - es +# - meilisearch depends_on: db: condition: service_healthy @@ -48,16 +48,18 @@ services: interval: 5s retries: 20 -# es: +# meilisearch: # restart: always -# image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.2 +# image: getmeili/meilisearch:v1.1.1 # environment: -# - "ES_JAVA_OPTS=-Xms512m -Xmx512m" -# - "TAKE_FILE_OWNERSHIP=111" +# - MEILI_NO_ANALYTICS=true +# - MEILI_ENV=production +# env_file: +# - .config/meilisearch.env # networks: # - internal_network # volumes: -# - ./elasticsearch:/usr/share/elasticsearch/data +# - ./meili_data:/meili_data networks: internal_network: diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 4b9062051b..9a5936660e 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -250,12 +250,12 @@ noMoreHistory: "لا يوجد المزيد من التاريخ" startMessaging: "ابدأ محادثة" nUsersRead: "قرأه {n}" agreeTo: "اوافق على {0}" -tos: "شروط الخدمة" start: "البداية" home: "الرئيسي" remoteUserCaution: "هذه المعلومات قد لا تكون مكتملة بما أن المستخدم من مثيل بعيد." activity: "النشاط" images: "الصور" +image: "الصور" birthday: "تاريخ الميلاد" yearsOld: "{age} سنة" registeredDate: "انضم في" @@ -801,6 +801,8 @@ cannotLoad: "تعذر التحميل" like: "أعجبني" show: "المظهر" color: "اللون" +horizontal: "جانبي" +youFollowing: "متابَع" _role: priority: "الأولوية" _priority: @@ -977,29 +979,6 @@ _time: minute: "د" hour: "سا" day: "ي" -_tutorial: - title: "كيف تستخدم Misskey" - step1_1: "مرحبًا!" - step1_2: "تدعى هذه الصفحة 'الخيط الزمني' وهي تحوي ملاحظات الأشخاص الذي تتابعهم مرتبة حسب تاريخ نشرها." - step1_3: "خيطك الزمني فارغ حاليًا بما أنك لا تتابع أي شخص ولم تنشر أي ملاحظة." - step2_1: "لننهي إعداد ملفك الشخصي قبل كتابة ملاحظة أو متابعة أشخاص." - step2_2: "أعطاء معلومات عن شخصيتك يمنح من له نفس إهتماماتك فرصة متابعتك والتفاعل مع ملاحظاتك." - step3_1: "هل أنهيت إعداد حسابك؟" - step3_2: "إذا تاليًا لتنشر ملاحظة. أنقر على أيقونة القلم في أعلى الشاشة" - step3_3: "املأ النموذج وانقر الزرّ الموجود في أعلى اليمين للإرسال." - step3_4: "ليس لديك ما تقوله؟ إذا اكتب \"بدأتُ استخدم ميسكي\"." - step4_1: "هل نشرت ملاحظتك الأولى؟" - step4_2: "مرحى! يمكنك الآن رؤية ملاحظتك في الخيط الزمني." - step5_1: "والآن، لنجعل الخيط الزمني أكثر حيوية وذلك بمتابعة بعض المستخدمين." - step5_2: "تعرض صفحة {features} الملاحظات المتداولة في هذا المثيل ويتيح لك {Explore} العثور على المستخدمين الرائدين. اعثر على الأشخاص الذين يثيرون إهتمامك وتابعهم!" - step5_3: "لمتابعة مستخدمين ادخل ملفهم الشخصي بالنقر على صورتهم الشخصية ثم اضغط زر 'تابع'." - step5_4: "إذا كان لدى المستخدم رمز قفل بجوار اسمه ، وجب عليك انتظاره ليقبل طلب المتابعة يدويًا." - step6_1: "الآن ستتمكن من رؤية ملاحظات المستخدمين المتابَعين في الخيط الزمني." - step6_2: "يمكنك التفاعل بسرعة مع الملاحظات عن طريق إضافة \"تفاعل\"." - step6_3: "لإضافة تفاعل لملاحظة ، انقر فوق علامة \"+\" أسفل للملاحظة واختر الإيموجي المطلوب." - step7_1: "مبارك ! أنهيت الدورة التعليمية الأساسية لاستخدام ميسكي." - step7_2: "إذا أردت معرفة المزيد عن ميسكي زر {help}." - step7_3: "حظًا سعيدًا واستمتع بوقتك مع ميسكي! 🚀" _2fa: alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين." step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})." diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 734943960c..4b1a9cb758 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -252,12 +252,12 @@ noMoreHistory: "আর কোন ইতিহাস নেই" startMessaging: "চ্যাট শুরু করুন" nUsersRead: "{n} জন পড়েছেন" agreeTo: "{0} এর প্রতি আমি সম্মত" -tos: "পরিষেবার শর্তাদি" start: "শুরু করুন" home: "মূল পাতা" remoteUserCaution: "এই ব্যাবহারকারী রিমোট ইন্সট্যান্সের, নিম্নক্ত তথ্য অসম্পূর্ণ হতে পারে।" activity: "কার্যকলাপ" images: "ছবি" +image: "ছবি" birthday: "জন্মদিন" yearsOld: "{age} বছর" registeredDate: "যোগদানের তারিখ" @@ -836,6 +836,8 @@ account: "অ্যাকাউন্টগুলি" like: "পছন্দ করা" show: "প্রদর্শন" color: "রং" +horizontal: "পাশে" +youFollowing: "অনুসরণ করা হচ্ছে" _role: priority: "অগ্রাধিকার" _priority: @@ -1039,29 +1041,6 @@ _time: minute: "মিনিট" hour: "ঘণ্টা" day: "দিন" -_tutorial: - title: "Misskey কিভাবে ব্যাবহার করবেন" - step1_1: "স্বাগতম!" - step1_2: "এই স্ক্রীনটিকে \"টাইমলাইন\" বলা হয় এবং কালানুক্রমিক ক্রমে আপনার এবং আপনি যাদের \"অনুসরণ করেন\" তাদের \"নোটগুলি\" দেখায়৷" - step1_3: "আপনি আপনার টাইমলাইনে কিছু দেখতে পাবেন না কারণ আপনি এখনও কোনো নোট পোস্ট করেননি এবং আপনি কাউকে অনুসরণ করছেন না৷" - step2_1: "নোট তৈরি করার আগে বা কাউকে অনুসরণ করার আগে প্রথমে আপনার প্রোফাইলটি সম্পূর্ণ করুন।" - step2_2: "আপনি কে তা জানা অনেক লোকের জন্য আপনার নোটগুলি দেখা এবং অনুসরণ করাকে সহজ করে তোলে৷" - step3_1: "আপনি কি সফলভাবে আপনার প্রোফাইল সেট আপ করেছেন?" - step3_2: "এখন, কিছু নোট পোস্ট করার চেষ্টা করুন। পোস্ট ফর্ম খুলতে পেন্সিল চিহ্নযুক্ত বাটনে ক্লিক করুন।" - step3_3: "বিষয়বস্তু লেখার পরে, আপনি ফর্মের উপরের ডানদিকের বাটনে ক্লিক করে পোস্ট করতে পারেন।" - step3_4: "পোস্ট করার মত কিছু মনে পরছে না? \"আমি মিসকি সেট আপ করছি\" বললে কেমন হয়?" - step4_1: "পোস্ট করেছেন?" - step4_2: "সাবাশ! এখন আপনার নোট টাইমলাইনে দেখা যাবে।" - step5_1: "এখন অন্যদেরকে অনুসরণ করে আপনার টাইমলাইনকে প্রাণবন্ত করে তুলুন।" - step5_2: "আপনি {featured}-এ জনপ্রিয় নোটগুলি দেখতে পারেন, যাতে আপনি যে ব্যক্তিকে পছন্দ করেন তাকে বেছে নিতে এবং অনুসরণ করতে পারেন, অথবা {explore}-এ জনপ্রিয় ব্যবহারকারীদের দেখতে পারেন৷" - step5_3: "একজন ব্যবহারকারীকে অনুসরণ করতে, ব্যবহারকারীর আইকনে ক্লিক করুন এবং ব্যবহারকারীর পৃষ্ঠাতে \"অনুসরণ করুন\" বাটনে ক্লিক করুন।" - step5_4: "যদি ব্যবহারকারীর নামের পাশে একটি লক আইকন থাকে তাহলে আপনার অনুসরণের অনুরোধ গ্রহণ করার জন্য তারা কিছু সময় নিতে পারে।" - step6_1: "সবকিছু ঠিক থাকলে আপনি টাইমলাইনে অন্য ব্যবহারকারীদের নোট দেখতে পাবেন।" - step6_2: "আপনি সহজেই আপনার প্রতিক্রিয়া জানাতে অন্য ব্যক্তির নোটে \"রিঅ্যাকশন\" যোগ করতে পারেন।" - step6_3: "একটি রিঅ্যাকশন যোগ করতে, নোটে \"+\" চিহ্নে ক্লিক করুন এবং আপনার পছন্দের রিঅ্যাকশন নির্বাচন করুন।" - step7_1: "অভিনন্দন! আপনি এখন Misskey-র প্রাথমিক টিউটোরিয়ালটি শেষ করেছেন।" - step7_2: "আপনি যদি Misskey সম্পর্কে আরও জানতে চান, তাহলে {help} এ দেখুন।" - step7_3: "এখন Misskey উপভোগ করুন 🚀" _2fa: alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷" step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 2b1168f780..588a07fd87 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -216,6 +216,7 @@ instanceFollowers: "Seguidors del servidor" instanceUsers: "Usuaris del servidor" changePassword: "Canvia la contrasenya" security: "Seguretat" +retypedNotMatch: "L'entrada no coincideix" currentPassword: "Contrasenya actual" newPassword: "Contrasenya nova" newPasswordRetype: "Contrasenya nou (repeteix-la)" @@ -239,6 +240,7 @@ start: "Comença" home: "Inici" activity: "Activitat" images: "Imatges" +image: "Imatges" birthday: "Aniversari" yearsOld: "{age} anys" registeredDate: "Data de registre" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 19815c6f37..d055fde38f 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -12,12 +12,14 @@ fetchingAsApObject: "Načítám data z Fediversu..." ok: "Potvrdit" gotIt: "Rozumím!" cancel: "Zrušit" +noThankYou: "Ne děkuji" enterUsername: "Zadej uživatelské jméno" renotedBy: "{user} přeposla/a" noNotes: "Žádné poznámky" noNotifications: "Žádná oznámení" instance: "Instance" settings: "Nastavení" +notificationSettings: "Nastavení oznámení" basicSettings: "Obecná nastavení" otherSettings: "Rozšířená nastavení" openInWindow: "Otevřít v novém okně" @@ -236,12 +238,12 @@ noMoreHistory: "To je vše" startMessaging: "Zahájit chat" nUsersRead: "přečteno {n} uživateli" agreeTo: "Souhlasím s {0}" -tos: "Podmínky užívání" start: "Začít" home: "Domů" remoteUserCaution: "Tyto informace nemusí být aktuální jelikož uživatel je ze vzdálené instance." activity: "Aktivita" images: "Obrázky" +image: "Obrázky" birthday: "Datum narození" yearsOld: "{age} let" registeredDate: "Datum registrace" @@ -664,6 +666,8 @@ _time: second: "Sekund" minute: "Minut" hour: "Hodin" +_2fa: + renewTOTPCancel: "Ne děkuji" _weekday: sunday: "Neděle" monday: "Pondělí" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 095c71642a..d678fadd4a 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -263,14 +263,16 @@ noMoreHistory: "Kein weiterer Verlauf vorhanden" startMessaging: "Neuen Chat erstellen" nUsersRead: "Von {n} Benutzern gelesen" agreeTo: "Ich stimme {0} zu" +agree: "Zustimmen" agreeBelow: "Ich stimme Untenstehendem zu" basicNotesBeforeCreateAccount: "Wichtige Infos" -tos: "Nutzungsbedingungen" +termsOfService: "Nutzungsbedingungen" start: "Anfangen" home: "Startseite" remoteUserCaution: "Diese Informationen sind möglicherweise unvollständig, da der Benutzer von einer fremden Instanz stammt." activity: "Aktivität" images: "Bilder" +image: "Bild" birthday: "Geburtstag" yearsOld: "{age} Jahre alt" registeredDate: "Registrationsdatum" @@ -468,12 +470,14 @@ enableAdvancedMfm: "Erweitertes MFM aktivieren" enableAnimatedMfm: "Animiertes MFM aktivieren" doing: "In Bearbeitung …" category: "Kategorie" -tags: "Schlagwörter" +tags: "Aliasse" docSource: "Quellcode dieses Dokuments" createAccount: "Benutzerkonto erstellen" existingAccount: "Bestehendes Benutzerkonto" regenerate: "Regenerieren" fontSize: "Schriftgröße" +mediaListWithOneImageAppearance: "Höhe von Medienlisten mit nur einem Bild" +limitTo: "Auf {x} begrenzen" noFollowRequests: "Keine ausstehenden Follow-Anfragen vorhanden" openImageInNewTab: "Bilder in neuem Tab öffnen" dashboard: "Dashboard" @@ -556,6 +560,7 @@ accountDeletedDescription: "Dieses Konto wurde gelöscht." menu: "Menü" divider: "Trenner" addItem: "Element hinzufügen" +rearrange: "Sortieren" relays: "Relays" addRelay: "Relay hinzufügen" inboxUrl: "inbox-URL" @@ -699,6 +704,8 @@ contact: "Kontakt" useSystemFont: "Standardschriftart des Systems verwenden" clips: "Clips" experimentalFeatures: "Experimentelle Funktionalitäten" +experimental: "Experimentell" +thisIsExperimentalFeature: "Dies ist eine experimentelle Funktion. Änderungen an ihrer Funktionsweise sind vorbehalten, zudem kann eine Verwendung zu unerwarteten Effekten führen." developer: "Entwickler" makeExplorable: "Benutzerkonto in „Erkunden“ sichtbar machen" makeExplorableDescription: "Wenn diese Option deaktiviert ist, ist dein Benutzerkonto nicht im „Erkunden“-Bereich sichtbar." @@ -905,6 +912,7 @@ remoteOnly: "Nur für fremde Instanzen" failedToUpload: "Hochladen fehlgeschlagen" cannotUploadBecauseInappropriate: "Diese Datei kann nicht hochgeladen werden, da Anteile der Datei als möglicherweise NSFW festgestellt wurden." cannotUploadBecauseNoFreeSpace: "Die Datei konnte nicht hochgeladen werden, da dein Drive-Speicherplatz aufgebraucht ist." +cannotUploadBecauseExceedsFileSizeLimit: "Diese Datei kann wegen Überschreitung der Maximalgröße nicht hochgeladen werden." beta: "Beta" enableAutoSensitive: "NSFW-Automarkierung" enableAutoSensitiveDescription: "Setzt soweit möglich durch Verwendung von Machine Learning automatisch NSFW-Markierungen für Medien, die NSFW-Anteile beinhalten. Auch wenn du diese Option deaktiviert hast, ist sie möglicherweise auf Instanzebene aktiviert." @@ -918,8 +926,8 @@ subscribePushNotification: "Push-Benachrichtigungen aktivieren" unsubscribePushNotification: "Push-Benachrichtigungen deaktivieren" pushNotificationAlreadySubscribed: "Push-Benachrichtigungen sind bereits aktiviert" pushNotificationNotSupported: "Entweder dein Browser oder deine Instanz unterstützt Push-Benachrichtigungen nicht" -sendPushNotificationReadMessage: "Push-Benachrichtigungen löschen, sobald die relevanten Benachrichtigungen oder Nachrichten gelesen wurden" -sendPushNotificationReadMessageCaption: "Eine Push-Benachrichtigungen mit dem Inhalt \"{emptyPushNotificationMessage}\" wird kurz eingeblendet. Dies kann gegebenenfalls den Batterieverbrauch deines Gerätes erhöhen." +sendPushNotificationReadMessage: "Push-Benachrichtigungen löschen, sobald sie gelesen wurden" +sendPushNotificationReadMessageCaption: "Dies kann gegebenenfalls den Batterieverbrauch deines Gerätes erhöhen." windowMaximize: "Maximieren" windowMinimize: "Minimieren" windowRestore: "Wiederherstellen" @@ -938,6 +946,7 @@ didYouLikeMisskey: "Gefällt dir Misskey?" pleaseDonate: "Misskey ist die kostenlose Software, die von {host} verwendet wird. Wir würden uns über Spenden freuen, damit dessen Entwicklung weitergeführt werden kann!" roles: "Rollen" role: "Rolle" +noRole: "Rolle nicht gefunden" normalUser: "Standardbenutzer" undefined: "Undefiniert" assign: "Zuweisen" @@ -947,6 +956,10 @@ manageCustomEmojis: "Kann benutzerdefinierte Emojis verwalten" youCannotCreateAnymore: "Du hast das Erstellungslimit erreicht." cannotPerformTemporary: "Vorübergehend nicht verfügbar" cannotPerformTemporaryDescription: "Diese Aktion ist wegen des Überschreitenes des Ausführungslimits temporär nicht verfügbar. Bitte versuche es nach einiger Zeit erneut." +invalidParamError: "Ungültige Parameter" +invalidParamErrorDescription: "Die Anfrageparameter sind fehlerhaft. Dies liegt meist an einem Bug, kann aber auch durch eine zu langen Eingabe o.ä. ausgelöst werden." +permissionDeniedError: "Aktion verweigert" +permissionDeniedErrorDescription: "Dieses Benutzerkonto besitzt nicht die Berechtigung, um diese Aktion auszuführen." preset: "Vorlage" selectFromPresets: "Aus Vorlagen wählen" achievements: "Errungenschaften" @@ -990,17 +1003,70 @@ enableChartsForFederatedInstances: "Diagramme für fremde Instanzen erstellen" showClipButtonInNoteFooter: "\"Clip\" zum Notizmenu hinzufügen" largeNoteReactions: "Reaktionen vergrößert anzeigen" noteIdOrUrl: "Notiz-ID oder URL" -accountMigration: "Konto-Umzug" -accountMoved: "Dieser Benutzer ist zu einem neuen Konto umgezogen:" +video: "Video" +videos: "Videos" +dataSaver: "Datensparmodus" +accountMigration: "Kontomigration" +accountMoved: "Dieser Benutzer ist zu einem neuen Konto migriert:" +accountMovedShort: "Dieses Konto wurde migriert." +operationForbidden: "Aktion nicht möglich" forceShowAds: "Werbung immer anzeigen" +addMemo: "Bemerkung hinzufügen" +editMemo: "Bemerkung bearbeiten" +reactionsList: "Reaktionen" +renotesList: "Renotes" +notificationDisplay: "Benachrichtigungen" +leftTop: "Oben links" +rightTop: "Oben rechts" +leftBottom: "Unten links" +rightBottom: "Unten rechts" +stackAxis: "Stapelrichtung" +vertical: "Vertikal" +horizontal: "Horizontal" +position: "Position" +serverRules: "Serverregeln" +pleaseConfirmBelowBeforeSignup: "Lies bitte Untenstehendes vor der Registration." +pleaseAgreeAllToContinue: "Zum Fortfahren muss allen obigen Feldern zugestimmt werden." +continue: "Fortfahren" +preservedUsernames: "Reservierte Benutzernamen" +preservedUsernamesDescription: "Gib zu reservierende Benutzernamen durch Zeilenumbrüche getrennt an. Diese werden für die Registrierung gesperrt, können aber von Administratoren zur manuellen Erstellung von Konten verwendet werden. Existierende Konten, die diese Namen bereits verwenden, werden nicht beeinträchtigt." +createNoteFromTheFile: "Notiz für diese Datei schreiben" +archive: "Archivieren" +channelArchiveConfirmTitle: "{name} wirklich archivieren?" +channelArchiveConfirmDescription: "Ein archivierter Kanal taucht nicht mehr in der Kanalliste oder in Suchergebnissen auf. Zudem können ihm keine Beiträge mehr hinzugefügt werden." +thisChannelArchived: "Dieser Kanal wurde archiviert." +displayOfNote: "Anzeige von Notizen" +initialAccountSetting: "Kontoeinrichtung" +youFollowing: "Gefolgt" +_initialAccountSetting: + accountCreated: "Dein Konto wurde erfolgreich erstellt!" + letsFillYourProfile: "Lass uns zuerst dein Profil einrichten." + profileSetting: "Profileinstellungen" + theseSettingsCanEditLater: "Diese Einstellungen kannst du jederzeit ändern." + youCanEditMoreSettingsInSettingsPageLater: "In den Einstellungen findest du noch viele weitere Optionen. Schau dort später mal vorbei." + followUsers: "Folge zuerst ein paar Nutzern, um deine Chronik zu füllen." + pushNotificationDescription: "Durch die Aktivierung von Push-Benachrichtigungen kannst du von {name} Benachrichtigungen direkt auf dein Gerät erhalten." + initialAccountSettingCompleted: "Kontoeinrichtung abgeschlossen!" + haveFun: "Viel Spaß mit {name}!" + ifYouNeedLearnMore: "Besuche {link}, falls du mehr über {name} (Misskey) lernen möchtest." + skipAreYouSure: "Die Kontoeinrichtung wirklich überspringen?" +_serverRules: + description: "Eine Reihe von Regeln, die vor der Registrierung angezeigt werden. Eine Zusammenfassung der Nutzungsbedingungen anzuzeigen ist empfohlen." _accountMigration: - moveTo: "Dieses Konto zu einem neuen umziehen" + moveFrom: "Von einem anderen Konto zu diesem migrieren" + moveFromSub: "Alias für ein anderes Konto erstellen" + moveFromLabel: "Migrationsursprung #{n}" + moveFromDescription: "Um von einem anderen Konto zu diesem zu migrieren, muss zuvor hier ein Alias eingerichtet werden.\nGib das Konto, von dem migriert werden soll, in folgendem Format ein: @username@server.example.com\n\nZum Löschen des Alias kann das Feld leergelassen werden (nicht empfohlen)." + moveTo: "Dieses Konto zu einem neuen migrieren" moveToLabel: "Umzugsziel:" - moveAccountDescription: "Ein Umzug kann nicht rückgängig gemacht werden. Stelle zuerst sicher, dass du auf dem Umzugsziel einen Alias erstellt hast. Gib dann das Umzugsziel in folgendem Format ein: @person@instance.com" - moveFrom: "Von einem anderen Konto zu diesem umziehen" - moveFromLabel: "Umzugsursprung:" - moveFromDescription: "Stelle sicher, dass du auf dem Umzugsursprungskonto einen Alias zu diesem Konto erstellt hast, falls du die Follower des Ursprungskontos übertragen möchtest. Dies muss vor dem Umzug geschehen! Gib dann das Ursprungskonto in folgendem Format an: @person@instance.com" - migrationConfirm: "Dieses Konto wirklich zu {account} umziehen? Sobald der Umzug beginnt, kann er nicht rückgängig gemacht werden, und dieses Konto nicht wieder im ursprünglichen Zustand verwendet werden.\n\nÜberprüfe zusätzlich, dass du auf dem Umzugsziel einen Alias eingerichtet hast." + moveCannotBeUndone: "Die Migration eines Benutzerkontos ist unwiderruflich." + moveAccountDescription: "Hierdurch wird dein Konto zu einem anderen migriert.\n ・Follower von diesem Konto werden automatisch auf das neue Konto migriert\n ・Dieses Konto wird allen Nutzern, denen es derzeit folgt, nicht mehr folgen\n ・Mit diesem Konto können keine neuen Notizen usw. erstellt werden\n\nWährend die Migration der Follower automatisch erfolgt, muss die Migration der Konten, denen du folgst, manuell vorbereitet werden. Exportiere hierzu die Liste der gefolgten Nutzer über das Einstellungsmenu, und importiere diese Liste im neuen Konto. Das gleiche Verfahren gilt für erstellte Listen und stummgeschaltete oder blockierte Nutzer.\n\n(Diese Erklärung gilt für Misskey v13.12.0 oder später. Die Funktionsweise andere ActivityPub-Software, beispielsweise Mastodon, kann hiervon abweichen.)" + moveAccountHowTo: "Um ein Konto zu migrieren, erstelle zuerst auf dem Umzugsziel einen Alias für dieses Konto.\nGib dann das Umzugsziel in folgendem Format ein: @username@server.example.com" + startMigration: "Migrieren" + migrationConfirm: "Dieses Konto wirklich zu {account} umziehen? Sobald der Umzug beginnt, kann er nicht rückgängig gemacht werden, und dieses Konto nicht wieder im ursprünglichen Zustand verwendet werden." + movedAndCannotBeUndone: "\nDieses Konto wurde migriert.\nDiese Aktion ist unwiderruflich." + postMigrationNote: "Dieses Konto wird 24 Stunden nach Abschluss der Migration allen Konten, denen es derzeit folgt, nicht mehr folgen.\n\nSowohl die Anzahl der Follower als auch die der Konten, denen dieses Konto folgt, wird dann auf Null gesetzt. Um zu vermeiden, dass Follower dieses Kontos dessen Beiträge, welche nur für Follower bestimmt sind, nicht mehr sehen können, werden sie diesem Konto jedoch weiterhin folgen." + movedTo: "Umzugsziel:" _achievements: earnedAt: "Freigeschaltet am" _types: @@ -1027,7 +1093,7 @@ _achievements: title: "Supernotiz" description: "10.000 Notizen gesendet" _notes20000: - title: "Brauche... mehr... Notizen" + title: "Brauche... mehr... Notizen..." description: "20.000 Notizen gesendet" _notes30000: title: "Notizen, Notizen, Notizen" @@ -1171,7 +1237,10 @@ _achievements: description: "Du hast einen verborgenen Schatz gefunden" _client30min: title: "Kurze Pause" - description: "Habe Misskey für 30 Minuten geöffnet" + description: "Habe Misskey für mindestens 30 Minuten geöffnet" + _client60min: + title: "Munter mit Misskey" + description: "Habe Misskey für mindestens 60 Minuten geöffnet" _noteDeletedWithin1min: title: "Ups" description: "Lösche eine Notiz innerhalb von 1 Minute nachdem sie gesendet wurde" @@ -1260,6 +1329,8 @@ _role: iconUrl: "Icon-URL" asBadge: "Als Abzeichen anzeigen" descriptionOfAsBadge: "Ist dies aktiviert, so wird das Icon dieser Rolle an der Seite der Namen von Benutzern mit dieser Rolle angezeigt." + isExplorable: "Rollenchronik veröffentlichen" + descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Rollenchronik dieser Rolle frei zugänglich. Die Chronik von Rollen, welche nicht öffentlich sind, wird auch bei Aktivierung nicht veröffentlicht." displayOrder: "Position" descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position." canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen" @@ -1276,6 +1347,7 @@ _role: canInvite: "Erstellung von Einladungscodes für diese Instanz" canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten" driveCapacity: "Drive-Kapazität" + alwaysMarkNsfw: "Dateien immer als NSFW markieren" pinMax: "Maximale Anzahl an angehefteten Notizen" antennaMax: "Maximale Anzahl an Antennen" wordMuteMax: "Maximale Zeichenlänge für Wortstummschaltungen" @@ -1527,32 +1599,16 @@ _time: minute: "Minute(n)" hour: "Stunde(n)" day: "Tag(en)" -_tutorial: +_timelineTutorial: title: "Wie du Misskey verwendest" - step1_1: "Willkommen!" - step1_2: "Diese Seite ist die „Chronik“. Sie zeigt dir deine geschrieben „Notizen“ sowie die aller Benutzer, denen du „folgst“, in chronologischer Reihenfolge." - step1_3: "Deine Chronik sollte momentan leer sein, da du bis jetzt noch keine Notizen geschrieben hast und auch noch keinen Benutzern folgst." - step2_1: "Lass uns zuerst dein Profil vervollständigen, bevor du Notizen schreibst oder jemandem folgst." - step2_2: "Informationen darüber, was für eine Person du bist, macht es anderen leichter zu wissen, ob sie deine Notizen sehen wollen und ob sie dir folgen möchten." - step3_1: "Mit dem Einrichten deines Profils fertig?" - step3_2: "Dann lass uns als nächstes versuchen, eine Notiz zu schreiben. Dies kannst du tun, indem du auf den Knopf mit dem Stift-Icon auf dem Bildschirm drückst." - step3_3: "Fülle das Fenster aus und drücke auf den Knopf oben rechts zum Senden." - step3_4: "Fällt dir nichts ein, das du schreiben möchtest? Versuch's mit \"Hallo Misskey!\"" - step4_1: "Fertig mit dem Senden deiner ersten Notiz?" - step4_2: "Falls deine Notiz nun in deiner Chronik auftaucht, hast du alles richtig gemacht." - step5_1: "Lass uns nun deiner Chronik etwas mehr Leben einhauchen, indem du einigen anderen Benutzern folgst." - step5_2: "{featured} zeigt dir beliebte Notizen dieser Instanz. In {explore} kannst du beliebte Benutzer finden. Schau dort, ob du Benutzer findest, die dich interessieren." - step5_3: "Klicke zum Anzeigen des Profils eines Benutzers auf dessen Profilbild und dann auf den \"Folgen\"-Knopf, um diesem zu folgen." - step5_4: "Je nach Benutzer kann es etwas Zeit in Anspruch nehmen, bis dieser deine Follow-Anfrage bestätigt." - step6_1: "Wenn du nun auch die Notizen anderer Benutzer in deiner Chronik siehst, hast du auch diesmal alles richtig gemacht." - step6_2: "Du kannst ebenso „Reaktionen“ verwenden, um schnell auf Notizen anderer Benutzer zu reagieren." - step6_3: "Um eine Reaktion anzufügen, klicke auf das „+“-Symbol in der Notiz und wähle ein Emoji aus, mit dem du reagieren möchtest." - step7_1: "Glückwunsch! Du hast die Einführung in die Verwendung von Misskey abgeschlossen." - step7_2: "Wenn du mehr über Misskey lernen möchtest, schau dich im {help}-Bereich um." - step7_3: "Und nun, viel Spaß mit Misskey! 🚀" - step8_1: "Möchtest du abschließend Push-Benachrichtigungen aktivieren?" - step8_2: "Push-Benachrichtigungen erlauben es dir, über Reaktionen, Follows oder Erwähnungen usw. zu erfahren, auch wenn Misskey zu dieser Zeit nicht geöffnet ist." - step8_3: "Diese Einstellung kannst du jederzeit ändern." + step1_1: "Dieser Bildschirm ist die \"Chronik\". Hier werden alle \"Notizen\" von {name} angezeigt." + step1_2: "Es gibt einige verschiedene Chroniken. Beispielsweise werden in der \"Startseite\" alle Notizen von Nutzern, denen du folgst, angezeigt, und in der \"Lokalen Chronik\" werden Notizen aller Nutzer auf {name} angezeigt." + step2_1: "Lass uns als nächstes versuchen, eine Notiz zu schreiben. Dies kannst du tun, indem du auf den Knopf mit dem Stift-Icon drückst." + step2_2: "Stell dich den anderen vor oder schreibe einfach \"Hallo {name}!\", wenn du darauf keine Lust hast oder dir nichts einfällt." + step3_1: "Fertig mit dem Senden deiner ersten Notiz?" + step3_2: "Falls deine Notiz nun in deiner Chronik auftaucht, hast du alles richtig gemacht." + step4_1: "Notizen können zusätzlich mit \"Reaktionen\" ausgestattet werden." + step4_2: "Um eine Reaktion anzufügen, klicke auf das „+“-Symbol einer Notiz und wähle ein Emoji aus, mit dem du reagieren möchtest." _2fa: alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung registriert." registerTOTP: "Authentifizierungs-App registrieren" diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 32964e9982..41b1ea7c65 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -172,11 +172,11 @@ explore: "Εξερευνήστε" messageRead: "Διαβάστηκε" startMessaging: "Ξεκινήστε μία συνομιλία" nUsersRead: "διαβάστηκε από {n}" -tos: "Όροι χρήσης" start: "Ας αρχίσουμε" home: "Κεντρικό" activity: "Δραστηριότητα" images: "Εικόνες" +image: "Εικόνες" birthday: "Γενέθλια" registeredDate: "Έγινε μέλος στις" location: "Τοποθεσία" diff --git a/locales/en-US.yml b/locales/en-US.yml index e2407bd3e7..ea91bcc0e5 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -263,14 +263,16 @@ noMoreHistory: "There is no further history" startMessaging: "Start a new chat" nUsersRead: "read by {n}" agreeTo: "I agree to {0}" +agree: "Agree" agreeBelow: "I agree to the below" basicNotesBeforeCreateAccount: "Important notes" -tos: "Terms of Service" +termsOfService: "Terms of Service" start: "Begin" home: "Home" remoteUserCaution: "As this user is from a remote instance, the shown information may be incomplete." activity: "Activity" images: "Images" +image: "Image" birthday: "Birthday" yearsOld: "{age} years old" registeredDate: "Joined on" @@ -468,12 +470,14 @@ enableAdvancedMfm: "Enable advanced MFM" enableAnimatedMfm: "Enable animated MFM" doing: "Processing..." category: "Category" -tags: "Tags" +tags: "Aliases" docSource: "Source of this document" createAccount: "Create account" existingAccount: "Existing account" regenerate: "Regenerate" fontSize: "Font size" +mediaListWithOneImageAppearance: "Height of media lists with one image only" +limitTo: "Limit to {x}" noFollowRequests: "You don't have any pending follow requests" openImageInNewTab: "Open images in new tab" dashboard: "Dashboard" @@ -556,6 +560,7 @@ accountDeletedDescription: "This account has been deleted." menu: "Menu" divider: "Divider" addItem: "Add Item" +rearrange: "Rearrange" relays: "Relays" addRelay: "Add Relay" inboxUrl: "Inbox URL" @@ -699,6 +704,8 @@ contact: "Contact" useSystemFont: "Use the system's default font" clips: "Clips" experimentalFeatures: "Experimental features" +experimental: "Experimental" +thisIsExperimentalFeature: "This is an experimental feature. Its functionality is subject to change, and it may not operate as intended." developer: "Developer" makeExplorable: "Make account visible in \"Explore\"" makeExplorableDescription: "If you turn this off, your account will not show up in the \"Explore\" section." @@ -905,6 +912,7 @@ remoteOnly: "Remote only" failedToUpload: "Upload failed" cannotUploadBecauseInappropriate: "This file could not be uploaded because parts of it have been detected as potentially NSFW." cannotUploadBecauseNoFreeSpace: "Upload failed due to lack of Drive capacity." +cannotUploadBecauseExceedsFileSizeLimit: "This file cannot be uploaded as it exceeds the file size limit." beta: "Beta" enableAutoSensitive: "Automatic NSFW-Marking" enableAutoSensitiveDescription: "Allows automatic detection and marking of NSFW media through Machine Learning where possible. Even if this option is disabled, it may be enabled instance-wide." @@ -918,8 +926,8 @@ subscribePushNotification: "Enable push notifications" unsubscribePushNotification: "Disable push notifications" pushNotificationAlreadySubscribed: "Push notifications are already enabled" pushNotificationNotSupported: "Your browser or instance does not support push notifications" -sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications or messages have been read" -sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the power consumption of your device." +sendPushNotificationReadMessage: "Delete push notifications once they have been read" +sendPushNotificationReadMessageCaption: "This may increase the power consumption of your device." windowMaximize: "Maximize" windowMinimize: "Minimize" windowRestore: "Restore" @@ -938,6 +946,7 @@ didYouLikeMisskey: "Have you taken a liking to Misskey?" pleaseDonate: "{host} uses the free software, Misskey. We would highly appreciate your donations so development of Misskey can continue!" roles: "Roles" role: "Role" +noRole: "Role not found" normalUser: "Normal user" undefined: "Undefined" assign: "Assign" @@ -947,6 +956,10 @@ manageCustomEmojis: "Manage Custom Emojis" youCannotCreateAnymore: "You've hit the creation limit." cannotPerformTemporary: "Temporarily unavailable" cannotPerformTemporaryDescription: "This action cannot be performed temporarily due to exceeding the execution limit. Please wait for a while and then try again." +invalidParamError: "Invalid parameters" +invalidParamErrorDescription: "The request parameters are invalid. This is normally caused by a bug, but may also be due to inputs exceeding size limits or similar." +permissionDeniedError: "Operation denied" +permissionDeniedErrorDescription: "This account does not have the permission to perform this action." preset: "Preset" selectFromPresets: "Choose from presets" achievements: "Achievements" @@ -990,17 +1003,70 @@ enableChartsForFederatedInstances: "Generate remote instance data charts" showClipButtonInNoteFooter: "Add \"Clip\" to note action menu" largeNoteReactions: "Enlargen displayed reactions" noteIdOrUrl: "Note ID or URL" +video: "Video" +videos: "Videos" +dataSaver: "Data Saver" accountMigration: "Account Migration" accountMoved: "This user has moved to a new account:" +accountMovedShort: "This account has been migrated." +operationForbidden: "Operation forbidden" forceShowAds: "Always show ads" +addMemo: "Add memo" +editMemo: "Edit memo" +reactionsList: "Reactions" +renotesList: "Renotes" +notificationDisplay: "Notifications" +leftTop: "Top left" +rightTop: "Top right" +leftBottom: "Bottom left" +rightBottom: "Bottom right" +stackAxis: "Stacking direction" +vertical: "Vertical" +horizontal: "Horizontal" +position: "Position" +serverRules: "Server rules" +pleaseConfirmBelowBeforeSignup: "Please confirm the below before signing up." +pleaseAgreeAllToContinue: "You must agree to all above fields to continue." +continue: "Continue" +preservedUsernames: "Reserved usernames" +preservedUsernamesDescription: "List usernames to reserve separated by linebreaks. These will become unable during normal account creation, but can be used by administrators to manually create accounts. Already existing accounts using these usernames will not be affected." +createNoteFromTheFile: "Compose note from this file" +archive: "Archive" +channelArchiveConfirmTitle: "Really archive {name}?" +channelArchiveConfirmDescription: "An archived channel won't appear in the channel list or search results anymore. New posts can also not be added to it anymore." +thisChannelArchived: "This channel has been archived." +displayOfNote: "Note display" +initialAccountSetting: "Profile configuration" +youFollowing: "Followed" +_initialAccountSetting: + accountCreated: "Your account was successfully created!" + letsFillYourProfile: "First, let's set up your profile." + profileSetting: "Profile settings" + theseSettingsCanEditLater: "You can always change these settings later." + youCanEditMoreSettingsInSettingsPageLater: "There are many more settings you can configure from the \"Settings\" page. Be sure to visit it later." + followUsers: "Try following some users that interest you to build up your timeline." + pushNotificationDescription: "Enabling push notifications will allow you to receive notifications from {name} directly on your device." + initialAccountSettingCompleted: "Profile configuration complete!" + haveFun: "Enjoy {name}!" + ifYouNeedLearnMore: "If you'd like to learn more about how to use {name} (Misskey), please visit {link}." + skipAreYouSure: "Really skip profile configuration?" +_serverRules: + description: "A set of rules to be displayed before registration. Setting a summary of the Terms of Service is recommended." _accountMigration: + moveFrom: "Migrate another account to this one" + moveFromSub: "Create alias to another account" + moveFromLabel: "Original Account #{n}" + moveFromDescription: "You must create an alias for the account to move from on this account.\nEnter the account to migrate from in the following format: @username@server.example.com\nTo delete the alias, leave the field empty (not recommended)." moveTo: "Migrate this account to a different one" moveToLabel: "Account to move to:" - moveAccountDescription: "This action is irreversible. First, create an alias for this account on the account you wish to move to. Then, enter the account to move to in the following format: @person@instance.com" - moveFrom: "Migrate another account to this one" - moveFromLabel: "Account to move from:" - moveFromDescription: "Create an alias for the account to move from on this account if you wish to transfer its followers. This has to be done before the transfer! Then, enter the account to move to in the following format: @person@instance.com" - migrationConfirm: "Really migrate this account to {account}? Once started, this process cannot be stopped or taken back, and you will not be able to use this account in its original state anymore.\n\nAlso, confirm you've created an alias at the account to migrate to." + moveCannotBeUndone: "Account migration cannot be undone." + moveAccountDescription: "This will migrate your account to a different one.\n ・Followers from this account will automatically be migrated to the new account\n ・This account will unfollow all users it is currently following\n ・You will be unable to create new notes etc. on this account\n\nWhile migration of followers is automatic, you must manually prepare some steps to migrate the list of users you are following. To do so, carry out a follows export that you will later import on the new account in the settings menu. The same procedure applies to your lists as well as your muted and blocked users.\n\n(This explanation applies to Misskey v13.12.0 and later. Other ActivityPub software, such as Mastodon, might function differently.)" + moveAccountHowTo: "To migrate, first create an alias for this account on the account to move to.\nAfter you have created the alias, enter the account to move to in the following format: @username@server.example.com" + startMigration: "Migrate" + migrationConfirm: "Really migrate this account to {account}? Once started, this process cannot be stopped or taken back, and you will not be able to use this account in its original state anymore." + movedAndCannotBeUndone: "\nThis account has been migrated.\nMigration cannot be reversed." + postMigrationNote: "This account will unfollow all accounts it is currently following 24 hours after migration finishes.\nBoth the number of follows and followers will then become zero. To avoid your followers from being unable to see followers only posts of this account, they will however continue following this account." + movedTo: "Account to move to:" _achievements: earnedAt: "Unlocked at" _types: @@ -1171,7 +1237,10 @@ _achievements: description: "You've found the hidden treasure" _client30min: title: "Short break" - description: "Spend 30 minutes on Misskey" + description: "Keep Misskey opened for at least 30 minutes" + _client60min: + title: "No \"Miss\" in Misskey" + description: "Keep Misskey opened for at least 60 minutes" _noteDeletedWithin1min: title: "Nevermind" description: "Delete a note within a minute of posting it" @@ -1260,6 +1329,8 @@ _role: iconUrl: "Icon URL" asBadge: "Show as badge" descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on." + isExplorable: "Role timeline is public" + descriptionOfIsExplorable: "This role's timeline will become publicly accessible if enabled. Timelines of non-public roles will not be made public even if set." displayOrder: "Position" descriptionOfDisplayOrder: "The higher the number, the higher its UI position." canEditMembersByModerator: "Allow moderators to edit the list of members for this role" @@ -1276,6 +1347,7 @@ _role: canInvite: "Can create instance invite codes" canManageCustomEmojis: "Can manage custom emojis" driveCapacity: "Drive capacity" + alwaysMarkNsfw: "Always mark files as NSFW" pinMax: "Maximum number of pinned notes" antennaMax: "Maximum number of antennas" wordMuteMax: "Maximum number of characters allowed in word mutes" @@ -1527,32 +1599,16 @@ _time: minute: "Minute(s)" hour: "Hour(s)" day: "Day(s)" -_tutorial: +_timelineTutorial: title: "How to use Misskey" - step1_1: "Welcome!" - step1_2: "This page is called the \"timeline\". It shows chronologically ordered \"notes\" of people who you \"follow\"." - step1_3: "Your timeline is currently empty, since you have not posted any notes or followed anyone yet." - step2_1: "Let's finish setting up your profile before writing a note or following anyone." - step2_2: "Providing some information about who you are will make it easier for others to tell if they want to see your notes or follow you." - step3_1: "Finished setting up your profile?" - step3_2: "Then let's try posting a note next. You can do so by pressing the button with a pencil icon on the screen." - step3_3: "Fill in the modal and press the button on the top right to post." - step3_4: "Have nothing to say? Try \"just setting up my msky\"!" - step4_1: "Finished posting your first note?" - step4_2: "Hurray! Now your first note should be displayed on your timeline." - step5_1: "Now, let's try making your timeline more lively by following other people." - step5_2: "{featured} will show you popular notes in this instance. {explore} will let you find popular users. Try finding people you'd like to follow there!" - step5_3: "To follow other users, click on their icon and press the \"Follow\" button on their profile." - step5_4: "If the other user has a lock icon next to their name, it may take some time for that user to manually approve your follow request." - step6_1: "You should be able to see other users' notes on your timeline now." - step6_2: "You can also put \"reactions\" on other people's notes to quickly respond to them." - step6_3: "To attach a \"reaction\", press the \"+\" mark on another user's note and choose an emoji you'd like to react with." - step7_1: "Congratulations! You have now finished Misskey's basic tutorial." - step7_2: "If you would like to learn more about Misskey, try the {help} section." - step7_3: "Now then, have fun with Misskey! 🚀" - step8_1: "Lastly, would you like to enable push notifications?" - step8_2: "Enabling these will allow you to receive notifications for mentions, reactions, follows, etc. even when Misskey is not opened." - step8_3: "You can always change this setting later." + step1_1: "This is the \"timeline\". All \"notes\" submitted on {name} will be chronologically displayed here." + step1_2: "There are a few different timelines. For example, the \"Home timeline\" will contain notes of users you follow, and the \"Local timeline\" will contain notes from all users of {name}." + step2_1: "Let's try posting a note next. You can do so by pressing the button with a pencil icon." + step2_2: "How about writing a self-introduction, or just \"Hello {name}!\" if you don't feel like it?" + step3_1: "Finished posting your first note?" + step3_2: "Your first note should now be displayed on your timeline." + step4_1: "You can also attach \"Reactions\" to notes." + step4_2: "To attach a reaction, press the \"+\" mark on a note and choose an emoji you'd like to react with." _2fa: alreadyRegistered: "You have already registered a 2-factor authentication device." registerTOTP: "Register authenticator app" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 29cf9b3ec9..42d52e5a2b 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -264,12 +264,12 @@ nUsersRead: "Leído por {n} personas" agreeTo: "De acuerdo con {0}" agreeBelow: "Estoy de acuerdo con lo siguiente" basicNotesBeforeCreateAccount: "Notas básicas" -tos: "Términos de uso" start: "Comenzar" home: "Inicio" remoteUserCaution: "Para el usuario remoto, la información está incompleta" activity: "Actividad" images: "Imágenes" +image: "Imágenes" birthday: "Fecha de nacimiento" yearsOld: "{age} años" registeredDate: "Fecha de registro" @@ -991,14 +991,17 @@ largeNoteReactions: "Agrandar las reacciones de las notas" noteIdOrUrl: "ID o URL de la nota" accountMigration: "Migración de cuenta" accountMoved: "Este usuario se ha mudado a una nueva cuenta:" +horizontal: "Horizontal" +youFollowing: "Siguiendo" _accountMigration: - moveTo: "Mover esta cuenta a una nueva" - moveToLabel: "Cuenta destino:" - moveAccountDescription: "Esta operación no puede deshacerse. En primer lugar, asegúrese de haber creado un alias para esta cuenta en la cuenta a la que se va a trasladar. Después de crear el alias, introduzca la cuenta a la que se está trasladando de la siguiente manera: @person@instance.com" moveFrom: "Trasladar de otra cuenta a ésta" moveFromLabel: "Cuenta desde la que se realiza el traslado:" moveFromDescription: "Si quieres transferir seguidores de otra cuenta a esta cuenta y trasladarlos, tendrás que crear un alias aquí. Asegúrate de crearlo antes de realizar el traslado. Introduce la cuenta desde la que estás moviendo los seguidores así: @person@instance.com" + moveTo: "Mover esta cuenta a una nueva" + moveToLabel: "Cuenta destino:" + moveAccountDescription: "Esta operación no puede deshacerse. En primer lugar, asegúrese de haber creado un alias para esta cuenta en la cuenta a la que se va a trasladar. Después de crear el alias, introduzca la cuenta a la que se está trasladando de la siguiente manera: @person@instance.com" migrationConfirm: "¿Estás seguro de que quieres mover esta cuenta a {account}? Una vez trasladada, no podrás deshacer el traslado y no podrás volver a utilizar la cuenta original.\n\nAdemás, compruebe que ha configurado un alias en el destino del traslado." + movedTo: "Cuenta destino:" _achievements: earnedAt: "Desbloqueado el" _types: @@ -1523,32 +1526,6 @@ _time: minute: "Minutos" hour: "Horas" day: "Días" -_tutorial: - title: "Cómo usar Misskey" - step1_1: "Bienvenido" - step1_2: "Esta imagen se llama \"Linea de tiempo\" y muestra en orden cronológico las \"notas\" tuyas y de la gente que \"sigues\"" - step1_3: "Si no estás escribiendo ninguna nota y no estás siguiendo a nadie, es esperable que no se muestre nada en la linea de tiempo" - step2_1: "Antes de crear notas y seguir a alguien, primero vamos a crear tu perfil" - step2_2: "Si provees información sobre quien eres, será más fácil para que otros usuarios te sigan" - step3_1: "¿Has podido crear tu perfil sin problemas?" - step3_2: "Con esto, prueba hacer una nota. Aprieta el botón con forma de lápiz que está arriba de la imagen y abre el formulario." - step3_3: "Si has escrito el contenido, aprieta el botón que está arriba a la derecha del formulario para postear." - step3_4: "¿No se te ocurre un contenido? Prueba con decir \"Empecé a usar Misskey\"" - step4_1: "¿Has posteado?" - step4_2: "Si tu nota puede verse en la linea de tiempo, fue todo un éxito." - step5_1: "Luego, ponte a seguir a otra gente y haz que tu linea de tiempo esté más animada." - step5_2: "Puedes ver las notas destacadas en {featured} y desde allí seguir a usuarios que te importan. También puedes buscar usuario destacados en {explore}." - step5_3: "Para seguir a un usuario, haz click en su avatar para ver su página de usuario y allí apretar el botón \"seguir\"" - step5_4: "De esa manera, puede pasar un tiempo hasta que el usuario apruebe al seguidor." - step6_1: "Si puedes ver en la linea de tiempo las notas de otros usuarios, fue todo un éxito." - step6_2: "En las notas de otros usuarios puedes añadir una \"reacción\", para poder responder rápidamente." - step6_3: "Para añadir una reacción, haz click en el botón \"+\" de la nota y elige la reacción que prefieras." - step7_1: "Así terminó la explicación del funcionamiento básico de Misskey. Eso fue todo." - step7_2: "Si quieres conocer más sobre Misskey, prueba con la sección {help}." - step7_3: "Así, disfruta de Misskey 🚀" - step8_1: "Por último, ¿por qué no activar las notificaciones emergentes?" - step8_2: "Al recibir notificaciones emergentes, estarás al tanto de reacciones, seguimientos y menciones incluso cuando Misskey no esté abierto." - step8_3: "La configuración de las notificaciones puede modificarse posteriormente." _2fa: alreadyRegistered: "Ya has completado la configuración." registerTOTP: "Registrar aplicación autenticadora" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index e0767fdc09..e98ee9ff9b 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -20,6 +20,7 @@ noNotes: "Aucune note" noNotifications: "Aucune notification" instance: "Instance" settings: "Paramètres" +notificationSettings: "Paramètres des notifications " basicSettings: "Paramètres généraux" otherSettings: "Paramètres avancés" openInWindow: "Ouvrir dans une nouvelle fenêtre" @@ -259,12 +260,12 @@ noMoreHistory: "Il n’y a plus d’historique" startMessaging: "Commencer à discuter" nUsersRead: "Lu par {n} personnes" agreeTo: "J’accepte {0}" -tos: "les conditions d’utilisation" start: "Commencer" home: "Principal" remoteUserCaution: "Les informations de ce compte risqueraient d’être incomplètes du fait que l’utilisateur·rice provient d’une instance distante." activity: "Activité" images: "Images" +image: "Images" birthday: "Date de naissance" yearsOld: "{age} ans" registeredDate: "Inscrit le" @@ -838,13 +839,16 @@ tenMinutes: "10 minutes" oneHour: "1 heure" oneDay: "1 jour" oneWeek: "1 semaine" +oneMonth: "Un mois" reflectMayTakeTime: "Cela peut prendre un certain temps avant que cela ne se termine." failedToFetchAccountInformation: "Impossible de récupérer les informations du compte." rateLimitExceeded: "Limite de taux dépassée" cropImage: "Recadrer l'image" cropImageAsk: "Voulez-vous recadrer cette image ?" +cropYes: "Rogner" file: "Fichiers" recentNHours: "Dernières {n} heures" +recentNDays: "Derniers {n} jours" noEmailServerWarning: "Serveur de courrier non configuré." thereIsUnresolvedAbuseReportWarning: "Il n’y a aucun rapport non résolu." recommended: "Recommandé" @@ -900,11 +904,47 @@ numberOfLikes: "Favoris" show: "Affichage" neverShow: "Ne plus afficher" remindMeLater: "Peut-être plus tard" +roles: "Rôles" +role: "Rôles" +normalUser: "Simple utilisateur·rice" +assign: "Attribuer" color: "Couleur" +manageCustomEmojis: "Gestion des émojis personnalisés" +preset: "Préréglage" +selectFromPresets: "Sélectionner à partir des préréglages" +license: "Licence" +video: "Vidéo" +videos: "Vidéos" +dataSaver: "Économiseur de données" +accountMigration: "Migration de compte" +accountMoved: "Cet·te utilisateur·rice a migré son compte vers :" +notificationDisplay: "Style des notifications" +leftTop: "En haut à gauche" +rightTop: "En haut à droite" +leftBottom: "En bas à gauche" +rightBottom: "En bas à droite" +vertical: "Vertical" +horizontal: "Latéral" +youFollowing: "Abonné·e" _achievements: _types: + _notes1: + description: "Publiez votre première note" + flavor: "Passez un bon moment avec Misskey !" _notes100000: title: "ALL YOUR NOTE ARE BELONG TO US" + _login3: + description: "Se connecter pour un total de 3 jours" + _login7: + description: "Se connecter pour un total de 7 jours" + _login15: + description: "Se connecter pour un total de 15 jours" + _login30: + description: "Se connecter pour un total de 30 jours" + _login60: + description: "Se connecter pour un total de 60 jours" + _login100: + description: "Se connecter pour un total de 100 jours" _login1000: flavor: "Merci d'utiliser Misskey !" _markedAsCat: @@ -912,12 +952,21 @@ _achievements: flavor: "Je n'ai pas encore de nom" _following50: title: "Beaucoup d'amis" + _followers10: + title: "Abonnez-moi !" + _viewInstanceChart: + title: "Analyste" + _loggedInOnNewYearsDay: + title: "Bonne année !" _role: + assignTarget: "Attribuer" priority: "Priorité" _priority: low: "Basse" middle: "Moyen" high: "Haute" + _options: + canManageCustomEmojis: "Gestion des émojis personnalisés" _sensitiveMediaDetection: description: "L'apprentissage automatique peut être utilisé pour détecter automatiquement les médias sensibles à modérer. La sollicitation des serveurs augmente légèrement." sensitivity: "Sensibilité de la détection" @@ -1141,31 +1190,6 @@ _time: minute: "min" hour: "h" day: "j" -_tutorial: - title: "Comment utiliser Misskey" - step1_1: "Bienvenue," - step1_2: "Cette page est appelée « un fil ». Elle affiche les « notes » des personnes auxquelles vous êtes abonné dans un ordre chronologique." - step1_3: "Votre fil est actuellement vide vu que vous ne suivez aucun compte et que vous n’avez publié aucune note, pour l’instant." - step2_1: "Procédons d’abord à la préparation de votre profil avant d’écrire une note et/ou de vous abonner à un compte." - step2_2: "En fournissant quelques informations sur vous, il sera plus facile pour les autres de s’abonner à votre compte." - step3_1: "Vous avez fini de créer votre profil ?" - step3_2: "L’étape suivante consiste à créer une note. Vous pouvez commencer en cliquant sur l’icône crayon sur l’écran." - step3_3: "Remplissez le cadran et cliquez sur le bouton en haut à droite pour envoyer." - step3_4: "Vous n’avez rien à dire ? Essayez d’écrire « J’ai commencé à utiliser Misskey »." - step4_1: "Avez-vous publié votre première note ?" - step4_2: "Youpi ! Celle-ci est maintenant affichée sur votre fil d’actualité." - step5_1: "Maintenant, essayons de nous abonner à d’autres personnes afin de rendre votre fil plus vivant." - step5_2: "La page {featured} affiche les notes en tendance sur la présente instance et {explore} vous permet de trouver des utilisateur·rice·s en tendance. Essayez de vous abonner aux gens que vous aimez !" - step5_3: "Pour pouvoir suivre d’autres utilisateur·rice, cliquez sur leur avatar afin d’afficher la page du profil utilisateur ensuite appuyez sur le bouton « S’abonner »." - step5_4: "Si l’autre utilisateur possède une icône sous forme d’un cadenas à côté de son nom, il devra accepter votre demande d’abonnement manuellement." - step6_1: "Maintenant, vous êtes en mesure de voir s’afficher les notes des autres utilisateur·rice·s sur votre propre fil." - step6_2: "Vous avez également la possibilité d’intéragir rapidement avec les notes des autres utilisateur·rice·s en ajoutant des « réactions »." - step6_3: "Pour ajouter une réaction à une note, cliquez sur le signe « + » de celle-ci et sélectionnez l’émoji souhaité." - step7_1: "Félicitations ! Vous avez atteint la fin du tutoriel de base pour l’utilisation de Misskey." - step7_2: "Si vous désirez en savoir plus sur Misskey, jetez un œil sur la section {help}." - step7_3: "Bon courage et amusez-vous bien sur Misskey ! 🚀" - step8_1: "Enfin, souhaitez-vous activer les notifications push ?" - step8_2: "En les activant, vous recevrez des notifications pour les mentions, les réactions, les suivis, etc., même lorsque Misskey n'est pas ouvert." _2fa: alreadyRegistered: "Configuration déjà achevée." step1: "Tout d'abord, installez une application d'authentification, telle que {a} ou {b}, sur votre appareil." diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 97c0b02fe5..5159775fc4 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -263,12 +263,12 @@ nUsersRead: "Dibaca oleh {n}" agreeTo: "Saya setuju kepada {0}" agreeBelow: "Saya setuju dengan di bawah ini" basicNotesBeforeCreateAccount: "Catatan penting" -tos: "Syarat dan ketentuan" start: "Mulai" home: "Beranda" remoteUserCaution: "Informasi ini mungkin tidak mutakhir, karena pengguna ini berasal dari instansi luar." activity: "Aktivitas" images: "Gambar" +image: "Gambar" birthday: "Tanggal lahir" yearsOld: "{age} tahun" registeredDate: "Bergabung pada" @@ -953,6 +953,8 @@ letsLookAtTimeline: "LIhat timeline" disableFederationConfirm: "Matikan federasi?" disableFederationConfirmWarn: "Mematikan federasi tidak membuat kiriman menjadi privat. Umumnya, mematikan federasi tidak diperlukan." disableFederationOk: "Matikan federasi" +horizontal: "Horisontal" +youFollowing: "Mengikuti" _achievements: earnedAt: "Terbuka pada" _types: @@ -1472,32 +1474,6 @@ _time: minute: "menit" hour: "jam" day: "hari" -_tutorial: - title: "Cara menggunakan Misskey" - step1_1: "Selamat datang!" - step1_2: "Halaman ini disebut \"linimasa\". Halaman ini menampilkan \"catatan\" yang diurutkan secara kronologis dari orang-orang yang kamu \"ikuti\"." - step1_3: "Linimasa kamu kosong, karena kamu belum mencatat catatan apapun atau mengikuti siapapun." - step2_1: "Selesaikan menyetel profilmu sebelum menulis sebuah catatan atau mengikuti seseorang." - step2_2: "Menyediakan beberapa informasi tentang siapa kamu akan membuat orang lain mudah untuk mengikutimu kembali." - step3_1: "Selesai menyetel profil kamu?" - step3_2: "Langkah selanjutnya adalah membuat catatan. Kamu bisa lakukan ini dengan mengklik ikon pensil pada layar kamu." - step3_3: "Isilah di dalam modal dan tekan tombol pada atas kanan untuk memcatat catatan kamu." - step3_4: "Bingung tidak berpikiran untuk mengatakan sesuatu? Coba saja \"baru aja ikutan bikin akun misskey punyaku\"!" - step4_1: "Selesai mencatat catatan pertamamu?" - step4_2: "Horee! Sekarang catatan pertamamu sudah ditampilkan di linimasa milikmu." - step5_1: "Sekarang, mari mencoba untuk membuat linimasamu lebih hidup dengan mengikuti orang lain." - step5_2: "{featured} akan memperlihatkan catatan yang sedang tren saat ini untuk kamu. {explore} akan membantumu untuk mencari pengguna yang sedang tren juga saat ini. Coba ikuti seseorang yang kamu suka!" - step5_3: "Untuk mengikuti pengguna lain, klik pada ikon mereka dan tekan tombol follow pada profil mereka." - step5_4: "Jika pengguna lain memiliki ikon gembok di sebelah nama mereka, maka pengguna rersebut harus menyetujui permintaan mengikuti dari kamu secara manual." - step6_1: "Sekarang kamu dapat melihat catatan pengguna lain pada linimasamu." - step6_2: "Kamu juga bisa memberikan \"reaksi\" ke catatan orang lain untuk merespon dengan cepat." - step6_3: "Untuk memberikan \"reaksi\", tekan tanda \"+\" pada catatan pengguna lain dan pilih emoji yang kamu suka untuk memberikan reaksimu kepada mereka." - step7_1: "Yay, Selamat! Kamu sudah menyelesaikan tutorial dasar Misskey." - step7_2: "Jika kamu ingin mempelajari lebih lanjut tentang Misskey, cobalah berkunjung ke bagian {help}." - step7_3: "Semoga berhasil dan bersenang-senanglah! 🚀" - step8_1: "Yang terakhir, apakah kamu ingin menyalakam pemberitahuan push?" - step8_2: "Menyalakan ini akan memungkinkan kamu menerima pemberitahuan untuk sebutan, reaksi, ikuti, dll. Bahkan ketika Misskey sedang tidak dibuka." - step8_3: "Kamu dapat mengganti pengaturan ini nanti." _2fa: alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor." step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat kamu." diff --git a/locales/it-IT.yml b/locales/it-IT.yml index f43adcb35b..cacd91cac9 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -263,14 +263,16 @@ noMoreHistory: "Non c'è più cronologia da visualizzare" startMessaging: "Nuovo messaggio" nUsersRead: "Letto da {n} persone" agreeTo: "Sono d'accordo con {0}" +agree: "D'accordo" agreeBelow: "Accetto quanto riportato sotto" basicNotesBeforeCreateAccount: "Note importanti" -tos: "Termini di servizio" +termsOfService: "Informativa Privacy" start: "Inizia!" home: "Home" remoteUserCaution: "Può darsi che le informazioni siano incomplete perché questo è un utente remoto." activity: "Attività" images: "Immagini" +image: "Immagini" birthday: "Compleanno" yearsOld: "{age} anni" registeredDate: "Iscrizione a.." @@ -474,6 +476,8 @@ createAccount: "Crea il tuo profilo" existingAccount: "Profilo esistente" regenerate: "Generare di nuovo" fontSize: "Dimensione carattere" +mediaListWithOneImageAppearance: "Altezza dell'elenco media con una sola immagine " +limitTo: "Limita a {x}" noFollowRequests: "Non hai alcuna richiesta di follow" openImageInNewTab: "Apri le immagini in un nuovo tab" dashboard: "Pannello di controllo" @@ -947,6 +951,10 @@ manageCustomEmojis: "Gestisci le emoji personalizzate" youCannotCreateAnymore: "Non puoi creare, hai raggiunto il limite." cannotPerformTemporary: "Indisponibilità temporanea" cannotPerformTemporaryDescription: "L'attività non può essere svolta, poiché si è raggiunto il limite di esecuzioni possibili. Per favore, riprova più tardi." +invalidParamError: "Errore, parametro non valido" +invalidParamErrorDescription: "Riscontrato un errore nei parametri della richiesta. Solitamente potrebbe essere un malfunzionamento, ma a volte potrebbe essere causato dalla quantità eccessiva di caratteri nel parametro." +permissionDeniedError: "Errore, attività non autorizzata" +permissionDeniedErrorDescription: "Non si dispone dell'autorizzazione per eseguire questa operazione." preset: "Preimpostato" selectFromPresets: "Seleziona preimpostato" achievements: "Obiettivi raggiunti" @@ -970,7 +978,7 @@ invitationRequiredToRegister: "L'accesso a questa istanza è solo ad invito. Pu emailNotSupported: "L'istanza non supporta l'invio di email" postToTheChannel: "Pubblica nel canale" cannotBeChangedLater: "Non sarà più modificabile" -reactionAcceptance: "Accettazione reazioni" +reactionAcceptance: "Reazioni consentite" likeOnly: "Solo i Like" likeOnlyForRemote: "Solo Like remoti" rolesAssignedToMe: "I miei ruoli" @@ -990,17 +998,39 @@ enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate" showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note" largeNoteReactions: "Ingrandisci le reazioni" noteIdOrUrl: "ID della Nota o URL" +video: "Video" +videos: "Video" +dataSaver: "Risparmia dati" accountMigration: "Migrazione del profilo" accountMoved: "Questo profilo ha migrato altrove:" forceShowAds: "Mostra sempre i banner" +addMemo: "Aggiungi Memo" +editMemo: "Modifica Memo" +notificationDisplay: "Stile delle notifiche" +leftTop: "In alto a sinistra" +rightTop: "In alto a destra" +leftBottom: "In basso a sinistra" +rightBottom: "In basso a destra" +stackAxis: "Allineamento" +vertical: "Verticale" +horizontal: "Laterale" +position: "Posizione" +serverRules: "Regolamento" +pleaseConfirmBelowBeforeSignup: "Ai sensi del regolamento EU 679/2016 GDPR, autorizzo il trattamento dati personali come descritto nella informativa Privacy." +pleaseAgreeAllToContinue: "Per continuare, occorre selezionare ed essere d'accordo su tutto." +continue: "Continua" +youFollowing: "Seguiti" +_serverRules: + description: "In Europa è necessario mostrare l'informativa sul trattamento dei dati personali, prima della registrazione al servizio." _accountMigration: - moveTo: "Migrare questo profilo verso un un altro" - moveToLabel: "Profilo verso cui migrare" - moveAccountDescription: "Questa attività è irreversibile! Innanzitutto, assicurati di aver creato, nella istanza di destinazione, un alias con l'indirizzo di questo profilo. Successivamente, indica qui il profilo di destinazione in questo modo: @persona@istanza.it" moveFrom: "Migra un altro profilo dentro a questo" moveFromLabel: "Profilo da cui migrare:" moveFromDescription: "Se desideri spostare i profili follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@istanza.it" + moveTo: "Migrare questo profilo verso un un altro" + moveToLabel: "Profilo verso cui migrare" + moveAccountDescription: "Questa attività è irreversibile! Innanzitutto, assicurati di aver creato, nella istanza di destinazione, un alias con l'indirizzo di questo profilo. Successivamente, indica qui il profilo di destinazione in questo modo: @persona@istanza.it" migrationConfirm: "Vuoi davvero migrare questo profilo su {account}? L'azione è irreversibile e non potrai più utilizzare questo profilo nel suo stato originale.\nInoltre, assicurati di aver già creato un alias sull'account a cui ti stai trasferendo." + movedTo: "Profilo verso cui migrare" _achievements: earnedAt: "Data di conseguimento" _types: @@ -1172,6 +1202,9 @@ _achievements: _client30min: title: "Piccola grande pausa" description: "Hai passato più di 30 minuti su Misskey" + _client60min: + title: "Misskey negli occhi" + description: "Hai letto Misskey almeno per un'ora" _noteDeletedWithin1min: title: "Ooops!" description: "Hai eliminato una nota entro un minuto dalla sua pubblicazione" @@ -1260,6 +1293,8 @@ _role: iconUrl: "URL dell'icona" asBadge: "Mostra come badge" descriptionOfAsBadge: "Se indicato, accanto al nome utente viene visualizzata l'icona del ruolo." + isExplorable: "La timeline del ruolo è pubblica" + descriptionOfIsExplorable: "Selezionandolo, la timeline del ruolo diventerà accessibile pubblicamente. Tranne se il ruolo non è pubblico." displayOrder: "Ordine di visualizzazione" descriptionOfDisplayOrder: "I valori più alti vengono visualizzati per primi" canEditMembersByModerator: "Anche i Moderatori assegnano profili a questo ruolo" @@ -1527,32 +1562,6 @@ _time: minute: "min" hour: "ore" day: "giorni" -_tutorial: - title: "Come usare Misskey" - step1_1: "Eccoci!" - step1_2: "Questa pagina si chiama \"Timeline \" e mostra in ordine cronologico le \"note\" delle persone che segui." - step1_3: "Attualmente la tua Timeline è vuota perché non segui alcun profilo e non hai ancora pubblicato alcuna nota." - step2_1: "Prima di scrivere una «Nota» o di seguire altri profili, prepara il tuo profilo!" - step2_2: "Se aggiungi informazioni personali aumenterai le tue possibilità di essere seguit@ da altre persone. " - step3_1: "Hai finito di impostare il tuo profilo?" - step3_2: "Ora puoi pubblicare una «Nota». Proviamo subito! Premi il bottone con l'icona «penna» per iniziare a scrivere in una finestra di dialogo. " - step3_3: "Scritto il testo della nota, puoi pubblicarla premendo il pulsante nella parte superiore destra della finestra di dialogo." - step3_4: "Non ti viene niente in mente? Perché non scrivi semplicemente \"Ho appena iniziato a usare Misskey\"?" - step4_1: "Hai pubblicato qualcosa?" - step4_2: "Se puoi visualizzare la tua nota sulla timeline, ce l'hai fatta!" - step5_1: "Adesso, cerca di seguire altre persone per vivacizzare la tua timeline. " - step5_2: "La pagina {featured} mostra le note di tendenza su questa istanza, magari ti aiuterà a trovare profili che ti piacciono e che vorrai seguire. Altrimenti potrai trovare utenti popolari anche usando {explore}." - step5_3: "Clicca l'immagine per aprire il profilo e premere il bottone \"Seguire\"" - step5_4: "Se l'altro profilo ha un lucchetto vicino al nome, significa che occorre un po' di tempo prima che approvi manualmente la tua richiesta di follow." - step6_1: "Adesso, dovresti essere in grado di vedere le note dagli altri profili sulla tua timeline." - step6_2: "Puoi anche rispondere alle note con un click, scegliendo le reazioni immediate." - step6_3: "Per inviare una reazione, premi l'icona + della nota e scegli l'emoji che vuoi mandare." - step7_1: "Congratulazioni! Hai completato l'esercitazione iniziale su come usare Misskey." - step7_2: "Se vuoi saperne di più su Misskey, puoi dare un'occhiata alla sezione {help}." - step7_3: "Da ultimo, buon divertimento su Misskey! 🚀" - step8_1: "Per concludere, vuoi attivare le notifiche push?" - step8_2: "Attivandole, otterrai notifiche di follow, reazioni e menzioni anche quando Misskey è chiuso." - step8_3: "Potrai modificare questa impostazione." _2fa: alreadyRegistered: "La configurazione è stata già completata." registerTOTP: "Registra un'app di autenticazione" @@ -1890,6 +1899,7 @@ _deck: channel: "Canale" mentions: "Menzioni" direct: "Diretta" + roleTimeline: "Timeline Ruolo" _dialog: charactersExceeded: "Hai superato il limite di {max} caratteri! ({corrente})" charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({corrente})" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 092a4aed32..8bbf9459f5 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -263,14 +263,16 @@ noMoreHistory: "これより過去の履歴はありません" startMessaging: "チャットを開始" nUsersRead: "{n}人が読みました" agreeTo: "{0}に同意" +agree: "同意する" agreeBelow: "下記に同意する" basicNotesBeforeCreateAccount: "基本的な注意事項" -tos: "利用規約" +termsOfService: "利用規約" start: "始める" home: "ホーム" remoteUserCaution: "リモートユーザーのため、情報が不完全です。" activity: "アクティビティ" images: "画像" +image: "画像" birthday: "誕生日" yearsOld: "{age}歳" registeredDate: "登録日" @@ -474,6 +476,8 @@ createAccount: "アカウントを作成" existingAccount: "既存のアカウント" regenerate: "再生成" fontSize: "フォントサイズ" +mediaListWithOneImageAppearance: "画像が1枚のみのメディアリストの高さ" +limitTo: "{x}を上限に" noFollowRequests: "フォロー申請はありません" openImageInNewTab: "画像を新しいタブで開く" dashboard: "ダッシュボード" @@ -556,6 +560,7 @@ accountDeletedDescription: "このアカウントは削除されています。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" +rearrange: "並び替え" relays: "リレー" addRelay: "リレーの追加" inboxUrl: "inboxのURL" @@ -699,6 +704,8 @@ contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使う" clips: "クリップ" experimentalFeatures: "実験的機能" +experimental: "実験的" +thisIsExperimentalFeature: "これは実験的な機能です。仕様が変更されたり、正常に動作しなかったりする可能性があります。" developer: "開発者" makeExplorable: "アカウントを見つけやすくする" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。" @@ -905,6 +912,7 @@ remoteOnly: "リモートのみ" failedToUpload: "アップロード失敗" cannotUploadBecauseInappropriate: "不適切な内容を含む可能性があると判定されたためアップロードできません。" cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" +cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。" beta: "ベータ" enableAutoSensitive: "自動NSFW判定" enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" @@ -938,6 +946,7 @@ didYouLikeMisskey: "Misskeyを気に入っていただけましたか?" pleaseDonate: "Misskeyは{host}が使用している無料のソフトウェアです。これからも開発を続けられるように、ぜひ寄付をお願いします!" roles: "ロール" role: "ロール" +noRole: "ロールはありません" normalUser: "一般ユーザー" undefined: "未定義" assign: "アサイン" @@ -947,6 +956,10 @@ manageCustomEmojis: "カスタム絵文字の管理" youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" +invalidParamError: "パラメータエラー" +invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。" +permissionDeniedError: "操作が拒否されました" +permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。" preset: "プリセット" selectFromPresets: "プリセットから選択" achievements: "実績" @@ -990,18 +1003,74 @@ enableChartsForFederatedInstances: "リモートサーバーのチャートを showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" largeNoteReactions: "ノートのリアクションを大きく表示" noteIdOrUrl: "ノートIDまたはURL" -accountMigration: "アカウントの引っ越し" -accountMoved: "このユーザーは新しいアカウントに引っ越しました:" +video: "動画" +videos: "動画" +dataSaver: "データセーバー" +accountMigration: "アカウントの移行" +accountMoved: "このユーザーは新しいアカウントに移行しました:" +accountMovedShort: "このアカウントは移行されています" +operationForbidden: "この操作はできません" forceShowAds: "常に広告を表示する" +addMemo: "メモを追加" +editMemo: "メモを編集" +reactionsList: "リアクション一覧" +renotesList: "Renote一覧" +notificationDisplay: "通知の表示" +leftTop: "左上" +rightTop: "右上" +leftBottom: "左下" +rightBottom: "右下" +stackAxis: "スタック方向" +vertical: "縦" +horizontal: "横" +position: "位置" +serverRules: "サーバールール" +pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。" +pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。" +continue: "続ける" +preservedUsernames: "予約ユーザー名" +preservedUsernamesDescription: "予約するユーザー名を改行で列挙します。ここで指定されたユーザー名はアカウント作成時に使えなくなりますが、管理者によるアカウント作成時はこの制限を受けません。また、既に存在するアカウントも影響を受けません。" +createNoteFromTheFile: "このファイルからノートを作成" +archive: "アーカイブ" +channelArchiveConfirmTitle: "{name}をアーカイブしますか?" +channelArchiveConfirmDescription: "アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。" +thisChannelArchived: "このチャンネルはアーカイブされています。" +displayOfNote: "ノートの表示" +initialAccountSetting: "初期設定" +youFollowing: "フォロー中" + +_initialAccountSetting: + accountCreated: "アカウントの作成が完了しました!" + letsStartAccountSetup: "アカウントの初期設定を行いましょう。" + letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。" + profileSetting: "プロフィール設定" + theseSettingsCanEditLater: "これらの設定は後から変更できます。" + youCanEditMoreSettingsInSettingsPageLater: "この他にも様々な設定を「設定」ページから行えます。ぜひ後で確認してみてください。" + followUsers: "タイムラインを構築するため、気になるユーザーをフォローしてみましょう。" + pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。" + initialAccountSettingCompleted: "初期設定が完了しました!" + haveFun: "{name}をお楽しみください!" + ifYouNeedLearnMore: "{name}(Misskey)の使い方などを詳しく知るには{link}をご覧ください。" + skipAreYouSure: "初期設定をスキップしますか?" + +_serverRules: + description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" _accountMigration: - moveTo: "このアカウントを新しいアカウントに引っ越す" - moveToLabel: "引っ越し先のアカウント:" - moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@instance.com" - moveFrom: "別のアカウントからこのアカウントに引っ越す" - moveFromLabel: "引っ越し元のアカウント:" - moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com" - migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用できなくなります。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。" + moveFrom: "別のアカウントからこのアカウントに移行" + moveFromSub: "別のアカウントへエイリアスを作成" + moveFromLabel: "移行元のアカウント #{n}" + moveFromDescription: "別のアカウントからこのアカウントに移行したい場合、ここでエイリアスを作成しておく必要があります。\n移行元のアカウントをこのように入力してください: @username@server.example.com\n削除するには、入力欄を空にして保存します(非推奨)。" + moveTo: "このアカウントを新しいアカウントへ移行" + moveToLabel: "移行先のアカウント:" + moveCannotBeUndone: "アカウントを移行すると、取り消すことはできません。" + moveAccountDescription: "新しいアカウントへ移行します。\n ・フォロワーが新しいアカウントを自動でフォローします\n ・このアカウントからのフォローは全て解除されます\n ・このアカウントではノートの作成などができなくなります\n\nフォロワーの移行は自動ですが、フォローの移行は手動で行う必要があります。移行前にこのアカウントでフォローエクスポートし、移行後すぐに移行先アカウントでインポートを行なってください。\nリスト・ミュート・ブロックについても同様ですので、手動で移行する必要があります。\n\n(この説明はこのサーバー(Misskey v13.12.0以降)の仕様です。Mastodonなどの他のActivityPubソフトウェアでは挙動が異なる場合があります。)" + moveAccountHowTo: "アカウントの移行には、まずは移行先のアカウントでこのアカウントに対しエイリアスを作成します。\nエイリアス作成後、移行先のアカウントを次のように入力してください: @username@server.example.com" + startMigration: "移行する" + migrationConfirm: "本当にこのアカウントを {account} に移行しますか?一度移行すると取り消せず、二度とこのアカウントを元の状態で使用できなくなります。" + movedAndCannotBeUndone: "\nアカウントは移行されています。\n移行を取り消すことはできません。" + postMigrationNote: "このアカウントからのフォロー解除は移行操作から24時間後に実行されます。\nこのアカウントのフォロー・フォロワー数は0になっています。フォロワーの解除はされないため、あなたのフォロワーはこのアカウントのフォロワー向け投稿を引き続き閲覧できます。" + movedTo: "移行先のアカウント:" _achievements: earnedAt: "獲得日時" @@ -1174,6 +1243,9 @@ _achievements: _client30min: title: "ひとやすみ" description: "クライアントを起動してから30分以上経過した" + _client60min: + title: "Misskeyの見すぎ" + description: "クライアントを起動してから60分以上経過した" _noteDeletedWithin1min: title: "いまのなし" description: "投稿してから1分以内にその投稿を削除した" @@ -1263,6 +1335,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして表示" descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。" + isExplorable: "ロールタイムラインを公開" + descriptionOfIsExplorable: "オンにすると、ロールのタイムラインを公開します。ロールの公開がオフの場合、タイムラインの公開はされません。" displayOrder: "表示順" descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" @@ -1279,6 +1353,7 @@ _role: canInvite: "サーバー招待コードの発行" canManageCustomEmojis: "カスタム絵文字の管理" driveCapacity: "ドライブ容量" + alwaysMarkNsfw: "ファイルにNSFWを常に付与" pinMax: "ノートのピン留めの最大数" antennaMax: "アンテナの作成可能数" wordMuteMax: "ワードミュートの最大文字数" @@ -1556,32 +1631,16 @@ _time: hour: "時間" day: "日" -_tutorial: +_timelineTutorial: title: "Misskeyの使い方" - step1_1: "ようこそ。" - step1_2: "この画面は「タイムライン」と呼ばれ、あなたや、あなたが「フォロー」する人の「ノート」が時系列で表示されます。" - step1_3: "あなたはまだ何もノートを投稿しておらず、誰もフォローしていないので、タイムラインには何も表示されていないはずです。" - step2_1: "ノートを作成したり誰かをフォローしたりする前に、まずあなたのプロフィールを完成させましょう。" - step2_2: "あなたがどんな人かわかると、多くの人にノートを見てもらえたり、フォローしてもらいやすくなります。" - step3_1: "プロフィール設定はうまくできましたか?" - step3_2: "では試しに、何かノートを投稿してみてください。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" - step3_3: "内容を書いたら、フォーム右上のボタンを押すと投稿できます。" - step3_4: "内容が思いつかない?「Misskey始めました」というのはいかがでしょう。" - step4_1: "投稿できましたか?" - step4_2: "あなたのノートがタイムラインに表示されていれば成功です。" - step5_1: "次は、他の人をフォローしてタイムラインを賑やかにしたいところです。" - step5_2: "{featured}で人気のノートが見れるので、その中から気になった人を選んでフォローしたり、{explore}で人気のユーザーを探すこともできます。" - step5_3: "ユーザーをフォローするには、ユーザーのアイコンをクリックしてユーザーページを表示し、「フォロー」ボタンを押します。" - step5_4: "ユーザーによっては、フォローが承認されるまで時間がかかる場合があります。" - step6_1: "タイムラインに他のユーザーのノートが表示されていれば成功です。" - step6_2: "他の人のノートには、「リアクション」を付けることができ、簡単にあなたの反応を伝えられます。" - step6_3: "リアクションを付けるには、ノートの「+」マークをクリックして、好きなリアクションを選択します。" - step7_1: "これで、Misskeyの基本的な使い方の説明は終わりました。お疲れ様でした。" - step7_2: "もっとMisskeyについて知りたいときは、{help}を見てみてください。" - step7_3: "では、Misskeyをお楽しみください🚀" - step8_1: "最後に、プッシュ通知を有効化してみませんか?" - step8_2: "プッシュ通知を受け取ることで、Misskeyを開いていない時にもリアクションやフォロー、メンションなどに気づけます。" - step8_3: "通知の設定は後から変更できます。" + step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。" + step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。" + step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" + step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。" + step3_1: "投稿できましたか?" + step3_2: "あなたのノートがタイムラインに表示されていれば成功です。" + step4_1: "ノートには、「リアクション」を付けることができます。" + step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。" _2fa: alreadyRegistered: "既に設定は完了しています。" @@ -1763,7 +1822,7 @@ _profile: metadataDescription: "プロフィールに表として追加情報を表示することができます。" metadataLabel: "ラベル" metadataContent: "内容" - changeAvatar: "アバター画像を変更" + changeAvatar: "アイコン画像を変更" changeBanner: "バナー画像を変更" _exportOrImport: diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 4117a25b0e..512cce1452 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1,7 +1,7 @@ --- _lang_: "日本語 (関西弁)" headlineMisskey: "ノートでつながるネットワーク" -introMisskey: "ようお越し!Misskeyは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな新しい世界を探検しよか🚀" +introMisskey: "ようお越し!Misskeyは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「ツッコミ」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな新しい世界を探検しよか🚀" poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつなんやで。" monthAndDay: "{month}月 {day}日" search: "探す" @@ -26,7 +26,7 @@ otherSettings: "ほかの設定" openInWindow: "ウィンドウで開くで" profile: "プロフィール" timeline: "タイムライン" -noAccountDescription: "自己紹介はあらへん" +noAccountDescription: "自己紹介食ってもた" login: "ログイン" loggingIn: "ログインしよるで" logout: "ログアウト" @@ -38,9 +38,9 @@ addUser: "ユーザーを追加や" favorite: "お気に入り" favorites: "お気に入り" unfavorite: "やっぱ気に入らん" -favorited: "お気に入りに登録したで" +favorited: "お気に入りに入れたで" alreadyFavorited: "もうお気に入りに入れとるがな。" -cantFavorite: "アカン、お気に入り登録できへんかったで。" +cantFavorite: "アカン、お気に入りに入れれんかったわ。" pin: "ピン留めしとく" unpin: "やっぱピン留めせん" copyContent: "内容をコピー" @@ -61,7 +61,7 @@ youGotNewFollower: "フォローされたで" receiveFollowRequest: "フォローリクエストされたで" followRequestAccepted: "フォローが承認されたで" mention: "メンション" -mentions: "うち宛て" +mentions: "自分宛て" directNotes: "ダイレクト投稿" importAndExport: "インポートとエクスポート" import: "インポート" @@ -109,12 +109,12 @@ inChannelQuote: "チャンネル内引用" pinnedNote: "ピン留めされとるノート" pinned: "ピン留めしとく" you: "あんた" -clickToShow: "押したら出ら" +clickToShow: "押したら見えるで" sensitive: "気いつけて見いや" add: "増やす" -reaction: "リアクション" -reactions: "リアクション" -reactionSetting: "ピッカーに出しとくリアクション" +reaction: "ツッコミ" +reactions: "ツッコミ" +reactionSetting: "ピッカーに出しとくツッコミ" reactionSettingDescription2: "ドラッグで並び替え、クリックで削除、+を押して追加やで。" rememberNoteVisibility: "公開範囲覚えといて" attachCancel: "のっけるのやめる" @@ -263,14 +263,16 @@ noMoreHistory: "これより昔のんはあらへんで" startMessaging: "チャットやるで" nUsersRead: "{n}人が読んでもうた" agreeTo: "{0}に同意したで" +agree: "せやな" agreeBelow: "下記に同意したる" basicNotesBeforeCreateAccount: "よう読んでやってや" -tos: "利用規約" +termsOfService: "使うための決め事" start: "始める" home: "ホーム" remoteUserCaution: "リモートユーザーやから、足りひん情報あるかもしれへん。" activity: "アクティビティ" images: "画像" +image: "画像" birthday: "生まれた日" yearsOld: "{age}歳" registeredDate: "始めた日" @@ -322,7 +324,7 @@ instanceName: "サーバー名" instanceDescription: "サーバーの紹介" maintainerName: "管理者はんの名前" maintainerEmail: "管理者はんのメールアドレス" -tosUrl: "利用規約のURL" +tosUrl: "使うための決め事のURL" thisYear: "今年" thisMonth: "今月" today: "今日" @@ -460,7 +462,7 @@ uiLanguage: "UIの表示言語" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" -disableDrawer: "メニューをドロワーで表示せぇへん" +disableDrawer: "メニューをドロワーで表示せえへん" showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで" noHistory: "履歴はないわ。" signinHistory: "ログイン履歴" @@ -474,6 +476,8 @@ createAccount: "アカウントを作るで" existingAccount: "前に作ったアカウント" regenerate: "もっぺん生成するで" fontSize: "字の大きさ" +mediaListWithOneImageAppearance: "画像が1枚のみのメディアリストの高さ" +limitTo: "{x}をいっぱいに" noFollowRequests: "フォロー申請はあらへんで" openImageInNewTab: "画像を新しいタブで開くで" dashboard: "ダッシュボード" @@ -548,7 +552,7 @@ removeAllFollowingDescription: "{host}からのフォローをすべて解除す userSuspended: "このユーザーは...凍結されとる。" userSilenced: "このユーザーは...サイレンスされとる。" yourAccountSuspendedTitle: "あんたのアカウント凍結されとるで" -yourAccountSuspendedDescription: "あんたのアカウントは、サーバーの利用規約に違反したとかの理由で、凍結されとるで。細かいことは管理者までお問い合わせたってなー。絶対に新しいアカウント作ったらあかんで。絶対やで。" +yourAccountSuspendedDescription: "あんたのアカウントは、サーバーの使うための決め事に違反したとかの理由で、凍結されとるで。細かいことは管理者までお問い合わせたってなー。絶対に新しいアカウント作ったらあかんで。絶対やで。" tokenRevoked: "トークンが無効やで" tokenRevokedDescription: "ログイントークンが失効しとるで。もっかいログインしてもろてもええか?" accountDeleted: "アカウントは削除されとるで" @@ -556,6 +560,7 @@ accountDeletedDescription: "このアカウントは削除されとるで。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" +rearrange: "並び替え" relays: "リレー" addRelay: "リレーの追加" inboxUrl: "inboxのURL" @@ -582,7 +587,7 @@ preferencesBackups: "設定のバックアップ" deck: "デッキ" undeck: "デッキ解除" useBlurEffectForModal: "モーダルにぼかし効果を使用" -useFullReactionPicker: "フル機能にリアクションピッカーを使用" +useFullReactionPicker: "フル機能の突っ込みピッカーを使用" width: "幅" height: "高さ" large: "大" @@ -675,8 +680,8 @@ repliedCount: "返信された数やで" renotedCount: "Renoteされた数やで" followingCount: "フォロー数やで" followersCount: "フォロワー数やで" -sentReactionsCount: "リアクションした数やで" -receivedReactionsCount: "リアクションされた数" +sentReactionsCount: "ツッコんだ数" +receivedReactionsCount: "ツッコまれた数" pollVotesCount: "アンケートに投票した数" pollVotedCount: "アンケートに投票された数" yes: "ええで" @@ -699,6 +704,8 @@ contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使うで" clips: "クリップ" experimentalFeatures: "おためし機能やで" +experimental: "実験的" +thisIsExperimentalFeature: "これは実験的な機能やで。仕様が変更になったりちゃんと動かなかったりするかもやで。" developer: "開発者やで" makeExplorable: "アカウントを見つけやすくするで" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。" @@ -745,7 +752,7 @@ receiveAnnouncementFromInstance: "サーバーからのお知らせを受け取 emailNotification: "メール通知" publish: "公開" inChannelSearch: "チャンネル内検索" -useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開くようにする" +useReactionPickerForContextMenu: "右クリックでツッコミピッカーを開くようにする" typingUsers: "{users}が今書きよるで" jumpToSpecifiedDate: "特定の日付にジャンプ" showingPastTimeline: "過去のタイムラインを表示してるで" @@ -830,8 +837,8 @@ unread: "未読" filter: "フィルタ" controlPanel: "コントロールパネル" manageAccounts: "アカウントを管理" -makeReactionsPublic: "リアクション一覧を公開するで" -makeReactionsPublicDescription: "あんたがしたリアクション一覧を誰でも見れるようにするで。" +makeReactionsPublic: "ツッコミ一覧を公開するで" +makeReactionsPublicDescription: "あんたがしたツッコミ一覧を誰でも見れるようにするで。" classic: "クラシック" muteThread: "スレッドをミュート" unmuteThread: "スレッドのミュートを解除" @@ -905,6 +912,7 @@ remoteOnly: "リモートのみ" failedToUpload: "アップロードに失敗してもうたわ…" cannotUploadBecauseInappropriate: "不適切な内容を含むかもしれへんって判定されたからアップロードできへんわ。" cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いからアップロードできへんわ。" +cannotUploadBecauseExceedsFileSizeLimit: "ファイルが思うたよりも大きいさかいアップロードできへんでこれ。" beta: "ベータ" enableAutoSensitive: "自動NSFW判定" enableAutoSensitiveDescription: "使える時は、機械学習を使って自動でメディアにNSFWフラグを設定するで。この機能をオフにしても、サーバーによっては自動で設定されることがあるで。" @@ -938,6 +946,7 @@ didYouLikeMisskey: "Misskey気に入ってくれた?" pleaseDonate: "Misskeyは{host}が使用している無料のソフトウェアやで。これからも開発を続けれるように、寄付したってな~。" roles: "ロール" role: "ロール" +noRole: "ロールはありまへん" normalUser: "一般ユーザー" undefined: "未定義" assign: "アサイン" @@ -947,6 +956,10 @@ manageCustomEmojis: "カスタム絵文字の管理" youCannotCreateAnymore: "これ以上作れなさそうやわ" cannotPerformTemporary: "一時的に利用できへんで" cannotPerformTemporaryDescription: "操作回数が制限を超えたから一時的に利用できへんくなったで。ちょっと時間置いてからもう一回やってやー。" +invalidParamError: "パラメータがエラー言うとりますわ" +invalidParamErrorDescription: "リクエストパラメータに問題があんねん。普通はバグやねんけど、もしかすると入力した文字数が多すぎるとかの可能性もあるから確認してや〜" +permissionDeniedError: "操作が拒否されてもうた。" +permissionDeniedErrorDescription: "自分のアカウントにはこの操作を行う権限があらへんねん" preset: "プリセット" selectFromPresets: "プリセットから選ぶ" achievements: "実績" @@ -970,7 +983,7 @@ invitationRequiredToRegister: "今このサーバー招待制になってもう emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ" postToTheChannel: "チャンネルに投稿" cannotBeChangedLater: "後からは変えられへんで。" -reactionAcceptance: "リアクションの受け入れ" +reactionAcceptance: "ツッコミの受け入れ" likeOnly: "いいねだけ" likeOnlyForRemote: "リモートからはいいねだけな" rolesAssignedToMe: "自分に割り当てられたロール" @@ -988,19 +1001,71 @@ retryAllQueuesConfirmText: "一時的にサーバー重なるかもしれへん enableChartsForRemoteUser: "リモートユーザーのチャートを作る" enableChartsForFederatedInstances: "リモートサーバーのチャートを作る" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" -largeNoteReactions: "ノートのリアクションを大きする" +largeNoteReactions: "ノートのツッコミを大きする" noteIdOrUrl: "ノートIDかURL" +video: "動画" +videos: "動画" +dataSaver: "データケチケチ" accountMigration: "アカウントのお引っ越し" accountMoved: "このユーザーはさらのアカウントに引っ越したで:" +accountMovedShort: "このアカウントは移行されとるで" +operationForbidden: "この操作はできまへん" forceShowAds: "常に広告を表示しとく" +addMemo: "メモを足す" +editMemo: "メモをいらう" +reactionsList: "ツッコミ一覧" +renotesList: "Renote一覧" +notificationDisplay: "通知見せる" +leftTop: "左上" +rightTop: "右上" +leftBottom: "左下" +rightBottom: "右下" +stackAxis: "重ねる方向" +vertical: "縦" +horizontal: "横" +position: "位置" +serverRules: "サーバールール" +pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、下に書いてること確認してな。" +pleaseAgreeAllToContinue: "続けるんやったら、全ての「せやな」にチェック入れてる必要があるで。" +continue: "続けるで" +preservedUsernames: "予約ユーザー名" +preservedUsernamesDescription: "予約しとくユーザー名を行ごとに挙げるで。ここで指定されたユーザー名はアカウント作るときに使えへんくなるけど、管理者は例外や。あと、もうあるアカウントも例外やな。" +createNoteFromTheFile: "このファイル使うてノート作るで" +archive: "アーカイブ" +channelArchiveConfirmTitle: "{name}をアーカイブしてええか?" +channelArchiveConfirmDescription: "アーカイブしたら、チャンネル一覧とか検索結果からなくなるし、新しく書き込みもできへんなるで。" +thisChannelArchived: "このチャンネル、アーカイブされとるで。" +displayOfNote: "ノートの表示" +initialAccountSetting: "初期設定" +youFollowing: "フォロー中やで" +_initialAccountSetting: + accountCreated: "アカウント作り終わったで。" + letsFillYourProfile: "最初はあんたのプロフィールを設定しよか。" + profileSetting: "プロフィール設定" + theseSettingsCanEditLater: "この設定はあとから変えれるで。" + youCanEditMoreSettingsInSettingsPageLater: "これ以外にもいろんな設定を「設定」ページからできるで。後で確認してみてな。" + followUsers: "タイムラインを構築するために、気になるユーザーをフォローしてみ。" + pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をあんたのデバイスで受け取れるで。" + initialAccountSettingCompleted: "初期設定が終わったで。" + haveFun: "{name}、楽しんでな~" + ifYouNeedLearnMore: "{name}(Misskey)の使い方とかをよー知りたいんやったら{link}をみてな。" + skipAreYouSure: "初期設定飛ばすか?" +_serverRules: + description: "新規登録前に見せる、サーバーの簡潔なルールを設定すんで。内容は使うための決め事の要約とすることを推奨するわ。" _accountMigration: - moveTo: "このアカウントをさらのアカウントに引っ越すで" - moveToLabel: "引っ越し先のアカウント:" - moveAccountDescription: "この操作は戻されへんで。まず引っ越し先のアカウントでこのアカウントへのエイリアスが作れたか確認してきなはれや。エイリアスができてたら、引っ越し先のアカウントをこんな風に入力してくれへんか?:@person@instance.com" moveFrom: "別のアカウントからこのアカウントに引っ越す" + moveFromSub: "別のアカウントへエイリアスを作る" moveFromLabel: "引っ越し元のアカウント:" moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したかったら、ここでエイリアスを作っとく必要があるで。必ずお引っ越しを実行する前に作っとかなあかんで!引っ越し元のアカウントをこんな風に入力してくれへんか?:@person@instance.com" + moveTo: "このアカウントをさらのアカウントに引っ越すで" + moveToLabel: "引っ越し先のアカウント:" + moveCannotBeUndone: "アカウントを移行すると、取り消すことはできへんくなります。" + moveAccountDescription: "この操作は戻されへんで。まず引っ越し先のアカウントでこのアカウントへのエイリアスが作れたか確認してきなはれや。エイリアスができてたら、引っ越し先のアカウントをこんな風に入力してくれへんか?:@person@instance.com" + moveAccountHowTo: "アカウントの引っ越しには、まず引っ越し先のアカウントで自分のアカウントに対しエイリアスを作成しなはれや。\nエイリアス作成した後、引っ越し先のアカウントを次のように入力してくれへんか?:@username@server.example.com" + startMigration: "引っ越しする" migrationConfirm: "ほんまにこのアカウントを {account} に引っ越すんか?一回引っ越してもうたら取り消されへんし、二度とこのアカウントを元に戻されへんくなるで。\nそれと、引っ越し先のアカウントでエイリアスが作れたかちゃ~んと確認しーや?" + movedAndCannotBeUndone: "\nアカウントはもう引っ越されてます。\n引っ越しを取り消すことはできまへん。" + movedTo: "引っ越し先のアカウント:" _achievements: earnedAt: "貰った日ぃ" _types: @@ -1172,6 +1237,9 @@ _achievements: _client30min: title: "ねんね" description: "クライアントを起動してから30分以上経過した" + _client60min: + title: "Misskeyの見過ぎや!" + description: "クライアント付けてから1時間経ってもうたで。" _noteDeletedWithin1min: title: "*おおっと*" description: "投稿してから1分以内にその投稿を消した" @@ -1203,7 +1271,7 @@ _achievements: description: "ドライブのフォルダを再帰的な入れ子にしようとした" _reactWithoutRead: title: "ちゃんと読んだんか?" - description: "100文字以上のテキストを含むノートに投稿されてから3秒以内にリアクションした" + description: "100文字以上のテキストを含むノートに投稿されてから3秒以内にツッコんだ" _clickedClickHere: title: "ここをクリック" description: "ここをクリックした" @@ -1260,6 +1328,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして見せる" descriptionOfAsBadge: "オンにすると、ユーザー名の横んとこにロールのアイコンが表示されるで。" + isExplorable: "ロールタイムラインを公開するで〜" + descriptionOfIsExplorable: "オンにしたらロールのタイムラインを公開するで〜。でもロールの公開をオフにしたら公開されへんよ。" displayOrder: "表示順" descriptionOfDisplayOrder: "数がでかいほど、UI上で先に表示されるで。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" @@ -1276,6 +1346,7 @@ _role: canInvite: "サーバー招待コードの発行" canManageCustomEmojis: "カスタム絵文字の管理" driveCapacity: "ドライブ容量" + alwaysMarkNsfw: "勝手にファイルにNSFWをくっつける" pinMax: "ノートのピン留めの最大数" antennaMax: "アンテナの作成可能数" wordMuteMax: "ワードミュートの最大文字数" @@ -1527,32 +1598,16 @@ _time: minute: "分" hour: "時間" day: "日" -_tutorial: - title: "Misskeyの使い方" - step1_1: "よう来たなあ" - step1_2: "この画面は「タイムライン」って言って、あんたや、あんたが「フォロー」する人の「ノート」が時系列で表示されるんやで。" - step1_3: "あんたはまだ何もノートを投稿してなくて、誰もフォローしてへんから、タイムラインには何も表示されてないはずやで。" - step2_1: "ノートを作ったり誰かをフォローしたりする前に、まずあんたのプロフィールを完成させよか。" - step2_2: "あんたがどんな人かわかると、多くの人にノートを見てもらえたり、フォローしてもらいやすくなるで。" - step3_1: "プロフィール設定はええ感じにできたか?" - step3_2: "ほな試しに、何かノートを投稿してみてやー。画面上にある鉛筆マークのボタンを押すとフォームが開くはずやで。" - step3_3: "内容を書いたら、フォーム右上のボタンを押すと投稿できるで。" - step3_4: "内容が思いつかへん?ほな「関西人なら面白いこと言うてえ〜や〜」とかどうやろか。" - step4_1: "投稿できたん?" - step4_2: "あんたのノートがタイムラインに表示されていれば成功やで" - step5_1: "次は、ほかの人をフォローしてタイムラインを賑やかにしよか" - step5_2: "{featured}で人気のノートが見れるから、その中から気になった人を選んでフォローしたり、{explore}で人気のユーザーを探すこともできるで。" - step5_3: "ユーザーをフォローしたかったら、ユーザーのアイコンをクリックしてユーザーページを表示して、「フォロー」ボタンを押すんやで。" - step5_4: "ユーザーによっては、フォローが承認されるまでちょっと時間がかかることがあるで。" - step6_1: "タイムラインに他のユーザーのノートが表示されていれば成功やで。" - step6_2: "他の人のノートには、「リアクション」を付けることができて、簡単にあんたの反応を伝えられるで。" - step6_3: "リアクションを付けるんやったら、ノートの「+」マークをクリックして、好きなリアクションを選択してな。" - step7_1: "これで、Misskeyの基本的な使い方の説明は終わりやで。お疲れさん。" - step7_2: "もっとMisskeyについて知りたいときは、{help}を見るとええかもな。" - step7_3: "ほな、Misskeyを楽しんでなー🚀" - step8_1: "最後に、プッシュ通知を有効化してみやん?" - step8_2: "プッシュ通知を受け取ることで、Misskeyを開いていない時でもリアクションやフォロー、メンションとかに気づけるで。" - step8_3: "通知の設定はあとから変更できるで" +_timelineTutorial: + title: "Misskeyってなんや?" + step1_1: "これは「タイムライン」や。{name}に投稿された「ノート」が順番に表示されるで。" + step1_2: "タイムラインには何個か種類があってな、例えば「ホームタイムライン」だったらあんたのフォローしてる人のノート、「ローカルタイムライン」には{name}全部のノートが流れてくるで。" + step2_1: "試しに、何かノートを投稿してみ。画面の鉛筆マークのボタンでフォームが開くで。" + step2_2: "最初のノートは、自己紹介とか「{name}始めてみたんや」とかがええと思うで。" + step3_1: "投稿できた?" + step3_2: "あんたのノートがタイムラインに出てきたら成功や。" + step4_1: "ノートには、「リアクション」を付けれるで。" + step4_2: "ツッコむんやったら、ノートの「+」マークを押して、好きな絵文字を選ぶで。" _2fa: alreadyRegistered: "もう設定終わっとるわ。" registerTOTP: "認証アプリの設定はじめる" @@ -1596,8 +1651,8 @@ _permissions: "write:notes": "ノートを作成・削除するで" "read:notifications": "通知を見るで" "write:notifications": "通知を操作するで" - "read:reactions": "リアクションを見る" - "write:reactions": "リアクションを操作するで" + "read:reactions": "ツッコミを見る" + "write:reactions": "ツッコミを操作するで" "write:votes": "投票する" "read:pages": "ページを見る" "write:pages": "ページを操作するで" @@ -1853,7 +1908,7 @@ _notification: reply: "リプライ" renote: "Renote" quote: "引用" - reaction: "リアクション" + reaction: "ツッコミ" pollEnded: "アンケートが終了したで" receiveFollowRequest: "フォロー許可してほしいみたいやで" followRequestAccepted: "フォローが受理されたで" @@ -1912,5 +1967,5 @@ _webhookSettings: note: "ノートを投稿したとき~!" reply: "返信があるとき~!" renote: "Renoteされるとき~!" - reaction: "リアクションがあるとき~!" + reaction: "ツッコミがあるとき~!" mention: "メンションがあるとき~!" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 3ff00482d6..ef690d260b 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -2,7 +2,7 @@ _lang_: "한국어" headlineMisskey: "노트로 연결되는 네트워크" introMisskey: "환영합니다! Misskey는 오픈 소스 분산형 마이크로 블로그 서비스입니다.\n'노트'를 작성해서 지금 일어나고 있는 일을 공유하거나, 당신만의 이야기를 모두에게 발신하세요📡\n'리액션' 기능으로 친구의 노트에 총알같이 반응을 추가할 수도 있습니다👍\n새로운 세계를 탐험해 보세요🚀" -poweredByMisskeyDescription: "{name}은(는) 오픈소스 플랫폼Misskey를 사용한 서버 중 하나입니다." +poweredByMisskeyDescription: "{name}은(는) 오픈소스 플랫폼 Misskey를 사용한 서버 가운데 하나입니다." monthAndDay: "{month}월 {day}일" search: "검색" notifications: "알림" @@ -20,6 +20,7 @@ noNotes: "노트가 없습니다" noNotifications: "표시할 알림이 없습니다" instance: "서버" settings: "설정" +notificationSettings: "알림 설정" basicSettings: "기본 설정" otherSettings: "기타 설정" openInWindow: "창으로 열기" @@ -262,14 +263,16 @@ noMoreHistory: "이것보다 과거의 기록이 없습니다" startMessaging: "대화 시작하기" nUsersRead: "{n}명이 읽음" agreeTo: "{0}에 동의" +agree: "동의합니다" agreeBelow: "아래 내용에 동의합니다" basicNotesBeforeCreateAccount: "기본적인 주의사항" -tos: "이용 약관" +termsOfService: "이용 약관" start: "시작하기" home: "홈" remoteUserCaution: "리모트 유저이기 때문에, 정보가 정확하지 않을 수 있습니다." activity: "활동" images: "이미지" +image: "이미지" birthday: "생일" yearsOld: "{age}세" registeredDate: "등록일" @@ -473,6 +476,8 @@ createAccount: "계정 만들기" existingAccount: "기존 계정" regenerate: "재생성" fontSize: "글자 크기" +mediaListWithOneImageAppearance: "이미지가 1개 뿐인 미디어 목록의 높이" +limitTo: "{x}로 제한" noFollowRequests: "처리되지 않은 팔로우 요청이 없습니다" openImageInNewTab: "새 탭에서 이미지 열기" dashboard: "대시보드" @@ -506,6 +511,7 @@ objectStorageUseSSLDesc: "API 호출시 HTTPS 를 사용하지 않는 경우 OFF objectStorageUseProxy: "연결에 프록시를 사용" objectStorageUseProxyDesc: "오브젝트 스토리지 API 호출시 프록시를 사용하지 않는 경우 OFF 로 설정해 주세요" objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" +s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급합니다. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해야 할 수 있습니다." serverLogs: "서버 로그" deleteAll: "모두 삭제" showFixedPostForm: "타임라인 상단에 글 작성란을 표시" @@ -547,11 +553,14 @@ userSuspended: "이 계정은 정지된 상태입니다." userSilenced: "이 계정은 사일런스된 상태입니다." yourAccountSuspendedTitle: "계정이 정지되었습니다" yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반하거나, 기타 다른 이유로 인해 정지되었습니다. 자세한 사항은 관리자에게 문의해 주십시오. 계정을 새로 생성하지 마십시오." +tokenRevoked: "유효하지 않은 토큰입니다" +tokenRevokedDescription: "로그인 토큰이 비활성화되었습니다. 다시 로그인하여 주십시오." accountDeleted: "계정이 정지되었습니다" accountDeletedDescription: "이 계정이 삭제되었습니다." menu: "메뉴" divider: "구분선" addItem: "항목 추가" +rearrange: "정렬" relays: "릴레이" addRelay: "릴레이 추가" inboxUrl: "Inbox 주소" @@ -695,6 +704,8 @@ contact: "연락처" useSystemFont: "시스템 기본 글꼴을 사용" clips: "클립" experimentalFeatures: "실험실" +experimental: "실험실" +thisIsExperimentalFeature: "이 기능은 실험적인 기능입니다. 사양이 변경되거나 정상적으로 동작하지 않을 가능성이 있습니다." developer: "개발자" makeExplorable: "\"발견하기\"에 내 계정 보이기" makeExplorableDescription: "비활성화하면 \"발견하기\"에 나의 계정을 표시하지 않습니다." @@ -901,6 +912,7 @@ remoteOnly: "리모트만" failedToUpload: "업로드 실패" cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없습니다." cannotUploadBecauseNoFreeSpace: "드라이브 용량이 부족하여 업로드할 수 없습니다." +cannotUploadBecauseExceedsFileSizeLimit: "파일 크기가 너무 크기 때문에 업로드할 수 없습니다." beta: "베타" enableAutoSensitive: "자동 NSFW 탐지" enableAutoSensitiveDescription: "이용 가능할 경우 기계학습을 통해 자동으로 미디어 NSFW를 설정합니다. 이 기능을 해제하더라도, 서버 정책에 따라 자동으로 설정될 수 있습니다." @@ -917,6 +929,7 @@ pushNotificationNotSupported: "브라우저나 서버에서 푸시 알림이 지 sendPushNotificationReadMessage: "푸시 알림이나 메시지를 읽은 뒤 푸시 알림을 삭제" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」이라는 알림이 잠깐 표시됩니다. 기기의 전력 소비량이 증가할 수 있습니다." windowMaximize: "최대화" +windowMinimize: "최소화" windowRestore: "복구" caption: "캡션" loggedInAsBot: "봇 계정으로 로그인중" @@ -933,6 +946,7 @@ didYouLikeMisskey: "Misskey가 마음에 드시나요?" pleaseDonate: "{host}은(는) 무료 소프트웨어 Misskey를 사용합니다. 후원을 통해 저희의 개발이 이어질 수 있게 도와주세요!" roles: "역할" role: "역할" +noRole: "역할이 없습니다" normalUser: "일반 사용자" undefined: "정의되지 않음" assign: "할당" @@ -942,6 +956,10 @@ manageCustomEmojis: "커스텀 이모지 관리" youCannotCreateAnymore: "더 이상 생성할 수 없습니다." cannotPerformTemporary: "일시적으로 사용할 수 없음" cannotPerformTemporaryDescription: "조작 횟수 제한을 초과하여 일시적으로 사용이 불가합니다. 잠시 후 다시 시도해 주세요." +invalidParamError: "매개변수 오류" +invalidParamErrorDescription: "요청 매개변수에 문제가 있습니다. 대부분의 경우 Misskey의 버그가 원인이지만, 입력 문자수가 너무 많았을 가능성 등도 있습니다." +permissionDeniedError: "작업이 거부되었습니다" +permissionDeniedErrorDescription: "이 작업을 수행할 권한이 없습니다." preset: "프리셋" selectFromPresets: "프리셋에서 선택" achievements: "도전 과제" @@ -958,10 +976,98 @@ copyErrorInfo: "오류 정보 복사" joinThisServer: "이 서버에 가입" exploreOtherServers: "다른 서버 둘러보기" letsLookAtTimeline: "타임라인 구경하기" +disableFederationConfirm: "정말로 연합을 끄시겠습니까?" +disableFederationConfirmWarn: "연합을 끄더라도 게시물이 비공개로 전환되는 것은 아닙니다. 대부분의 경우 연합을 비활성화할 필요가 없습니다." +disableFederationOk: "연합을 끄기" invitationRequiredToRegister: "현재 이 서버는 비공개입니다. 회원가입을 하시려면 초대 코드가 필요합니다." emailNotSupported: "이 서버에서는 메일 전송을 지원하지 않습니다" postToTheChannel: "채널에 게시하기" cannotBeChangedLater: "나중에 변경할 수 없습니다." +reactionAcceptance: "리액션 수신" +likeOnly: "좋아요만 받기" +likeOnlyForRemote: "리모트에서는 좋아요만 받기" +rolesAssignedToMe: "나에게 할당된 역할" +resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?" +sensitiveWords: "민감한 단어" +sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다." +notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다." +license: "라이선스" +unfavoriteConfirm: "즐겨찾기를 해제하시겠습니까?" +myClips: "내 클립" +drivecleaner: "드라이브 정리" +retryAllQueuesNow: "모든 큐를 다시 시도" +retryAllQueuesConfirmTitle: "지금 다시 시도하시겠습니까?" +retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다." +enableChartsForRemoteUser: "리모트 유저의 차트를 생성" +enableChartsForFederatedInstances: "리모트 서버의 차트를 생성" +showClipButtonInNoteFooter: "노트 동작에 클립을 추가" +largeNoteReactions: "노트의 리액션을 크게 표시" +noteIdOrUrl: "노트 ID 및 URL" +video: "동영상" +videos: "동영상" +dataSaver: "데이터 절약 모드" +accountMigration: "계정 이동" +accountMoved: "이 사용자는 다음 계정으로 이사했습니다:" +accountMovedShort: "이사한 계정입니다" +operationForbidden: "사용할 수 없습니다" +forceShowAds: "광고를 항상 표시" +addMemo: "메모 추가" +editMemo: "메모 편집" +reactionsList: "리액션 목록" +renotesList: "Renote 목록" +notificationDisplay: "알림 표시" +leftTop: "왼쪽 상단" +rightTop: "오른쪽 상단" +leftBottom: "왼쪽 하단" +rightBottom: "오른쪽 하단" +stackAxis: "나열 방향" +vertical: "세로" +horizontal: "가로" +position: "위치" +serverRules: "서버 규칙" +pleaseConfirmBelowBeforeSignup: "이 서버에 가입하기 전에 아래 사항을 확인하여 주십시오." +pleaseAgreeAllToContinue: "계속하시려면 모든 항목에 동의하십시오." +continue: "계속" +preservedUsernames: "예약된 사용자명" +preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." +createNoteFromTheFile: "이 파일로 노트를 작성" +archive: "아카이브" +channelArchiveConfirmTitle: "{name} 을(를) 아카이브하시겠습니까?" +channelArchiveConfirmDescription: "아카이브한 채널은 채널 목록과 검색 결과에 표시되지 않으며, 채널에 새로운 노트를 작성할 수 없게 됩니다." +thisChannelArchived: "이 채널은 아카이브되었습니다." +displayOfNote: "노트 표시" +initialAccountSetting: "초기 설정" +youFollowing: "팔로잉" +_initialAccountSetting: + accountCreated: "계정 생성이 완료되었습니다!" + letsStartAccountSetup: "계정의 초기 설정을 진행합니다." + letsFillYourProfile: "우선 나의 프로필을 설정해 보아요." + profileSetting: "프로필 설정" + theseSettingsCanEditLater: "이 설정들은 나중에도 변경할 수 있습니다." + youCanEditMoreSettingsInSettingsPageLater: "이 외에도 '설정' 페이지에서 다양한 설정을 나의 입맛에 맛게 조절할 수 있습니다. 꼭 확인해 보세요!" + followUsers: "관심사가 맞는 유저를 팔로우하여 타임라인을 가꾸어 봅시다." + pushNotificationDescription: "푸시 알림을 활성화하면 {name}의 알림을 나의 기기에서 받아볼 수 있게 됩니다." + initialAccountSettingCompleted: "초기 설정을 모두 마쳤습니다!" + haveFun: "{name}와 함께 즐거운 시간 보내세요!" + ifYouNeedLearnMore: "{name}(Misskey)의 사용 방법에 대해 자세히 알아보려면 {link}를 참고해 주세요." + skipAreYouSure: "초기 설정을 넘기시겠습니까?" +_serverRules: + description: "회원 가입 이전에 간단하게 표시할 서버 규칙입니다. 이용 약관의 요약으로 구성하는 것을 추천합니다." +_accountMigration: + moveFrom: "다른 계정에서 이 계정으로 이사" + moveFromSub: "다른 계정에 대한 별칭을 생성" + moveFromLabel: "기존 계정:" + moveFromDescription: "다른 계정에서 이 계정으로 팔로워를 가져오려면, 우선 여기에서 별칭을 지정해야 합니다. 반드시 이사하기 전에 지정해야 합니다! 기존 계정을 다음과 같은 형식으로 입력해 주십시오: @person@instance.com" + moveTo: "이 계정에서 다른 계정으로 이사" + moveToLabel: "이사할 계정:" + moveCannotBeUndone: "한 번 이사하면, 두 번 다시 되돌릴 수 없습니다." + moveAccountDescription: "이 작업은 취소할 수 없습니다. 먼저 이사할 계정에서 이 계정에 대한 별칭을 지정하였는지 다시 한 번 확인해 주십시오. 별칭을 지정한 다음, 이사할 계정을 다음과 같은 형식으로 입력해 주십시오: @person@instance.com" + moveAccountHowTo: "계정을 이사하려면 우선 이사갈 계정에서 이 계정에 대한 별칭을 지정해야 합니다.\n별칭을 작성한 다음, 이사갈 계정을 다음과 같이 입력하십시오:\n@username@server.example.com" + startMigration: "이사하기" + migrationConfirm: "정말로 이 계정을 {account} 으로 이전하시겠습니까? 한 번 이전한 다음에는 취소할 수 없으며, 두 번 다시 원래 상태로 복구할 수 없습니다.\n이사할 계정에서 계정 별칭을 지정하였는지 다시 한 번 확인하십시오." + movedAndCannotBeUndone: "\n이사한 계정입니다.\n이사는 취소할 수 없습니다." + postMigrationNote: "이 계정의 팔로잉 해제는 이사 후 24시간 뒤에 실행됩니다.\n이 계정의 팔로우 및 팔로워 수는 0으로 표시됩니다. 팔로워 해제는 이루어지지 않으므로, 당신의 팔로워는 이 계정의 팔로워 한정 게시물을 계속해서 열람할 수 있습니다." + movedTo: "이사할 계정:" _achievements: earnedAt: "달성 일시" _types: @@ -1133,6 +1239,8 @@ _achievements: _client30min: title: "잠깐 쉬어" description: "클라이언트를 시작하고 30분이 경과하였습니다" + _client60min: + description: "클라이언트를 시작하고 60분이 경과하였습니다" _noteDeletedWithin1min: title: "있었는데요 없었습니다" description: "노트를 포스트한 후 1분 이내에 삭제했습니다" @@ -1221,6 +1329,10 @@ _role: iconUrl: "아이콘 URL" asBadge: "뱃지로 표시" descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시됩니다." + isExplorable: "역할 타임라인 공개" + descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다." + displayOrder: "표시 순서" + descriptionOfDisplayOrder: "값이 클 수록 UI에서 먼저 표시됩니다." canEditMembersByModerator: "모더레이터의 역할 수정 허용" descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." priority: "우선순위" @@ -1235,6 +1347,7 @@ _role: canInvite: "서버 초대 코드 발행" canManageCustomEmojis: "커스텀 이모지 관리" driveCapacity: "드라이브 용량" + alwaysMarkNsfw: "파일을 항상 NSFW로 지정" pinMax: "고정할 수 있는 노트 수" antennaMax: "최대 안테나 생성 허용 수" wordMuteMax: "단어 뮤트할 수 있는 문자 수" @@ -1246,15 +1359,18 @@ _role: rateLimitFactor: "요청 빈도 제한" descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다." canHideAds: "광고 숨기기" + canSearchNotes: "노트 검색 이용 가능 여부" _condition: isLocal: "로컬 사용자" isRemote: "리모트 사용자" - createdLessThan: "가압한 지 다음 일수 이내인 유저" + createdLessThan: "가입한 지 다음 일수 이내인 유저" createdMoreThan: "가입한 지 다음 일수 이상인 유저" followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" followersMoreThanOrEq: "팔로워 수가 다음 이상인 유저" followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저" followingMoreThanOrEq: "팔로잉 수가 다음 이상인 유저" + notesLessThanOrEq: "노트 수가 다음 이하인 유저" + notesMoreThanOrEq: "노트 수가 다음 이상인 유저" and: "다음을 모두 만족" or: "다음을 하나라도 만족" not: "다음을 만족하지 않음" @@ -1278,8 +1394,8 @@ _ffVisibility: private: "비공개" _signup: almostThere: "거의 다 끝났습니다" - emailAddressInfo: "당신이 사용하고 있는 이메일 주소를 입력해 주세요. 이메일 주소는 다른 유저에게 공개되지 않습니다." - emailSent: "입력하신 메일 주소({email})로 확인 메일을 보내드렸습니다. 가입을 완료하시려면 보내드린 메일에 있는 링크로 접속해 주세요." + emailAddressInfo: "당신이 사용하고 있는 이메일 주소를 입력해 주세요. 이메일 주소는 다른 유저에게 공개되지 않습니다." + emailSent: "입력하신 메일 주소({email})로 확인 메일을 보내드렸습니다. 가입을 완료하시려면 보내드린 메일에 있는 링크로 접속해 주세요." _accountDelete: accountDelete: "계정 삭제" mayTakeTime: "계정 삭제는 서버에 부하를 가하기 때문에, 작성한 콘텐츠나 업로드한 파일의 수가 많으면 완료까지 시간이 걸릴 수 있습니다." @@ -1364,6 +1480,8 @@ _channel: following: "팔로잉" usersCount: "{n}명 참여 중" notesCount: "{n}노트" + nameAndDescription: "이름과 설명" + nameOnly: "이름만" _menuDisplay: sideFull: "가로" sideIcon: "가로(아이콘)" @@ -1481,32 +1599,16 @@ _time: minute: "분" hour: "시간" day: "일" -_tutorial: +_timelineTutorial: title: "Misskey의 사용 방법" - step1_1: "환영합니다!" - step1_2: "이 페이지는 \"타임라인\"이라고 불립니다. 당신이 \"팔로우\"하고 있는 사람들의 \"노트\"가 시간순으로 나타납니다." - step1_3: "아직 아무 유저도 팔로우하고 있지 않기에 타임라인은 비어 있을 것입니다." - step2_1: "새 노트를 작성하거나 다른 사람을 팔로우하기 전에, 먼저 프로필을 완성해보도록 합시다." - step2_2: "당신이 어떤 사람인지를 알린다면, 다른 사람들이 당신을 팔로우할 확률이 올라갈 것입니다." - step3_1: "프로필 설정은 잘 끝내셨나요?" - step3_2: "그럼 시험삼아 노트를 작성해 보세요. 화면에 있는 연필 버튼을 누르면 작성 폼이 열립니다." - step3_3: "내용을 작성한 후, 폼 오른쪽 상단의 버튼을 눌러 노트를 올릴 수 있습니다." - step3_4: "쓸 말이 없나요? \"Misskey 시작했어요!\" 같은 건 어떨까요? :>" - step4_1: "노트 작성을 끝내셨나요?" - step4_2: "당신의 노트가 타임라인에 표시되어 있다면 성공입니다." - step5_1: "이제, 다른 사람을 팔로우하여 타임라인을 활기차게 만들어보도록 합시다." - step5_2: "{featured}에서 이 서버의 인기 노트를 보실 수 있습니다. {explore}에서는 인기 사용자를 찾을 수 있구요. 마음에 드는 사람을 골라 팔로우해 보세요!" - step5_3: "다른 유저를 팔로우하려면 해당 유저의 아이콘을 클릭하여 프로필 페이지를 띄운 후, 팔로우 버튼을 눌러 주세요." - step5_4: "사용자에 따라 팔로우가 승인될 때까지 시간이 걸릴 수 있습니다." - step6_1: "타임라인에 다른 사용자의 노트가 나타난다면 성공입니다." - step6_2: "다른 유저의 노트에 \"리액션\"을 붙여 간단하게 당신의 반응을 전달할 수도 있습니다." - step6_3: "리액션을 붙이려면, 노트의 \"+\" 버튼을 클릭하고 원하는 이모지를 선택합니다." - step7_1: "이것으로 Misskey의 기본 튜토리얼을 마치겠습니다. 수고하셨습니다!" - step7_2: "Misskey에 대해 더 알고 싶으시다면 {help}를 참고해 주세요." - step7_3: "그럼 Misskey를 즐기세요! 🚀" - step8_1: "마지막으로, 푸시 알림을 활성화해 보지 않으실래요?" - step8_2: "푸시 알림을 활성화하면, Misskey를 열지 않았을 때에도 리액션이나 팔로우, 멘션 등을 확인할 수 있습니다." - step8_3: "알림 설정은 나중에도 변경할 수 있습니다." + step1_1: "이것은 '타임라인'입니다. {name}에 게시된 '노트'가 시간 순서대로 표시됩니다." + step1_2: "타임라인은 몇 가지 종류로 나뉩니다. 그 중에 '홈 타임라인'은 내가 팔로우한 사람의 노트가 표시되며, '로컬 타임라인'에는 {name} 의 모든 노트가 표시됩니다." + step2_1: "그럼 시험삼아 노트를 작성해 봅시다. 화면에 있는 연필 버튼을 눌러 보세요." + step2_2: "첫 노트이니까 자기소개, 혹은 가볍게 \"안녕 {name}\"라고 올려 보는 건 어떨까요?" + step3_1: "노트 작성을 끝내셨나요?" + step3_2: "당신의 노트가 타임라인에 표시되어 있다면 성공입니다." + step4_1: "노트에는 '리액션'을 붙일 수 있습니다." + step4_2: "리액션을 붙이려면, 노트의 \"+\" 버튼을 클릭하고 원하는 이모지를 선택합니다." _2fa: alreadyRegistered: "이미 설정이 완료되었습니다." registerTOTP: "인증 앱 설정 시작" @@ -1844,9 +1946,27 @@ _deck: channel: "채널" mentions: "받은 멘션" direct: "다이렉트" + roleTimeline: "역할 타임라인" _dialog: charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {min}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" +_disabledTimeline: + title: "비활성화된 타임라인" + description: "현재 역할에서는 이 타임라인을 이용할 수 없습니다." +_drivecleaner: + orderBySizeDesc: "크기가 큰 순" + orderByCreatedAtAsc: "등록일이 오래된 순" _webhookSettings: + createWebhook: "Webhook 생성" name: "이름" + secret: "시크릿" + events: "Webhook을 실행할 타이밍" active: "활성화" + _events: + follow: "누군가를 팔로우했을 때" + followed: "누군가 나를 팔로우했을 때" + note: "노트를 게시할 때" + reply: "답글을 받았을 때" + renote: "누군가 내 글을 Renote했을 때" + reaction: "누군가 내 노트에 리액션했을 때" + mention: "누군가 나를 멘션했을 때" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 3f97b3982c..f686b04257 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -211,11 +211,11 @@ uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດ" messageRead: "ອ່ານແລ້ວ" startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" nUsersRead: "ອ່ານໂດຍ {n}" -tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" home: "ໜ້າຫຼັກ" activity: "ກິດຈະກຳ" images: "ຮູບພາບ" +image: "ຮູບພາບ" birthday: "ວັນເກີດ" yearsOld: "{age} ປີ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 31c28a66e3..6bca484f14 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -254,12 +254,12 @@ noMoreHistory: "Er is geen verdere geschiedenis" startMessaging: "Start een gesprek" nUsersRead: "gelezen door {n}" agreeTo: "Ik stem in met {0}" -tos: "Gebruiksvoorwaarden" start: "Aan de slag" home: "Startpagina" remoteUserCaution: "Aangezien deze gebruiker van een externe server afkomstig is, kan de weergegeven informatie onvolledig zijn." activity: "Activiteit" images: "Afbeeldingen" +image: "Afbeeldingen" birthday: "Geboortedatum" yearsOld: "{age} jaar" registeredDate: "Inschrijvingsdatum" diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 83e189b9cf..f7f0d442d4 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -1,2 +1,483 @@ --- _lang_: "Norsk Bokmål" +search: "Søk" +notifications: "Varsler" +username: "Brukernavn" +password: "Passord" +forgotPassword: "Glemt passord" +ok: "OK" +gotIt: "Skjønner" +cancel: "Avbryt" +noThankYou: "Avbryt" +noNotifications: "Ingen varsler" +instance: "Server" +settings: "Innstillinger" +notificationSettings: "Varslingsinnstillinger" +otherSettings: "Andre innstillinger" +profile: "Profil" +timeline: "Tidslinje" +login: "Logg inn" +loggingIn: "Logget inn" +logout: "Logg ut" +signup: "Bli med" +uploading: "Laster opp" +save: "Lagre" +users: "Brukere" +addUser: "Legg til bruker" +favorite: "Favoritt" +favorites: "Favoritter" +unfavorite: "Fjern favoritt" +pin: "Fest" +unpin: "Opphev festing" +copyContent: "Kopier innhold" +copyLink: "Kopier lenke" +delete: "Slett" +deleteAndEdit: "Slett og rediger" +addToList: "Legg til i liste" +sendMessage: "Send melding" +copyRSS: "Kopier RSS" +copyUsername: "Kopier brukernavn" +searchUser: "Søk brukere" +reply: "Svar" +loadMore: "Vis mer" +showMore: "Vis mer" +showLess: "Lukk" +files: "Filer" +download: "Nedlastinger" +lists: "Lister" +noLists: "Ingen lister" +following: "Følg" +followers: "Følgere" +followsYou: "Følger deg" +createList: "Opprett liste" +error: "Feil" +retry: "Prøv igjen" +pageLoadError: "Kunne ikke hente side." +privacy: "Personvern" +follow: "Følg" +followRequest: "Følgeforespørsel" +followRequests: "Følgeforespørsel" +unfollow: "Avfølg" +followRequestPending: "Venter på godkjenning" +quote: "Sitat" +pinned: "Fest" +you: "Du" +clickToShow: "Klikk for å vise" +add: "Legg til" +reaction: "Reaksjon" +reactions: "Reaksjoner" +mute: "Skjul" +unmute: "Vis" +block: "Blokker" +unblock: "Opphev blokkering" +blockConfirm: "Blokker?" +selectList: "Velg liste" +selectChannel: "Velg kanal" +emoji: "Emoji" +emojis: "Emojier" +addEmoji: "Legg til emoji" +addAccount: "Legg til konto" +selectUser: "Velg bruker" +instances: "Server" +registeredAt: "Registrerte seg" +perHour: "Per time" +perDay: "Per dag" +blockThisInstance: "Blokker denne serveren" +version: "Versjon" +statistics: "Statistikk" +clearQueue: "Tøm kø" +clearQueueConfirmTitle: "Vil du tømme kø?" +blockedInstances: "Blokkerte severe" +muteAndBlock: "Skjul og blokker" +mutedUsers: "Skjulte brukere" +blockedUsers: "Blokkerte brukere" +editProfile: "Rediger profil" +pinLimitExceeded: "Du kan ikke feste flere." +noCustomEmojis: "Ingen emoji" +blocked: "Blokkert" +all: "Alle" +changePassword: "Endre passord" +security: "Sikkerhet" +newPassword: "Nytt passord" +newPasswordRetype: "Nytt passord (gjenta)" +more: "Mer!" +remove: "Slett" +removed: "Slettet" +saved: "Lagret" +upload: "Laste opp" +explore: "Utforsk" +messageRead: "Lest" +agree: "Jeg godtar" +home: "Hjem" +images: "Bilder" +image: "Bilder" +birthday: "Bursdag" +yearsOld: "{age} år gammel" +light: "Lys" +dark: "Mørk" +fileName: "Filnavn" +selectFile: "Velg fil" +selectFiles: "Velg fil" +selectFolder: "Velg mappe" +selectFolders: "Velg mappe" +renameFile: "Endre filnavn" +folderName: "Mappenavn" +createFolder: "Opprett mappe" +renameFolder: "Endre mappenavn" +deleteFolder: "Slett mappe" +addFile: "Legg til fil" +emptyFolder: "Denne mappen er tom" +copyUrl: "Kopier URL" +rename: "Endre navn" +doNothing: "Gjør ingenting" +accept: "Tillatt" +reject: "Avslå" +instanceName: "Servernavn" +thisYear: "I år" +today: "I dag" +pages: "Sider" +pinnedUsers: "Festete brukrere" +pinnedPages: "Festete sider" +hcaptcha: "hCaptcha" +recaptcha: "reCAPTCHA" +name: "Navn" +popularUsers: "Populære brukere" +exploreUsersCount: "Det finnes {count} brukere" +userList: "Lister" +about: "Infomasjon" +aboutMisskey: "Om Misskey" +share: "Del" +help: "Hjelp" +close: "Lukk" +members: "Medlemmer" +text: "Tekst" +next: "Neste" +retype: "Gjenta" +available: "Tilgjengelig" +unavailable: "Utilgjengelig" +tooShort: "For kort" +tooLong: "For langt" +or: "eller" +language: "Språk" +aboutX: "Om {x}" +category: "Kategorier" +createAccount: "Opprett konto" +objectStorageRegion: "Region" +objectStorageUseSSL: "Bruk SSL" +objectStorageUseProxy: "Bruk Proxy" +deleteAll: "Slett alt" +listen: "Lytt" +none: "Ingen" +chooseEmoji: "Velg emoji" +recentUsed: "Sist brukte" +install: "Installer" +nothing: "Ingenting" +deleteAllFiles: "Slett alle filer" +deleteAllFilesConfirm: "Vil du slette alle filer?" +accountDeleted: "Kontoen blir slettet" +accountDeletedDescription: "Denne kontoen blir slettet" +menu: "Meny" +poll: "Avstemning" +description: "Beskrivelse" +author: "Forfatter" +height: "Høyde" +large: "Stor" +small: "Liten" +notificationType: "Varseltype" +edit: "Rediger" +email: "E-post" +smtpUser: "Brukernavn" +smtpPass: "Passord" +userSaysSomething: "{name} sa noe" +copy: "Kopier" +channel: "Kanaler" +create: "Opprett" +notificationSetting: "Varslingsinnstillinger" +other: "Andre" +behavior: "Oppførsel" +sample: "Eksempel" +abuseReports: "Rappoter" +reportAbuse: "Rappoter" +send: "Send" +openInNewTab: "Åpne i ny fane" +waitingFor: "Venter på {x}" +system: "System" +followingCount: "Følger" +followersCount: "Følgere" +yes: "Ja" +no: "Nei" +left: "Venstre" +saveAs: "Lagre som" +value: "Verdi" +deleteConfirm: "Vil du slette?" +invalidValue: "Verdien er ugyldig." +emailNotification: "E-postvarsler" +inChannelSearch: "Søk i kanal" +clear: "Tøm" +markAllAsRead: "Merk alt som lest" +addDescription: "Legg til beskrivelse" +info: "Infomasjon" +unknown: "Ukjent" +selectAccount: "Velg konto" +user: "Brukere" +accounts: "Kontoer" +switch: "Bytt" +gallery: "Galleri" +ads: "Annonser" +high: "Høy" +low: "Lav" +sent: "Send" +learnMore: "Les mer" +translate: "Oversett" +unread: "Ulest" +manageAccounts: "Administrer konto" +classic: "Klassisk" +muteThread: "Skjul denne tråden" +unmuteThread: "Vis denne tråden" +tablet: "Nettbrett" +auto: "Automatisk" +size: "Størrelse" +searchByGoogle: "Søk" +tenMinutes: "10 minutter" +oneHour: "1 time" +oneDay: "1 dag" +oneWeek: "1 uke" +oneMonth: "1 måned" +file: "Filer" +recommended: "Anbefalt" +check: "Sjekk" +deleteAccount: "Slett konto" +document: "Dokumenter" +logoutConfirm: "Vil du logge ut?" +pleaseSelect: "Vennligst velg" +type: "Type" +beta: "Beta" +account: "Kontoer" +move: "Flytt" +pushNotification: "Push-varsler" +tools: "Verktøy" +like: "Liker!" +unlike: "Liker ikke" +numberOfLikes: "Likerklikk" +show: "Vis" +neverShow: "Ikke vis igjen" +remindMeLater: "Kanskje senere" +didYouLikeMisskey: "Likte du Misskey?" +roles: "Roller" +role: "Rolle" +color: "Farge" +youCannotCreateAnymore: "Du kan ikke opprette flere." +cannotPerformTemporary: "Midlertidig utilgjengelig" +thisPostMayBeAnnoyingCancel: "Avbryt" +exploreOtherServers: "Utforsk andre severe" +letsLookAtTimeline: "La oss se på tidslinje" +cannotBeChangedLater: "Du kan ikke endre senere." +likeOnly: "Bare liker" +retryAllQueuesConfirmTitle: "Vil du prøve igjen akkurat nå?" +video: "Video" +videos: "Videoer" +continue: "Fortsett" +youFollowing: "Følger" +_initialAccountSetting: + theseSettingsCanEditLater: "Du kan endre disse innstillingene senere." +_achievements: + _types: + _notes100000: + flavor: "Du har jammen mye å si." + _noteFavorited1: + title: "Stjernekikker" + _myNoteFavorited1: + title: "Jeg vil gjerne få en stjerne" + _following50: + title: "Mange venner" + _following100: + title: "100 venner" + _following300: + title: "For mange venner" + _followers10: + title: "Følg meg!" + _followers100: + title: "Populær" + _postedAtLateNight: + flavor: "Det er på tide å gå til sengs." + _driveFolderCircularReference: + title: "Rundskrivreferanse" + _reactWithoutRead: + title: "Leste du det virkelig?" + _clickedClickHere: + title: "Klikk her" + description: "Du har klikket her" + _justPlainLucky: + title: "Rett og slett heldig" + _setNameToSyuilo: + description: "Du har satt navnet ditt til \"syuilo\"" + _loggedInOnBirthday: + title: "Gratulerer med dagen" + _loggedInOnNewYearsDay: + title: "Godt nytt år" + _brainDiver: + title: "Brain Diver" + flavor: "Misskey-Misskey La-Tu-Ma" +_role: + options: "Alternativ" + _priority: + low: "Lav" + high: "Høy" +_emailUnavailable: + used: "Allerede brukt" +_accountDelete: + accountDelete: "Slett konto" +_ad: + hide: "Ikke vis" +_gallery: + like: "Liker!" + unlike: "Liker ikke" +_preferencesBackups: + saveNew: "Lagre som ny" + cannotSave: "Kunne ikke lagre" +_registry: + key: "Nøkkel" + keys: "Nøkler" +_aboutMisskey: + about: "Misskey er programvare med åpen kildekode som har blitt utviklet av syuilo siden 2014." + translation: "Oversett Misskey" +_instanceTicker: + none: "Ikke vis" + always: "Alltid vis" +_channel: + create: "Opprett kanal" + edit: "Rediger kanal" + featured: "Populært" + following: "Følger" + nameAndDescription: "Navn og beskrivelse" +_wordMute: + soft: "Myk" + hard: "Hard" +_theme: + description: "Beskrivelse" + color: "Farge" + key: "Nøkkel" + keys: + link: "Lenke" +_sfx: + notification: "Varsler" +_ago: + future: "Fremitid" + justNow: "Akkurat nå" + secondsAgo: "{n} sekunder siden" + minutesAgo: "{n} minutter siden" + hoursAgo: "{n} timer siden" + daysAgo: "{n} dager siden" + weeksAgo: "{n} uker siden" + monthsAgo: "{n} måneder siden" + yearsAgo: "{n} år siden" + invalid: "Ingenting" +_time: + second: "Sekunder" + minute: "Minutter" + hour: "Timer" + day: "Dager" +_timelineTutorial: + title: "Hvordan bruke Misskey" +_2fa: + renewTOTPCancel: "Avbryt" +_weekday: + sunday: "Søndag" + monday: "Mandag" + tuesday: "Tirsdag" + wednesday: "Onsdag" + thursday: "Torsdag" + friday: "Fredag" + saturday: "Lørdag" +_widgets: + profile: "Profil" + notifications: "Varsler" + timeline: "Tidslinje" + calendar: "Kalender" + trends: "Populært" + clock: "Klokke" + photos: "Bilder" + button: "Knapp" + aiscriptApp: "AiScript App" + userList: "Brukerliste" + _userList: + chooseList: "Velg liste" +_cw: + show: "Vis mer" +_poll: + noOnlyOneChoice: "Trenger minst to valger." + choiceN: "Valg {n}" + noMore: "Du kan ikke legge til flere." + deadlineTime: "Timer" + votesCount: "{n} stemmer" + vote: "Stem" + showResult: "Vis resultatet" + voted: "Stemt" + closed: "Avsluttet" +_visibility: + home: "Hjem" + followers: "Følgere" +_postForm: + _placeholders: + a: "Hva skjer?" +_profile: + name: "Navn" + username: "Brukernavn" + metadataContent: "Innhold" +_exportOrImport: + followingList: "Følg" + muteList: "Skjul" + blockingList: "Blokker" + userLists: "Lister" +_charts: + filesIncDec: "Forskjell på antall filer" +_instanceCharts: + users: "Forskjell på antall brukere" + ff: "Forskjell på antall Følg/Følgere" + files: "Forskjell på antall filer" +_timelines: + home: "Hjem" +_play: + new: "Opprett Play" + edit: "Rediger Play" + featured: "Populært" + summary: "Beskrivelse" +_pages: + like: "Liker" + unlike: "Liker ikke" + my: "Mine sider" + featured: "Populært" + contents: "Innhold" + url: "Side URL" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + selectType: "Velg type" + blocks: + text: "Tekst" + section: "Seksjon" + image: "Bilde" + button: "Knapp" +_notification: + _types: + follow: "Følg" + reply: "Svar" + quote: "Sitat" + reaction: "Reaksjon" + _actions: + reply: "Svar" +_deck: + swapLeft: "Flytt til venstre" + swapRight: "Flytt til høyre" + swapUp: "Flytt opp" + swapDown: "Flytt ned" + profile: "Profil" + newProfile: "Ny profil" + deleteProfile: "Slett profil" + _columns: + notifications: "Varsler" + tl: "Tidslinje" + list: "Lister" + channel: "Kanaler" + direct: "Direkte" +_webhookSettings: + name: "Navn" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 517e8431f9..24eee60bae 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -257,12 +257,12 @@ startMessaging: "Rozpocznij czat" nUsersRead: "przeczytano przez {n}" agreeTo: "Wyrażam zgodę na {0}" agreeBelow: "Zaakceptuj poniżej" -tos: "Regulamin" start: "Rozpocznij" home: "Strona główna" remoteUserCaution: "Te informacje mogą nie być aktualne, ponieważ użytkownik pochodzi ze zdalnej instancji." activity: "Aktywność" images: "Zdjęcia" +image: "Zdjęcia" birthday: "Data urodzenia" yearsOld: "{age} lat" registeredDate: "Zarejestrowano" @@ -870,6 +870,7 @@ loggedInAsBot: "Jesteś obecnie zalogowany/a jako bot" like: "Polub" show: "Wyświetlanie" color: "Kolor" +youFollowing: "Śledzeni" _role: priority: "Priorytet" _priority: @@ -1086,32 +1087,6 @@ _time: minute: "minuta" hour: "godz." day: "dzień" -_tutorial: - title: "Jak korzystać z Misskey" - step1_1: "Witaj!" - step1_2: "Ta strona nazywa się „oś czasu”. Pokazuje chronologicznie uporządkowane wpisy osób, które „śledzisz”." - step1_3: "Twoja oś czasu jest jeszcze pusta, ponieważ nie opublikowałeś(-aś) jeszcze żadnych wpisów i nie obserwujesz jeszcze nikogo." - step2_1: "Ukończmy konfigurację profilu zanim utworzymy wpis lub zaczniemy kogoś obserwować." - step2_2: "Podanie pewnych informacji o tym, kim jesteś, ułatwi innym określenie, czy chcą widzieć Twoje wpisy lub Cię obserwować." - step3_1: "Zakończyłeś(-aś) konfigurację profilu?" - step3_2: "Następnie spróbujmy opublikować wpis. Możesz to zrobić, naciskając przycisk z ikoną ołówka na ekranie." - step3_3: "Wypełnij pole i kliknij przycisk w prawym górnym rogu by wysłać post." - step3_4: "Nie masz nic do powiedzenia? Spróbuj \"ustawiam swój misskey\"!" - step4_1: "Zakończyłeś publikowanie pierwszego wpisu?" - step4_2: "Hurra! Teraz Twój pierwszy wpis powinien być wyświetlany na Twojej osi czasu." - step5_1: "Teraz spróbujmy ożywić Twoją oś czasu, przez zaobserwowanie innych ludzi." - step5_2: "{featured} pokaże Ci popularne wpisy na tej instancji. {explore} pozwoli Ci znaleźć popularnych użytkowników. Spróbuj znaleźć tam osoby, które chcesz obserwować!" - step5_3: "Aby obserwować innych użytkowników, kliknij ich ikonę i naciśnij przycisk \"Obserwuj\" na ich profilu." - step5_4: "Jeśli inny użytkownik ma ikonę kłódki obok swojej nazwy, może minąć trochę czasu, zanim ten użytkownik ręcznie zatwierdzi Twoją prośbę o obserwowanie." - step6_1: "Powinieneś teraz widzieć wpisy innych użytkowników na swojej osi czasu." - step6_2: "Możesz także umieścić „reakcje” na wpisach innych osób, aby szybko na nie odpowiedzieć." - step6_3: "Aby dodać \"reakcję\", naciśnij znak \"+\" na wpisie innego użytkownika i wybierz emotikonę, którą chcesz zareagować." - step7_1: "Gratulacje! Ukończyłeś podstawowy samouczek Misskey." - step7_2: "Jeśli chcesz dowiedzieć się więcej o Misskey, wypróbuj sekcję {help}." - step7_3: "A teraz powodzenia i baw się dobrze z Misskey! 🚀" - step8_1: "Na sam koniec, czy nie chciał(a)byś włączyć powiadomień push?" - step8_2: "Włączenie tej opcji pozwoli ci otrzymywać powiadomienia o reakcjach, śledzeniach i wzmiankach nawet wtedy, gdy Misskey nie będzie otwarty." - step8_3: "Ustawienia powiadomień można zmienić później." _2fa: alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego." step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b}) na swoim urządzeniu." diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 8a7232fa2c..e7b43f6380 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -252,12 +252,12 @@ noMoreHistory: "Sem mais história" startMessaging: "Iniciar conversação" nUsersRead: "{n} Pessoas leem" agreeTo: "Eu concordo com {0}" -tos: "Termos de serviço" start: "começar" home: "casa" remoteUserCaution: "As informações estão incompletas porque é um utilizador remoto." activity: "atividade" images: "imagem" +image: "imagem" birthday: "aniversário" yearsOld: "{age} anos" registeredDate: "Data de registro" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index bbf0160de9..6ad45d0636 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -252,12 +252,12 @@ noMoreHistory: "Nu există mai mult istoric" startMessaging: "Începe un chat nou" nUsersRead: "citit de {n}" agreeTo: "Sunt de acord cu {0}" -tos: "Termenii de utilizare" start: "Să începem" home: "Acasă" remoteUserCaution: "Deoarece acest utilizator este dintr-o instanță externă, informația afișată poate fi incompletă." activity: "Activitate" images: "Imagini" +image: "Imagini" birthday: "Zi de naștere" yearsOld: "{age} ani" registeredDate: "Data înregistrării" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 8a09d30306..55d4b70590 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -20,6 +20,7 @@ noNotes: "Нет ни одной заметки" noNotifications: "Нет ни одного уведомления" instance: "Инстанс" settings: "Настройки" +notificationSettings: "Настройки уведомлений" basicSettings: "Основные настройки" otherSettings: "Прочие настройки" openInWindow: "Открыть в плавающем окне" @@ -122,6 +123,8 @@ unmarkAsSensitive: "Снять отметку «не для всех»" enterFileName: "Введите имя файла" mute: "Скрыть" unmute: "Отменить скрытие" +renoteMute: "Заглушить репосты" +renoteUnmute: "Включить репосты" block: "Заблокировать" unblock: "Разблокировать" suspend: "Заморозить" @@ -153,6 +156,7 @@ flagShowTimelineReplies: "Показывать ответы на заметки flagShowTimelineRepliesDescription: "Если этот параметр включен, то в ленте, в дополнение к заметкам пользователя, отображаются ответы на другие заметки пользователя." autoAcceptFollowed: "Принимать подписчиков автоматически" addAccount: "Добавить учётную запись" +reloadAccountsList: "Обновить список учётных записей" loginFailed: "Неудачная попытка входа" showOnRemote: "Перейти к оригиналу на сайт" general: "Общее" @@ -259,14 +263,16 @@ noMoreHistory: "История закончилась" startMessaging: "Начать общение" nUsersRead: "Прочитали {n}" agreeTo: "Я соглашаюсь с {0}" +agree: "Согласиться" agreeBelow: "Согласен со следующими" basicNotesBeforeCreateAccount: "Записи, перед созданием аккаунта" -tos: "Пользовательское соглашение" +termsOfService: "Условия использования" start: "Начать" home: "Главная" remoteUserCaution: "Это пользователь с другого сайта, поэтому информация может быть неточной." activity: "Активность" images: "Изображения" +image: "Изображения" birthday: "День рождения" yearsOld: "Возраст: {age}" registeredDate: "Дата регистрации" @@ -457,6 +463,7 @@ aboutX: "Описание {x}" emojiStyle: "Стиль эмодзи" native: "Системные" disableDrawer: "Не использовать выдвижные меню" +showNoteActionsOnlyHover: "Показывать кнопки управления заметкой только при наведении" noHistory: "История пока пуста" signinHistory: "Журнал посещений" enableAdvancedMfm: "Включить расширенный MFM" @@ -469,6 +476,8 @@ createAccount: "Новая учётная запись" existingAccount: "Существующая учётная запись" regenerate: "Создать повторно" fontSize: "Размер шрифта" +mediaListWithOneImageAppearance: "Показывать список медиа только одним изображением" +limitTo: "Обрезать до {x}" noFollowRequests: "Нерассмотренные запросы на подписку отсутствуют" openImageInNewTab: "Открыть изображение в новой вкладке" dashboard: "Панель управления" @@ -502,9 +511,11 @@ objectStorageUseSSLDesc: "Отключите, если не собираетес objectStorageUseProxy: "Использовать прокси" objectStorageUseProxyDesc: "Отключите, если не будете испоьзовать прокси для соединений по протоколу ObjectStorage." objectStorageSetPublicRead: "Устанавливать public-read при загрузке на сервер" +s3ForcePathStyleDesc: "Включение s3ForcePathStyle принудительно указывает имя корзины как часть пути в URL-адресе вместо имени хоста. Может потребоваться активация при использовании таких вещей, как локальный Minio." serverLogs: "Журнал сервера" deleteAll: "Удалить всё" showFixedPostForm: "Показывать поле для ввода новой заметки наверху ленты" +showFixedPostFormInChannel: "Показывать поле для ввода новой заметки наверху ленты (каналы)" newNoteRecived: "Появилась новая заметка" sounds: "Звуки" sound: "Звуки" @@ -542,6 +553,10 @@ userSuspended: "Эта учётная запись заморожена" userSilenced: "Этот пользователь был заглушен" yourAccountSuspendedTitle: "Эта учетная запись заблокирована" yourAccountSuspendedDescription: "Эта учетная запись была заблокирована из-за нарушения условий предоставления услуг сервера. Свяжитесь с администратором, если вы хотите узнать более подробную причину. Пожалуйста, не создавайте новую учетную запись." +tokenRevoked: "Токен недействителен" +tokenRevokedDescription: "Срок действия вашего токена входа истек. Пожалуйста, войдите снова." +accountDeleted: "Эта учетная запись удалена" +accountDeletedDescription: "Эта учетная запись удалена" menu: "Меню" divider: "Линия-разделитель" addItem: "Добавить элемент" @@ -688,6 +703,8 @@ contact: "Как связаться" useSystemFont: "Использовать шрифт, предлагаемый системой" clips: "Подборки" experimentalFeatures: "Экспериментальные функции" +experimental: "Экспериментальные" +thisIsExperimentalFeature: "Это экспериментальная функция. Технические характеристики могут измениться или он может работать неправильно." developer: "Разработчик" makeExplorable: "Опубликовать профиль в «Обзоре»." makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»." @@ -852,6 +869,7 @@ tenMinutes: "10 минут" oneHour: "1 час" oneDay: "1 день" oneWeek: "1 неделя" +oneMonth: "1 месяц" reflectMayTakeTime: "Изменения могут занять время для отображения" failedToFetchAccountInformation: "Не удалось получить информацию об аккаунте" rateLimitExceeded: "Ограничение скорости превышено" @@ -893,6 +911,7 @@ remoteOnly: "Только удалённо" failedToUpload: "Сбой выгрузки" cannotUploadBecauseInappropriate: "Файл не может быть загружен, так как было установлено, что он может содержать неприемлемое содержимое." cannotUploadBecauseNoFreeSpace: "Файл не может быть загружен, так как не осталось места на диске" +cannotUploadBecauseExceedsFileSizeLimit: "Файл не может быть загружен, так как он превышает лимит размера файла." beta: "Бета" enableAutoSensitive: "Автоматическое определение NSFW" enableAutoSensitiveDescription: "Если доступно, используйте машинное обучение для автоматической установки флага NSFW на носителе. Даже если эта функция отключена, она может быть установлена ​​автоматически в зависимости от инстанта." @@ -909,6 +928,7 @@ pushNotificationNotSupported: "Push-уведмления не поддержив sendPushNotificationReadMessage: "Удалять push-уведомления когда сообщение или прочитано" sendPushNotificationReadMessageCaption: "На мгновение появится уведомление \"{emptyPushNotificationMessage}\". Расход заряда батареи может увеличиться " windowMaximize: "Развернуть" +windowMinimize: "Свернуть" windowRestore: "Восстановить" caption: "Подпись (Automatic Translation)" loggedInAsBot: "Вы под аккаунтом бота!" @@ -925,6 +945,7 @@ didYouLikeMisskey: "Вам нравится Misskey?" pleaseDonate: "Сайт {host} работает на Misskey. Это бесплатное программное обеспечение, и ваши пожертвования очень бы помогли продолжать его разработку!" roles: "Роли" role: "Роль" +noRole: "Нет роли" normalUser: "Обычный пользователь" undefined: "неопределён" assign: "Назначить" @@ -934,6 +955,10 @@ manageCustomEmojis: "Управлять пользовательскими эм youCannotCreateAnymore: "Вы достигли лимита создания." cannotPerformTemporary: "Временно недоступен" cannotPerformTemporaryDescription: "Это действие временно невозможно выполнить из-за превышения лимита выполнения." +invalidParamError: "Ошибка параметра" +invalidParamErrorDescription: "Проблема с параметрами запроса. Обычно это ошибка, но это также может быть связано с тем, что вы набрали слишком много символов." +permissionDeniedError: "Операция запрещена" +permissionDeniedErrorDescription: "У этой учетной записи нет разрешения на выполнение этой операции." preset: "Шаблоны" selectFromPresets: "Выбрать из шаблонов" achievements: "Достижения" @@ -950,6 +975,37 @@ copyErrorInfo: "Скопировать код ошибки" joinThisServer: "Присоединяйтесь к этому серверу" exploreOtherServers: "Искать другие сервера" letsLookAtTimeline: "Давайте посмотрим на ленту" +disableFederationConfirm: "Отключить федерацию?" +disableFederationConfirmWarn: "Дефедерация не делает заметку приватной. В большинстве случаев без федерации не обойтись." +disableFederationOk: "Не федерируется" +invitationRequiredToRegister: "Этот сервер в настоящее время только по приглашению. Зарегистрироваться могут только те, у кого есть код приглашения." +emailNotSupported: "Доставка почты не поддерживается на этом сервере" +postToTheChannel: "Отправить в канал" +cannotBeChangedLater: "Это нельзя изменить позже" +reactionAcceptance: "Принятие реакций" +likeOnly: "Только лайки" +likeOnlyForRemote: "Только лайки с удалённых серверов" +rolesAssignedToMe: "Мои роли" +resetPasswordConfirm: "Сбросить пароль?" +sensitiveWords: "Чувствительные слова" +sensitiveWordsDescription: "Установите общедоступный диапазон заметки, содержащей заданное слово, на домашний. Можно сделать несколько настроек, разделив их переносами строк." +notesSearchNotAvailable: "Поиск заметок недоступен" +license: "Лицензия" +unfavoriteConfirm: "Удалить избранное?" +myClips: "Мои клипы" +drivecleaner: "Очиститель дисков" +retryAllQueuesNow: "Повторить все очереди сейчас" +retryAllQueuesConfirmTitle: "Хотите попробовать ещё раз?" +retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться" +enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей" +enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов" +largeNoteReactions: "Показывать большие реакции на заметки" +noteIdOrUrl: "ID или ссылка на заметку" +video: "Видео" +videos: "Видео" +dataSaver: "Экономия трафика" +horizontal: "Сбоку" +youFollowing: "Подписки" _achievements: earnedAt: "Разблокировано в" _types: @@ -1469,32 +1525,6 @@ _time: minute: "мин" hour: "ч" day: "сут" -_tutorial: - title: "Как пользоваться Misskey" - step1_1: "Добро пожаловать!" - step1_2: "Эта страница называется «лента». Здесь будут появляться «заметки»: ваши личные и тех, на кого вы «подписаны». Они будут располагаться в порядке времени их появления." - step1_3: "Правда, ваша лента пока пуста. Она начнёт заполняться, когда вы будете писать свои заметки и подписываться на других." - step2_1: "Давайте, заполним профиль, прежде чем начать писать заметки и подписываться на других." - step2_2: "То, что вы расскажете в профиле, поможет лучше вас узнать, а значит, многим будет легче присоединиться — вы скорее получите новых подписчиков и читателей." - step3_1: "Успешно заполнили профиль?" - step3_2: "Что ж, теперь самое время опубликовать заметку. Если нажать вверху страницы на изображение карандаша, появится форма для текста." - step3_3: "Напишите в неё, что хотите, и нажмите на кнопку в правом верхнем углу." - step3_4: "Ничего не приходит в голову? Как насчёт: «Я новенький, пока осваиваюсь в Misskey»?" - step4_1: "С написанием первой заметки покончено?" - step4_2: "Отлично, теперь она должна появиться в вашей ленте." - step5_1: "А теперь самое время немного оживить ленту, подписавшись на других." - step5_2: "На странице «{featured}» собраны популярные сегодня заметки, читая которые, вы можете найти кого-то вам интересного, а на странице «{explore}» можно посмотреть, кто популярен у остальных." - step5_3: "Чтобы подписаться на кого-нибудь, щёлкните по его аватару и в открывшемся профиле нажмите кнопку «Подписаться»." - step5_4: "Некоторые пользователи (около их имени «висит замок») вручную подтверждают чужие подписки. Так что иногда подписка начинает работать не сразу.\n" - step6_1: "Если теперь в ленте видны и чужие заметки, значит у вас получилось." - step6_2: "Здесь можно непринуждённо выразить свои чувства к чьей-то заметке, отметив «реакцию» под ней." - step6_3: "Отмечайте реакции, нажмая на символ «+» под заметкой и выбирая значок по душе." - step7_1: "На этом вводный урок по использованию Misskey закончен. Спасибо, что прошли его до конца!" - step7_2: "Хотите изучить Misskey глубже — добро пожаловать в раздел «{help}»." - step7_3: "Приятно вам провести время с Misskey🚀" - step8_1: "Ах, да, не хотите ли включить push-уведомления?" - step8_2: "С push-уведомлениями вы будете в курсе репостов, ответов, реакций и всего такого, даже когда закрыли Misskey." - step8_3: "Эту настройку вы всегда сможете поменять" _2fa: alreadyRegistered: "Двухфакторная аутентификация уже настроена." registerTOTP: "Начните настраивать приложение-аутентификатор" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 7c74060158..3e96119389 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -261,12 +261,12 @@ nUsersRead: "prečítané {n} používateľmi" agreeTo: "Súhlasím s {0}" agreeBelow: "Súhlasím s nasledovným" basicNotesBeforeCreateAccount: "Základné bezpečnostné opatrenia" -tos: "Podmienky používania" start: "Začať" home: "Domov" remoteUserCaution: "Tieto informácie nemusia byť aktuálne, keďže používateľ je na vzdialenom serveri." activity: "Aktivita" images: "Obrázky" +image: "Obrázky" birthday: "Dátum narodenia" yearsOld: "{age} rokov" registeredDate: "Dátum registrácie" @@ -917,6 +917,8 @@ remindMeLater: "Pripomenúť neskôr" didYouLikeMisskey: "Páči sa vám Misskey?" pleaseDonate: "Misskey je bezplatný softvér, ktorý používa {host}. Prosím, prispejte, aby sme ho mohli ďalej rozvíjať!" color: "Farba" +horizontal: "Strana" +youFollowing: "Sledované" _role: priority: "Priorita" _priority: @@ -1146,32 +1148,6 @@ _time: minute: "min" hour: "hod" day: "dní" -_tutorial: - title: "Ako používať Misskey" - step1_1: "Vitajte!" - step1_2: "Táto stránka sa volá \"časová os\". Zobrazuje chronologicky zoradené \"poznámky\" od ľudí, ktorých sledujete." - step1_3: "Vaša časová os je teraz prázdna pretože ste nepridali žiadne poznámky ani nikoho zatiaľ nesledujete." - step2_1: "Podˇme dokončiť nastavenia vášho profilu pred napísaním poznámky alebo sledovaním niekoho." - step2_2: "Poskytnutím informácií o vás uľahčíte ostatným, či chcú vidieť alebo sledovať vaše poznámky." - step3_1: "Dokončili ste nastavovanie svojho profilu?" - step3_2: "Poďme vyskúšať napísať poznámku. Môžete to spraviť stlačením ikony ceruzky na vrchu obrazovky." - step3_3: "Vyplňte polia a stlačte tlačítko vpravo hore." - step3_4: "Nemáte čo povedať? Skúste \"len si nastavujem môj msky\"!" - step4_1: "Napísali ste svoju prvú poznámku?" - step4_2: "Hurá! Teraz by vaša prvá poznámka mala byť na vašej časovej osi." - step5_1: "Teraz skúsme oživiť časovú os sledovaním nejakých ľudí." - step5_2: "{featured} zobrazí populárne poznámku na tomto serveri. {explore} môžete objavovať populárnych používateľov. Skúste tam nájsť ľudí, ktorých by ste radi sledovali!" - step5_3: "Ak chcete sledovať ďalších používateľov, kliknite na ich ikonu a stlačte tlačidlo \"Sledovať\" na ich profile." - step5_4: "Ak má niektorý používateľ ikonu zámku vedľa svojho mena, znamená to, že môže trvať určitý čas, kým daný používateľ schváli vašu žiadosť o sledovanie." - step6_1: "Teraz by ste mali vidieť poznámky ďalších používateľov na svojej časovej osi." - step6_2: "Môžete dať \"reakcie\" na poznámky ďalších ľudí ako rýchlu odpoveď." - step6_3: "Reakciu pridáte kliknutím na \"+\" niekoho poznámke a vybratím emoji, ktorou chcete reagovať." - step7_1: "Gralujeme! Dokončili ste základného sprievodcu Misskey." - step7_2: "Ak sa chcete naučiť viac o Misskey, skúste sekciu {help}." - step7_3: "A teraz, veľa šťastia, bavte sa s Misskey! 🚀" - step8_1: "A nakoniec, prečo si neaktivovať push oznámenia?" - step8_2: "Vďaka push notifikáciám sa dozviete o reakciách, sledovaniach a zmienkach, aj keď Misskey nie je otvorené." - step8_3: "Nastavenia notifikácií môžete neskôr zmeniť." _2fa: alreadyRegistered: "Už ste zaregistrovali 2-faktorové autentifikačné zariadenie." step1: "Najprv si nainštalujte autentifikačnú aplikáciu (napríklad {a} alebo {b}) na svoje zariadenie." diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 6369adb454..7a00e3fcea 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -256,11 +256,11 @@ noMoreHistory: "Det finns ingen mer historik" startMessaging: "Starta en chatt" nUsersRead: "läst av {n}" agreeTo: "Jag accepterar {0}" -tos: "Användarvillkor" home: "Hem" remoteUserCaution: "Då denna användaren kommer från en fjärrinstans, kan informationen visad vara ofullständig." activity: "Aktivitet" images: "Bilder" +image: "Bilder" birthday: "Födelsedag" yearsOld: "{age} år gammal" registeredDate: "Gick med" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index a863103e2c..d1f87dbfdb 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -18,8 +18,9 @@ enterUsername: "ใส่ชื่อผู้ใช้" renotedBy: "รีโน้ตโดย {ผู้ใช้}" noNotes: "ไม่มีโน้ต" noNotifications: "ไม่มีการแจ้งเตือน" -instance: "ตัวอย่าง" +instance: "อินสแตนซ์" settings: "การตั้งค่า" +notificationSettings: "ตั้งค่าการแจ้งเตือน" basicSettings: "การตั้งค่าพื้นฐาน" otherSettings: "การตั้งค่าอื่นๆ" openInWindow: "เปิดในหน้าต่าง" @@ -99,7 +100,7 @@ followRequestPending: "กำลังรอดำเนินการร้อ enterEmoji: "ใส่อีโมจิ" renote: "รีโน้ต" unrenote: "เลิกรีโน้ต" -renoted: "รีโน้ตเอาไว้" +renoted: "รีโน้ตแล้วนะ" cantRenote: "โพสต์นี้ไม่สามารถรีโน้ตไว้ใหม่ได้นะ" cantReRenote: "ไม่สามารถรีโน้ตเอาไว้ใหม่ได้นะ" quote: "อ้างคำพูด" @@ -262,14 +263,16 @@ noMoreHistory: "ในนั้นไม่มีประวัติอีก startMessaging: "เริ่มการสนทนา" nUsersRead: "อ่านโดย {n}" agreeTo: "ฉันยอมรับที่จะ {0}" +agree: "ยอมรับ" agreeBelow: "ฉันยอมรับถึงด้านล่าง" basicNotesBeforeCreateAccount: "หมายเหตุสำคัญ" -tos: "ข้อกำหนดและเงื่อนไข" +termsOfService: "เงื่อนไขการให้บริการ" start: "เริ่มต้น​ใช้งาน​" home: "หน้าแรก" remoteUserCaution: "เนื่องจากผู้ใช้งานรายนี้นั้น มาจากอินสแตนซ์ระยะไกล ข้อมูลที่แสดงดังกล่าวนั้นอาจจะไม่สมบูรณ์ก็ได้นะ" activity: "กิจกรรม" images: "รูปภาพ" +image: "รูปภาพ" birthday: "วันเกิด" yearsOld: "{age} ปี" registeredDate: "วันที่สมัครสมาชิก" @@ -467,12 +470,14 @@ enableAdvancedMfm: "เปิดใช้งาน MFM ขั้นสูง" enableAnimatedMfm: "เปิดการใช้งาน MFM ด้วยแอนิเมชั่น" doing: "กำลังประมวลผล......" category: "หมวดหมู่" -tags: "แท็ก" +tags: "นามแฝง" docSource: "ที่มาของเอกสารนี้" createAccount: "สร้างบัญชี" existingAccount: "บัญชีที่มีอยู่" regenerate: "สร้างอีกครั้ง" fontSize: "ขนาดตัวอักษร" +mediaListWithOneImageAppearance: "ความสูงของลิสต์สื่อจะต้องมีรูปภาพเดียวเท่านั้น" +limitTo: "จำกัดไว้ที่ {x}" noFollowRequests: "คุณไม่มีคำขอติดตามที่รอดำเนินการ" openImageInNewTab: "เปิดรูปภาพในแท็บใหม่" dashboard: "หน้ากระดานหลัก" @@ -671,7 +676,7 @@ notesCount: "จำนวนของโน้ต" repliesCount: "จำนวนการตอบกลับที่ส่ง" renotesCount: "จำนวนรีโน้ตที่ส่ง" repliedCount: "จำนวนของการตอบกลับที่ได้รับ" -renotedCount: "จำนวนรีโน้ตที่ได้รับ" +renotedCount: "จำนวนรีโน้ตที่ได้รับแล้ว" followingCount: "จำนวนบัญชีที่ติดตาม" followersCount: "จำนวนผู้ติดตาม" sentReactionsCount: "จำนวนปฏิกิริยาที่ส่ง" @@ -698,6 +703,8 @@ contact: "ติดต่อ" useSystemFont: "ใช้ฟอนต์เริ่มต้นของระบบ" clips: "คลิป" experimentalFeatures: "ฟังก์ชั่นทดสอบ" +experimental: "ทดลอง" +thisIsExperimentalFeature: "นี่คือฟีเจอร์ทดลองนะค่ะ ฟังก์ชันการทำงานบางอย่างอาจเปลี่ยนแปลงได้ และอาจไม่ทำงานหรือไม่เสถียรตามที่ตั้งใจไว้นะ" developer: "สำหรับนักพัฒนา" makeExplorable: "ทำให้บัญชีมองเห็นใน \"สำรวจ\"" makeExplorableDescription: "ถ้าหากคุณปิดการทำงานนี้ บัญชีของคุณนั้นจะไม่แสดงในส่วน \"สำรวจ\" นะ" @@ -904,6 +911,7 @@ remoteOnly: "รีโมทเท่านั้น" failedToUpload: "การอัปโหลดล้มเหลว" cannotUploadBecauseInappropriate: "ไม่สามารถอัปโหลดไฟล์นี้ได้เนื่องจากระบบตรวจพบบางส่วนของไฟล์ว่านี้อาจจะเป็น NSFW" cannotUploadBecauseNoFreeSpace: "การอัปโหลดนั้นล้มเหลวเนื่องจากไม่มีความจุของไดรฟ์" +cannotUploadBecauseExceedsFileSizeLimit: "ไม่สามารถอัปโหลดไฟล์นี้ได้แล้วเนื่องจากเกินขีดจำกัดของขนาดไฟล์แล้ว" beta: "เบต้า" enableAutoSensitive: "ทำเครื่องหมาย NSFW อัตโนมัติ" enableAutoSensitiveDescription: "อนุญาตให้ตรวจหาและทำเครื่องหมายสื่อ NSFW โดยอัตโนมัติผ่านการเรียนรู้ของเครื่องหากเป็นไปได้ แม้ว่าตัวเลือกนี้จะถูกปิดใช้งาน แต่ก็สามารถเปิดใช้งานได้ทั้งอินสแตนซ์นี้" @@ -937,6 +945,7 @@ didYouLikeMisskey: "คุณเคยชอบ Misskey ไหม?" pleaseDonate: "{host} ใช้ซอฟต์แวร์ฟรี Misskey เราขอขอบคุณการบริจาคของคุณอย่างสูงเพื่อให้การพัฒนา Misskey สามารถดำเนินต่อไปได้นะ!" roles: "บทบาท" role: "บทบาท" +noRole: "ไม่พบบทบาท" normalUser: "ผู้ใช้มาตรฐาน" undefined: "ไม่ได้กำหนด" assign: "กำหนด" @@ -946,6 +955,10 @@ manageCustomEmojis: "จัดการอีโมจิแบบกำหน youCannotCreateAnymore: "คุณถึงขีดจํากัดการสร้างแล้วนะ" cannotPerformTemporary: "ไม่สามารถใช้การได้ชั่วคราว" cannotPerformTemporaryDescription: "การดําเนินการนี้ไม่สามารถดําเนินการได้ชั่วคราว เนื่องจากเกินขีดจํากัดการดําเนินการ กรุณารอสักครู่แล้วลองใหม่อีกครั้งนะค่ะ" +invalidParamError: "ข้อผิดพลาดพารามิเตอร์" +invalidParamErrorDescription: "คำขอพารามิเตอร์ไม่ถูกต้อง สิ่งนี้มักจะเกิดจากข้อผิดพลาด แต่อาจเกิดจากอินพุตเกินขีดจำกัดของขนาดหรือที่คล้ายกัน" +permissionDeniedError: "การดำเนินถูกปฏิเสธ" +permissionDeniedErrorDescription: "บัญชีนี้ไม่มีสิทธิ์อนุญาตในการดำเนินการนี้" preset: "พรีเซ็ต" selectFromPresets: "เลือกจากการพรีเซ็ต" achievements: "ความสำเร็จ" @@ -989,17 +1002,52 @@ enableChartsForFederatedInstances: "สร้างแผนภูมิข้ showClipButtonInNoteFooter: "เพิ่ม \"คลิป\" เพื่อบันทึกเมนูการทำงาน" largeNoteReactions: "ขยายรีแอคชั่นการแสดงผล" noteIdOrUrl: "โน้ต ID หรือ URL" +video: "วีดีโอ" +videos: "วีดีโอ" +dataSaver: "ประหยัดข้อมูล" accountMigration: "การโยกย้ายบัญชี" accountMoved: "ผู้ใช้รายนี้ได้ย้ายไปยังบัญชีใหม่แล้ว:" +accountMovedShort: "บัญชีนี้ถูกโอนย้ายไปแล้วค่ะ" +operationForbidden: "ห้ามดำเนินการ" forceShowAds: "แสดงโฆษณาเสมอ" +addMemo: "เพิ่มมีโม" +editMemo: "แก้ไขมีโม" +reactionsList: "ปฏิกิริยา" +renotesList: "Renotes รีโน้ต" +notificationDisplay: "การแจ้งเตือน" +leftTop: "บนซ้าย" +rightTop: "บนขวา" +leftBottom: "ล่างซ้าย" +rightBottom: "ล่างขวา" +stackAxis: "ทิศทางการซ้อน" +vertical: "แนวตั้ง" +horizontal: "ด้านข้าง" +position: "ตำแหน่ง" +serverRules: "กฎของเซิฟเวอร์" +pleaseConfirmBelowBeforeSignup: "โปรดยืนยันด้านล่างก่อนกำลังลงชื่อสมัครนะค่ะ" +pleaseAgreeAllToContinue: "คุณต้องยอมรับทุกช่องตรงด้านบนเพื่อดำเนินการต่อค่ะ" +continue: "ดำเนินการต่อ" +preservedUsernames: "ชื่อผู้ใช้ที่สงวนไว้" +preservedUsernamesDescription: "ลิสต์ชื่อผู้ใช้ที่จะสำรองโดยคั่นด้วยการแบ่งบรรทัดนั้น เพราะสิ่งเหล่านี้จะไม่สามารถทำได้ในระหว่างการสร้างบัญชีตามปกติ บัญชีที่มีอยู่แล้วนั้นโดยใช้ชื่อผู้ใช้เหล่านี้จะไม่ได้รับผลกระทบอะไร" +createNoteFromTheFile: "เรียบเรียงโน้ตจากไฟล์นี้" +youFollowing: "ติดตามแล้ว" +_serverRules: + description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ" _accountMigration: - moveTo: "ย้ายข้อมูลบัญชีนี้ไปยังบัญชีอีกหนึ่ง" - moveToLabel: "บัญชีที่จะย้ายไปที่:" - moveAccountDescription: "การกระทำนี้ไม่สามารถย้อนกลับได้นะ ขั้นตอนแรก ต้องสร้างนามแฝงสำหรับบัญชีนี้ในบัญชีที่คุณต้องการย้ายไป หลังจากนั้นแล้ว ป้อนบัญชีที่จะย้ายไปในรูปแบบดังต่อไปนี้: @person@instance.com" moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง" + moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น" moveFromLabel: "บัญชีที่จะย้ายจาก:" moveFromDescription: "สร้างนามแฝงสำหรับบัญชีที่จะย้ายจากบัญชีนี้ ถ้าหากคุณต้องการโอนผู้ติดตาม สิ่งนี้ต้องทำก่อนโอนก่อนนะค่ะ! หลังจากนั้น ป้อนบัญชีที่จะย้ายไปในรูปแบบต่อไปนี้: @person@instance.com" + moveTo: "ย้ายข้อมูลบัญชีนี้ไปยังบัญชีอีกหนึ่ง" + moveToLabel: "บัญชีที่จะย้ายไปที่:" + moveCannotBeUndone: "ไม่สามารถยกเลิกการโอนย้ายบัญชีได้" + moveAccountDescription: "การกระทำนี้ไม่สามารถย้อนกลับได้นะ ขั้นตอนแรก ต้องสร้างนามแฝงสำหรับบัญชีนี้ในบัญชีที่คุณต้องการย้ายไป หลังจากนั้นแล้ว ป้อนบัญชีที่จะย้ายไปในรูปแบบดังต่อไปนี้: @person@instance.com" + moveAccountHowTo: "หากต้องการย้ายข้อมูลก่อนอื่นให้สร้างชื่อแทนสำหรับบัญชีนี้ ในบัญชีที่จะต้องการย้ายไป\nหลังจากที่คุณสร้างนามแฝงนั้นแล้ว ให้ป้อนบัญชีที่ต้องการจะย้ายไปในรูปแบบดังต่อไปนี้: @username@server.example.com" + startMigration: "โอนย้าย" migrationConfirm: "ย้ายข้อมูลบัญชีนี้ไปที่ {account} จริงๆนะ เมื่อมีการเริ่มต้นแล้ว กระบวนการนี้จะไม่สามารถหยุดหรือนำกลับคืนมาได้ และคุณจะไม่สามารถใช้บัญชีนี้ในสถานะดั้งเดิมได้อีกต่อไป\n\nนอกจากนี้ เพื่อให้แน่ใจยืนยันว่าคุณได้สร้างนามแฝงในบัญชีที่จะย้ายข้อมูลนะค่ะ" + movedAndCannotBeUndone: "\nบัญชีนี้ถูกโอนย้ายไปแล้ว\nไม่สามารถย้อนกลับโอนย้ายข้อมูลได้" + postMigrationNote: "บัญชีนี้จะถูกเลิกติดตามบัญชีทั้งหมดที่กำลังติดตามภายใน 24 ชั่วโมงหลังจากการย้ายข้อมูลนั้นเสร็จสิ้น ทั้งจำนวนผู้ติดตามและผู้ติดตามนั้นจะกลายเป็นศูนย์ เพื่อหลีกเลี่ยงป้องกันไม่ให้ผู้ติดตามของคุณนั้นไม่สามารถเห็นโพสต์เฉพาะผู้ติดตามของบัญชีนี้ได้ แต่อย่างไรก็ตามแล้วพวกเขาจะยังคงติดตามบัญชีนี้ต่อไป" + movedTo: "บัญชีที่จะย้ายไปที่:" _achievements: earnedAt: "ได้รับเมื่อ" _types: @@ -1171,6 +1219,9 @@ _achievements: _client30min: title: "พักผ่อนสักหน่อย" description: "ใช้เวลา 30 นาทีบน Misskey" + _client60min: + title: "ไม่มี \"Miss\" ใน Misskey นะค่ะ !" + description: "เปิด Misskey ค้างไว้แล้วอย่างน้อย 60 นาที" _noteDeletedWithin1min: title: "ไม่เป็นไร" description: "ลบโน้ตภายในหนึ่งนาทีหลังจากที่โพสต์" @@ -1259,6 +1310,8 @@ _role: iconUrl: "ไอคอน URL" asBadge: "แสดงเป็นตรา" descriptionOfAsBadge: "ไอคอนของบทบาทนี้จะปรากฏถัดจากชื่อผู้ใช้ของผู้ใช้งานด้วยบทบาทนี้ถ้าหากเปิดใช้งาน" + isExplorable: "บทบาทไทม์ไลน์เป็นแบบสาธารณะ" + descriptionOfIsExplorable: "ไทม์ไลน์ของบทบาทนี้จะสามารถเข้าถึงได้แบบสาธารณะถ้าหากเปิดใช้งาน เส้นเวลาของบทบาทนั้นจะไม่ถูกเปิดเผยต่อสาธารณะ ถึงแม้ว่าจะไม่เปิดเผยต่อสาธารณะแม้แต่ว่า...จะตั้งค่าไว้ยังไงก็ตาม" displayOrder: "ตำแหน่ง" descriptionOfDisplayOrder: "ยิ่งตัวเลขสูง ตำแหน่ง UI ก็ยิ่งสูงขึ้นนะ" canEditMembersByModerator: "อนุญาตให้ผู้ดูแลแก้ไขสมาชิก" @@ -1407,6 +1460,8 @@ _channel: following: "ติดตามแล้ว" usersCount: "{n} ผู้เข้าร่วม" notesCount: "{n} โน้ต" + nameAndDescription: "ชื่อและคำอธิบาย" + nameOnly: "ชื่อเท่านั้น" _menuDisplay: sideFull: "ด้านข้าง" sideIcon: "ด้านข้าง (ไอคอน)" @@ -1524,32 +1579,6 @@ _time: minute: "นาที" hour: "ชั่วโมง" day: "วัน" -_tutorial: - title: "วิธีการใช้งาน Misskey" - step1_1: "ยินดีต้อนรับค่ะ!" - step1_2: "หน้านี้เรียกว่า \"ไทม์ไลน์\" มันจะแสดง \"โน้ตย่อ\" ที่เรียงลำดับตามลำดับเวลาของคนที่คุณ \"ติดตาม\"" - step1_3: "ไทม์ไลน์ของคุณนั้นว่างเปล่า เนื่องจากคุณยังไม่ได้โพสต์โน้ตย่อหรือไม่ได้ติดตามใครเลย" - step2_1: "มาตั้งค่าโปรไฟล์ของคุณให้เสร็จก่อนเขียนโน้ตย่อหรือติดตามใครก็ได้" - step2_2: "การให้ข้อมูลบางอย่างเกี่ยวกับตัวคุณนั้น จะทำให้ผู้อื่นทราบว่าต้องการดูโน้ตย่อของคุณหรือติดตามคุณได้ง่ายขึ้น" - step3_1: "ตั้งค่าโปรไฟล์ของคุณเสร็จแล้ว?" - step3_2: "จากนั้นลองโพสต์โน้ตกันต่อไป คุณสามารถทำได้โดยกดปุ่มที่มีไอคอนดินสอบนหน้าจอนะ" - step3_3: "กรอกโมดอลแล้วกดปุ่มด้านบนขวาเพื่อโพสต์" - step3_4: "ไม่มีอะไรจะพูดงั้นหรอ ลอง \"เพียงแค่ตั้งค่าว่า Misskey ของฉัน\"!" - step4_1: "เสร็จสิ้นการโพสต์โน้ตย่อแรกของคุณแล้วอย่างงั้นหรอ?" - step4_2: "ไชโย! ตอนนี้โน้ตย่อแรกของคุณได้ปรากฏบนไทม์ไลน์ของคุณแล้วนะ" - step5_1: "ตอนนี้ มาลองทำไทม์ไลน์เพิ่มเติมของคุณให้ดูมีชีวิตชีวามากขึ้นโดยการติดตามคนอื่น" - step5_2: "{featured} จะแสดงโน้ตยอดนิยมให้คุณเห็นในกรณีนี้ {explore} จะช่วยให้คุณค้นหาผู้ใช้ยอดนิยมได้ ลองหาคนที่คุณต้องการติดตามที่นั่นสิ!" - step5_3: "หากต้องการติดตามผู้ใช้รายอื่น ให้คลิกที่ไอคอนและกดปุ่ม \"ติดตาม\" บนโปรไฟล์ของพวกเขาได้เลยจ้า" - step5_4: "หากผู้ใช้รายอื่นมีไอคอนแม่กุญแจที่อยู่ข้างชื่อ อาจต้องใช้เวลาสักระยะกว่าที่ผู้ใช้รายนั้นจะอนุมัติคำขอติดตามของคุณ" - step6_1: "คุณสามารถเห็นโน้ตย่อของผู้ใช้รายอื่นบนไทม์ไลน์ของคุณได้แล้วตอนนี้" - step6_2: "คุณยังสามารถใส่ \"ปฏิกิริยา\" ลงในโน้ตของคนอื่นเพื่อตอบกลับได้อย่างรวดเร็ว" - step6_3: "หากต้องการแนบ \"ปฏิกิริยา\" ให้กดเครื่องหมาย \"+\" ในโน้ตของผู้ใช้รายอื่นแล้วเลือกอีโมจิที่คุณต้องการโต้ตอบด้วย" - step7_1: "ยินดีด้วยนะ! คุณได้เสร็จสิ้นการกวดวิชาพื้นฐานของ Misskey แล้ว" - step7_2: "ถ้าหากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับ Misskey ให้ลองใช้ส่วน {help}" - step7_3: "ตอนนี้ ถ้าอย่างนั้นก็ขอให้โชคดีและสนุกกับ Misskey! 🚀" - step8_1: "สุดท้ายนี้นายต้องการเปิดใช้งานการแจ้งเตือนแบบพุชหรือป่าว?" - step8_2: "การเปิดใช้งานสิ่งเหล่านี้ จะช่วยให้คุณนั้นได้รับการแจ้งเตือนสำหรับการกล่าวถึง การแสดงรีแอคชั่น การติดตาม ฯลฯ เป็นต้น ถึงแม้ว่าจะไม่ได้เปิด Misskey ก็ตาม" - step8_3: "คุณสามารถเปลี่ยนการตั้งค่านี้ในภายหลังได้ตลอดเวลานะ" _2fa: alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว" registerTOTP: "ลงทะเบียนแอพตัวตรวจสอบสิทธิ์" @@ -1887,6 +1916,7 @@ _deck: channel: "แชนแนล" mentions: "พูดถึง" direct: "ไดเร็ค" + roleTimeline: "บทบาทไทม์ไลน์" _dialog: charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}" charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 895d2e1f12..4f215bc980 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -258,12 +258,12 @@ startMessaging: "Розпочати діалог" nUsersRead: "Прочитали {n}" agreeTo: "Я погоджуюсь з {0}" agreeBelow: "Я погоджуюся з наведеним нижче" -tos: "Умови використання" start: "Розпочати" home: "Домівка" remoteUserCaution: "Інформація може бути неповною, оскільки це віддалений користувач." activity: "Активність" images: "Зображення" +image: "Зображення" birthday: "День народження" yearsOld: "{age} років" registeredDate: "Приєднання" @@ -899,6 +899,8 @@ achievements: "Досягнення" joinThisServer: "Зареєструватися на цьому сервері" exploreOtherServers: "Знайти інший сервер" letsLookAtTimeline: "Перегляд історії" +horizontal: "Збоку" +youFollowing: "Підписки" _achievements: earnedAt: "Відкрито" _types: @@ -1330,31 +1332,6 @@ _time: minute: "х" hour: "г" day: "д" -_tutorial: - title: "Як користуватись Misskey" - step1_1: "Ласкаво просимо!" - step1_2: "Ця сторінка має назву \"стрічка подій\". На ній з'являються записи користувачів на яких ви підписані." - step1_3: "Наразі ваша стрічка порожня, оскільки ви ще не написали жодної нотатки і не підписані на інших." - step2_1: "Перш ніж зробити запис або підписатись на когось, заповніть свій профіль." - step2_2: "Надання деякої інформації про себе допоможе іншим користувачам вирішити підписатись на вас." - step3_1: "Ви успішно налаштували свій обліковий запис?" - step3_2: "Наступним кроком є написання нотатки. Це можна зробити, натиснувши зображення олівця на екрані." - step3_3: "Після написання вмісту ви можете опублікувати його, натиснувши кнопку у верхньому правому куті форми." - step3_4: "Не знаєте що написати? Спробуйте \"Привіт, Misskey!\"" - step4_1: "Ви розмістили свій перший запис?" - step4_2: "Ура! Ваш перший запис відображається на вашій стрічці подій." - step5_1: "Настав час оживити вашу стрічку подій підписавшись на інших користувачів." - step5_2: "{explore} допоможе вам знайти цікавих людей та підписатися на них." - step5_3: "Щоб підписатись на інших користувачів, нажміть на їхнє зображення, а потім на кнопку \"підписатись\"." - step5_4: "Якщо користувач має замок при імені, то йому потрібно буде вручну підтвердити вашу заявку на підписку." - step6_1: "Тепер ви повинні бачити записи інших користувачів на вашій стрічці подій." - step6_2: "Також ви можете швидко відповісти, або \"відреагувати\" на записи інших користувачів." - step6_3: "Щоб \"відреагувати\", нажміть на знак плюс \"+\" на записі і виберіть емоджі яким ви хочете \"відреагувати\"." - step7_1: "Вітаю! Ви пройшли ознайомлення з Misskey." - step7_2: "Якщо ви хочете більше дізнатись про Misskey, зайдіть в розділ {help}." - step7_3: "Насолоджуйтесь Misskey! 🚀" - step8_1: "Наостанку, чи бажаєте ви ввімкнути push-сповіщення?" - step8_3: "Ви завжди можете змінити цей параметр пізніше." _2fa: alreadyRegistered: "Двофакторна автентифікація вже налаштована." step1: "Спершу встановіть на свій пристрій програму автентифікації (наприклад {a} або {b})." diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index ebd046b6d4..d620d99d80 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -261,12 +261,12 @@ nUsersRead: "đọc bởi {n}" agreeTo: "Tôi đồng ý {0}" agreeBelow: "Đồng ý với nội dung dưới đây" basicNotesBeforeCreateAccount: "Những điều cơ bản cần chú ý " -tos: "Điều khoản dịch vụ" start: "Bắt đầu" home: "Trang chính" remoteUserCaution: "Vì người dùng này ở máy chủ khác, thông tin hiển thị có thể không đầy đủ." activity: "Hoạt động" images: "Hình ảnh" +image: "Hình ảnh" birthday: "Sinh nhật" yearsOld: "{age} tuổi" registeredDate: "Tham gia" @@ -944,6 +944,8 @@ copyErrorInfo: "Sao chép thông tin lỗi" joinThisServer: "Đăng ký trên chủ máy này" exploreOtherServers: "Tìm chủ máy khác" letsLookAtTimeline: "Thử xem Timeline" +horizontal: "Thanh bên" +youFollowing: "Đang theo dõi" _achievements: earnedAt: "Ngày thu nhận" _types: @@ -1349,31 +1351,6 @@ _time: minute: "phút" hour: "giờ" day: "ngày" -_tutorial: - title: "Cách dùng Misskey" - step1_1: "Xin chào!" - step1_2: "Trang này gọi là \"bảng tin\". Nó hiện \"tút\" từ những người mà bạn \"theo dõi\" theo thứ tự thời gian." - step1_3: "Bảng tin của bạn đang trống, bởi vì bạn chưa đăng tút nào hoặc chưa theo dõi ai." - step2_1: "Hãy hoàn thành việc thiết lập hồ sơ của bạn trước khi viết tút hoặc theo dõi bất kỳ ai." - step2_2: "Cung cấp một số thông tin giới thiệu bạn là ai sẽ giúp người khác dễ dàng biết được họ muốn đọc tút hay theo dõi bạn." - step3_1: "Hoàn thành thiết lập hồ sơ của bạn?" - step3_2: "Sau đó, hãy thử đăng một tút tiếp theo. Bạn có thể làm như vậy bằng cách nhấn vào nút có biểu tượng bút chì trên màn hình." - step3_3: "Nhập nội dung vào khung soạn thảo và nhấn nút đăng ở góc trên." - step3_4: "Chưa biết nói gì? Thử \"Tôi mới tham gia Misskey\"!" - step4_1: "Đăng xong tút đầu tiên của bạn?" - step4_2: "De! Tút đầu tiên của bạn đã hiện trên bảng tin." - step5_1: "Bây giờ, hãy thử làm cho bảng tin của bạn sinh động hơn bằng cách theo dõi những người khác." - step5_2: "{feature} sẽ hiển thị cho bạn các tút nổi bật trên máy chủ này. {explore} sẽ cho phép bạn tìm thấy những người dùng thú vị. Hãy thử tìm những người bạn muốn theo dõi ở đó!" - step5_3: "Để theo dõi những người dùng khác, hãy nhấn vào ảnh đại diện của họ và nhấn nút \"Theo dõi\" trên hồ sơ của họ." - step5_4: "Nếu người dùng khác có biểu tượng ổ khóa bên cạnh tên của họ, có thể mất một khoảng thời gian để người dùng đó phê duyệt yêu cầu theo dõi của bạn theo cách thủ công." - step6_1: "Bạn sẽ có thể xem tút của những người dùng khác trên bảng tin của mình ngay bây giờ." - step6_2: "Bạn cũng có thể đặt \"biểu cảm\" trên tút của người khác để phản hồi nhanh chúng." - step6_3: "Để đính kèm \"biểu cảm\", hãy nhấn vào dấu \"+\" trên tút của người dùng khác rồi chọn biểu tượng cảm xúc mà bạn muốn dùng." - step7_1: "Xin chúc mừng! Bây giờ bạn đã hoàn thành phần hướng dẫn cơ bản của Misskey." - step7_2: "Nếu bạn muốn tìm hiểu thêm về Misskey, hãy thử phần {help}." - step7_3: "Bây giờ, chúc may mắn và vui vẻ với Misskey! 🚀" - step8_1: "Cuối cùng, bạn hãy bật thông báo đẩy nha!" - step8_2: "Nhận thông báo đẩy bạn sẽ có thể thấy phản hồi, theo dõi, lượt nhắc được trong khi đóng Misskey" _2fa: alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước." passwordToTOTP: "Nhắn mật mã" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 62f6bc6c32..1fe82185be 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -114,7 +114,7 @@ sensitive: "敏感内容" add: "添加" reaction: "回应" reactions: "回应" -reactionSetting: "在选择器中显示的回应" +reactionSetting: "在选择器中显示回应" reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。" rememberNoteVisibility: "保存上次设置的可见性" attachCancel: "删除附件" @@ -167,7 +167,7 @@ searchWith: "搜索:{q}" youHaveNoLists: "列表为空" followConfirm: "你确定要关注{name}吗?" proxyAccount: "代理账户" -proxyAccountDescription: "代理账户是在某些情况下充当用户的远程关注者的账户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该实例,因此将代之以代理账户。" +proxyAccountDescription: "代理账户是在某些情况下充当用户的远程关注者的账户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该服务器,因此将代之以代理账户。" host: "主机名" selectUser: "选择用户" recipient: "收件人" @@ -263,14 +263,16 @@ noMoreHistory: "没有更多的历史记录" startMessaging: "添加聊天" nUsersRead: "{n}人已读" agreeTo: "勾选则表示已阅读并同意{0}" -agreeBelow: "同意以下观点" +agree: "同意" +agreeBelow: "同意以下内容" basicNotesBeforeCreateAccount: "基本注意事项" -tos: "服务条款" +termsOfService: "服务条款" start: "开始" home: "首页" remoteUserCaution: "由于此用户来自其它服务器,显示的信息可能不完整。" activity: "活动" images: "图片" +image: "图片" birthday: "生日" yearsOld: "{age}岁" registeredDate: "注册于" @@ -403,7 +405,7 @@ totpDescription: "使用认证应用输入一次性密码。" moderator: "监察员" moderation: "管理" nUsersMentioned: "{n} 被提到" -securityKeyAndPasskey: "安全密钥/密码" +securityKeyAndPasskey: "安全密钥或 Passkey" securityKey: "安全密钥" lastUsed: "最后使用:" lastUsedAt: "最后使用: {t}" @@ -474,6 +476,8 @@ createAccount: "注册账户" existingAccount: "现有的账户" regenerate: "重新生成" fontSize: "字体大小" +mediaListWithOneImageAppearance: "仅一张图片的媒体列表高度" +limitTo: "上限为{x}" noFollowRequests: "没有关注申请" openImageInNewTab: "在新标签页中打开图片" dashboard: "管理面板" @@ -613,7 +617,7 @@ testEmail: "邮件发送测试" wordMute: "文字屏蔽" regexpError: "正则表达式错误" regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:" -instanceMute: "实例的屏蔽" +instanceMute: "被屏蔽的服务器" userSaysSomething: "{name}说了什么,但是被屏蔽词过滤了" makeActive: "启用" display: "显示" @@ -699,6 +703,8 @@ contact: "联系人" useSystemFont: "使用系统默认字体" clips: "便签" experimentalFeatures: "实验性功能" +experimental: "实验性的" +thisIsExperimentalFeature: "这是一项实验性功能。规范可能会变更,或者可能无法正常工作。" developer: "开发者" makeExplorable: "使账号可见。" makeExplorableDescription: "关闭时,账号不会显示在\"发现\"中。" @@ -886,7 +892,7 @@ typeToConfirm: "输入 {x} 以确认操作。" deleteAccount: "删除账户" document: "文档" numberOfPageCache: "缓存页数" -numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。" +numberOfPageCacheDescription: "设置较高的值会更方便用户,但服务器负载和内存使用量会增加。" logoutConfirm: "是否确认登出?" lastActiveDate: "最后活跃时间" statusbar: "状态栏" @@ -905,6 +911,7 @@ remoteOnly: "仅远程" failedToUpload: "上传失败" cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法上传。" cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。" +cannotUploadBecauseExceedsFileSizeLimit: "无法上传文件,超过文件大小限制。" beta: "测试" enableAutoSensitive: "自动 NSFW 识别" enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。" @@ -938,6 +945,7 @@ didYouLikeMisskey: "您喜欢Misskey吗?" pleaseDonate: "Misskey是{host}所使用的免费软件。为了今后也能够维持Misskey的开发,请在有余力的情况下进行捐助!" roles: "角色" role: "角色" +noRole: "角色不存在" normalUser: "普通用户" undefined: "未定义" assign: "分配" @@ -947,6 +955,10 @@ manageCustomEmojis: "管理自定义表情符号" youCannotCreateAnymore: "抱歉,您无法再创建更多了。" cannotPerformTemporary: "暂时不可用" cannotPerformTemporaryDescription: "因操作过于频繁,暂时不可用,请稍后再试。" +invalidParamError: "参数错误" +invalidParamErrorDescription: "请求参数出现问题。通常是因为bug造成的,但也可能是输入文字数量过多之类的原因。" +permissionDeniedError: "操作被拒绝" +permissionDeniedErrorDescription: "本账户没有执行该操作的权限。" preset: "預設值" selectFromPresets: "從預設值中選擇" achievements: "成就" @@ -990,17 +1002,45 @@ enableChartsForFederatedInstances: "生成远程服务器的图表" showClipButtonInNoteFooter: "在贴文下方显示便签按钮" largeNoteReactions: "使用大图标来显示回应" noteIdOrUrl: "帖子ID或URL" +video: "视频" +videos: "视频" +dataSaver: "省流量模式" accountMigration: "账户迁移" accountMoved: "此用户已迁移账户" +accountMovedShort: "此帐户已迁移" +operationForbidden: "不允许此操作" forceShowAds: "总是显示广告" +addMemo: "添加备注" +editMemo: "编辑备注" +reactionsList: "回应列表" +renotesList: "转发列表" +notificationDisplay: "显示通知" +leftTop: "屏幕左上方" +rightTop: "屏幕右上方" +leftBottom: "屏幕左下方" +rightBottom: "屏幕右下方" +stackAxis: "堆叠方向" +vertical: "纵向" +horizontal: "横向" +position: "位置" +serverRules: "服务器规则" +pleaseConfirmBelowBeforeSignup: "在这个服务器上注册账号前,请确认以下信息。" +pleaseAgreeAllToContinue: "必须全部勾选「同意」才能够继续。" +continue: "继续" +preservedUsernames: "保留的用户名" +createNoteFromTheFile: "从文件创建帖子" +youFollowing: "正在关注" +_serverRules: + description: "在新用户注册前显示服务器的简单规则。推荐显示服务条款的主要内容。" _accountMigration: - moveTo: "把这个账户迁移到新的账户" - moveToLabel: "迁移后的账户" - moveAccountDescription: "此操作无法取消。请先确认您已在迁移后的账户上,为此账户创造了别名。创造别名后,请如以下输入您的迁移后的账户:@person@instance.com" moveFrom: "从别的账号迁移到此账户" moveFromLabel: "迁移前的账户" moveFromDescription: "如果迁移时需要继承其他账户的关注者,请在此创造别名。此操作需要在实行迁移之前完成!请如已下输入需要迁移的账户:@person@instance.com" + moveTo: "把这个账户迁移到新的账户" + moveToLabel: "迁移后的账户" + moveAccountDescription: "此操作无法取消。请先确认您已在迁移后的账户上,为此账户创造了别名。创造别名后,请如以下输入您的迁移后的账户:@person@instance.com" migrationConfirm: "确定要把此账户迁移到{account}吗?一旦确定后,此操作无法取消,此账户也无法以原来的状态使用。\n同时,请确认迁移后的账户,已创造别名。" + movedTo: "迁移后的账户" _achievements: earnedAt: "达成时间" _types: @@ -1172,6 +1212,9 @@ _achievements: _client30min: title: "休息一下!" description: "启动客户端超过30分钟" + _client60min: + title: "Misskey重度依赖" + description: "启动客户端超过60分钟" _noteDeletedWithin1min: title: "欲言又止" description: "发帖后一分钟内就将其删除" @@ -1260,6 +1303,8 @@ _role: iconUrl: "图标URL" asBadge: "作为徽章显示" descriptionOfAsBadge: "开启后,用户名旁边将会出现角色图标。" + isExplorable: "公开角色时间线" + descriptionOfIsExplorable: "打开后将公开角色时间线。如果角色不是公开的,就无法公开时间线。" displayOrder: "显示顺序" descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" canEditMembersByModerator: "允许监察者编辑成员" @@ -1527,32 +1572,6 @@ _time: minute: "分" hour: "小时" day: "日" -_tutorial: - title: "Misskey的使用方法" - step1_1: "欢迎!" - step1_2: "这个页面叫做「时间线」,它会按照时间顺序显示所有你「关注」的人所发的「帖子」。" - step1_3: "如果你并没有发布任何帖子,也没有关注其他的人,你的时间线页面应当什么都没有显示。" - step2_1: "在您想要发帖或关注其他人之前,请先设置一下个人资料吧。" - step2_2: "如果别人能够更加的了解你,关注你的概率也会得到提升。" - step3_1: "已经设置完个人资料了吗?" - step3_2: "那么接下来,试着写一些什么东西来发布吧。你可以通过点击屏幕上的铅笔图标来打开投稿页面。" - step3_3: "写完内容后,点击窗口右上方的按钮就可以投稿。" - step3_4: "不知道说些什么好吗?那就写下「Misskey我来啦!」这样的话吧。" - step4_1: "将你的话语发布出去了吗?" - step4_2: "太棒了!现在你可以在你的时间线中看到你刚刚发布的帖子了。" - step5_1: "接下来,关注其他人来使时间线更生动吧。" - step5_2: "{featured}将向您展示热门趋势的帖子。 {explore}将让您找到热门用户。 尝试关注您喜欢的人!" - step5_3: "要关注其他用户,请单击他的头像,然后在他的个人资料上按下“关注”按钮。" - step5_4: "如果用户的名称旁边有锁定图标,则该用户需要手动批准您的关注请求。" - step6_1: "现在,您将可以在时间线上看到其他用户的帖子。" - step6_2: "您还可以在其他人的帖子上进行「回应」,以快速做出简单回复。" - step6_3: "在他人的贴子上按下「+」图标,即可选择想要的表情来进行「回应」。" - step7_1: "对Misskey基本操作的简单介绍,就到此结束了。 辛苦了!" - step7_2: "如果你想了解更多有关Misskey的信息,请参见{help}。" - step7_3: "接下来,享受Misskey带来的乐趣吧🚀" - step8_1: "最后,您想要启用推送通知消息吗?" - step8_2: "通过接收推送通知消息,即使没开 Misskey,您也可以收到回应、关注、提及等的消息。" - step8_3: "您也可以稍后再更改通知设置。" _2fa: alreadyRegistered: "此设备已被注册" registerTOTP: "开始设置认证应用" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 09253db2d7..4073b9629d 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -2,7 +2,7 @@ _lang_: "繁體中文" headlineMisskey: "貼文連繫網路" introMisskey: "歡迎! Misskey是一個開放原始碼且去中心化的社群網路。\n透過「貼文」分享周邊新鮮事,並告訴其他人您的想法!📡\n透過「反應」功能,對大家的貼文表達情感!👍\n一起來探索這個新的世界吧!🚀" -poweredByMisskeyDescription: "{name}是使用開放原始碼平台Misskey的服務之一(稱為 Misskey 實例)。\n" +poweredByMisskeyDescription: "{name}是使用開放原始碼平台Misskey的服務之一(稱為 Misskey 伺服器)。\n" monthAndDay: "{month}月 {day}日" search: "搜尋" notifications: "通知" @@ -18,7 +18,7 @@ enterUsername: "輸入使用者名稱" renotedBy: "{user} 轉發了" noNotes: "無貼文。" noNotifications: "沒有通知" -instance: "實例" +instance: "伺服器" settings: "設定" notificationSettings: "通知選項" basicSettings: "基本設定" @@ -86,7 +86,7 @@ somethingHappened: "發生錯誤" retry: "重試" pageLoadError: "載入頁面失敗" pageLoadErrorDescription: "這通常是因為網路錯誤或是瀏覽器快取殘留的原因。請先清除瀏覽器快取,稍後再重試" -serverIsDead: "伺服器沒有回應。請稍等片刻,然後重試。" +serverIsDead: "伺服器沒有回應。請稍等片刻再試。" youShouldUpgradeClient: "請重新載入以使用新版本的客戶端顯示此頁面" enterListName: "輸入清單名稱" privacy: "隱私" @@ -96,7 +96,7 @@ follow: "追隨" followRequest: "追隨請求" followRequests: "追隨請求" unfollow: "取消追隨" -followRequestPending: "追隨許可批准中" +followRequestPending: "追隨許可待批准" enterEmoji: "輸入表情符號" renote: "轉發" unrenote: "取消轉發" @@ -150,11 +150,11 @@ cacheRemoteFiles: "快取遠端檔案" cacheRemoteFilesDescription: "禁用此設定會停止遠端檔案的緩存,從而節省儲存空間,但資料會因直接連線從而產生額外連接數據。" flagAsBot: "此使用者是機器人" flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整Misskey內部系統將本帳戶識別為機器人" -flagAsCat: "此使用者是貓" +flagAsCat: "喵~~~~~~~~~~~~~~!!!!!!!!!!!!" flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示" flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" flagShowTimelineRepliesDescription: "啟用時,時間線除了顯示用戶的貼文以外,還會顯示用戶對其他貼文的回覆。" -autoAcceptFollowed: "自動追隨中使用者的追隨請求" +autoAcceptFollowed: "自動允許來自追隨中使用者的追隨請求" addAccount: "添加帳戶" reloadAccountsList: "更新帳戶清單的資訊" loginFailed: "登入失敗" @@ -167,7 +167,7 @@ searchWith: "搜尋: {q}" youHaveNoLists: "你沒有任何清單" followConfirm: "你真的要追隨{name}嗎?" proxyAccount: "代理帳戶" -proxyAccountDescription: "代理帳戶是在某些情況下充當其他伺服器用戶的帳戶。例如,當使用者將一個來自其他伺服器的帳戶放在列表中時,由於沒有其他使用者追蹤該帳戶,該指令不會傳送到該伺服器上,因此會由代理帳戶追蹤。" +proxyAccountDescription: "代理帳戶是在某些情況下充當其他伺服器用戶的帳戶。例如,當使用者將一個來自其他伺服器的帳戶放在列表中時,由於沒有其他使用者追隨該帳戶,該指令不會傳送到該伺服器上,因此會由代理帳戶追隨。" host: "主機" selectUser: "選取使用者" recipient: "收件人" @@ -182,7 +182,7 @@ charts: "圖表" perHour: "每小時" perDay: "每日" stopActivityDelivery: "停止發送活動" -blockThisInstance: "封鎖此實例" +blockThisInstance: "封鎖此伺服器" operations: "操作" software: "軟體" version: "版本" @@ -193,15 +193,15 @@ jobQueue: "佇列" cpuAndMemory: "CPU及記憶體用量" network: "網路" disk: "硬碟" -instanceInfo: "實例資訊" +instanceInfo: "伺服器資訊" statistics: "統計" clearQueue: "清除佇列" clearQueueConfirmTitle: "確定要清除佇列嗎?" clearQueueConfirmText: "未發佈的貼文將不會發佈。您通常不需要確認。" clearCachedFiles: "清除快取資料" clearCachedFilesConfirm: "確定要清除所有遠端暫存資料嗎?" -blockedInstances: "已封鎖的實例" -blockedInstancesDescription: "請逐行輸入需要封鎖的實例。已封鎖的實例將無法與本實例進行通訊。" +blockedInstances: "已封鎖的伺服器" +blockedInstancesDescription: "請逐行輸入需要封鎖的伺服器。已封鎖的伺服器將無法與本伺服器進行通訊。" muteAndBlock: "靜音和封鎖" mutedUsers: "已靜音用戶" blockedUsers: "已封鎖用戶" @@ -224,8 +224,8 @@ all: "全部" subscribing: "訂閱中" publishing: "直播中" notResponding: "沒有回應" -instanceFollowing: "追蹤實例" -instanceFollowers: "追蹤實例" +instanceFollowing: "追隨的伺服器" +instanceFollowers: "伺服器的追隨者" instanceUsers: "用戶" changePassword: "修改密碼" security: "安全性" @@ -263,14 +263,16 @@ noMoreHistory: "沒有更多歷史紀錄" startMessaging: "開始聊天" nUsersRead: "{n}人已讀" agreeTo: "我同意{0}" +agree: "同意" agreeBelow: "同意以下內容" basicNotesBeforeCreateAccount: "基本注意事項" -tos: "使用條款" +termsOfService: "服務條款" start: "開始" home: "首頁" remoteUserCaution: "由於該使用者來自遠端實例,因此資訊可能非即時的。" activity: "動態" images: "圖片" +image: "圖片" birthday: "生日" yearsOld: "{age}歲" registeredDate: "註冊日期" @@ -318,8 +320,8 @@ unwatch: "取消追隨" accept: "接受" reject: "拒絕" normal: "正常" -instanceName: "實例名稱" -instanceDescription: "實例說明" +instanceName: "伺服器名稱" +instanceDescription: "伺服器介紹" maintainerName: "管理員名稱" maintainerEmail: "管理員郵箱" tosUrl: "服務條款URL" @@ -390,7 +392,7 @@ recentlyUpdatedUsers: "最近發文的使用者" recentlyRegisteredUsers: "新加入使用者" recentlyDiscoveredUsers: "最近發現的使用者" exploreUsersCount: "有{count}個使用者" -exploreFediverse: "探索聯邦世界" +exploreFediverse: "探索聯邦宇宙" popularTags: "熱門標籤" userList: "使用者清單" about: "關於" @@ -474,7 +476,9 @@ createAccount: "建立帳戶" existingAccount: "現有帳戶" regenerate: "再生" fontSize: "字體大小" -noFollowRequests: "沒有跟隨您的請求" +mediaListWithOneImageAppearance: "僅1枚圖片的媒體列表高度" +limitTo: "上限為{x}" +noFollowRequests: "沒有追隨您的請求" openImageInNewTab: "於新分頁中開啟圖片" dashboard: "儀表板" local: "本地" @@ -483,7 +487,7 @@ total: "合計" weekOverWeekChanges: "與上週相比" dayOverDayChanges: "與前一日相比" appearance: "外觀" -clientSettings: "用戶端設定" +clientSettings: "客戶端設定" accountSettings: "帳戶設定" promotion: "推廣" promote: "推廣" @@ -543,8 +547,8 @@ disablePagesScript: "停用頁面的AiScript腳本" updateRemoteUser: "更新遠端使用者資訊" deleteAllFiles: "刪除所有檔案" deleteAllFilesConfirm: "要删除所有檔案嗎?" -removeAllFollowing: "解除所有追蹤" -removeAllFollowingDescription: "解除{host}所有的追蹤。在實例不再存在時執行。" +removeAllFollowing: "解除所有追隨" +removeAllFollowingDescription: "解除{host}所有的追隨。在伺服器不再存在時執行。" userSuspended: "該使用者已被停用" userSilenced: "該用戶已被禁言。" yourAccountSuspendedTitle: "帳戶已被凍結" @@ -556,6 +560,7 @@ accountDeletedDescription: "這個帳戶已被刪除。" menu: "選單" divider: "分割線" addItem: "新增項目" +rearrange: "排序方式" relays: "中繼" addRelay: "新增中繼" inboxUrl: "收件夾URL" @@ -673,8 +678,8 @@ repliesCount: "回覆數量" renotesCount: "轉發數量" repliedCount: "回覆數量" renotedCount: "轉發次數" -followingCount: "正在跟隨的用戶數量" -followersCount: "跟隨者數量" +followingCount: "正在追隨的用戶數量" +followersCount: "追隨者數量" sentReactionsCount: "反應發送次數" receivedReactionsCount: "收到反應次數" pollVotesCount: "已統計的投票數" @@ -699,6 +704,8 @@ contact: "聯絡人" useSystemFont: "使用系統預設的字型" clips: "摘錄" experimentalFeatures: "實驗中的功能" +experimental: "實驗性" +thisIsExperimentalFeature: "這是實驗性的功能。可能會有變更規格和不能正常動作的可能性。" developer: "開發者" makeExplorable: "使自己的帳戶能夠在「探索」頁面中顯示" makeExplorableDescription: "如果關閉,帳戶將不會被顯示在「探索」頁面中。" @@ -734,8 +741,8 @@ registry: "登錄表" closeAccount: "停用帳戶" currentVersion: "目前版本" latestVersion: "最新版本" -youAreRunningUpToDateClient: "您所使用的用戶端已經是最新的。" -newVersionOfClientAvailable: "新版本的用戶端可用。" +youAreRunningUpToDateClient: "您所使用的客戶端已經是最新的。" +newVersionOfClientAvailable: "新版本的客戶端可用。" usageAmount: "使用量" capacity: "容量" inUse: "已使用" @@ -821,7 +828,7 @@ pubSub: "Pub/Sub 帳戶" lastCommunication: "最近的通信" resolved: "已解決" unresolved: "未解決" -breakFollow: "移除追蹤者" +breakFollow: "解除追隨者" breakFollowConfirm: "確定要取消被追隨嗎?" itsOn: "已開啟" itsOff: "已關閉" @@ -905,6 +912,7 @@ remoteOnly: "僅限遠端" failedToUpload: "上傳失敗" cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上傳。" cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上傳。" +cannotUploadBecauseExceedsFileSizeLimit: "由於超過了檔案大小的限制,無法上傳。" beta: "Beta" enableAutoSensitive: "自動NSFW判定" enableAutoSensitiveDescription: "如果可用,請利用機器學習在媒體上自動設置 NSFW 旗標。 即使關閉此功能,依實例而定也可能會自動設置。" @@ -938,6 +946,7 @@ didYouLikeMisskey: "您是否喜愛Misskey呢?" pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發能夠持續!" roles: "角色" role: "角色" +noRole: "沒有角色" normalUser: "一般使用者" undefined: "未定義" assign: "指派" @@ -947,6 +956,10 @@ manageCustomEmojis: "管理自訂表情符號" youCannotCreateAnymore: "您無法再建立更多了。" cannotPerformTemporary: "暫時無法進行" cannotPerformTemporaryDescription: "由於超過操作次數限制,暫時無法進行。請過一段時間之後再嘗試。" +invalidParamError: "參數錯誤" +invalidParamErrorDescription: "請求參數有問題。通常是bug造成的,但也有輸入的字元數過多之類的可能性。" +permissionDeniedError: "操作被拒絕" +permissionDeniedErrorDescription: "本帳號沒有執行這個操作的權限。" preset: "預設值" selectFromPresets: "從預設值中選擇" achievements: "成就" @@ -990,17 +1003,57 @@ enableChartsForFederatedInstances: "生成遠端伺服器的圖表" showClipButtonInNoteFooter: "將摘錄添加至貼文" largeNoteReactions: "將貼文的反應放大顯示" noteIdOrUrl: "貼文ID或URL" +video: "影片" +videos: "影片" +dataSaver: "數據節省模式" accountMigration: "遷移帳戶" accountMoved: "這個使用者已遷移至新的帳戶:" +accountMovedShort: "此帳戶已遷移" +operationForbidden: "不允許此操作" forceShowAds: "總是顯示廣告" +addMemo: "新增備註" +editMemo: "編輯備註" +reactionsList: "反應列表" +renotesList: "轉發貼文列表" +notificationDisplay: "通知的顯示" +leftTop: "左上" +rightTop: "右上" +leftBottom: "左下" +rightBottom: "右下" +stackAxis: "堆疊方向" +vertical: "縱向" +horizontal: "側向" +position: "位置" +serverRules: "伺服器規則" +pleaseConfirmBelowBeforeSignup: "在本伺服器註冊之前,請確認下列事項。" +pleaseAgreeAllToContinue: "必須全部勾選「同意」才能繼續。" +continue: "繼續" +preservedUsernames: "保留的使用者名稱" +preservedUsernamesDescription: "換行列舉要保留的使用者名稱。此處指定的使用者名稱,在建立帳戶時無法使用,但由管理者所建立的帳戶不受此限。此外,既有的帳戶也不受影響。" +createNoteFromTheFile: "由此檔案建立貼文" +archive: "封存" +channelArchiveConfirmTitle: "要封存{name}嗎?" +channelArchiveConfirmDescription: "封存以後,在頻道列表與搜索結果中不會顯示,也無法發布新的貼文。" +thisChannelArchived: "這個頻道已被封存。" +displayOfNote: "顯示貼文" +youFollowing: "關注中" +_serverRules: + description: "設定伺服器的簡要規則,在新的註冊之前顯示。建議的內容是使用條款的摘要。" _accountMigration: + moveFrom: "從其他帳戶遷移到這個帳戶" + moveFromSub: "為另一個帳戶建立別名" + moveFromLabel: "要遷移過來的帳戶:" + moveFromDescription: "如果你想把追隨者從別的帳戶遷移過來,必須先在這裡建立別名。請務必在執行遷移之前建立別名!請像這樣輸入要遷移的帳戶:@person@instance.com" moveTo: "將這個帳戶遷移至新的帳戶" moveToLabel: "要遷移到的帳戶:" + moveCannotBeUndone: "一旦遷移帳戶,就無法取消。" moveAccountDescription: "這個操作不可撤銷。首先,請確認已在要遷移到的帳戶中為這個帳戶建立了一個別名。建立別名之後,像這樣輸入你要遷移到的帳戶:@person@instance.com" - moveFrom: "從其他帳戶遷移到這個帳戶" - moveFromLabel: "要遷移過來的帳戶:" - moveFromDescription: "如果你想把跟隨者從別的帳戶遷移過來,必須先在這裡建立別名。請務必在執行遷移之前建立別名!請像這樣輸入要遷移的帳戶:@person@instance.com" + moveAccountHowTo: "要遷移帳戶,首先要在目標帳戶中為此帳戶建立一個別名。\n 建立別名後,像這樣輸入目標帳戶:@username@server.example.com" + startMigration: "遷移" migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" + movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。" + postMigrationNote: "在遷移操作後的24小時之後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。" + movedTo: "要遷移到的帳戶:" _achievements: earnedAt: "獲得日期" _types: @@ -1125,31 +1178,31 @@ _achievements: description: "首次追隨了" _following10: title: "跟著跟著" - description: "跟隨超過10人了" + description: "追隨超過10人了" _following50: title: "朋友很多" - description: "跟隨超過50人了" + description: "追隨超過50人了" _following100: title: "100位朋友" - description: "跟隨超過100人了" + description: "追隨超過100人了" _following300: title: "朋友過多" - description: "跟隨超過300人了" + description: "追隨超過300人了" _followers1: title: "第一個追隨者" description: "第一次被追隨" _followers10: title: "Follow me!" - description: "跟隨者超過10人了" + description: "追隨者超過10人了" _followers50: title: "成群結隊" - description: "跟隨者超過50人了" + description: "追隨者超過50人了" _followers100: title: "熱門人物" - description: "跟隨者超過100人了" + description: "追隨者超過100人了" _followers300: title: "請排成一排" - description: "跟隨者超過300人了" + description: "追隨者超過300人了" _followers500: title: "基地台" description: "超過500名追隨者了" @@ -1171,7 +1224,10 @@ _achievements: description: "發現了隱藏的寶藏" _client30min: title: "休息一下" - description: "用戶端啟動已超過30分鐘" + description: "客戶端啟動已超過30分鐘" + _client60min: + title: "Misskey看太多" + description: "客戶端啟動已超過60分鐘" _noteDeletedWithin1min: title: "現在沒有了" description: "發文後1分鐘內刪文" @@ -1260,6 +1316,8 @@ _role: iconUrl: "圖示的URL" asBadge: "顯示為徽章" descriptionOfAsBadge: "開啟的話,角色圖示會顯示在用戶名旁邊。" + isExplorable: "公開角色時間軸" + descriptionOfIsExplorable: "若開啟則公開角色時間軸。若角色不是公開的,則無法公開時間軸。" displayOrder: "顯示順序" descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" canEditMembersByModerator: "允許編輯審查員的成員" @@ -1276,6 +1334,7 @@ _role: canInvite: "發行實例邀請碼" canManageCustomEmojis: "管理自訂表情符號" driveCapacity: "雲端硬碟容量" + alwaysMarkNsfw: "總是將檔案標記為NSFW" pinMax: "置頂貼文的最大數量" antennaMax: "可建立的天線數量" wordMuteMax: "靜音文字的最大字數" @@ -1527,32 +1586,6 @@ _time: minute: "分鐘" hour: "小時" day: "日" -_tutorial: - title: "Misskey使用方法" - step1_1: "歡迎!" - step1_2: "此為「時間軸」頁面,它會按照時間順序顯示你「追隨」的人發出的「貼文」。" - step1_3: "由於你沒有發佈任何貼文,也沒有追隨任何人,所以你的時間軸目前是空的。" - step2_1: "在發文或追隨其他人之前先讓我們設定一下個人資料吧。" - step2_2: "提供一些關於自己的資訊來讓其他人更有追隨你的意願。" - step3_1: "個人資料都設定好了嗎?" - step3_2: "接下來,讓我們來試試看發個文,按一下畫面上的鉛筆圖示來開始" - step3_3: "輸入完內容後,按視窗右上角的按鈕來發文" - step3_4: "不知道該寫什麼內容嗎?試試看「開始使用Misskey了」如何。" - step4_1: "貼文發出去了嗎?" - step4_2: "如果你的貼文出現在時間軸上,就代表發文成功。" - step5_1: "現在試試看追隨其他人來讓你的時間軸變得更生動吧。" - step5_2: "你會在{featured}上看到受歡迎的貼文,你也可以從列表中追隨你喜歡的人,或者在{explore}上找到熱門使用者。" - step5_3: "想要追隨其他人,只要點擊他們的大頭貼並按「追隨」即可。" - step5_4: "如果使用者的名字旁有鎖頭的圖示,代表他們需要手動核准你的追隨請求。" - step6_1: "現在你可以在時間軸上看到其他用戶的貼文。" - step6_2: "你也可以對別人的貼文作出「反應」,作出簡單的回覆。" - step6_3: "在他人的貼文按下\"+\"圖標,即可選擇喜好的表情符號進行回應。" - step7_1: "以上為Misskey的基本操作說明,教學在此告一段落。辛苦了。" - step7_2: "歡迎到{help}來瞭解更多Misskey相關介紹。" - step7_3: "那麼,祝您在Misskey玩的開心~ 🚀" - step8_1: "最後,要不要試試看啟用推播通知呢?" - step8_2: "透過接收推播通知,即使沒有打開Misskey,您也會知道反應、追隨與提及的情況。" - step8_3: "通知的設定可以在之後變更。" _2fa: alreadyRegistered: "此設備已經被註冊過了" registerTOTP: "開始設定驗證應用程式" diff --git a/package.json b/package.json index a22bb2873f..28e1cdcf1e 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "13.11.3", + "version": "13.12.1", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@8.1.1", + "packageManager": "pnpm@8.3.1", "workspaces": [ "packages/frontend", "packages/backend", @@ -51,19 +51,19 @@ "gulp-replace": "1.1.4", "gulp-terser": "2.1.0", "js-yaml": "4.1.0", - "typescript": "5.0.3" + "typescript": "5.0.4" }, "devDependencies": { "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", - "@typescript-eslint/eslint-plugin": "5.57.1", - "@typescript-eslint/parser": "5.57.1", + "@typescript-eslint/eslint-plugin": "5.59.2", + "@typescript-eslint/parser": "5.59.2", "cross-env": "7.0.3", - "cypress": "12.9.0", - "eslint": "8.37.0", + "cypress": "12.11.0", + "eslint": "8.39.0", "start-server-and-test": "2.0.0" }, "optionalDependencies": { - "@tensorflow/tfjs-core": "4.2.0" + "@tensorflow/tfjs-core": "4.4.0" } } diff --git a/packages/backend/migration/1680702787050-UserMemo.js b/packages/backend/migration/1680702787050-UserMemo.js new file mode 100644 index 0000000000..7446bf8da5 --- /dev/null +++ b/packages/backend/migration/1680702787050-UserMemo.js @@ -0,0 +1,18 @@ +export class UserMemo1680702787050 { + name = 'UserMemo1680702787050' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "user_memo" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "targetUserId" character varying(32) NOT NULL, "memo" character varying(2048) NOT NULL, CONSTRAINT "PK_e9aaa58f7d3699a84d79078f4d9" PRIMARY KEY ("id")); COMMENT ON COLUMN "user_memo"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "user_memo"."targetUserId" IS 'The ID of target user.'; COMMENT ON COLUMN "user_memo"."memo" IS 'Memo.'`); + await queryRunner.query(`CREATE INDEX "IDX_650b49c5639b5840ee6a2b8f83" ON "user_memo" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_66ac4a82894297fd09ba61f3d3" ON "user_memo" ("targetUserId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_faef300913c738265638ba3ebc" ON "user_memo" ("userId", "targetUserId") `); + await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35"`); + await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e"`); + await queryRunner.query(`DROP TABLE "user_memo"`); + } +} diff --git a/packages/backend/migration/1681400427971-serverRules.js b/packages/backend/migration/1681400427971-serverRules.js new file mode 100644 index 0000000000..2364e8e1d2 --- /dev/null +++ b/packages/backend/migration/1681400427971-serverRules.js @@ -0,0 +1,11 @@ +export class ServerRules1681400427971 { + name = 'ServerRules1681400427971' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "serverRules" character varying(280) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "serverRules"`); + } +} diff --git a/packages/backend/migration/1681870960239-RoleTLSetting.js b/packages/backend/migration/1681870960239-RoleTLSetting.js new file mode 100644 index 0000000000..2280f44eaa --- /dev/null +++ b/packages/backend/migration/1681870960239-RoleTLSetting.js @@ -0,0 +1,12 @@ +export class RoleTLSetting1681870960239 { + name = 'RoleTLSetting1681870960239' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "isExplorable" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isExplorable"`); + } + +} diff --git a/packages/backend/migration/1682190963894-movedAt.js b/packages/backend/migration/1682190963894-movedAt.js new file mode 100644 index 0000000000..1f8f030a5c --- /dev/null +++ b/packages/backend/migration/1682190963894-movedAt.js @@ -0,0 +1,13 @@ +export class MovedAt1682190963894 { + name = 'MovedAt1682190963894' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedAt"`); + } +} diff --git a/packages/backend/migration/1682754135458-preservedUsernames.js b/packages/backend/migration/1682754135458-preservedUsernames.js new file mode 100644 index 0000000000..46a0826f43 --- /dev/null +++ b/packages/backend/migration/1682754135458-preservedUsernames.js @@ -0,0 +1,11 @@ +export class PreservedUsernames1682754135458 { + name = 'PreservedUsernames1682754135458' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "preservedUsernames" character varying(1024) array NOT NULL DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "preservedUsernames"`); + } +} diff --git a/packages/backend/migration/1682985520254-channelColor.js b/packages/backend/migration/1682985520254-channelColor.js new file mode 100644 index 0000000000..294b7372b2 --- /dev/null +++ b/packages/backend/migration/1682985520254-channelColor.js @@ -0,0 +1,11 @@ +export class ChannelColor1682985520254 { + name = 'ChannelColor1682985520254' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "color" character varying(16) NOT NULL DEFAULT '#86b300'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "color"`); + } +} diff --git a/packages/backend/migration/1683328299359-channelArchive.js b/packages/backend/migration/1683328299359-channelArchive.js new file mode 100644 index 0000000000..83695ff537 --- /dev/null +++ b/packages/backend/migration/1683328299359-channelArchive.js @@ -0,0 +1,13 @@ +export class ChannelArchive1683328299359 { + name = 'ChannelArchive1683328299359' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "isArchived" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_cc7c72974f1b2f385a8921f094" ON "channel" ("isArchived") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_cc7c72974f1b2f385a8921f094"`); + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "isArchived"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 875774bbd5..e0ece2bfe5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -23,33 +23,44 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.3.46", - "@swc/core-darwin-x64": "1.3.46", - "@swc/core-linux-arm-gnueabihf": "1.3.46", - "@swc/core-linux-arm64-gnu": "1.3.46", - "@swc/core-linux-arm64-musl": "1.3.46", - "@swc/core-linux-x64-gnu": "1.3.46", - "@swc/core-linux-x64-musl": "1.3.46", - "@swc/core-win32-arm64-msvc": "1.3.46", - "@swc/core-win32-ia32-msvc": "1.3.46", - "@swc/core-win32-x64-msvc": "1.3.46", - "@tensorflow/tfjs": "4.2.0", - "@tensorflow/tfjs-node": "4.2.0" + "@swc/core-darwin-arm64": "1.3.56", + "@swc/core-darwin-x64": "1.3.56", + "@swc/core-linux-arm-gnueabihf": "1.3.56", + "@swc/core-linux-arm64-gnu": "1.3.56", + "@swc/core-linux-arm64-musl": "1.3.56", + "@swc/core-linux-x64-gnu": "1.3.56", + "@swc/core-linux-x64-musl": "1.3.56", + "@swc/core-win32-arm64-msvc": "1.3.56", + "@swc/core-win32-ia32-msvc": "1.3.56", + "@swc/core-win32-x64-msvc": "1.3.56", + "@tensorflow/tfjs": "4.4.0", + "@tensorflow/tfjs-node": "4.4.0", + "slacc-android-arm-eabi": "0.0.7", + "slacc-android-arm64": "0.0.7", + "slacc-darwin-arm64": "0.0.7", + "slacc-darwin-universal": "0.0.7", + "slacc-darwin-x64": "0.0.7", + "slacc-linux-arm-gnueabihf": "0.0.7", + "slacc-linux-arm64-gnu": "0.0.7", + "slacc-linux-arm64-musl": "0.0.7", + "slacc-linux-x64-gnu": "0.0.7", + "slacc-win32-arm64-msvc": "0.0.7", + "slacc-win32-x64-msvc": "0.0.7" }, "dependencies": { - "@aws-sdk/client-s3": "3.306.0", - "@aws-sdk/lib-storage": "3.306.0", - "@aws-sdk/node-http-handler": "3.306.0", - "@bull-board/api": "5.0.0", - "@bull-board/fastify": "5.0.0", - "@bull-board/ui": "5.0.0", + "@aws-sdk/client-s3": "3.321.1", + "@aws-sdk/lib-storage": "3.321.1", + "@aws-sdk/node-http-handler": "3.321.1", + "@bull-board/api": "5.1.2", + "@bull-board/fastify": "5.1.2", + "@bull-board/ui": "5.1.2", "@discordapp/twemoji": "14.1.2", "@fastify/accepts": "4.1.0", "@fastify/cookie": "8.3.0", "@fastify/cors": "8.2.1", "@fastify/http-proxy": "9.0.0", - "@fastify/multipart": "7.5.0", - "@fastify/static": "6.10.0", + "@fastify/multipart": "7.6.0", + "@fastify/static": "6.10.1", "@fastify/view": "7.4.1", "@nestjs/common": "9.4.0", "@nestjs/core": "9.4.0", @@ -57,7 +68,7 @@ "@peertube/http-signature": "1.7.0", "@sinonjs/fake-timers": "10.0.2", "@swc/cli": "0.1.62", - "@swc/core": "1.3.46", + "@swc/core": "1.3.56", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", @@ -73,25 +84,26 @@ "cli-highlight": "2.1.11", "color-convert": "2.0.1", "content-disposition": "0.5.4", - "date-fns": "2.29.3", + "date-fns": "2.30.0", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", - "fastify": "4.15.0", + "fastify": "4.17.0", "feed": "4.2.2", - "file-type": "18.2.1", + "file-type": "18.3.0", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", "got": "12.6.0", - "happy-dom": "8.9.0", + "happy-dom": "9.10.2", "hpagent": "1.2.0", - "ioredis": "4.28.5", + "ioredis": "5.3.2", "ip-cidr": "3.1.0", "is-svg": "4.3.2", "js-yaml": "4.1.0", "jsdom": "21.1.1", "json5": "2.2.3", "jsonld": "8.1.1", - "jsrsasign": "10.7.0", + "meilisearch": "0.32.3", + "jsrsasign": "10.8.6", "mfm-js": "0.23.3", "mime-types": "2.1.35", "misskey-js": "workspace:*", @@ -111,7 +123,7 @@ "pug": "3.0.2", "punycode": "2.3.0", "pureimage": "0.3.17", - "qrcode": "1.5.1", + "qrcode": "1.5.3", "random-seed": "0.3.0", "ratelimiter": "3.4.1", "re2": "1.18.0", @@ -119,37 +131,38 @@ "reflect-metadata": "0.1.13", "rename": "1.0.4", "rndstr": "1.0.0", - "rss-parser": "3.12.0", - "rxjs": "7.8.0", + "rss-parser": "3.13.0", + "rxjs": "7.8.1", "s-age": "1.1.2", "sanitize-html": "2.10.0", "seedrandom": "3.0.5", - "semver": "7.3.8", - "sharp": "0.32.0", + "semver": "7.5.0", + "sharp": "0.32.1", "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", + "slacc": "0.0.7", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", "systeminformation": "5.17.12", "tinycolor2": "1.6.0", "tmp": "0.2.1", - "tsc-alias": "1.8.5", + "tsc-alias": "1.8.6", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typeorm": "0.3.13", - "typescript": "5.0.3", + "typeorm": "0.3.15", + "typescript": "5.0.4", "ulid": "2.3.0", "unzipper": "0.10.11", "uuid": "9.0.0", "vary": "1.1.2", - "web-push": "3.5.0", + "web-push": "3.6.1", "websocket": "1.0.34", "ws": "8.13.0", "xev": "3.0.2" }, "devDependencies": { "@jest/globals": "29.5.0", - "@swc/jest": "0.2.24", + "@swc/jest": "0.2.26", "@types/accepts": "1.3.5", "@types/archiver": "5.3.2", "@types/bcryptjs": "2.4.2", @@ -159,14 +172,13 @@ "@types/content-disposition": "0.5.5", "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.21", - "@types/ioredis": "4.28.10", - "@types/jest": "29.5.0", + "@types/jest": "29.5.1", "@types/js-yaml": "4.0.5", "@types/jsdom": "21.1.1", "@types/jsonld": "1.5.8", "@types/jsrsasign": "10.5.8", "@types/mime-types": "2.1.1", - "@types/node": "18.15.11", + "@types/node": "18.16.3", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.7", "@types/oauth": "0.9.1", @@ -180,7 +192,7 @@ "@types/rename": "1.0.4", "@types/sanitize-html": "2.9.0", "@types/semver": "7.3.13", - "@types/sharp": "0.31.1", + "@types/sharp": "0.32.0", "@types/sinonjs__fake-timers": "8.1.2", "@types/tinycolor2": "1.4.3", "@types/tmp": "0.2.3", @@ -190,11 +202,11 @@ "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.57.1", - "@typescript-eslint/parser": "5.57.1", + "@typescript-eslint/eslint-plugin": "5.59.2", + "@typescript-eslint/parser": "5.59.2", "aws-sdk-client-mock": "^2.1.1", "cross-env": "7.0.3", - "eslint": "8.37.0", + "eslint": "8.39.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", "jest": "29.5.0", diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 174d0d8beb..5fb4e8ef3c 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -1,7 +1,8 @@ import { setTimeout } from 'node:timers/promises'; import { Global, Inject, Module } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; +import { MeiliSearch } from 'meilisearch'; import { DI } from './di-symbols.js'; import { loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; @@ -22,10 +23,25 @@ const $db: Provider = { inject: [DI.config], }; +const $meilisearch: Provider = { + provide: DI.meilisearch, + useFactory: (config) => { + if (config.meilisearch) { + return new MeiliSearch({ + host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`, + apiKey: config.meilisearch.apiKey, + }); + } else { + return null; + } + }, + inject: [DI.config], +}; + const $redis: Provider = { provide: DI.redis, useFactory: (config) => { - return new Redis({ + return new Redis.Redis({ port: config.redis.port, host: config.redis.host, family: config.redis.family == null ? 0 : config.redis.family, @@ -40,7 +56,7 @@ const $redis: Provider = { const $redisForPub: Provider = { provide: DI.redisForPub, useFactory: (config) => { - const redis = new Redis({ + const redis = new Redis.Redis({ port: config.redisForPubsub.port, host: config.redisForPubsub.host, family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, @@ -56,7 +72,7 @@ const $redisForPub: Provider = { const $redisForSub: Provider = { provide: DI.redisForSub, useFactory: (config) => { - const redis = new Redis({ + const redis = new Redis.Redis({ port: config.redisForPubsub.port, host: config.redisForPubsub.host, family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, @@ -73,8 +89,8 @@ const $redisForSub: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $redis, $redisForPub, $redisForSub], - exports: [$config, $db, $redis, $redisForPub, $redisForSub, RepositoryModule], + providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub], + exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts index 279a1fe59d..45ded5495c 100644 --- a/packages/backend/src/boot/common.ts +++ b/packages/backend/src/boot/common.ts @@ -34,4 +34,6 @@ export async function jobQueue() { jobQueue.get(QueueProcessorService).start(); jobQueue.get(ChartManagementService).start(); + + return jobQueue; } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 395d96dd3a..2be4de59ac 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; /** @@ -57,13 +57,11 @@ export type Source = { db?: number; prefix?: string; }; - elasticsearch: { + meilisearch?: { host: string; - port: number; + port: string; + apiKey: string; ssl?: boolean; - user?: string; - pass?: string; - index?: string; }; proxy?: string; @@ -86,8 +84,10 @@ export type Source = { deliverJobConcurrency?: number; inboxJobConcurrency?: number; + relashionshipJobConcurrency?: number; deliverJobPerSec?: number; inboxJobPerSec?: number; + relashionshipJobPerSec?: number; deliverJobMaxAttempts?: number; inboxJobMaxAttempts?: number; @@ -134,9 +134,11 @@ const dir = `${_dirname}/../../../.config`; /** * Path of configuration file */ -const path = process.env.NODE_ENV === 'test' - ? `${dir}/test.yml` - : `${dir}/default.yml`; +const path = process.env.MISSKEY_CONFIG_YML + ? resolve(dir, process.env.MISSKEY_CONFIG_YML) + : process.env.NODE_ENV === 'test' + ? resolve(dir, 'test.yml') + : resolve(dir, 'default.yml'); export function loadConfig() { const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index 6c7f214214..ee1a9a3093 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -56,6 +56,11 @@ export const FILE_TYPE_BROWSERSAFE = [ 'audio/webm', 'audio/aac', + + // see https://github.com/misskey-dev/misskey/pull/10686 + 'audio/flac', + 'audio/wav', + // backward compatibility 'audio/x-flac', 'audio/vnd.wave', ]; diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 3f2a19b771..ab11785e28 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -1,55 +1,90 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; +import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; -import type { LocalUser } from '@/models/entities/User.js'; -import { User } from '@/models/entities/User.js'; -import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js'; +import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; +import type { User } from '@/models/entities/User.js'; +import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { RelayService } from '@/core/RelayService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { AccountUpdateService } from '@/core/AccountUpdateService.js'; -import { RelayService } from '@/core/RelayService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ProxyAccountService } from '@/core/ProxyAccountService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { MetaService } from '@/core/MetaService.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; @Injectable() export class AccountMoveService { constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + private userEntityService: UserEntityService, + private idService: IdService, + private apPersonService: ApPersonService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, private globalEventService: GlobalEventService, - private userFollowingService: UserFollowingService, - private accountUpdateService: AccountUpdateService, + private proxyAccountService: ProxyAccountService, + private perUserFollowingChart: PerUserFollowingChart, + private federatedInstanceService: FederatedInstanceService, + private instanceChart: InstanceChart, + private metaService: MetaService, private relayService: RelayService, + private cacheService: CacheService, + private queueService: QueueService, ) { } /** - * Move a local account to a remote account. + * Move a local account to a new account. * * After delivering Move activity, its local followers unfollow the old account and then follow the new one. */ @bindThis - public async moveToRemote(src: LocalUser, dst: User): Promise { - // Make sure that the destination is a remote account. - if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote'); - if (!dst.uri) throw new Error('destination uri is empty'); + public async moveFromLocal(src: LocalUser, dst: LocalUser | RemoteUser): Promise { + const srcUri = this.userEntityService.getUserUri(src); + const dstUri = this.userEntityService.getUserUri(dst); // add movedToUri to indicate that the user has moved - const update = {} as Partial; - update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri]; - update.movedToUri = dst.uri; + const update = {} as Partial; + update.alsoKnownAs = src.alsoKnownAs?.includes(dstUri) ? src.alsoKnownAs : src.alsoKnownAs?.concat([dstUri]) ?? [dstUri]; + update.movedToUri = dstUri; + update.movedAt = new Date(); await this.usersRepository.update(src.id, update); + Object.assign(src, update); + + // Update cache + this.cacheService.uriPersonCache.set(srcUri, src); const srcPerson = await this.apRendererService.renderPerson(src); const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); @@ -64,51 +99,249 @@ export class AccountMoveService { const iObj = await this.userEntityService.pack(src.id, src, { detail: true, includeSecrets: true }); this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); - // follow the new account and unfollow the old one - const followings = await this.followingsRepository.find({ - relations: { - follower: true, - }, - where: { - followeeId: src.id, - followerHost: IsNull(), // follower is local - }, + // Unfollow after 24 hours + const followings = await this.followingsRepository.findBy({ + followerId: src.id, }); - for (const following of followings) { - if (!following.follower) continue; - try { - await this.userFollowingService.follow(following.follower, dst); - await this.userFollowingService.unfollow(following.follower, src); - } catch { - /* empty */ - } - } + this.queueService.createDelayedUnfollowJob(followings.map(following => ({ + from: { id: src.id }, + to: { id: following.followeeId }, + })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24); + + await this.postMoveProcess(src, dst); return iObj; } + @bindThis + public async postMoveProcess(src: User, dst: User): Promise { + // Copy blockings and mutings, and update lists + try { + await Promise.all([ + this.copyBlocking(src, dst), + this.copyMutings(src, dst), + this.updateLists(src, dst), + ]); + } catch { + /* skip if any error happens */ + } + + // follow the new account + const proxy = await this.proxyAccountService.fetch(); + const followings = await this.followingsRepository.findBy({ + followeeId: src.id, + followerHost: IsNull(), // follower is local + followerId: proxy ? Not(proxy.id) : undefined, + }); + const followJobs = followings.map(following => ({ + from: { id: following.followerId }, + to: { id: dst.id }, + })) as RelationshipJobData[]; + + // Decrease following count instead of unfollowing. + try { + await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src); + } catch { + /* skip if any error happens */ + } + + // Should be queued because this can cause a number of follow per one move. + this.queueService.createFollowJob(followJobs); + } + + @bindThis + public async copyBlocking(src: ThinUser, dst: ThinUser): Promise { + // Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving. + // So block the destination account here. + const srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id }); + const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id }); + const blockerIds = dstBlockings.map(blocking => blocking.blockerId); + // reblock the destination account + const blockJobs: RelationshipJobData[] = []; + for (const blocking of srcBlockings) { + if (blockerIds.includes(blocking.blockerId)) continue; // skip if already blocked + blockJobs.push({ from: { id: blocking.blockerId }, to: { id: dst.id } }); + } + // no need to unblock the old account because it may be still functional + this.queueService.createBlockJob(blockJobs); + } + + @bindThis + public async copyMutings(src: ThinUser, dst: ThinUser): Promise { + // Insert new mutings with the same values except mutee + const oldMutings = await this.mutingsRepository.findBy([ + { muteeId: src.id, expiresAt: IsNull() }, + { muteeId: src.id, expiresAt: MoreThan(new Date()) }, + ]); + if (oldMutings.length === 0) return; + + // Check if the destination account is already indefinitely muted by the muter + const existingMutingsMuterUserIds = await this.mutingsRepository.findBy( + { muteeId: dst.id, expiresAt: IsNull() }, + ).then(mutings => mutings.map(muting => muting.muterId)); + + const newMutings: Map = new Map(); + + // 重複しないようにIDを生成 + const genId = (): string => { + let id: string; + do { + id = this.idService.genId(); + } while (newMutings.has(id)); + return id; + }; + for (const muting of oldMutings) { + if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely + newMutings.set(genId(), { + ...muting, + createdAt: new Date(), + muteeId: dst.id, + }); + } + + const arrayToInsert = Array.from(newMutings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.mutingsRepository.insert(arrayToInsert); + } + /** - * Create an alias of an old remote account. + * Update lists while moving accounts. + * - No removal of the old account from the lists + * - Users number limit is not checked * - * The user's new profile will be published to the followers. + * @param src ThinUser (old account) + * @param dst User (new account) + * @returns Promise */ @bindThis - public async createAlias(me: LocalUser, updates: Partial): Promise { - await this.usersRepository.update(me.id, updates); - - // Publish meUpdated event - const iObj = await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, + public async updateLists(src: ThinUser, dst: User): Promise { + // Return if there is no list to be updated. + const oldJoinings = await this.userListJoiningsRepository.find({ + where: { + userId: src.id, + }, }); - this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + if (oldJoinings.length === 0) return; - if (me.isLocked === false) { - await this.userFollowingService.acceptAllFollowRequests(me); + const existingUserListIds = await this.userListJoiningsRepository.find({ + where: { + userId: dst.id, + }, + }).then(joinings => joinings.map(joining => joining.userListId)); + + const newJoinings: Map = new Map(); + + // 重複しないようにIDを生成 + const genId = (): string => { + let id: string; + do { + id = this.idService.genId(); + } while (newJoinings.has(id)); + return id; + }; + for (const joining of oldJoinings) { + if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list + newJoinings.set(genId(), { + createdAt: new Date(), + userId: dst.id, + userListId: joining.userListId, + }); } - this.accountUpdateService.publishToFollowers(me.id); + const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.userListJoiningsRepository.insert(arrayToInsert); - return iObj; + // Have the proxy account follow the new account in the same way as UserListService.push + if (this.userEntityService.isRemoteUser(dst)) { + const proxy = await this.proxyAccountService.fetch(); + if (proxy) { + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]); + } + } + } + + @bindThis + private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise { + if (localFollowerIds.length === 0) return; + + // Set the old account's following and followers counts to 0. + await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 }); + + // Decrease following counts of local followers by 1. + await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1); + + // Decrease follower counts of local followees by 1. + const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id }); + if (oldFollowings.length > 0) { + await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1); + } + + // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. + if (this.userEntityService.isRemoteUser(oldAccount)) { + this.federatedInstanceService.fetch(oldAccount.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } + + // FIXME: expensive? + for (const followerId of localFollowerIds) { + this.perUserFollowingChart.update({ id: followerId, host: null }, oldAccount, false); + } + } + + /** + * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる + * + * @param dst movedToUrlを指定するユーザー + * @param check + * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか + * @returns Promise + */ + @bindThis + public async validateAlsoKnownAs( + dst: LocalUser | RemoteUser, + check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise = () => true, + instant = false, + ): Promise { + let resultUser: LocalUser | RemoteUser | null = null; + + if (this.userEntityService.isRemoteUser(dst)) { + if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + await this.apPersonService.updatePerson(dst.uri); + } + dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst; + } + + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null; + + const dstUri = this.userEntityService.getUserUri(dst); + + for (const srcUri of dst.alsoKnownAs) { + try { + let src = await this.apPersonService.fetchPerson(srcUri); + if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー + + if (this.userEntityService.isRemoteUser(dst)) { + if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + await this.apPersonService.updatePerson(srcUri); + } + + src = await this.apPersonService.fetchPerson(srcUri) ?? src; + } + + if (src.movedToUri === dstUri) { + if (await check(resultUser, src)) { + resultUser = src; + } + if (instant && resultUser) return resultUser; + } + } catch { + /* skip if any error happens */ + } + } + + return resultUser; } } diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 1ca38d8bb0..9e223f1492 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -64,6 +64,7 @@ export const ACHIEVEMENT_TYPES = [ 'iLoveMisskey', 'foundTreasure', 'client30min', + 'client60min', 'noteDeletedWithin1min', 'postedAtLateNight', 'postedAt0min0sec', diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 166c78f479..2d4226a32d 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { Antenna } from '@/models/entities/Antenna.js'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts index ee179b7f01..8dd805552b 100644 --- a/packages/backend/src/core/AppLockService.ts +++ b/packages/backend/src/core/AppLockService.ts @@ -1,7 +1,7 @@ import { promisify } from 'node:util'; import { Inject, Injectable } from '@nestjs/common'; import redisLock from 'redis-lock'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 561face5c3..cf1e81ffc8 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { LocalUser, User } from '@/models/entities/User.js'; diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 8775536e4a..d3a1b1b024 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -50,6 +50,7 @@ import { WebhookService } from './WebhookService.js'; import { ProxyAccountService } from './ProxyAccountService.js'; import { UtilityService } from './UtilityService.js'; import { FileInfoService } from './FileInfoService.js'; +import { SearchService } from './SearchService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; import FederationChart from './chart/charts/federation.js'; import NotesChart from './chart/charts/notes.js'; @@ -171,6 +172,8 @@ const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', u const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; +const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; + const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart }; @@ -295,6 +298,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebhookService, UtilityService, FileInfoService, + SearchService, ChartLoggerService, FederationChart, NotesChart, @@ -413,6 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebhookService, $UtilityService, $FileInfoService, + $SearchService, $ChartLoggerService, $FederationChart, $NotesChart, @@ -532,6 +537,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebhookService, UtilityService, FileInfoService, + SearchService, FederationChart, NotesChart, UsersChart, @@ -649,6 +655,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebhookService, $UtilityService, $FileInfoService, + $SearchService, $FederationChart, $NotesChart, $UsersChart, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 185171dee2..93557ce617 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource, In, IsNull } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; @@ -197,6 +197,22 @@ export class CustomEmojiService { emojis: await this.emojiEntityService.packDetailedMany(ids), }); } + + @bindThis + public async setLicenseBulk(ids: Emoji['id'][], license: string | null) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + license: license, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } @bindThis public async delete(id: Emoji['id']) { diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index bd999c67da..bd535c6032 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -86,9 +86,13 @@ export class DownloadService { const contentDisposition = res.headers['content-disposition']; if (contentDisposition != null) { - const parsed = parse(contentDisposition); - if (parsed.parameters.filename) { - filename = parsed.parameters.filename; + try { + const parsed = parse(contentDisposition); + if (parsed.parameters.filename) { + filename = parsed.parameters.filename; + } + } catch (e) { + this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e }); } } }).on('downloadProgress', (progress: Got.Progress) => { diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index c6258474ec..1483b55469 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -59,6 +59,8 @@ type AddFileArgs = { uri?: string | null; /** Mark file as sensitive */ sensitive?: boolean | null; + /** Extension to force */ + ext?: string | null; requestIp?: string | null; requestHeaders?: Record | null; @@ -125,7 +127,7 @@ export class DriveService { /*** * Save file * @param path Path for original - * @param name Name for original + * @param name Name for original (should be extention corrected) * @param type Content-Type for original * @param hash Hash for original * @param size Size for original @@ -151,7 +153,7 @@ export class DriveService { } // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 - // 許可されているファイル形式でしか拡張子をつけない + // 許可されているファイル形式でしかURLに拡張子をつけない if (!FILE_TYPE_BROWSERSAFE.includes(type)) { ext = ''; } @@ -173,7 +175,7 @@ export class DriveService { //#region Uploads this.registerLogger.info(`uploading original: ${key}`); const uploads = [ - this.upload(key, fs.createReadStream(path), type, ext, name), + this.upload(key, fs.createReadStream(path), type, null, name), ]; if (alts.webpublic) { @@ -189,7 +191,7 @@ export class DriveService { thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`)); } await Promise.all(uploads); @@ -396,8 +398,9 @@ export class DriveService { ); } + // Expire oldest file (without avatar or banner) of remote user @bindThis - private async deleteOldFile(user: RemoteUser) { + private async expireOldFile(user: RemoteUser, driveCapacity: number) { const q = this.driveFilesRepository.createQueryBuilder('file') .where('file.userId = :userId', { userId: user.id }) .andWhere('file.isLink = FALSE'); @@ -410,12 +413,17 @@ export class DriveService { q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); } + //This selete is hard coded, be careful if change database schema + q.addSelect('SUM("file"."size") OVER (ORDER BY "file"."id" DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', 'acc_usage'); q.orderBy('file.id', 'ASC'); - const oldFile = await q.getOne(); + const fileList = await q.getRawMany(); + const exceedFileIds = fileList.filter((x: any) => x.acc_usage > driveCapacity).map((x: any) => x.file_id); - if (oldFile) { - this.deleteFile(oldFile, true); + for (const fileId of exceedFileIds) { + const file = await this.driveFilesRepository.findOneBy({ id: fileId }); + if (file == null) continue; + this.deleteFile(file, true); } } @@ -437,10 +445,16 @@ export class DriveService { sensitive = null, requestIp = null, requestHeaders = null, + ext = null, }: AddFileArgs): Promise { let skipNsfwCheck = false; const instance = await this.metaService.fetch(); - if (user == null) skipNsfwCheck = true; + const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw; + if (user == null) { + skipNsfwCheck = true; + } else if (userRoleNSFW) { + skipNsfwCheck = true; + } if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true; if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; @@ -468,7 +482,7 @@ export class DriveService { // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 // extを付加してデータベースの文字数制限に当たることはまずない (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', - info.type.ext, + ext ?? info.type.ext, ); if (user && !force) { @@ -489,22 +503,19 @@ export class DriveService { //#region Check drive usage if (user && !isLink) { const usage = await this.driveFileEntityService.calcDriveUsageOf(user); + const isLocalUser = this.userEntityService.isLocalUser(user); const policies = await this.roleService.getUserPolicies(user.id); const driveCapacity = 1024 * 1024 * policies.driveCapacityMb; this.registerLogger.debug('drive capacity override applied'); this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); - this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); - // If usage limit exceeded - if (usage + info.size > driveCapacity) { - if (this.userEntityService.isLocalUser(user)) { + if (driveCapacity < usage + info.size) { + if (isLocalUser) { throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); - } else { - // (アバターまたはバナーを含まず)最も古いファイルを削除する - this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser); } + await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser, driveCapacity - info.size); } } //#endregion @@ -565,6 +576,7 @@ export class DriveService { if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; + if (userRoleNSFW) file.isSensitive = true; if (url !== null) { file.src = url; diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 56660ae0d0..8b9a87a380 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { InstancesRepository } from '@/models/index.js'; import type { Instance } from '@/models/entities/Instance.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; @@ -23,8 +23,8 @@ export class FederatedInstanceService { private idService: IdService, ) { this.federatedInstanceCache = new RedisKVCache(this.redisClient, 'federatedInstance', { - lifetime: 1000 * 60 * 60 * 24, // 24h - memoryCacheLifetime: 1000 * 60 * 30, // 30m + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60 * 3, // 3m fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), toRedisConverter: (value) => JSON.stringify(value), fromRedisConverter: (value) => { @@ -65,15 +65,16 @@ export class FederatedInstanceService { } @bindThis - public async updateCachePartial(host: string, data: Partial): Promise { - host = this.utilityService.toPuny(host); + public async update(id: Instance['id'], data: Partial): Promise { + const result = await this.instancesRepository.createQueryBuilder().update() + .set(data) + .where('id = :id', { id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); - const cached = await this.federatedInstanceCache.get(host); - if (cached == null) return; - - this.federatedInstanceCache.set(host, { - ...cached, - ...data, - }); + this.federatedInstanceCache.set(result.host, result); } } diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index bbc8b4332e..8103d5afe9 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js'; import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { DOMWindow } from 'jsdom'; type NodeInfo = { @@ -42,6 +43,7 @@ export class FetchInstanceMetadataService { private appLockService: AppLockService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, + private federatedInstanceService: FederatedInstanceService, ) { this.logger = this.loggerService.getLogger('metadata', 'cyan'); } @@ -96,7 +98,7 @@ export class FetchInstanceMetadataService { if (favicon) updates.faviconUrl = favicon; if (themeColor) updates.themeColor = themeColor; - await this.instancesRepository.update(instance.id, updates); + await this.federatedInstanceService.update(instance.id, updates); this.logger.succ(`Successfuly updated metadata of ${instance.host}`); } catch (e) { diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index e39b134b7e..b6cae5ea75 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -5,7 +5,7 @@ import * as stream from 'node:stream'; import * as util from 'node:util'; import { Injectable } from '@nestjs/common'; import { FSWatcher } from 'chokidar'; -import { fileTypeFromFile } from 'file-type'; +import * as fileType from 'file-type'; import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; @@ -301,21 +301,34 @@ export class FileInfoService { return fs.promises.access(path).then(() => true, () => false); } + @bindThis + public fixMime(mime: string | fileType.MimeType): string { + // see https://github.com/misskey-dev/misskey/pull/10686 + if (mime === "audio/x-flac") { + return "audio/flac"; + } + if (mime === "audio/vnd.wave") { + return "audio/wav"; + } + + return mime; + } + /** * Detect MIME Type and extension */ @bindThis public async detectType(path: string): Promise<{ - mime: string; - ext: string | null; -}> { + mime: string; + ext: string | null; + }> { // Check 0 byte const fileSize = await this.getFileSize(path); if (fileSize === 0) { return TYPE_OCTET_STREAM; } - const type = await fileTypeFromFile(path); + const type = await fileType.fileTypeFromFile(path); if (type) { // XMLはSVGかもしれない @@ -324,7 +337,7 @@ export class FileInfoService { } return { - mime: type.mime, + mime: this.fixMime(type.mime), ext: type.ext, }; } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 2c2687a90c..0ed5241148 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; import type { UserList } from '@/models/entities/UserList.js'; diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 1322927c2c..0b861be8d0 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { Meta } from '@/models/entities/Meta.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 79629cb2a8..364976e4a7 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,7 +1,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; @@ -46,6 +46,7 @@ import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; +import { SearchService } from '@/core/SearchService.js'; const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); @@ -198,6 +199,7 @@ export class NoteCreateService implements OnApplicationShutdown { private apRendererService: ApRendererService, private roleService: RoleService, private metaService: MetaService, + private searchService: SearchService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private activeUsersChart: ActiveUsersChart, @@ -728,17 +730,9 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private index(note: Note) { - if (note.text == null || this.config.elasticsearch == null) return; - /* - es!.index({ - index: this.config.elasticsearch.index ?? 'misskey_note', - id: note.id.toString(), - body: { - text: normalizeForSearch(note.text), - userId: note.userId, - userHost: note.userHost, - }, - });*/ + if (note.text == null && note.cw == null) return; + + this.searchService.indexNote(note); } @bindThis diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 6691c42836..a245908c98 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -1,5 +1,5 @@ import { setTimeout } from 'node:timers/promises'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; @@ -111,7 +111,7 @@ export class NotificationService implements OnApplicationShutdown { // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); - if (latestReadNotificationId && (latestReadNotificationId >= await redisIdPromise)) return; + if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return; this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 9b44cf6413..a4c569bdec 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import push from 'web-push'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema'; diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index bac85d7a15..d4905a5f88 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -78,7 +78,7 @@ const $db: Provider = { const $relationship: Provider = { provide: 'queue:relationship', - useFactory: (config: Config) => q(config, 'relationship'), + useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64), inject: [DI.config], }; diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 375ac49911..b4ffffecc0 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -1,15 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; +import Bull from 'bull'; import type { IActivity } from '@/core/activitypub/type.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; -import Bull from 'bull'; @Injectable() export class QueueService { @@ -152,6 +153,16 @@ export class QueueService { }); } + @bindThis + public createExportAntennasJob(user: ThinUser) { + return this.dbQueue.add('exportAntennas', { + user: { id: user.id }, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importFollowing', { @@ -235,6 +246,17 @@ export class QueueService { }); } + @bindThis + public createImportAntennasJob(user: ThinUser, antenna: Antenna) { + return this.dbQueue.add('importAntennas', { + user: { id: user.id }, + antenna, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { return this.dbQueue.add('deleteAccount', { @@ -258,6 +280,12 @@ export class QueueService { return this.relationshipQueue.addBulk(jobs); } + @bindThis + public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) { + const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay })); + return this.relationshipQueue.addBulk(jobs); + } + @bindThis public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel)); @@ -271,7 +299,7 @@ export class QueueService { } @bindThis - private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): { + private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): { name: string, data: RelationshipJobData, opts: Bull.JobOptions, @@ -287,6 +315,7 @@ export class QueueService { opts: { removeOnComplete: true, removeOnFail: true, + ...opts, }, }; } diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index b72dce5180..ff68c24219 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -4,7 +4,7 @@ import chalk from 'chalk'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; -import type { RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -33,7 +33,7 @@ export class RemoteUserResolveService { } @bindThis - public async resolveUser(username: string, host: string | null): Promise { + public async resolveUser(username: string, host: string | null): Promise { const usernameLower = username.toLowerCase(); if (host == null) { @@ -44,7 +44,7 @@ export class RemoteUserResolveService { } else { return u; } - }); + }) as LocalUser; } host = this.utilityService.toPuny(host); @@ -57,7 +57,7 @@ export class RemoteUserResolveService { } else { return u; } - }); + }) as LocalUser; } const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null; @@ -109,7 +109,7 @@ export class RemoteUserResolveService { if (u == null) { throw new Error('user not found'); } else { - return u; + return u as LocalUser | RemoteUser; } }); } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 2a4271aa98..68087ccc3b 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { In } from 'typeorm'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; @@ -25,6 +25,7 @@ export type RolePolicies = { canSearchNotes: boolean; canHideAds: boolean; driveCapacityMb: number; + alwaysMarkNsfw: boolean; pinLimit: number; antennaLimit: number; wordMuteLimit: number; @@ -45,6 +46,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canSearchNotes: false, canHideAds: false, driveCapacityMb: 100, + alwaysMarkNsfw: false, pinLimit: 5, antennaLimit: 5, wordMuteLimit: 200, @@ -279,6 +281,7 @@ export class RoleService implements OnApplicationShutdown { canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), + alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts new file mode 100644 index 0000000000..e68fde088d --- /dev/null +++ b/packages/backend/src/core/SearchService.ts @@ -0,0 +1,178 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { bindThis } from '@/decorators.js'; +import { Note } from '@/models/entities/Note.js'; +import { User } from '@/models/index.js'; +import type { NotesRepository } from '@/models/index.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { QueryService } from '@/core/QueryService.js'; +import { IdService } from '@/core/IdService.js'; +import type { Index, MeiliSearch } from 'meilisearch'; + +type K = string; +type V = string | number | boolean; +type Q = + { op: '=', k: K, v: V } | + { op: '!=', k: K, v: V } | + { op: '>', k: K, v: number } | + { op: '<', k: K, v: number } | + { op: '>=', k: K, v: number } | + { op: '<=', k: K, v: number } | + { op: 'and', qs: Q[] } | + { op: 'or', qs: Q[] } | + { op: 'not', q: Q }; + +function compileValue(value: V): string { + if (typeof value === 'string') { + return `'${value}'`; // TODO: escape + } else if (typeof value === 'number') { + return value.toString(); + } else if (typeof value === 'boolean') { + return value.toString(); + } + throw new Error('unrecognized value'); +} + +function compileQuery(q: Q): string { + switch (q.op) { + case '=': return `(${q.k} = ${compileValue(q.v)})`; + case '!=': return `(${q.k} != ${compileValue(q.v)})`; + case '>': return `(${q.k} > ${compileValue(q.v)})`; + case '<': return `(${q.k} < ${compileValue(q.v)})`; + case '>=': return `(${q.k} >= ${compileValue(q.v)})`; + case '<=': return `(${q.k} <= ${compileValue(q.v)})`; + case 'and': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' AND ') })`; + case 'or': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' OR ') })`; + case 'not': return `(NOT ${compileQuery(q.q)})`; + default: throw new Error('unrecognized query operator'); + } +} + +@Injectable() +export class SearchService { + private meilisearchNoteIndex: Index | null = null; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.meilisearch) + private meilisearch: MeiliSearch | null, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private queryService: QueryService, + private idService: IdService, + ) { + if (meilisearch) { + this.meilisearchNoteIndex = meilisearch.index('notes'); + this.meilisearchNoteIndex.updateSettings({ + searchableAttributes: [ + 'text', + 'cw', + ], + sortableAttributes: [ + 'createdAt', + ], + filterableAttributes: [ + 'createdAt', + 'userId', + 'userHost', + 'channelId', + ], + typoTolerance: { + enabled: false, + }, + pagination: { + maxTotalHits: 10000, + }, + }); + } + } + + @bindThis + public async indexNote(note: Note): Promise { + if (note.text == null && note.cw == null) return; + if (!['home', 'public'].includes(note.visibility)) return; + + if (this.meilisearch) { + this.meilisearchNoteIndex!.addDocuments([{ + id: note.id, + createdAt: note.createdAt.getTime(), + userId: note.userId, + userHost: note.userHost, + channelId: note.channelId, + cw: note.cw, + text: note.text, + }], { + primaryKey: 'id', + }); + } + } + + @bindThis + public async searchNote(q: string, me: User | null, opts: { + userId?: Note['userId'] | null; + channelId?: Note['channelId'] | null; + host?: string | null; + }, pagination: { + untilId?: Note['id']; + sinceId?: Note['id']; + limit?: number; + }): Promise { + if (this.meilisearch) { + const filter: Q = { + op: 'and', + qs: [], + }; + if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() }); + if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() }); + if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); + if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); + if (opts.host) { + if (opts.host === '.') { + // TODO: Meilisearchが2023/05/07現在値がNULLかどうかのクエリが書けない + } else { + filter.qs.push({ op: '=', k: 'userHost', v: opts.host }); + } + } + const res = await this.meilisearchNoteIndex!.search(q, { + sort: ['createdAt:desc'], + matchingStrategy: 'all', + attributesToRetrieve: ['id', 'createdAt'], + filter: compileQuery(filter), + limit: pagination.limit, + }); + if (res.hits.length === 0) return []; + const notes = await this.notesRepository.findBy({ + id: In(res.hits.map(x => x.id)), + }); + return notes.sort((a, b) => a.id > b.id ? -1 : 1); + } else { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); + + if (opts.userId) { + query.andWhere('note.userId = :userId', { userId: opts.userId }); + } else if (opts.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); + } + + query + .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + + return await query.take(pagination.limit).getMany(); + } + } +} diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index d7bc05b8bd..29eb65fda4 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -13,8 +13,9 @@ import { UsedUsername } from '@/models/entities/UsedUsername.js'; import generateUserToken from '@/misc/generate-native-user-token.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import UsersChart from './chart/charts/users.js'; -import { UtilityService } from './UtilityService.js'; +import UsersChart from '@/core/chart/charts/users.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class SignupService { @@ -34,6 +35,7 @@ export class SignupService { private utilityService: UtilityService, private userEntityService: UserEntityService, private idService: IdService, + private metaService: MetaService, private usersChart: UsersChart, ) { } @@ -44,6 +46,7 @@ export class SignupService { password?: string | null; passwordHash?: UserProfile['password'] | null; host?: string | null; + ignorePreservedUsernames?: boolean; }) { const { username, password, passwordHash, host } = opts; let hash = passwordHash; @@ -76,7 +79,17 @@ export class SignupService { if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { throw new Error('USED_USERNAME'); } - + + const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0; + + if (!opts.ignorePreservedUsernames && !isTheFirstUser) { + const instance = await this.metaService.fetch(true); + const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + if (isPreserved) { + throw new Error('USED_USERNAME'); + } + } + const keyPair = await new Promise((res, rej) => generateKeyPair('rsa', { modulusLength: 4096, @@ -112,9 +125,7 @@ export class SignupService { usernameLower: username.toLowerCase(), host: this.utilityService.toPunyNullable(host), token: secret, - isRoot: (await this.usersRepository.countBy({ - host: IsNull(), - })) === 0, + isRoot: isTheFirstUser, })); await transactionalEntityManager.save(new UserKeypair({ diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index a8eded6733..7d90bc2c08 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; @@ -22,6 +22,8 @@ import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import Logger from '../logger.js'; +import { IsNull } from 'typeorm'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; const logger = new Logger('following/create'); @@ -73,6 +75,7 @@ export class UserFollowingService implements OnModuleInit { private federatedInstanceService: FederatedInstanceService, private webhookService: WebhookService, private apRendererService: ApRendererService, + private accountMoveService: AccountMoveService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, ) { @@ -87,7 +90,7 @@ export class UserFollowingService implements OnModuleInit { const [follower, followee] = await Promise.all([ this.usersRepository.findOneByOrFail({ id: _follower.id }), this.usersRepository.findOneByOrFail({ id: _followee.id }), - ]); + ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser]; // check blocking const [blocking, blocked] = await Promise.all([ @@ -137,6 +140,20 @@ export class UserFollowingService implements OnModuleInit { if (followed) autoAccept = true; } + // Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account. + if (followee.isLocked && !autoAccept) { + autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs( + follower, + (oldSrc, newSrc) => this.followingsRepository.exist({ + where: { + followeeId: followee.id, + followerId: newSrc.id, + }, + }), + true, + )); + } + if (!autoAccept) { await this.createFollowRequest(follower, followee, requestId); return; @@ -210,32 +227,40 @@ export class UserFollowingService implements OnModuleInit { this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); - //#region Increment counts - await Promise.all([ - this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), - this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + const [followeeUser, followerUser] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: followee.id }), + this.usersRepository.findOneByOrFail({ id: follower.id }), ]); - //#endregion - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, true); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, true); - } - }); + // Neither followee nor follower has moved. + if (!followeeUser.movedToUri && !followerUser.movedToUri) { + //#region Increment counts + await Promise.all([ + this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), + this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetch(follower.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, true); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetch(followee.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, true); + } + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, true); } - //#endregion - - this.perUserFollowingChart.update(follower, followee, true); // Publish follow event if (this.userEntityService.isLocalUser(follower) && !silent) { @@ -283,12 +308,18 @@ export class UserFollowingService implements OnModuleInit { }, silent = false, ): Promise { - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, + const following = await this.followingsRepository.findOne({ + relations: { + follower: true, + followee: true, + }, + where: { + followerId: follower.id, + followeeId: followee.id, + } }); - if (following == null) { + if (following === null || !following.follower || !following.followee) { logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } @@ -297,7 +328,7 @@ export class UserFollowingService implements OnModuleInit { this.cacheService.userFollowingsCache.refresh(follower.id); - this.decrementFollowing(follower, followee); + this.decrementFollowing(following.follower, following.followee); // Publish unfollow event if (!silent && this.userEntityService.isLocalUser(follower)) { @@ -316,50 +347,87 @@ export class UserFollowingService implements OnModuleInit { } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser), follower)); this.queueService.deliver(follower, content, followee.inbox, false); } if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { // local user has null host - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); + const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower as PartialRemoteUser, followee as PartialLocalUser), followee)); this.queueService.deliver(followee, content, follower.inbox, false); } } @bindThis private async decrementFollowing( - follower: { id: User['id']; host: User['host']; }, - followee: { id: User['id']; host: User['host']; }, + follower: User, + followee: User, ): Promise { this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id }); - //#region Decrement following / followers counts - await Promise.all([ - this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), - this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), - ]); - //#endregion + // Neither followee nor follower has moved. + if (!follower.movedToUri && !followee.movedToUri) { + //#region Decrement following / followers counts + await Promise.all([ + this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), + this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, false); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetch(follower.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, false); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetch(followee.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, false); + } else { + // Adjust following/followers counts + for (const user of [follower, followee]) { + if (user.movedToUri) continue; // No need to update if the user has already moved. + + const nonMovedFollowees = await this.followingsRepository.count({ + relations: { + followee: true, + }, + where: { + followerId: user.id, + followee: { + movedToUri: IsNull(), + } + } + }); + const nonMovedFollowers = await this.followingsRepository.count({ + relations: { + follower: true, + }, + where: { + followeeId: user.id, + follower: { + movedToUri: IsNull(), + } + } + }); + await this.usersRepository.update( + { id: user.id }, + { followingCount: nonMovedFollowees, followersCount: nonMovedFollowers }, + ); + } + + // TODO: adjust charts } - //#endregion - - this.perUserFollowingChart.update(follower, followee, false); } @bindThis @@ -415,7 +483,7 @@ export class UserFollowingService implements OnModuleInit { } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); + const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); this.queueService.deliver(follower, content, followee.inbox, false); } } @@ -430,7 +498,7 @@ export class UserFollowingService implements OnModuleInit { }, ): Promise { if (this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser | PartialRemoteUser, followee as PartialRemoteUser), follower)); if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので this.queueService.deliver(follower, content, followee.inbox, false); @@ -475,7 +543,7 @@ export class UserFollowingService implements OnModuleInit { await this.insertFollowingDoc(followee, follower); if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); + const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as PartialLocalUser, request.requestId!), followee)); this.queueService.deliver(followee, content, follower.inbox, false); } @@ -562,15 +630,22 @@ export class UserFollowingService implements OnModuleInit { */ @bindThis private async removeFollow(followee: Both, follower: Both): Promise { - const following = await this.followingsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, + const following = await this.followingsRepository.findOne({ + relations: { + followee: true, + follower: true, + }, + where: { + followeeId: followee.id, + followerId: follower.id, + } }); - if (!following) return; + if (!following || !following.followee || !following.follower) return; await this.followingsRepository.delete(following.id); - this.decrementFollowing(follower, followee); + + this.decrementFollowing(following.follower, following.followee); } /** diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index 22a9fb2b8e..72c35c529c 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { User } from '@/models/entities/User.js'; import type { UserKeypairsRepository } from '@/models/index.js'; import { RedisKVCache } from '@/misc/cache.js'; diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index d00bb89c76..b197d335d8 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -35,7 +35,7 @@ export class UserSuspendService { if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにDelete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user)); + const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const queue: string[] = []; @@ -65,7 +65,7 @@ export class UserSuspendService { if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにUndo Delete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); const queue: string[] = []; diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index 69df2d0c1b..3ee7990643 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -43,7 +43,8 @@ export class WebfingerService { const m = query.match(/^([^@]+)@(.*)/); if (m) { const hostname = m[2]; - return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); + const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; + return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`; } throw new Error(`Invalid query (${query})`); diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 926115613b..57baade777 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { WebhooksRepository } from '@/models/index.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index 4b032be89a..2b404ebeca 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -8,7 +8,7 @@ import type { UserPublickey } from '@/models/entities/UserPublickey.js'; import { CacheService } from '@/core/CacheService.js'; import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; -import { RemoteUser, User } from '@/models/entities/User.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { getApId } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; import type { IObject } from './type.js'; @@ -101,7 +101,7 @@ export class ApDbResolverService { * AP Person => Misskey User in DB */ @bindThis - public async getUserFromApId(value: string | IObject): Promise { + public async getUserFromApId(value: string | IObject): Promise { const parsed = this.parseUri(value); if (parsed.local) { @@ -109,11 +109,11 @@ export class ApDbResolverService { return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ id: parsed.id, - }).then(x => x ?? undefined)) ?? null; + }).then(x => x ?? undefined)) as LocalUser | undefined ?? null; } else { return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ uri: parsed.uri, - })); + })) as RemoteUser | null; } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 3fca0bb1fd..efef777fb0 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -13,13 +13,15 @@ import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; import { MetaService } from '@/core/MetaService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import { IdService } from '@/core/IdService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { CacheService } from '@/core/CacheService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; @@ -76,6 +78,8 @@ export class ApInboxService { private apNoteService: ApNoteService, private apPersonService: ApPersonService, private apQuestionService: ApQuestionService, + private accountMoveService: AccountMoveService, + private cacheService: CacheService, private queueService: QueueService, ) { this.logger = this.apLoggerService.logger; @@ -140,7 +144,7 @@ export class ApInboxService { } else if (isFlag(activity)) { await this.flag(actor, activity); } else if (isMove(activity)) { - //await this.move(actor, activity); + await this.move(actor, activity); } else { this.logger.warn(`unrecognized activity type: ${activity.type}`); } @@ -158,6 +162,7 @@ export class ApInboxService { return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; } + // don't queue because the sender may attempt again when timeout await this.userFollowingService.follow(actor, followee, activity.id); return 'ok'; } @@ -596,6 +601,7 @@ export class ApInboxService { throw e; }); + // don't queue because the sender may attempt again when timeout if (isFollow(object)) return await this.undoFollow(actor, object); if (isBlock(object)) return await this.undoBlock(actor, object); if (isLike(object)) return await this.undoLike(actor, object); @@ -736,53 +742,7 @@ export class ApInboxService { // fetch the new and old accounts const targetUri = getApHrefNullable(activity.target); if (!targetUri) return 'skip: invalid activity target'; - let new_acc = await this.apPersonService.resolvePerson(targetUri); - let old_acc = await this.apPersonService.resolvePerson(actor.uri); - // update them if they're remote - if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri); - if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri); - - // retrieve updated users - new_acc = await this.apPersonService.resolvePerson(targetUri); - old_acc = await this.apPersonService.resolvePerson(actor.uri); - - // check if alsoKnownAs of the new account is valid - let isValidMove = true; - if (old_acc.uri) { - if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) { - isValidMove = false; - } - } else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) { - isValidMove = false; - } - if (!isValidMove) { - return 'skip: accounts invalid'; - } - - // add target uri to movedToUri in order to indicate that the user has moved - await this.usersRepository.update(old_acc.id, { movedToUri: targetUri }); - - // follow the new account and unfollow the old one - const followings = await this.followingsRepository.find({ - relations: { - follower: true, - }, - where: { - followeeId: old_acc.id, - followerHost: IsNull(), // follower is local - }, - }); - for (const following of followings) { - if (!following.follower) continue; - try { - await this.userFollowingService.follow(following.follower, new_acc); - await this.userFollowingService.unfollow(following.follower, old_acc); - } catch { - /* empty */ - } - } - - return 'ok'; + return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do'; } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 0b22aa9bcf..60e19bfca5 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid'; import * as mfm from 'mfm-js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { PartialLocalUser, LocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import type { Relay } from '@/models/entities/Relay.js'; @@ -66,7 +66,7 @@ export class ApRendererService { public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept { return { type: 'Accept', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, }; } @@ -75,7 +75,7 @@ export class ApRendererService { public renderAdd(user: LocalUser, target: any, object: any): IAdd { return { type: 'Add', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), target, object, }; @@ -83,7 +83,7 @@ export class ApRendererService { @bindThis public renderAnnounce(object: any, note: Note): IAnnounce { - const attributedTo = `${this.config.url}/users/${note.userId}`; + const attributedTo = this.userEntityService.genLocalUserUri(note.userId); let to: string[] = []; let cc: string[] = []; @@ -103,7 +103,7 @@ export class ApRendererService { return { id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, + actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', published: note.createdAt.toISOString(), to, @@ -126,7 +126,7 @@ export class ApRendererService { return { type: 'Block', id: `${this.config.url}/blocks/${block.id}`, - actor: `${this.config.url}/users/${block.blockerId}`, + actor: this.userEntityService.genLocalUserUri(block.blockerId), object: block.blockee.uri, }; } @@ -135,7 +135,7 @@ export class ApRendererService { public renderCreate(object: IObject, note: Note): ICreate { const activity = { id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, + actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Create', published: note.createdAt.toISOString(), object, @@ -151,7 +151,7 @@ export class ApRendererService { public renderDelete(object: IObject | string, user: { id: User['id']; host: null }): IDelete { return { type: 'Delete', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), }; @@ -188,7 +188,7 @@ export class ApRendererService { public renderFlag(user: LocalUser, object: IObject | string, content: string): IFlag { return { type: 'Flag', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), content, object, }; @@ -199,7 +199,7 @@ export class ApRendererService { return { id: `${this.config.url}/activities/follow-relay/${relay.id}`, type: 'Follow', - actor: `${this.config.url}/users/${relayActor.id}`, + actor: this.userEntityService.genLocalUserUri(relayActor.id), object: 'https://www.w3.org/ns/activitystreams#Public', }; } @@ -210,21 +210,21 @@ export class ApRendererService { */ @bindThis public async renderFollowUser(id: User['id']) { - const user = await this.usersRepository.findOneByOrFail({ id: id }); - return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri; + const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser; + return this.userEntityService.getUserUri(user); } @bindThis public renderFollow( - follower: { id: User['id']; host: User['host']; uri: User['host'] }, - followee: { id: User['id']; host: User['host']; uri: User['host'] }, + follower: PartialLocalUser | PartialRemoteUser, + followee: PartialLocalUser | PartialRemoteUser, requestId?: string, ): IFollow { return { id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, type: 'Follow', - actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri!, - object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri!, + actor: this.userEntityService.getUserUri(follower)!, + object: this.userEntityService.getUserUri(followee)!, }; } @@ -252,7 +252,7 @@ export class ApRendererService { return { id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, type: 'Key', - owner: `${this.config.url}/users/${user.id}`, + owner: this.userEntityService.genLocalUserUri(user.id), publicKeyPem: createPublicKey(key.publicKey).export({ type: 'spki', format: 'pem', @@ -284,21 +284,21 @@ export class ApRendererService { } @bindThis - public renderMention(mention: User): IApMention { + public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention { return { type: 'Mention', - href: this.userEntityService.isRemoteUser(mention) ? mention.uri! : `${this.config.url}/users/${(mention as LocalUser).id}`, + href: this.userEntityService.getUserUri(mention)!, name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`, }; } @bindThis public renderMove( - src: { id: User['id']; host: User['host']; uri: User['host'] }, - dst: { id: User['id']; host: User['host']; uri: User['host'] }, + src: PartialLocalUser | PartialRemoteUser, + dst: PartialLocalUser | PartialRemoteUser, ): IMove { - const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!; - const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!; + const actor = this.userEntityService.getUserUri(src)!; + const target = this.userEntityService.getUserUri(dst)!; return { id: `${this.config.url}/moves/${src.id}/${dst.id}`, actor, @@ -351,7 +351,7 @@ export class ApRendererService { } } - const attributedTo = `${this.config.url}/users/${note.userId}`; + const attributedTo = this.userEntityService.genLocalUserUri(note.userId); const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); @@ -376,7 +376,7 @@ export class ApRendererService { }) : []; const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => this.renderMention(u)); + const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser)); const files = await getPromisedFiles(note.fileIds); @@ -450,7 +450,7 @@ export class ApRendererService { @bindThis public async renderPerson(user: LocalUser) { - const id = `${this.config.url}/users/${user.id}`; + const id = this.userEntityService.genLocalUserUri(user.id); const isSystem = !!user.username.match(/\./); const [avatar, banner, profile] = await Promise.all([ @@ -538,7 +538,7 @@ export class ApRendererService { return { type: 'Question', id: `${this.config.url}/questions/${note.id}`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), content: note.text ?? '', [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ name: text, @@ -555,7 +555,7 @@ export class ApRendererService { public renderReject(object: any, user: { id: User['id'] }): IReject { return { type: 'Reject', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, }; } @@ -564,7 +564,7 @@ export class ApRendererService { public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove { return { type: 'Remove', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), target, object, }; @@ -585,7 +585,7 @@ export class ApRendererService { return { type: 'Undo', ...(id ? { id } : {}), - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), }; @@ -595,7 +595,7 @@ export class ApRendererService { public renderUpdate(object: any, user: { id: User['id'] }): IUpdate { return { id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), type: 'Update', to: ['https://www.w3.org/ns/activitystreams#Public'], object, @@ -607,14 +607,14 @@ export class ApRendererService { public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: RemoteUser): ICreate { return { id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), type: 'Create', to: [pollOwner.uri], published: new Date().toISOString(), object: { id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, type: 'Note', - attributedTo: `${this.config.url}/users/${user.id}`, + attributedTo: this.userEntityService.genLocalUserUri(user.id), to: [pollOwner.uri], inReplyTo: note.uri, name: poll.choices[vote.choice], diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index df7bb46405..d3e0345c9c 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { LocalUser } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -151,7 +151,7 @@ export class Resolver { return Promise.all( [parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })), ) - .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, url))); + .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower as LocalUser | RemoteUser, followee as LocalUser | RemoteUser, url))); default: throw new Error(`resolveLocal: type ${parsed.type} unhandled`); } diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 3b671af127..0043907c21 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -12,6 +12,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; +import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApImageService { @@ -48,8 +49,8 @@ export class ApImageService { throw new Error('invalid image: url not privided'); } - if (!image.url.startsWith('https://')) { - throw new Error('invalid image: unexpected shcema of url: ' + image.url); + if (!checkHttps(image.url)) { + throw new Error('invalid image: unexpected schema of url: ' + image.url); } this.logger.info(`Creating the Image: ${image.url}`); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 5ca5f6e843..87a9db405f 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -32,6 +32,7 @@ import { ApQuestionService } from './ApQuestionService.js'; import { ApImageService } from './ApImageService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject, IPost } from '../type.js'; +import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApNoteService { @@ -71,7 +72,7 @@ export class ApNoteService { } @bindThis - public validateNote(object: any, uri: string) { + public validateNote(object: IObject, uri: string) { const expectHost = this.utilityService.extractDbHost(uri); if (object == null) { @@ -85,9 +86,10 @@ export class ApNoteService { if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); } - - if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`); + + const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); + if (object.attributedTo && actualHost !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } return null; @@ -129,13 +131,13 @@ export class ApNoteService { this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - if (note.id && !note.id.startsWith('https://')) { + if (note.id && !checkHttps(note.id)) { throw new Error('unexpected shcema of note.id: ' + note.id); } const url = getOneApHrefNullable(note.url); - if (url && !url.startsWith('https://')) { + if (url && !checkHttps(url)) { throw new Error('unexpected shcema of note url: ' + url); } @@ -148,7 +150,7 @@ export class ApNoteService { if (actor.isSuspended) { throw new Error('actor has been suspended'); } - + const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); let visibility = noteAudience.visibility; const visibleUsers = noteAudience.visibleUsers; diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 21797cfcb7..eea1d1b848 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,9 +3,9 @@ import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import type { RemoteUser } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; import { truncate } from '@/misc/truncate.js'; import type { CacheService } from '@/core/CacheService.js'; @@ -42,6 +42,8 @@ import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; import type { IActor, IObject } from '../type.js'; +import type { AccountMoveService } from '@/core/AccountMoveService.js'; +import { checkHttps } from '@/misc/check-https.js'; const nameLength = 128; const summaryLength = 2048; @@ -66,6 +68,7 @@ export class ApPersonService implements OnModuleInit { private usersChart: UsersChart; private instanceChart: InstanceChart; private apLoggerService: ApLoggerService; + private accountMoveService: AccountMoveService; private logger: Logger; constructor( @@ -131,9 +134,16 @@ export class ApPersonService implements OnModuleInit { this.usersChart = this.moduleRef.get('UsersChart'); this.instanceChart = this.moduleRef.get('InstanceChart'); this.apLoggerService = this.moduleRef.get('ApLoggerService'); + this.accountMoveService = this.moduleRef.get('AccountMoveService'); this.logger = this.apLoggerService.logger; } + private punyHost(url: string): string { + const urlObj = new URL(url); + const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`; + return host; + } + /** * Validate and convert to actor object * @param x Fetched object @@ -141,7 +151,7 @@ export class ApPersonService implements OnModuleInit { */ @bindThis private validateActor(x: IObject, uri: string): IActor { - const expectHost = this.utilityService.toPuny(new URL(uri).hostname); + const expectHost = this.punyHost(uri); if (x == null) { throw new Error('invalid Actor: object is null'); @@ -182,7 +192,7 @@ export class ApPersonService implements OnModuleInit { x.summary = truncate(x.summary, summaryLength); } - const idHost = this.utilityService.toPuny(new URL(x.id!).hostname); + const idHost = this.punyHost(x.id); if (idHost !== expectHost) { throw new Error('invalid Actor: id has different host'); } @@ -192,7 +202,7 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: publicKey.id is not a string'); } - const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname); + const publicKeyIdHost = this.punyHost(x.publicKey.id); if (publicKeyIdHost !== expectHost) { throw new Error('invalid Actor: publicKey.id has different host'); } @@ -202,27 +212,27 @@ export class ApPersonService implements OnModuleInit { } /** - * Personをフェッチします。 + * uriからUser(Person)をフェッチします。 * - * Misskeyに対象のPersonが登録されていればそれを返します。 + * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。 */ @bindThis - public async fetchPerson(uri: string, resolver?: Resolver): Promise { + public async fetchPerson(uri: string): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - const cached = this.cacheService.uriPersonCache.get(uri); + const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null; if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ - if (uri.startsWith(this.config.url + '/')) { + if (uri.startsWith(`${this.config.url}/`)) { const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }); + const u = await this.usersRepository.findOneBy({ id }) as LocalUser; if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }); + const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser; if (exist) { this.cacheService.uriPersonCache.set(uri, exist); @@ -237,7 +247,7 @@ export class ApPersonService implements OnModuleInit { * Personを作成します。 */ @bindThis - public async createPerson(uri: string, resolver?: Resolver): Promise { + public async createPerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); if (uri.startsWith(this.config.url)) { @@ -252,7 +262,7 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Creating the Person: ${person.id}`); - const host = this.utilityService.toPuny(new URL(object.id).hostname); + const host = this.punyHost(object.id); const { fields } = this.analyzeAttachments(person.attachment ?? []); @@ -264,8 +274,8 @@ export class ApPersonService implements OnModuleInit { const url = getOneApHrefNullable(person.url); - if (url && !url.startsWith('https://')) { - throw new Error('unexpected shcema of person url: ' + url); + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } // Create user @@ -282,6 +292,7 @@ export class ApPersonService implements OnModuleInit { name: truncate(person.name, nameLength), isLocked: !!person.manuallyApprovesFollowers, movedToUri: person.movedTo, + movedAt: person.movedTo ? new Date() : null, alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, username: person.preferredUsername, @@ -404,23 +415,26 @@ export class ApPersonService implements OnModuleInit { /** * Personの情報を更新します。 * Misskeyに対象のPersonが登録されていなければ無視します。 + * もしアカウントの移行が確認された場合、アカウント移行処理を行います。 + * * @param uri URI of Person * @param resolver Resolver * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) + * @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない(無限ループ防止) */ @bindThis - public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { + public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) { + if (uri.startsWith(`${this.config.url}/`)) { return; } //#region このサーバーに既に登録されているか - const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser; + const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null; - if (exist == null) { + if (exist === null) { return; } //#endregion @@ -459,8 +473,8 @@ export class ApPersonService implements OnModuleInit { const url = getOneApHrefNullable(person.url); - if (url && !url.startsWith('https://')) { - throw new Error('unexpected shcema of person url: ' + url); + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } const updates = { @@ -478,7 +492,16 @@ export class ApPersonService implements OnModuleInit { movedToUri: person.movedTo ?? null, alsoKnownAs: person.alsoKnownAs ?? null, isExplorable: !!person.discoverable, - } as Partial; + } as Partial & Pick; + + const moving = + // 移行先がない→ある + (!exist.movedToUri && updates.movedToUri) || + // 移行先がある→別のもの + (exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri); + // 移行先がある→ない、ない→ないは無視 + + if (moving) updates.movedAt = new Date(); if (avatar) { updates.avatarId = avatar.id; @@ -523,6 +546,31 @@ export class ApPersonService implements OnModuleInit { }); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); + + const updated = { ...exist, ...updates }; + + this.cacheService.uriPersonCache.set(uri, updated); + + // 移行処理を行う + if (updated.movedAt && ( + // 初めて移行する場合はmovedAtがnullなので移行処理を許可 + exist.movedAt == null || + // 以前のmovingから14日以上経過した場合のみ移行処理を許可 + // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) + exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() + )) { + this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); + return this.processRemoteMove(updated, movePreventUris) + .then(result => { + this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`); + return result; + }) + .catch(e => { + this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e }); + }); + } + + return 'skip'; } /** @@ -532,7 +580,7 @@ export class ApPersonService implements OnModuleInit { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolvePerson(uri: string, resolver?: Resolver): Promise { + public async resolvePerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); //#region このサーバーに既に登録されていたらそれを返す @@ -607,4 +655,53 @@ export class ApPersonService implements OnModuleInit { } }); } + + /** + * リモート由来のアカウント移行処理を行います + * @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提) + * @param movePreventUris ここに列挙されたURIにsrc.movedToUriが含まれる場合、移行処理はしない(無限ループ防止) + */ + @bindThis + private async processRemoteMove(src: RemoteUser, movePreventUris: string[] = []): Promise { + if (!src.movedToUri) return 'skip: no movedToUri'; + if (src.uri === src.movedToUri) return 'skip: movedTo itself (src)'; // ??? + if (movePreventUris.length > 10) return 'skip: too many moves'; + + // まずサーバー内で検索して様子見 + let dst = await this.fetchPerson(src.movedToUri); + + if (dst && this.userEntityService.isLocalUser(dst)) { + // targetがローカルユーザーだった場合データベースから引っ張ってくる + dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as LocalUser; + } else if (dst) { + if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move'; + + // targetを見つけたことがあるならtargetをupdatePersonする + await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]); + dst = await this.fetchPerson(src.movedToUri) ?? dst; + } else { + if (src.movedToUri.startsWith(`${this.config.url}/`)) { + // ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている + return 'failed: movedTo is local but not found'; + } + + // targetが知らない人だったらresolvePerson + // (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする) + dst = await this.resolvePerson(src.movedToUri); + } + + if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ??? + if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ??? + if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri'; + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) { + return 'skip: dst.alsoKnownAs is empty'; + } + if (!dst.alsoKnownAs?.includes(src.uri)) { + return 'skip: alsoKnownAs does not include from.uri'; + } + + await this.accountMoveService.postMoveProcess(src, dst); + + return 'ok'; + } } diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 987002606f..15ffd44861 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -74,6 +74,8 @@ export class ChannelEntityService { userId: channel.userId, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, pinnedNoteIds: channel.pinnedNoteIds, + color: channel.color, + isArchived: channel.isArchived, usersCount: channel.usersCount, notesCount: channel.notesCount, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 26debd6adc..32269a4101 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -335,6 +335,7 @@ export class NoteEntityService implements OnModuleInit { channel: channel ? { id: channel.id, name: channel.name, + color: channel.color, } : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 0dc63d969f..d76b863957 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; +import type { AccessTokensRepository, FollowRequestsRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Notification } from '@/models/entities/Notification.js'; import type { Note } from '@/models/entities/Note.js'; @@ -35,6 +35,9 @@ export class NotificationEntityService implements OnModuleInit { @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + @Inject(DI.accessTokensRepository) private accessTokensRepository: AccessTokensRepository, @@ -131,6 +134,15 @@ export class NotificationEntityService implements OnModuleInit { }); const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); + // 既に解決されたフォローリクエストの通知を除外 + const followRequestNotifications = validNotifications.filter(x => x.type === 'receiveFollowRequest'); + if (followRequestNotifications.length > 0) { + const reqs = await this.followRequestsRepository.find({ + where: { followerId: In(followRequestNotifications.map(x => x.notifierId!)) }, + }); + validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); + } + return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, { packedNotes, packedUsers, diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index e111a10b77..54818782dd 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -59,6 +59,7 @@ export class RoleEntityService { isPublic: role.isPublic, isAdministrator: role.isAdministrator, isModerator: role.isModerator, + isExplorable: role.isExplorable, asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, displayOrder: role.displayOrder, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index e02f7535d4..453c1473dd 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, Not } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; @@ -9,10 +9,9 @@ import type { Packed } from '@/misc/json-schema.js'; import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; -import type { Instance } from '@/models/entities/Instance.js'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; @@ -35,13 +34,13 @@ type IsMeAndIsUserDetailed(user: T): user is T & { host: null; }; +function isLocalUser(user: T): user is (T & { host: null; }); function isLocalUser(user: User | { host: User['host'] }): boolean { return user.host == null; } function isRemoteUser(user: User): user is RemoteUser; -function isRemoteUser(user: T): user is T & { host: string; }; +function isRemoteUser(user: T): user is (T & { host: string; }); function isRemoteUser(user: User | { host: User['host'] }): boolean { return !isLocalUser(user); } @@ -113,6 +112,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, + + @Inject(DI.userMemosRepository) + private userMemosRepository: UserMemoRepository, //private noteEntityService: NoteEntityService, //private driveFileEntityService: DriveFileEntityService, @@ -277,9 +279,20 @@ export class UserEntityService implements OnModuleInit { return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } + @bindThis + public getUserUri(user: LocalUser | PartialLocalUser | RemoteUser | PartialRemoteUser): string { + return this.isRemoteUser(user) + ? user.uri : this.genLocalUserUri(user.id); + } + + @bindThis + public genLocalUserUri(userId: string): string { + return `${this.config.url}/users/${userId}`; + } + public async pack( src: User['id'] | User, - me?: { id: User['id'] } | null | undefined, + me?: { id: User['id']; } | null | undefined, options?: { detail?: D, includeSecrets?: boolean, @@ -313,6 +326,7 @@ export class UserEntityService implements OnModuleInit { const meId = me ? me.id : null; const isMe = meId === user.id; + const iAmModerator = me ? await this.roleService.isModerator(me as User) : false; const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null; const pins = opts.detail ? await this.userNotePiningsRepository.createQueryBuilder('pin') @@ -366,8 +380,11 @@ export class UserEntityService implements OnModuleInit { ...(opts.detail ? { url: profile!.url, uri: user.uri, - movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null, - alsoKnownAs: user.alsoKnownAs, + movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, + alsoKnownAs: user.alsoKnownAs + ? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))) + .then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[]) + : null, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, @@ -409,6 +426,11 @@ export class UserEntityService implements OnModuleInit { isAdministrator: role.isAdministrator, displayOrder: role.displayOrder, }))), + memo: meId == null ? null : await this.userMemosRepository.findOneBy({ + userId: meId, + targetUserId: user.id, + }).then(row => row?.memo ?? null), + moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), ...(opts.detail && isMe ? { diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index 1688bb2ba7..bb190cf60f 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -40,7 +40,7 @@ export class ServerStatsService implements OnApplicationShutdown { const stats = { cpu: roundCpu(cpu), mem: { - used: round(memStats.used - memStats.buffers - memStats.cached), + used: round(memStats.total - memStats.available), active: round(memStats.active), }, net: { diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index d4b1fb31b1..c06c7a7159 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -1,6 +1,7 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), + meilisearch: Symbol('meilisearch'), redis: Symbol('redis'), redisForPub: Symbol('redisForPub'), redisForSub: Symbol('redisForSub'), @@ -70,5 +71,6 @@ export const DI = { roleAssignmentsRepository: Symbol('roleAssignmentsRepository'), flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), + userMemosRepository: Symbol('userMemosRepository'), //#endregion }; diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f413246a1f..5610929648 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -1,4 +1,4 @@ -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; export class RedisKVCache { @@ -38,7 +38,7 @@ export class RedisKVCache { await this.redisClient.set( `kvcache:${this.name}:${key}`, this.toRedisConverter(value), - 'ex', Math.round(this.lifetime / 1000), + 'EX', Math.round(this.lifetime / 1000), ); } } @@ -122,7 +122,7 @@ export class RedisSingleCache { await this.redisClient.set( `singlecache:${this.name}`, this.toRedisConverter(value), - 'ex', Math.round(this.lifetime / 1000), + 'EX', Math.round(this.lifetime / 1000), ); } } diff --git a/packages/backend/src/misc/check-https.ts b/packages/backend/src/misc/check-https.ts new file mode 100644 index 0000000000..b33f019973 --- /dev/null +++ b/packages/backend/src/misc/check-https.ts @@ -0,0 +1,4 @@ +export function checkHttps(url: string) { + return url.startsWith('https://') || + (url.startsWith('http://') && process.env.NODE_ENV !== 'production'); +} diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index e8c66683cc..910bebfcfe 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -1,3 +1,4 @@ +import { AhoCorasick } from 'slacc'; import RE2 from 're2'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; @@ -12,6 +13,8 @@ type UserLike = { id: User['id']; }; +const acCache = new Map(); + export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: Array): Promise { // 自分自身 if (me && (note.userId === me.id)) return false; @@ -21,7 +24,22 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi if (text === '') return false; - const matched = mutedWords.some(filter => { + const acable = mutedWords.filter(filter => Array.isArray(filter) && filter.length === 1).map(filter => filter[0]).sort(); + const unacable = mutedWords.filter(filter => !Array.isArray(filter) || filter.length !== 1); + const acCacheKey = acable.join('\n'); + const ac = acCache.get(acCacheKey) ?? AhoCorasick.withPatterns(acable); + acCache.delete(acCacheKey); + for (const obsoleteKeys of acCache.keys()) { + if (acCache.size > 1000) { + acCache.delete(obsoleteKeys); + } + } + acCache.set(acCacheKey, ac); + if (ac.isMatch(text)) { + return true; + } + + const matched = unacable.some(filter => { if (Array.isArray(filter)) { return filter.every(keyword => text.includes(keyword)); } else { diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 7be7b81904..588c98b58d 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -388,6 +388,12 @@ const $roleAssignmentsRepository: Provider = { inject: [DI.db], }; +const $userMemosRepository: Provider = { + provide: DI.userMemosRepository, + useFactory: (db: DataSource) => db.getRepository(UserMemo), + inject: [DI.db], +}; + @Module({ imports: [ ], @@ -456,6 +462,7 @@ const $roleAssignmentsRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $userMemosRepository, ], exports: [ $usersRepository, @@ -522,6 +529,7 @@ const $roleAssignmentsRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $userMemosRepository, ], }) export class RepositoryModule {} diff --git a/packages/backend/src/models/entities/Channel.ts b/packages/backend/src/models/entities/Channel.ts index 2d346fdf9d..d7c4583da3 100644 --- a/packages/backend/src/models/entities/Channel.ts +++ b/packages/backend/src/models/entities/Channel.ts @@ -64,6 +64,18 @@ export class Channel { }) public pinnedNoteIds: string[]; + @Column('varchar', { + length: 16, + default: '#86b300', + }) + public color: string; + + @Index() + @Column('boolean', { + default: false, + }) + public isArchived: boolean; + @Index() @Column('integer', { default: 0, diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index 2e4f90b57f..6d44e4edc7 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -405,4 +405,16 @@ export class Meta { default: { }, }) public policies: Record; + + @Column('varchar', { + length: 280, + array: true, + default: '{}', + }) + public serverRules: string[]; + + @Column('varchar', { + length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }', + }) + public preservedUsernames: string[]; } diff --git a/packages/backend/src/models/entities/Role.ts b/packages/backend/src/models/entities/Role.ts index eca9bcf270..61f40d59da 100644 --- a/packages/backend/src/models/entities/Role.ts +++ b/packages/backend/src/models/entities/Role.ts @@ -151,6 +151,11 @@ export class Role { }) public isAdministrator: boolean; + @Column('boolean', { + default: false, + }) + public isExplorable: boolean; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index 04dfa21107..8e10f999b6 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -75,6 +75,12 @@ export class User { }) public movedToUri: string | null; + @Column('timestamp with time zone', { + nullable: true, + comment: 'When the user moved to another account', + }) + public movedAt: Date | null; + @Column('simple-array', { nullable: true, comment: 'URIs the user is known as too', @@ -253,11 +259,23 @@ export type LocalUser = User & { uri: null; } +export type PartialLocalUser = Partial & { + id: User['id']; + host: null; + uri: null; +} + export type RemoteUser = User & { host: string; uri: string; } +export type PartialRemoteUser = Partial & { + id: User['id']; + host: string; + uri: string; +} + export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; export const passwordSchema = { type: 'string', minLength: 1 } as const; export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; diff --git a/packages/backend/src/models/entities/UserMemo.ts b/packages/backend/src/models/entities/UserMemo.ts new file mode 100644 index 0000000000..7dc34b4346 --- /dev/null +++ b/packages/backend/src/models/entities/UserMemo.ts @@ -0,0 +1,42 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +@Index(['userId', 'targetUserId'], { unique: true }) +export class UserMemo { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of target user.', + }) + public targetUserId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public targetUser: User | null; + + @Column('varchar', { + length: 2048, + comment: 'Memo.', + }) + public memo: string; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 48d6e15f2a..b8ba28db9b 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -55,6 +55,7 @@ import { UserPending } from '@/models/entities/UserPending.js'; import { UserProfile } from '@/models/entities/UserProfile.js'; import { UserPublickey } from '@/models/entities/UserPublickey.js'; import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; +import { UserMemo } from '@/models/entities/UserMemo.js'; import { Webhook } from '@/models/entities/Webhook.js'; import { Channel } from '@/models/entities/Channel.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; @@ -129,6 +130,7 @@ export { RoleAssignment, Flash, FlashLike, + UserMemo, }; export type AbuseUserReportsRepository = Repository; @@ -195,3 +197,4 @@ export type RolesRepository = Repository; export type RoleAssignmentsRepository = Repository; export type FlashsRepository = Repository; export type FlashLikesRepository = Repository; +export type UserMemoRepository = Repository; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index 745b39a6ba..fd61a70c0e 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -30,6 +30,10 @@ export const packedChannelSchema = { format: 'url', nullable: true, optional: false, }, + isArchived: { + type: 'boolean', + optional: false, nullable: false, + }, notesCount: { type: 'number', nullable: false, optional: false, @@ -59,5 +63,9 @@ export const packedChannelSchema = { format: 'id', }, }, + color: { + type: 'string', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 7d40979e3d..529c1303d1 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -80,9 +80,14 @@ export const packedUserDetailedNotMeOnlySchema = { }, alsoKnownAs: { type: 'array', - format: 'uri', nullable: true, optional: false, + items: { + type: 'string', + format: 'id', + nullable: false, + optional: false, + }, }, createdAt: { type: 'string', @@ -143,6 +148,7 @@ export const packedUserDetailedNotMeOnlySchema = { fields: { type: 'array', nullable: false, optional: false, + maxItems: 16, items: { type: 'object', nullable: false, optional: false, @@ -156,7 +162,6 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: false, }, }, - maxLength: 4, }, }, followersCount: { @@ -250,6 +255,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + memo: { + type: 'string', + nullable: false, optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index bb21ed827e..f3d404e6c9 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -70,6 +70,7 @@ import { Role } from '@/models/entities/Role.js'; import { RoleAssignment } from '@/models/entities/RoleAssignment.js'; import { Flash } from '@/models/entities/Flash.js'; import { FlashLike } from '@/models/entities/FlashLike.js'; +import { UserMemo } from '@/models/entities/UserMemo.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -183,6 +184,7 @@ export const entities = [ RoleAssignment, Flash, FlashLike, + UserMemo, ...charts, ]; diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts index 233a36dd04..df8ac3a301 100644 --- a/packages/backend/src/queue/DbQueueProcessorsService.ts +++ b/packages/backend/src/queue/DbQueueProcessorsService.ts @@ -9,11 +9,13 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; +import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; +import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import type Bull from 'bull'; @@ -32,11 +34,13 @@ export class DbQueueProcessorsService { private exportMutingProcessorService: ExportMutingProcessorService, private exportBlockingProcessorService: ExportBlockingProcessorService, private exportUserListsProcessorService: ExportUserListsProcessorService, + private exportAntennasProcessorService: ExportAntennasProcessorService, private importFollowingProcessorService: ImportFollowingProcessorService, private importMutingProcessorService: ImportMutingProcessorService, private importBlockingProcessorService: ImportBlockingProcessorService, private importUserListsProcessorService: ImportUserListsProcessorService, private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService, + private importAntennasProcessorService: ImportAntennasProcessorService, private deleteAccountProcessorService: DeleteAccountProcessorService, ) { } @@ -51,6 +55,7 @@ export class DbQueueProcessorsService { q.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done)); q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done)); + q.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done)); q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done)); q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job)); q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done)); @@ -58,6 +63,7 @@ export class DbQueueProcessorsService { q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job)); q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done)); q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); + q.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done)); q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); } } diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 4db9b38547..3d4cc77321 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -24,11 +24,13 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; +import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; +import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; @@ -55,11 +57,13 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ExportMutingProcessorService, ExportBlockingProcessorService, ExportUserListsProcessorService, + ExportAntennasProcessorService, ImportFollowingProcessorService, ImportMutingProcessorService, ImportBlockingProcessorService, ImportUserListsProcessorService, ImportCustomEmojisProcessorService, + ImportAntennasProcessorService, DeleteAccountProcessorService, DeleteFileProcessorService, CleanRemoteFilesProcessorService, diff --git a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts index af086fa4e7..736b4fa80d 100644 --- a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts +++ b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts @@ -17,7 +17,7 @@ export class RelationshipQueueProcessorsService { @bindThis public start(q: Bull.Queue): void { - const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative? + const maxJobs = this.config.relashionshipJobConcurrency ?? 16; q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job)); q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job)); q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job)); diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 0e99b7bcd2..f293bd4d7e 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -79,10 +79,7 @@ export class DeliverProcessorService { // Update stats this.federatedInstanceService.fetch(host).then(i => { if (i.isNotResponding) { - this.instancesRepository.update(i.id, { - isNotResponding: false, - }); - this.federatedInstanceService.updateCachePartial(host, { + this.federatedInstanceService.update(i.id, { isNotResponding: false, }); } @@ -101,10 +98,7 @@ export class DeliverProcessorService { // Update stats this.federatedInstanceService.fetch(host).then(i => { if (!i.isNotResponding) { - this.instancesRepository.update(i.id, { - isNotResponding: true, - }); - this.federatedInstanceService.updateCachePartial(host, { + this.federatedInstanceService.update(i.id, { isNotResponding: true, }); } @@ -123,10 +117,7 @@ export class DeliverProcessorService { // 相手が閉鎖していることを明示しているため、配送停止する if (job.data.isSharedInbox && res.statusCode === 410) { this.federatedInstanceService.fetch(host).then(i => { - this.instancesRepository.update(i.id, { - isSuspended: true, - }); - this.federatedInstanceService.updateCachePartial(host, { + this.federatedInstanceService.update(i.id, { isSuspended: true, }); }); diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts new file mode 100644 index 0000000000..894903e79b --- /dev/null +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { format as DateFormat } from 'date-fns'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, User } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { bindThis } from '@/decorators.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type { DBExportAntennasData } from '../types.js'; +import type Bull from 'bull'; + +@Injectable() +export class ExportAntennasProcessorService { + private logger: Logger; + + constructor ( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.antennasRepository) + private antennsRepository: AntennasRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private driveService: DriveService, + private utilityService: UtilityService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-antennas'); + } + + @bindThis + public async process(job: Bull.Job, done: () => void): Promise { + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + const [path, cleanup] = await createTemp(); + const stream = fs.createWriteStream(path, { flags: 'a' }); + const write = (input: string): Promise => { + return new Promise((resolve, reject) => { + stream.write(input, err => { + if (err) { + this.logger.error(err); + reject(); + } else { + resolve(); + } + }); + }); + }; + try { + const antennas = await this.antennsRepository.findBy({ userId: job.data.user.id }); + write('['); + for (const [index, antenna] of antennas.entries()) { + let users: User[] | undefined; + if (antenna.userListId !== null) { + const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId }); + users = await this.usersRepository.findBy({ + id: In(joinings.map(j => j.userId)), + }); + } + write(JSON.stringify({ + name: antenna.name, + src: antenna.src, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + users: antenna.users, + userListAccts: typeof users !== 'undefined' ? users.map((u) => { + return this.utilityService.getFullApAccount(u.username, u.host); // acct + }) : null, + caseSensitive: antenna.caseSensitive, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + notify: antenna.notify, + })); + if (antennas.length - 1 !== index) { + write(', '); + } + } + write(']'); + stream.end(); + + const fileName = 'antennas-' + DateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); + this.logger.succ('Exported to: ' + driveFile.id); + } finally { + cleanup(); + done(); + } + } +} + diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index a020006732..c7b54070d6 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -106,7 +106,7 @@ export class ExportBlockingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index daefcdf2f5..f2f2383a88 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -121,7 +121,7 @@ export class ExportFavoritesProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'favorites-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 59443de57f..fa9c1ac1ea 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -110,7 +110,7 @@ export class ExportFollowingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index a2a718b892..b14bf5f5b1 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -110,7 +110,7 @@ export class ExportMutingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 1aa20d6f1d..e4f12ad101 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -117,7 +117,7 @@ export class ExportNotesProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index ce8ed2f5e8..54bde44044 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -86,7 +86,7 @@ export class ExportUserListsProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts new file mode 100644 index 0000000000..d06131b8c8 --- /dev/null +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -0,0 +1,96 @@ +import { Injectable, Inject } from '@nestjs/common'; +import Ajv from 'ajv'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import Logger from '@/logger.js'; +import type { AntennasRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import { DBAntennaImportJobData } from '../types.js'; +import type Bull from 'bull'; + +const validate = new Ajv().compile({ + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + src: { type: 'string', enum: ['home', 'all', 'users', 'list'] }, + userListAccts: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + keywords: { type: 'array', items: { + type: 'array', items: { + type: 'string', + }, + } }, + excludeKeywords: { type: 'array', items: { + type: 'array', items: { + type: 'string', + }, + } }, + users: { type: 'array', items: { + type: 'string', + } }, + caseSensitive: { type: 'boolean' }, + withReplies: { type: 'boolean' }, + withFile: { type: 'boolean' }, + notify: { type: 'boolean' }, + }, + required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], +}); + +@Injectable() +export class ImportAntennasProcessorService { + private logger: Logger; + + constructor ( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + private queueLoggerService: QueueLoggerService, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('import-antennas'); + } + + @bindThis + public async process(job: Bull.Job, done: () => void): Promise { + const now = new Date(); + try { + for (const antenna of job.data.antenna) { + if (antenna.keywords.length === 0 || antenna.keywords[0].every(x => x === '')) continue; + if (!validate(antenna)) { + this.logger.warn('Validation Failed'); + continue; + } + const result = await this.antennasRepository.insert({ + id: this.idService.genId(), + createdAt: now, + lastUsedAt: now, + userId: job.data.user.id, + name: antenna.name, + src: antenna.src === 'list' && antenna.userListAccts ? 'users' : antenna.src, + userListId: null, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + users: (antenna.src === 'list' && antenna.userListAccts !== null ? antenna.userListAccts : antenna.users).filter(Boolean), + caseSensitive: antenna.caseSensitive, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + notify: antenna.notify, + }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); + this.logger.succ('Antenna created: ' + result.id); + this.globalEventService.publishInternalEvent('antennaCreated', result); + } + } catch (err: any) { + this.logger.error(err); + } finally { + done(); + } + } +} diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index ed7f38d013..ab8b1e9e22 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -84,9 +84,9 @@ export class InboxProcessorService { // HTTP-Signature keyIdを元にDBから取得 let authUser: { - user: RemoteUser; - key: UserPublickey | null; - } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); + user: RemoteUser; + key: UserPublickey | null; + } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 if (authUser == null) { @@ -174,13 +174,10 @@ export class InboxProcessorService { // Update stats this.federatedInstanceService.fetch(authUser.user.host).then(i => { - this.instancesRepository.update(i.id, { + this.federatedInstanceService.update(i.id, { latestRequestReceivedAt: new Date(), isNotResponding: false, }); - this.federatedInstanceService.updateCachePartial(host, { - isNotResponding: false, - }); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts index a5006dcf03..ff454df455 100644 --- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -10,6 +10,7 @@ import { QueueLoggerService } from '../QueueLoggerService.js'; import { RelationshipJobData } from '../types.js'; import type { UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; @Injectable() export class RelationshipProcessorService { @@ -39,7 +40,7 @@ export class RelationshipProcessorService { const [follower, followee] = await Promise.all([ this.usersRepository.findOneByOrFail({ id: job.data.from.id }), this.usersRepository.findOneByOrFail({ id: job.data.to.id }), - ]); + ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser]; await this.userFollowingService.unfollow(follower, followee, job.data.silent); return 'ok'; } diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 23c973d449..776dd3aa12 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -1,3 +1,4 @@ +import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; @@ -33,12 +34,14 @@ export type DbJobData = DbJobMap[T]; export type DbJobMap = { deleteDriveFiles: DbJobDataWithUser; exportCustomEmojis: DbJobDataWithUser; + exportAntennas: DBExportAntennasData; exportNotes: DbJobDataWithUser; exportFavorites: DbJobDataWithUser; exportFollowing: DbExportFollowingData; exportMuting: DbJobDataWithUser; exportBlocking: DbJobDataWithUser; exportUserLists: DbJobDataWithUser; + importAntennas: DBAntennaImportJobData; importFollowing: DbUserImportJobData; importFollowingToDb: DbUserImportToDbJobData; importMuting: DbUserImportJobData; @@ -59,6 +62,10 @@ export type DbExportFollowingData = { excludeInactive: boolean; }; +export type DBExportAntennasData = { + user: ThinUser +} + export type DbUserDeleteJobData = { user: ThinUser; soft?: boolean; @@ -69,6 +76,11 @@ export type DbUserImportJobData = { fileId: DriveFile['id']; }; +export type DBAntennaImportJobData = { + user: ThinUser, + antenna: Antenna +} + export type DbUserImportToDbJobData = { user: ThinUser; target: string; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index e13e9265ab..e675d9cf1b 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -11,7 +11,7 @@ import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { LocalUser, User } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; import type { Following } from '@/models/entities/Following.js'; import { countIf } from '@/misc/prelude/array.js'; @@ -630,7 +630,7 @@ export class ActivityPubServerService { id: request.params.followee, host: Not(IsNull()), }), - ]); + ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null]; if (follower == null || followee == null) { reply.code(404); @@ -665,7 +665,7 @@ export class ActivityPubServerService { id: followRequest.followeeId, host: Not(IsNull()), }), - ]); + ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null]; if (follower == null || followee == null) { reply.code(404); diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 794fa76d9e..98329ddffa 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -297,7 +297,8 @@ export class FileServerService { } else if ('badge' in request.query) { const mask = (await sharpBmp(file.path, file.mime)) .resize(96, 96, { - fit: 'inside', + fit: 'contain', + position: 'centre', withoutEnlargement: false, }) .greyscale() @@ -453,7 +454,8 @@ export class FileServerService { fileRole: 'original', file, filename: file.name, - mime: file.type, + // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる + mime: this.fileInfoService.fixMime(file.type), ext: null, path, }; diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index e722563036..9bf8deb221 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -8,6 +8,7 @@ import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; import type { User } from '@/models/entities/User.js'; import * as Acct from '@/misc/acct.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { FindOptionsWhere } from 'typeorm'; import { bindThis } from '@/decorators.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @@ -23,6 +24,7 @@ export class WellKnownServerService { private usersRepository: UsersRepository, private nodeinfoServerService: NodeinfoServerService, + private userEntityService: UserEntityService, ) { //this.createServer = this.createServer.bind(this); } @@ -130,7 +132,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => { const self = { rel: 'self', type: 'application/activity+json', - href: `${this.config.url}/users/${user.id}`, + href: this.userEntityService.genLocalUserUri(user.id), }; const profilePage = { rel: 'http://webfinger.net/rel/profile-page', diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index bf5cb20918..e3483c82c6 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -261,6 +261,17 @@ export class ApiCallService implements OnApplicationShutdown { } } + if (ep.meta.prohibitMoved) { + if (user?.movedToUri) { + throw new ApiError({ + message: 'You have moved your account.', + code: 'YOUR_ACCOUNT_MOVED', + id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', + httpStatusCode: 403, + }); + } + } + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 689f90287e..ee1aae5b6c 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -29,6 +29,7 @@ import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; +import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; @@ -193,6 +194,7 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js'; import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; +import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; @@ -201,6 +203,7 @@ import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; +import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; @@ -222,7 +225,6 @@ import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_knownAs from './endpoints/i/known-as.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -330,6 +332,7 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; +import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___retention from './endpoints/retention.js'; import { GetterService } from './GetterService.js'; @@ -364,6 +367,7 @@ const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass: const $admin_emoji_removeAliasesBulk: Provider = { provide: 'ep:admin/emoji/remove-aliases-bulk', useClass: ep___admin_emoji_removeAliasesBulk.default }; const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-aliases-bulk', useClass: ep___admin_emoji_setAliasesBulk.default }; const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; +const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; @@ -528,6 +532,7 @@ const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_ const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default }; const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default }; const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default }; +const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default }; const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default }; const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default }; const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default }; @@ -536,6 +541,7 @@ const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default }; const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default }; const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; +const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default }; const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default }; const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; @@ -557,7 +563,6 @@ const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.defau const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default }; -const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default }; const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; @@ -665,6 +670,7 @@ const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___use const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; +const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; @@ -703,6 +709,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_removeAliasesBulk, $admin_emoji_setAliasesBulk, $admin_emoji_setCategoryBulk, + $admin_emoji_setLicenseBulk, $admin_emoji_update, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, @@ -867,6 +874,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_exportNotes, $i_exportFavorites, $i_exportUserLists, + $i_exportAntennas, $i_favorites, $i_gallery_likes, $i_gallery_posts, @@ -875,6 +883,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_importFollowing, $i_importMuting, $i_importUserLists, + $i_importAntennas, $i_notifications, $i_pageLikes, $i_pages, @@ -896,7 +905,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_updateEmail, $i_update, $i_move, - $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1004,6 +1012,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_show, $users_stats, $users_achievements, + $users_updateMemo, $fetchRss, $retention, ], @@ -1036,6 +1045,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_removeAliasesBulk, $admin_emoji_setAliasesBulk, $admin_emoji_setCategoryBulk, + $admin_emoji_setLicenseBulk, $admin_emoji_update, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, @@ -1200,6 +1210,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_exportNotes, $i_exportFavorites, $i_exportUserLists, + $i_exportAntennas, $i_favorites, $i_gallery_likes, $i_gallery_posts, @@ -1208,6 +1219,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_importFollowing, $i_importMuting, $i_importUserLists, + $i_importAntennas, $i_notifications, $i_pageLikes, $i_pages, @@ -1229,7 +1241,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_updateEmail, $i_update, $i_move, - $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1335,6 +1346,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_show, $users_stats, $users_achievements, + $users_updateMemo, $fetchRss, $retention, ], diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index 1f8915ecca..fe2db1d66a 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import Limiter from 'ratelimiter'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index fbabf47aff..b2bd7d82e7 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -15,7 +16,6 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { bindThis } from '@/decorators.js'; import { SigninService } from './SigninService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; -import { IsNull } from 'typeorm'; @Injectable() export class SignupApiService { @@ -137,6 +137,11 @@ export class SignupApiService { throw new FastifyReplyError(400, 'USED_USERNAME'); } + const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + if (isPreserved) { + throw new FastifyReplyError(400, 'DENIED_USERNAME'); + } + const code = rndstr('a-z0-9', 16); // Generate hash of password diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 769a4490d6..258e8de034 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import * as websocket from 'websocket'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d0fe6a57c1..09bd7cbff4 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -29,6 +29,7 @@ import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; +import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; @@ -193,6 +194,7 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js'; import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; +import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; @@ -201,6 +203,7 @@ import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; +import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; @@ -222,7 +225,6 @@ import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_knownAs from './endpoints/i/known-as.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -330,6 +332,7 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; +import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___retention from './endpoints/retention.js'; @@ -362,6 +365,7 @@ const eps = [ ['admin/emoji/remove-aliases-bulk', ep___admin_emoji_removeAliasesBulk], ['admin/emoji/set-aliases-bulk', ep___admin_emoji_setAliasesBulk], ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], + ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], ['admin/emoji/update', ep___admin_emoji_update], ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], @@ -526,6 +530,7 @@ const eps = [ ['i/export-notes', ep___i_exportNotes], ['i/export-favorites', ep___i_exportFavorites], ['i/export-user-lists', ep___i_exportUserLists], + ['i/export-antennas', ep___i_exportAntennas], ['i/favorites', ep___i_favorites], ['i/gallery/likes', ep___i_gallery_likes], ['i/gallery/posts', ep___i_gallery_posts], @@ -534,6 +539,7 @@ const eps = [ ['i/import-following', ep___i_importFollowing], ['i/import-muting', ep___i_importMuting], ['i/import-user-lists', ep___i_importUserLists], + ['i/import-antennas', ep___i_importAntennas], ['i/notifications', ep___i_notifications], ['i/page-likes', ep___i_pageLikes], ['i/pages', ep___i_pages], @@ -554,8 +560,7 @@ const eps = [ ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], - //['i/move', ep___i_move], - //['i/known-as', ep___i_knownAs], + ['i/move', ep___i_move], ['i/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], @@ -663,6 +668,7 @@ const eps = [ ['users/show', ep___users_show], ['users/stats', ep___users_stats], ['users/achievements', ep___users_achievements], + ['users/update-memo', ep___users_updateMemo], ['fetch-rss', ep___fetchRss], ['retention', ep___retention], ]; @@ -700,6 +706,12 @@ export interface IEndpointMeta { readonly requireRolePolicy?: keyof RolePolicies; + /** + * 引っ越し済みのユーザーによるリクエストを禁止するか + * 省略した場合は false として解釈されます。 + */ + readonly prohibitMoved?: boolean; + /** * エンドポイントのリミテーションに関するやつ * 省略した場合はリミテーションは無いものとして解釈されます。 diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index bac8ae16e5..8a3541dffe 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -52,6 +52,7 @@ export default class extends Endpoint { const { account, secret } = await this.signupService.signup({ username: ps.username, password: ps.password, + ignorePreservedUsernames: true, }); const res = await this.userEntityService.pack(account, account, { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 814668294f..4aa4ad82b4 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -87,12 +87,18 @@ export default class extends Endpoint { //const emojis = await q.take(ps.limit).getMany(); emojis = await q.getMany(); + const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g); - emojis = emojis.filter(emoji => - emoji.name.includes(ps.query!) || - emoji.aliases.some(a => a.includes(ps.query!)) || - emoji.category?.includes(ps.query!)); - + if (queryarry) { + emojis = emojis.filter(emoji => + queryarry.includes(`:${emoji.name}:`) + ); + } else { + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query!) || + emoji.aliases.some(a => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!)); + } emojis.splice(ps.limit + 1); } else { emojis = await q.take(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts new file mode 100644 index 0000000000..b90b9757be --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', +} as const; + +export const paramDef = { + type: 'object', + properties: { + ids: { type: 'array', items: { + type: 'string', format: 'misskey:id', + } }, + license: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the license.', + }, + }, + required: ['ids'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + private customEmojiService: CustomEmojiService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.customEmojiService.setLicenseBulk(ps.ids, ps.license ?? null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts index 54ce095488..83f729953a 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -39,9 +39,9 @@ export default class extends Endpoint { const pairs = await Promise.all(followings.map(f => Promise.all([ this.usersRepository.findOneByOrFail({ id: f.followerId }), this.usersRepository.findOneByOrFail({ id: f.followeeId }), - ]))); + ]).then(([from, to]) => [{ id: from.id }, { id: to.id }]))); - this.queueService.createUnfollowJob(pairs.map(p => ({ to: p[0], from: p[1], silent: true }))); + this.queueService.createUnfollowJob(pairs.map(p => ({ from: p[0], to: p[1], silent: true }))); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index 0a529ecb08..4fd74e591d 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -3,6 +3,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { InstancesRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; export const meta = { tags: ['admin'], @@ -28,6 +29,7 @@ export default class extends Endpoint { private instancesRepository: InstancesRepository, private utilityService: UtilityService, + private federatedInstanceService: FederatedInstanceService, ) { super(meta, paramDef, async (ps, me) => { const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); @@ -36,7 +38,7 @@ export default class extends Endpoint { throw new Error('instance not found'); } - this.instancesRepository.update({ host: this.utilityService.toPuny(ps.host) }, { + this.federatedInstanceService.update(instance.id, { isSuspended: ps.isSuspended, }); }); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index fc318a621a..87a2d22ac2 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -118,6 +118,14 @@ export const meta = { optional: false, nullable: false, }, }, + preservedUsernames: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, hcaptchaSecretKey: { type: 'string', optional: true, nullable: true, @@ -311,6 +319,7 @@ export default class extends Endpoint { hiddenTags: instance.hiddenTags, blockedHosts: instance.blockedHosts, sensitiveWords: instance.sensitiveWords, + preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, turnstileSecretKey: instance.turnstileSecretKey, diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index 1359894634..916172f54a 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -25,6 +25,7 @@ export const paramDef = { isPublic: { type: 'boolean' }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, + isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, @@ -76,12 +77,13 @@ export default class extends Endpoint { isPublic: ps.isPublic, isAdministrator: ps.isAdministrator, isModerator: ps.isModerator, + isExplorable: ps.isExplorable, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, displayOrder: ps.displayOrder, policies: ps.policies, }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); - + this.globalEventService.publishInternalEvent('roleCreated', created); return await this.roleEntityService.pack(created, me); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 37b68c4c41..467f157a61 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -33,6 +33,7 @@ export const paramDef = { isPublic: { type: 'boolean' }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, + isExplorable: { type: 'boolean' }, asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, @@ -85,6 +86,7 @@ export default class extends Endpoint { isPublic: ps.isPublic, isModerator: ps.isModerator, isAdministrator: ps.isAdministrator, + isExplorable: ps.isExplorable, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, displayOrder: ps.displayOrder, diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts index 9c576dffe9..4ef4fdc665 100644 --- a/packages/backend/src/server/api/endpoints/admin/server-info.ts +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -2,7 +2,7 @@ import * as os from 'node:os'; import si from 'systeminformation'; import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 9d19efbbcf..42229c8f23 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -80,7 +80,7 @@ export default class extends Endpoint { isSilenced: isSilenced, isSuspended: user.isSuspended, lastActiveDate: user.lastActiveDate, - moderationNote: profile.moderationNote, + moderationNote: profile.moderationNote ?? '', signins, policies: await this.roleService.getUserPolicies(user.id), roles: await this.roleEntityService.packMany(roles, me), 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 11de29bf83..0e94f56cfd 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -94,6 +94,8 @@ export const paramDef = { enableActiveEmailValidation: { type: 'boolean' }, enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' }, + serverRules: { type: 'array', items: { type: 'string' } }, + preservedUsernames: { type: 'array', items: { type: 'string' } }, }, required: [], } as const; @@ -387,6 +389,14 @@ export default class extends Endpoint { set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; } + if (ps.serverRules !== undefined) { + set.serverRules = ps.serverRules; + } + + if (ps.preservedUsernames !== undefined) { + set.preservedUsernames = ps.preservedUsernames; + } + await this.metaService.update(set); this.moderationLogService.insertModerationLog(me, 'updateMeta'); }); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index b7ce3363a9..5754a9f12a 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 88623ce26a..dca0f443b7 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 3f85442131..5f980bdbeb 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { @@ -71,7 +73,7 @@ export default class extends Endpoint { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - + private antennaEntityService: AntennaEntityService, private globalEventService: GlobalEventService, ) { diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index dff8a9d10d..69e2f2504c 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', limit: { @@ -41,6 +43,7 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + color: { type: 'string', minLength: 1, maxLength: 16 }, }, required: ['name'], } as const; @@ -78,6 +81,7 @@ export default class extends Endpoint { name: ps.name, description: ps.description ?? null, bannerId: banner ? banner.id : null, + ...(ps.color !== undefined ? { color: ps.color } : {}), } as Channel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); return await this.channelEntityService.pack(channel, me); diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts index f52b45ccf3..c8544273a1 100644 --- a/packages/backend/src/server/api/endpoints/channels/favorite.ts +++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts index d25faae38d..1a8d1164c7 100644 --- a/packages/backend/src/server/api/endpoints/channels/featured.ts +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -38,6 +38,7 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const query = this.channelsRepository.createQueryBuilder('channel') .where('channel.lastNotedAt IS NOT NULL') + .andWhere('channel.isArchived = FALSE') .orderBy('channel.lastNotedAt', 'DESC'); const channels = await query.take(10).getMany(); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 8ab59991c7..f3ca66cfd2 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts index 59df0616be..8fae972cb1 100644 --- a/packages/backend/src/server/api/endpoints/channels/owned.ts +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -44,7 +44,8 @@ export default class extends Endpoint { private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId) + .andWhere('channel.isArchived = FALSE') .andWhere({ userId: me.id }); const channels = await query diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts index a954ba224c..a3b40b0bbd 100644 --- a/packages/backend/src/server/api/endpoints/channels/search.ts +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -46,15 +46,18 @@ export default class extends Endpoint { private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId) + .andWhere('channel.isArchived = FALSE'); - if (ps.type === 'nameAndDescription') { - query.andWhere(new Brackets(qb => { qb - .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) - .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); - })); - } else { - query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + if (ps.query !== '') { + if (ps.type === 'nameAndDescription') { + query.andWhere(new Brackets(qb => { qb + .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) + .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + })); + } else { + query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + } } const channels = await query diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 2491d14235..c881074bab 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts index 0c3f6c4855..67fb1ea03e 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index 855ba47f8c..f46ff9f286 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index 084b3f919e..30d7f8b244 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -47,12 +47,14 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + isArchived: { type: 'boolean', nullable: true }, pinnedNoteIds: { type: 'array', items: { type: 'string', format: 'misskey:id', }, }, + color: { type: 'string', minLength: 1, maxLength: 16 }, }, required: ['channelId'], } as const; @@ -104,6 +106,8 @@ export default class extends Endpoint { ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.description !== undefined ? { description: ps.description } : {}), ...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), + ...(ps.color !== undefined ? { color: ps.color } : {}), + ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), ...(banner ? { bannerId: banner.id } : {}), }); diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index b9d8dce47a..c3561e2a71 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', limit: { diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index a770dc986d..5395a5c373 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', res: { @@ -57,7 +59,7 @@ export default class extends Endpoint { if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) { throw new ApiError(meta.errors.tooManyClips); } - + const clip = await this.clipsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts index 6addf743a2..f08caaf8d7 100644 --- a/packages/backend/src/server/api/endpoints/clips/favorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:clip-favorite', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts index 5d88870ed2..50c5d758bd 100644 --- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts index 244843d50f..3da252a226 100644 --- a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:clip-favorite', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index a103c3f7d3..70f1959353 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index b3bdef41d3..a1c1f9325e 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -15,6 +15,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + limit: { duration: ms('1hour'), max: 120, diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index 3141e0fc01..3ecbba22b5 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -40,8 +40,13 @@ export const meta = { code: 'NO_SUCH_FOLDER', id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73', }, + + restrictedByRole: { + message: 'This feature is restricted by your role.', + code: 'RESTRICTED_BY_ROLE', + id: '7f59dccb-f465-75ab-5cf4-3ce44e3282f7', + }, }, - res: { type: 'object', optional: false, nullable: false, @@ -77,7 +82,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - + const alwaysMarkNsfw = (await this.roleService.getUserPolicies(me.id)).alwaysMarkNsfw; if (file == null) { throw new ApiError(meta.errors.noSuchFile); } @@ -93,6 +98,10 @@ export default class extends Endpoint { if (ps.comment !== undefined) file.comment = ps.comment; + if (ps.isSensitive !== undefined && ps.isSensitive !== file.isSensitive && alwaysMarkNsfw && !ps.isSensitive) { + throw new ApiError(meta.errors.restrictedByRole); + } + if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; if (ps.folderId !== undefined) { diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index cfef793831..c835587c4a 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -19,6 +19,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:drive', } as const; diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts index f21d9d5c33..3172bdbfda 100644 --- a/packages/backend/src/server/api/endpoints/flash/create.ts +++ b/packages/backend/src/server/api/endpoints/flash/create.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash', limit: { diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts index 5581b8ec60..23de2f3970 100644 --- a/packages/backend/src/server/api/endpoints/flash/like.ts +++ b/packages/backend/src/server/api/endpoints/flash/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts index b994f5d347..696512b06c 100644 --- a/packages/backend/src/server/api/endpoints/flash/unlike.ts +++ b/packages/backend/src/server/api/endpoints/flash/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts index cd4e413a40..78dfd4a06a 100644 --- a/packages/backend/src/server/api/endpoints/flash/update.ts +++ b/packages/backend/src/server/api/endpoints/flash/update.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash', limit: { diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts index 411c39110a..4ad16de911 100644 --- a/packages/backend/src/server/api/endpoints/following/create.ts +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -19,6 +19,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:following', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index cb8b6a2e3e..ca6bfa7e0f 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery', limit: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index 519e56ed6a..6ac5fa8606 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts index cfbedcc4d9..513089217d 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index f14d644a3a..a2a10d8400 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery', limit: { diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index c3f2ea9ea7..dd3549020e 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -44,7 +44,8 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const query = this.usersRepository.createQueryBuilder('user') - .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }); + .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }) + .andWhere('user.isSuspended = FALSE'); const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts index 102dae4fb7..4eef496385 100644 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -4,6 +4,7 @@ import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService export const meta = { requireCredential: true, + prohibitMoved: true, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/i/export-antennas.ts b/packages/backend/src/server/api/endpoints/i/export-antennas.ts new file mode 100644 index 0000000000..4182c1b247 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-antennas.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: ms('1hour'), + max: 1, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor ( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportAntennasJob(me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts new file mode 100644 index 0000000000..efb5ce4223 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -0,0 +1,84 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; +import type { AntennasRepository, DriveFilesRepository, UsersRepository, Antenna as _Antenna } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + secure: true, + requireCredential: true, + prohibitMoved: true, + + limit: { + duration: ms('1hour'), + max: 1, + }, + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '3b71d086-c3fa-431c-b01d-ded65a777172', + }, + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'e842c379-8ac7-4cf7-b07a-4d4de7e4671c', + }, + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '7f60115d-8d93-4b0f-bd0e-3815dcbb389f', + }, + tooManyAntennas: { + message: 'You cannot create antenna any more.', + code: 'TOO_MANY_ANTENNAS', + id: '600917d4-a4cb-4cc5-8ba8-7ac8ea3c7779', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + }, + required: ['fileId'], +} as const; + +@Injectable() // eslint-disable-next-line import/no-default-export +export default class extends Endpoint { + constructor ( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private roleService: RoleService, + private queueService: QueueService, + private downloadService: DownloadService, + ) { + super(meta, paramDef, async (ps, me) => { + const users = await this.usersRepository.findOneBy({ id: me.id }); + if (users === null) throw new ApiError(meta.errors.noSuchUser); + const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); + if (file === null) throw new ApiError(meta.errors.noSuchFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const antennas: (_Antenna & { userListAccts: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url)); + const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id }); + if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { + throw new ApiError(meta.errors.tooManyAntennas); + } + this.queueService.createImportAntennasJob(me, antennas); + }); + } +} + +export type Antenna = (_Antenna & { userListAccts: string[] | null })[]; diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 8c1c158ab1..811971591a 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), @@ -58,15 +60,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportBlockingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index 383bdc02b5..8af278c883 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), max: 1, @@ -57,15 +59,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportFollowingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 345ad916cb..eb0f9ba474 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), @@ -58,15 +60,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportMutingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index 875af7ec23..4568e93901 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), max: 1, @@ -57,15 +59,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportUserListsJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts deleted file mode 100644 index 964704d82b..0000000000 --- a/packages/backend/src/server/api/endpoints/i/known-as.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import ms from 'ms'; - -import { User } from '@/models/entities/User.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ApiError } from '@/server/api/error.js'; - -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; - -export const meta = { - tags: ['users'], - - secure: true, - requireCredential: true, - - limit: { - duration: ms('1day'), - max: 30, - }, - - errors: { - noSuchUser: { - message: 'No such user.', - code: 'NO_SUCH_USER', - id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', - }, - notRemote: { - message: 'User is not remote. You can only migrate from other instances.', - code: 'NOT_REMOTE', - id: '4362f8dc-731f-4ad8-a694-be2a88922a24', - }, - uriNull: { - message: 'User ActivityPup URI is null.', - code: 'URI_NULL', - id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - alsoKnownAs: { type: 'string' }, - }, - required: ['alsoKnownAs'], -} as const; - -@Injectable() -export default class extends Endpoint { - constructor( - private userEntityService: UserEntityService, - private remoteUserResolveService: RemoteUserResolveService, - private apiLoggerService: ApiLoggerService, - private accountMoveService: AccountMoveService, - ) { - super(meta, paramDef, async (ps, me) => { - // Check parameter - if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser); - - let unfiltered = ps.alsoKnownAs; - const updates = {} as Partial; - - if (!unfiltered) { - updates.alsoKnownAs = null; - } else { - // Parse user's input into the old account - if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); - if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); - if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); - - const userAddress = unfiltered.split('@'); - // Retrieve the old account - const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { - this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); - throw new ApiError(meta.errors.noSuchUser); - }); - - const toUrl: string | null = knownAs.uri; - if (!toUrl) throw new ApiError(meta.errors.uriNull); - // Only allow moving from a remote account - if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote); - - updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl]; - } - - return await this.accountMoveService.createAlias(me, updates); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts index ac76e1f620..261dd527c0 100644 --- a/packages/backend/src/server/api/endpoints/i/move.ts +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -7,40 +7,35 @@ import { DI } from '@/di-symbols.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApiError } from '@/server/api/error.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; + import { AccountMoveService } from '@/core/AccountMoveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +import * as Acct from '@/misc/acct.js'; export const meta = { tags: ['users'], secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1day'), max: 5, }, errors: { - noSuchMoveTarget: { - message: 'No such move target.', - code: 'NO_SUCH_MOVE_TARGET', - id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4', - }, - remoteAccountForbids: { + destinationAccountForbids: { message: - 'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?', - code: 'REMOTE_ACCOUNT_FORBIDS', + 'Destination account doesn\'t have proper \'Known As\' alias, or has already moved.', + code: 'DESTINATION_ACCOUNT_FORBIDS', id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', }, - notRemote: { - message: 'User is not remote. You can only migrate to other instances.', - code: 'NOT_REMOTE', - id: '4362f8dc-731f-4ad8-a694-be2a88922a24', - }, rootForbidden: { message: 'The root can\'t migrate.', code: 'NOT_ROOT_FORBIDDEN', @@ -84,57 +79,52 @@ export default class extends Endpoint { @Inject(DI.config) private config: Config, - private userEntityService: UserEntityService, private remoteUserResolveService: RemoteUserResolveService, private apiLoggerService: ApiLoggerService, private accountMoveService: AccountMoveService, private getterService: GetterService, private apPersonService: ApPersonService, + private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { // check parameter - if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget); + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser); // abort if user is the root if (me.isRoot) throw new ApiError(meta.errors.rootForbidden); // abort if user has already moved if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); - let unfiltered = ps.moveToAccount; - if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget); - // parse user's input into the destination account - if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); - if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); - if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); - - const userAddress = unfiltered.split('@'); + const { username, host } = Acct.parse(ps.moveToAccount); // retrieve the destination account - let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); - throw new ApiError(meta.errors.noSuchMoveTarget); + throw new ApiError(meta.errors.noSuchUser); }); - const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id); - if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull); + const destination = await this.getterService.getUser(moveTo.id) as LocalUser | RemoteUser; + const newUri = this.userEntityService.getUserUri(destination); // update local db - await this.apPersonService.updatePerson(remoteMoveTo.uri); + await this.apPersonService.updatePerson(newUri); // retrieve updated user - moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri); - // only allow moving to a remote account - if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote); + moveTo = await this.apPersonService.resolvePerson(newUri); - let allowed = false; - - const fromUrl = `${this.config.url}/users/${me.id}`; // make sure that the user has indicated the old account as an alias - moveTo.alsoKnownAs?.forEach((elem) => { - if (fromUrl.includes(elem)) allowed = true; - }); + const fromUrl = this.userEntityService.genLocalUserUri(me.id); + let allowed = false; + if (moveTo.alsoKnownAs) { + for (const knownAs of moveTo.alsoKnownAs) { + if (knownAs.includes(fromUrl)) { + allowed = true; + break; + } + } + } // abort if unintended - if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids); + if (!allowed || moveTo.movedToUri) throw new ApiError(meta.errors.destinationAccountForbids); - return await this.accountMoveService.moveToRemote(me, moveTo); + return await this.accountMoveService.moveFromLocal(me, moveTo); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index ba0487f223..e141be764a 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,5 +1,5 @@ import { Brackets, In } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts index d4af00027e..2293500945 100644 --- a/packages/backend/src/server/api/endpoints/i/pin.ts +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -8,6 +8,7 @@ export const meta = { tags: ['account', 'notes'], requireCredential: true, + prohibitMoved: true, kind: 'write:account', diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index be1c72b207..6c66300bb7 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -3,6 +3,7 @@ import * as mfm from 'mfm-js'; import { Inject, Injectable } from '@nestjs/common'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; +import * as Acct from '@/misc/acct.js'; import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/entities/User.js'; @@ -19,7 +20,10 @@ import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { CacheService } from '@/core/CacheService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -71,6 +75,30 @@ export const meta = { code: 'TOO_MANY_MUTED_WORDS', id: '010665b1-a211-42d2-bc64-8f6609d79785', }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + + forbiddenToSetYourself: { + message: 'You can\'t set yourself as your own alias.', + code: 'FORBIDDEN_TO_SET_YOURSELF', + id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4', + }, + + restrictedByRole: { + message: 'This feature is restricted by your role.', + code: 'RESTRICTED_BY_ROLE', + id: '8feff0ba-5ab5-585b-31f4-4df816663fad', + } }, res: { @@ -129,6 +157,12 @@ export const paramDef = { emailNotificationTypes: { type: 'array', items: { type: 'string', } }, + alsoKnownAs: { + type: 'array', + maxItems: 10, + uniqueItems: true, + items: { type: 'string' }, + }, }, } as const; @@ -153,6 +187,9 @@ export default class extends Endpoint { private globalEventService: GlobalEventService, private userFollowingService: UserFollowingService, private accountUpdateService: AccountUpdateService, + private accountMoveService: AccountMoveService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, private hashtagService: HashtagService, private roleService: RoleService, private cacheService: CacheService, @@ -208,7 +245,10 @@ export default class extends Endpoint { if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; - if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (typeof ps.alwaysMarkNsfw === 'boolean') { + if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); + profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + } if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; @@ -221,6 +261,10 @@ export default class extends Endpoint { updates.avatarId = avatar.id; updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); updates.avatarBlurhash = avatar.blurhash; + } else if (ps.avatarId === null) { + updates.avatarId = null; + updates.avatarUrl = null; + updates.avatarBlurhash = null; } if (ps.bannerId) { @@ -232,6 +276,10 @@ export default class extends Endpoint { updates.bannerId = banner.id; updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); updates.bannerBlurhash = banner.blurhash; + } else if (ps.bannerId === null) { + updates.bannerId = null; + updates.bannerUrl = null; + updates.bannerBlurhash = null; } if (ps.pinnedPageId) { @@ -252,6 +300,38 @@ export default class extends Endpoint { }); } + if (ps.alsoKnownAs) { + if (_user.movedToUri) { + throw new ApiError({ + message: 'You have moved your account.', + code: 'YOUR_ACCOUNT_MOVED', + id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', + httpStatusCode: 403, + }); + } + + // Parse user's input into the old account + const newAlsoKnownAs = new Set(); + for (const line of ps.alsoKnownAs) { + if (!line) throw new ApiError(meta.errors.noSuchUser); + const { username, host } = Acct.parse(line); + + // Retrieve the old account + const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself); + + const toUrl = this.userEntityService.getUserUri(knownAs); + if (!toUrl) throw new ApiError(meta.errors.uriNull); + + newAlsoKnownAs.add(toUrl); + } + + updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null; + } + //#region emojis/tags let emojis = [] as string[]; @@ -279,6 +359,9 @@ export default class extends Endpoint { //#endregion if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates); + if (Object.keys(updates).includes('alsoKnownAs')) { + this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates }); + } if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates); const iObj = await this.userEntityService.pack(user.id, user, { diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 37974ce2a3..584ea07c3b 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -201,10 +201,6 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - elasticsearch: { - type: 'boolean', - optional: false, nullable: false, - }, hcaptcha: { type: 'boolean', optional: false, nullable: false, @@ -310,6 +306,8 @@ export default class extends Endpoint { translatorAvailable: instance.deeplAuthKey != null, + serverRules: instance.serverRules, + policies: { ...DEFAULT_POLICIES, ...instance.policies }, mediaProxy: this.config.mediaProxy, @@ -329,7 +327,6 @@ export default class extends Endpoint { response.features = { registration: !instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, - elasticsearch: this.config.elasticsearch ? true : false, hcaptcha: instance.enableHcaptcha, recaptcha: instance.enableRecaptcha, turnstile: instance.enableTurnstile, diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index 6e24e1024d..ee358d5c6c 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -11,6 +11,7 @@ export const meta = { tags: ['account'], requireCredential: true, + prohibitMoved: true, kind: 'write:mutes', diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 69fafcb9c7..3f7f2cdece 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -18,6 +18,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + limit: { duration: ms('1hour'), max: 300, @@ -260,7 +262,7 @@ export default class extends Endpoint { let channel: Channel | null = null; if (ps.channelId != null) { - channel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); if (channel == null) { throw new ApiError(meta.errors.noSuchChannel); diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts index 0ce80a1a63..611ea19560 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -12,6 +12,7 @@ export const meta = { tags: ['notes', 'favorites'], requireCredential: true, + prohibitMoved: true, kind: 'write:favorites', diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index 2a44dc537e..3a33b037f8 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -17,6 +17,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:votes', errors: { diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index 04e374d1ae..97cb026779 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:reactions', errors: { diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index fb5abd917f..f6385400c3 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -1,11 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; +import { SearchService } from '@/core/SearchService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; @@ -43,8 +42,7 @@ export const paramDef = { offset: { type: 'integer', default: 0 }, host: { type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', + description: 'The local host is represented with `.`.', }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, channelId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, @@ -61,11 +59,8 @@ export default class extends Endpoint { @Inject(DI.config) private config: Config, - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, - private queryService: QueryService, + private searchService: SearchService, private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { @@ -74,27 +69,15 @@ export default class extends Endpoint { throw new ApiError(meta.errors.unavailable); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); - - if (ps.userId) { - query.andWhere('note.userId = :userId', { userId: ps.userId }); - } else if (ps.channelId) { - query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); - } - - query - .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - - const notes = await query.take(ps.limit).getMany(); + const notes = await this.searchService.searchNote(ps.query, me, { + userId: ps.userId, + channelId: ps.channelId, + host: ps.host, + }, { + untilId: ps.untilId, + sinceId: ps.sinceId, + limit: ps.limit, + }); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index 4015bf1f29..e08ab399f8 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:pages', limit: { diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts index d27990f7e1..543c126d9c 100644 --- a/packages/backend/src/server/api/endpoints/pages/like.ts +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:page-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts index e397e2a23b..f0c0198460 100644 --- a/packages/backend/src/server/api/endpoints/pages/unlike.ts +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:page-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index 35b402ec56..751274067e 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:pages', limit: { diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index b285269617..beb5850d78 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -13,6 +13,7 @@ export const meta = { tags: ['account'], requireCredential: true, + prohibitMoved: true, kind: 'write:mutes', diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index 655dd7cd83..4ced6d3ff1 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { resetDb } from '@/misc/reset-db.js'; diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index b45d4af1fe..6202c740f1 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, RolesRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; @@ -65,12 +65,15 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const role = await this.rolesRepository.findOneBy({ id: ps.roleId, + isPublic: true, }); if (role == null) { throw new ApiError(meta.errors.noSuchRole); } - + if (!role.isExplorable) { + return []; + } const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const noteIdsRes = await this.redisClient.xrevrange( `roleTimeline:${role.id}`, diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts index c80b6efdcd..6293c5cb50 100644 --- a/packages/backend/src/server/api/endpoints/username/available.ts +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -4,6 +4,7 @@ import type { UsedUsernamesRepository, UsersRepository } from '@/models/index.js import { Endpoint } from '@/server/api/endpoint-base.js'; import { localUsernameSchema } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; +import { MetaService } from '@/core/MetaService.js'; export const meta = { tags: ['users'], @@ -39,9 +40,10 @@ export default class extends Endpoint { @Inject(DI.usedUsernamesRepository) private usedUsernamesRepository: UsedUsernamesRepository, + + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { - // Get exist const exist = await this.usersRepository.countBy({ host: IsNull(), usernameLower: ps.username.toLowerCase(), @@ -49,8 +51,11 @@ export default class extends Endpoint { const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() }); + const meta = await this.metaService.fetch(); + const isPreserved = meta.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase()); + return { - available: exist === 0 && exist2 === 0, + available: exist === 0 && exist2 === 0 && !isPreserved, }; }); } diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index 8becb68a34..28cd9f6ce5 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -50,8 +50,9 @@ export default class extends Endpoint { private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.usersRepository.createQueryBuilder('user'); - query.where('user.isExplorable = TRUE'); + const query = this.usersRepository.createQueryBuilder('user') + .where('user.isExplorable = TRUE') + .andWhere('user.isSuspended = FALSE'); switch (ps.state) { case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index a840c1a04e..7510889526 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Create a new list of users.', @@ -58,7 +60,7 @@ export default class extends Endpoint { if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } - + const userList = await this.userListsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index d2dd5731ee..d50b70efc2 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Remove a user from a list.', diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 1c1fdc23f1..925037e484 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Add a user to an existing list.', diff --git a/packages/backend/src/server/api/endpoints/users/update-memo.ts b/packages/backend/src/server/api/endpoints/users/update-memo.ts new file mode 100644 index 0000000000..ca7756ef75 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/update-memo.ts @@ -0,0 +1,85 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserMemoRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '6fef56f3-e765-4957-88e5-c6f65329b8a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + memo: { + type: 'string', + nullable: true, + description: 'A personal memo for the target user. If null or empty, delete the memo.', + }, + }, + required: ['userId', 'memo'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userMemosRepository) + private userMemosRepository: UserMemoRepository, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get target + const target = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // 引数がnullか空文字であれば、パーソナルメモを削除する + if (ps.memo === '' || ps.memo == null) { + await this.userMemosRepository.delete({ + userId: me.id, + targetUserId: target.id, + }); + return; + } + + // 以前に作成されたパーソナルメモがあるかどうか確認 + const previousMemo = await this.userMemosRepository.findOneBy({ + userId: me.id, + targetUserId: target.id, + }); + + if (!previousMemo) { + await this.userMemosRepository.insert({ + id: this.idService.genId(), + userId: me.id, + targetUserId: target.id, + memo: ps.memo, + }); + } else { + await this.userMemosRepository.update(previousMemo.id, { + userId: me.id, + targetUserId: target.id, + memo: ps.memo, + }); + } + }); + } +} diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index b3e193cd34..e61e92c623 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -70,10 +70,10 @@ export class UrlPreviewService { await summaly(url, { followRedirects: false, lang: lang ?? 'ja-JP', - agent: { + agent: this.config.proxy ? { http: this.httpRequestService.httpAgent, https: this.httpRequestService.httpsAgent, - }, + } : undefined, }); this.logger.succ(`Got preview of ${url}: ${summary.title}`); diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 979acc4ba5..dea26da872 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -36,7 +36,7 @@ html link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.12.0') + link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.17.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index afb72c84d4..f885209b7f 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -4,8 +4,9 @@ import * as assert from 'assert'; // node-fetch only supports it's own Blob yet // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; -import { startServer, signup, post, api, uploadFile, simpleGet } from '../utils.js'; +import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import { User } from '@/models/index.js'; describe('Endpoints', () => { let app: INestApplicationContext; @@ -289,6 +290,16 @@ describe('Endpoints', () => { }, bob); assert.strictEqual(res.status, 200); + + const connection = await initTestDb(true); + const Users = connection.getRepository(User); + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.followersCount, 0); + assert.strictEqual(newBob.followingCount, 1); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + assert.strictEqual(newAlice.followersCount, 1); + assert.strictEqual(newAlice.followingCount, 0); + connection.destroy(); }); test('既にフォローしている場合は怒る', async () => { @@ -341,6 +352,16 @@ describe('Endpoints', () => { }, bob); assert.strictEqual(res.status, 200); + + const connection = await initTestDb(true); + const Users = connection.getRepository(User); + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.followersCount, 0); + assert.strictEqual(newBob.followingCount, 0); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + assert.strictEqual(newAlice.followersCount, 0); + assert.strictEqual(newAlice.followingCount, 0); + connection.destroy(); }); test('フォローしていない場合は怒る', async () => { @@ -382,6 +403,100 @@ describe('Endpoints', () => { }); }); + describe('channels/search', () => { + test('空白検索で一覧を取得できる', async () => { + await api('/channels/create', { + name: 'aaa', + description: 'bbb', + }, bob); + await api('/channels/create', { + name: 'ccc1', + description: 'ddd1', + }, bob); + await api('/channels/create', { + name: 'ccc2', + description: 'ddd2', + }, bob); + + const res = await api('/channels/search', { + query: '', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 3); + }); + test('名前のみの検索で名前を検索できる', async () => { + const res = await api('/channels/search', { + query: 'aaa', + type: 'nameOnly', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].name, 'aaa'); + }); + test('名前のみの検索で名前を複数検索できる', async () => { + const res = await api('/channels/search', { + query: 'ccc', + type: 'nameOnly', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 2); + }); + test('名前のみの検索で説明は検索できない', async () => { + const res = await api('/channels/search', { + query: 'bbb', + type: 'nameOnly', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 0); + }); + test('名前と説明の検索で名前を検索できる', async () => { + const res = await api('/channels/search', { + query: 'ccc1', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].name, 'ccc1'); + }); + test('名前と説明での検索で説明を検索できる', async () => { + const res = await api('/channels/search', { + query: 'ddd1', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].name, 'ccc1'); + }); + test('名前と説明の検索で名前を複数検索できる', async () => { + const res = await api('/channels/search', { + query: 'ccc', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 2); + }); + test('名前と説明での検索で説明を複数検索できる', async () => { + const res = await api('/channels/search', { + query: 'ddd', + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 2); + }); + }); + describe('drive', () => { test('ドライブ情報を取得できる', async () => { await uploadFile(alice, { @@ -849,4 +964,85 @@ describe('Endpoints', () => { assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED'); }); }); + + describe('パーソナルメモ機能のテスト', () => { + test('他者に関するメモを更新できる', async () => { + const memo = '10月まで低浮上とのこと。'; + + const res1 = await api('/users/update-memo', { + memo, + userId: bob.id, + }, alice); + + const res2 = await api('/users/show', { + userId: bob.id, + }, alice); + assert.strictEqual(res1.status, 204); + assert.strictEqual(res2.body?.memo, memo); + }); + + test('自分に関するメモを更新できる', async () => { + const memo = 'チケットを月末までに買う。'; + + const res1 = await api('/users/update-memo', { + memo, + userId: alice.id, + }, alice); + + const res2 = await api('/users/show', { + userId: alice.id, + }, alice); + assert.strictEqual(res1.status, 204); + assert.strictEqual(res2.body?.memo, memo); + }); + + test('メモを削除できる', async () => { + const memo = '10月まで低浮上とのこと。'; + + await api('/users/update-memo', { + memo, + userId: bob.id, + }, alice); + + await api('/users/update-memo', { + memo: '', + userId: bob.id, + }, alice); + + const res = await api('/users/show', { + userId: bob.id, + }, alice); + + // memoには常に文字列かnullが入っている(5cac151) + assert.strictEqual(res.body.memo, null); + }); + + test('メモは個人ごとに独立して保存される', async () => { + const memoAliceToBob = '10月まで低浮上とのこと。'; + const memoCarolToBob = '例の件について今度問いただす。'; + + await Promise.all([ + api('/users/update-memo', { + memo: memoAliceToBob, + userId: bob.id, + }, alice), + api('/users/update-memo', { + memo: memoCarolToBob, + userId: bob.id, + }, carol), + ]); + + const [resAlice, resCarol] = await Promise.all([ + api('/users/show', { + userId: bob.id, + }, alice), + api('/users/show', { + userId: bob.id, + }, carol), + ]); + + assert.strictEqual(resAlice.body.memo, memoAliceToBob); + assert.strictEqual(resCarol.body.memo, memoCarolToBob); + }); + }); }); diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts new file mode 100644 index 0000000000..7d6c646090 --- /dev/null +++ b/packages/backend/test/e2e/move.ts @@ -0,0 +1,456 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import rndstr from 'rndstr'; +import { loadConfig } from '@/config.js'; +import { User, UsersRepository } from '@/models/index.js'; +import { jobQueue } from '@/boot/common.js'; +import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Account Move', () => { + let app: INestApplicationContext; + let jq: INestApplicationContext; + let url: URL; + + let root: any; + let alice: any; + let bob: any; + let carol: any; + let dave: any; + let eve: any; + let frank: any; + + let Users: UsersRepository; + + beforeAll(async () => { + app = await startServer(); + jq = await jobQueue(); + const config = loadConfig(); + url = new URL(config.url); + const connection = await initTestDb(false); + root = await signup({ username: 'root' }); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + eve = await signup({ username: 'eve' }); + frank = await signup({ username: 'frank' }); + Users = connection.getRepository(User); + }, 1000 * 60 * 2); + + afterAll(async () => { + await Promise.all([app.close(), jq.close()]); + }); + + describe('Create Alias', () => { + afterEach(async () => { + await Users.update(bob.id, { alsoKnownAs: null }); + }, 1000 * 10); + + test('Able to create an alias', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + assert.strictEqual(res.body.alsoKnownAs?.length, 1); + assert.strictEqual(res.body.alsoKnownAs[0], alice.id); + }); + + test('Able to create a local alias without hostname', async () => { + await api('/i/update', { + alsoKnownAs: ['@alice'], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + }); + + test('Able to create a local alias without @', async () => { + await api('/i/update', { + alsoKnownAs: ['alice'], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + }); + + test('Able to set remote user (but may fail)', async () => { + const res = await api('/i/update', { + alsoKnownAs: ['@syuilo@example.com'], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Unable to add duplicated aliases to alsoKnownAs', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`, `@alice@${url.hostname}`], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); + assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); + }); + + test('Unable to add itself', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@bob@${url.hostname}`], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); + assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); + }); + + test('Unable to add a nonexisting local account to alsoKnownAs', async () => { + const res1 = await api('/i/update', { + alsoKnownAs: [`@nonexist@${url.hostname}`], + }, bob); + + assert.strictEqual(res1.status, 400); + assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + + const res2 = await api('/i/update', { + alsoKnownAs: ['@alice', 'nonexist'], + }, bob); + + assert.strictEqual(res2.status, 400); + assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Able to add two existing local account to alsoKnownAs', async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`, `@carol@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 2); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${carol.id}`); + }); + + test('Able to properly overwrite alsoKnownAs', async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + await api('/i/update', { + alsoKnownAs: [`@carol@${url.hostname}`, `@dave@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 2); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${carol.id}`); + assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${dave.id}`); + }); + }); + + describe('Local to Local', () => { + let antennaId = ''; + + beforeAll(async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, root); + const listRoot = await api('/users/lists/create', { + name: rndstr('0-9a-z', 8), + }, root); + await api('/users/lists/push', { + listId: listRoot.body.id, + userId: alice.id, + }, root); + + await api('/following/create', { + userId: root.id, + }, alice); + await api('/following/create', { + userId: eve.id, + }, alice); + const antenna = await api('/antennas/create', { + name: rndstr('0-9a-z', 8), + src: 'home', + keywords: [rndstr('0-9a-z', 8)], + excludeKeywords: [], + users: [], + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + }, alice); + antennaId = antenna.body.id; + + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + + await api('/following/create', { + userId: alice.id, + }, carol); + + await api('/mute/create', { + userId: alice.id, + }, dave); + await api('/blocking/create', { + userId: alice.id, + }, dave); + await api('/following/create', { + userId: eve.id, + }, dave); + + await api('/following/create', { + userId: dave.id, + }, eve); + const listEve = await api('/users/lists/create', { + name: rndstr('0-9a-z', 8), + }, eve); + await api('/users/lists/push', { + listId: listEve.body.id, + userId: bob.id, + }, eve); + + await api('/i/update', { + isLocked: true, + }, frank); + await api('/following/create', { + userId: frank.id, + }, alice); + await api('/following/requests/accept', { + userId: alice.id, + }, frank); + }, 1000 * 10); + + test('Prohibit the root account from moving', async () => { + const res = await api('/i/move', { + moveToAccount: `@bob@${url.hostname}`, + }, root); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); + assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); + }); + + test('Unable to move to a nonexisting local account', async () => { + const res = await api('/i/move', { + moveToAccount: `@nonexist@${url.hostname}`, + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Unable to move if alsoKnownAs is invalid', async () => { + const res = await api('/i/move', { + moveToAccount: `@carol@${url.hostname}`, + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + }); + + test('Relationships have been properly migrated', async () => { + const move = await api('/i/move', { + moveToAccount: `@bob@${url.hostname}`, + }, alice); + + assert.strictEqual(move.status, 200); + + await sleep(1000 * 3); // wait for jobs to finish + + // Unfollow delayed? + const aliceFollowings = await api('/users/following', { + userId: alice.id, + }, alice); + assert.strictEqual(aliceFollowings.status, 200); + assert.strictEqual(aliceFollowings.body.length, 3); + + const carolFollowings = await api('/users/following', { + userId: carol.id, + }, carol); + assert.strictEqual(carolFollowings.status, 200); + assert.strictEqual(carolFollowings.body.length, 2); + assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); + assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); + + const blockings = await api('/blocking/list', {}, dave); + assert.strictEqual(blockings.status, 200); + assert.strictEqual(blockings.body.length, 2); + assert.strictEqual(blockings.body[0].blockeeId, bob.id); + assert.strictEqual(blockings.body[1].blockeeId, alice.id); + + const mutings = await api('/mute/list', {}, dave); + assert.strictEqual(mutings.status, 200); + assert.strictEqual(mutings.body.length, 2); + assert.strictEqual(mutings.body[0].muteeId, bob.id); + assert.strictEqual(mutings.body[1].muteeId, alice.id); + + const rootLists = await api('/users/lists/list', {}, root); + assert.strictEqual(rootLists.status, 200); + assert.strictEqual(rootLists.body[0].userIds.length, 2); + assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id)); + assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); + + const eveLists = await api('/users/lists/list', {}, eve); + assert.strictEqual(eveLists.status, 200); + assert.strictEqual(eveLists.body[0].userIds.length, 1); + assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); + }); + + test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => { + await successfulApiCall({ + endpoint: '/following/create', + parameters: { + userId: frank.id, + }, + user: bob, + }); + const followers = await api('/users/followers', { + userId: frank.id, + }, frank); + + assert.strictEqual(followers.status, 200); + assert.strictEqual(followers.body.length, 2); + assert.strictEqual(followers.body[0].followerId, bob.id); + }); + + test('Unfollowed after 10 sec (24 hours in production).', async () => { + await sleep(1000 * 8); + + const following = await api('/users/following', { + userId: alice.id, + }, alice); + + assert.strictEqual(following.status, 200); + assert.strictEqual(following.body.length, 0); + }); + + test('Unable to move if the destination account has already moved.', async () => { + const res = await api('/i/move', { + moveToAccount: `@alice@${url.hostname}`, + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + }); + + test('Follow and follower counts are properly adjusted', async () => { + await api('/following/create', { + userId: alice.id, + }, eve); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + const newCarol = await Users.findOneByOrFail({ id: carol.id }); + let newEve = await Users.findOneByOrFail({ id: eve.id }); + assert.strictEqual(newAlice.movedToUri, `${url.origin}/users/${bob.id}`); + assert.strictEqual(newAlice.followingCount, 0); + assert.strictEqual(newAlice.followersCount, 0); + assert.strictEqual(newCarol.followingCount, 1); + assert.strictEqual(newEve.followingCount, 1); + assert.strictEqual(newEve.followersCount, 1); + + await api('/following/delete', { + userId: alice.id, + }, eve); + newEve = await Users.findOneByOrFail({ id: eve.id }); + assert.strictEqual(newEve.followingCount, 1); + assert.strictEqual(newEve.followersCount, 1); + }); + + test.each([ + '/antennas/create', + '/channels/create', + '/channels/favorite', + '/channels/follow', + '/channels/unfavorite', + '/channels/unfollow', + '/clips/add-note', + '/clips/create', + '/clips/favorite', + '/clips/remove-note', + '/clips/unfavorite', + '/clips/update', + '/drive/files/upload-from-url', + '/flash/create', + '/flash/like', + '/flash/unlike', + '/flash/update', + '/following/create', + '/gallery/posts/create', + '/gallery/posts/like', + '/gallery/posts/unlike', + '/gallery/posts/update', + '/i/claim-achievement', + '/i/move', + '/i/import-blocking', + '/i/import-following', + '/i/import-muting', + '/i/import-user-lists', + '/i/pin', + '/mute/create', + '/notes/create', + '/notes/favorites/create', + '/notes/polls/vote', + '/notes/reactions/create', + '/pages/create', + '/pages/like', + '/pages/unlike', + '/pages/update', + '/renote-mute/create', + '/users/lists/create', + '/users/lists/pull', + '/users/lists/push', + ])('Prohibit access after moving: %s', async (endpoint) => { + const res = await api(endpoint, {}, alice); + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit access after moving: /antennas/update', async () => { + const res = await api('/antennas/update', { + antennaId, + name: rndstr('0-9a-z', 8), + src: 'users', + keywords: [rndstr('0-9a-z', 8)], + excludeKeywords: [], + users: [eve.id], + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + }, alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit access after moving: /drive/files/create', async () => { + const res = await uploadFile(alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit updating alsoKnownAs after moving', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@eve@${url.hostname}`], + }, alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + }); +}); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index e87045a8cf..9c851a5dd6 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -352,6 +352,72 @@ describe('Note', () => { assert.strictEqual(myNote.renote.reply.files.length, 1); assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id); }); + + test('NSFWが強制されている場合変更できない', async () => { + const file = await uploadFile(alice); + + const res = await api('admin/roles/create', { + name: 'test', + description: '', + color: null, + iconUrl: null, + displayOrder: 0, + target: 'manual', + condFormula: {}, + isAdministrator: false, + isModerator: false, + isPublic: false, + isExplorable: false, + asBadge: false, + canEditMembersByModerator: false, + policies: { + alwaysMarkNsfw: { + useDefault: false, + priority: 0, + value: true, + }, + }, + }, alice); + + assert.strictEqual(res.status, 200); + + const assign = await api('admin/roles/assign', { + userId: alice.id, + roleId: res.body.id, + }, alice); + + assert.strictEqual(assign.status, 204); + assert.strictEqual(file.body.isSensitive, false); + + const nsfwfile = await uploadFile(alice); + + assert.strictEqual(nsfwfile.status, 200); + assert.strictEqual(nsfwfile.body.isSensitive, true); + + const liftnsfw = await api('drive/files/update', { + fileId: nsfwfile.body.id, + isSensitive: false, + }, alice); + + assert.strictEqual(liftnsfw.status, 400); + assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE'); + + const oldaddnsfw = await api('drive/files/update', { + fileId: file.body.id, + isSensitive: true, + }, alice); + + assert.strictEqual(oldaddnsfw.status, 200); + + await api('admin/roles/unassign', { + userId: alice.id, + roleId: res.body.id, + }); + + await api('admin/roles/delete', { + roleId: res.body.id, + }, alice); + }); }); describe('notes/create', () => { diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index bc3455e346..51537dda16 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -31,7 +31,7 @@ describe('ユーザー', () => { }, {}); }; - // FIXME: 足りないキーがたくさんある + // BUG misskey-jsとjson-schemaと実際に返ってくるデータが全部違う type UserLite = misskey.entities.UserLite & { badgeRoles: any[], }; @@ -51,10 +51,11 @@ describe('ユーザー', () => { type User = MeDetailed & { token: string }; - const show = async (id: string, me = alice): Promise => { + const show = async (id: string, me = root): Promise => { return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any; }; + // UserLiteのキーが過不足なく入っている? const userLite = (user: User): Partial => { return stripUndefined({ id: user.id, @@ -76,12 +77,13 @@ describe('ユーザー', () => { }); }; + // UserDetailedNotMeのキーが過不足なく入っている? const userDetailedNotMe = (user: User): Partial => { return stripUndefined({ ...userLite(user), url: user.url, uri: user.uri, - movedToUri: user.movedToUri, + movedTo: user.movedTo, alsoKnownAs: user.alsoKnownAs, createdAt: user.createdAt, updatedAt: user.updatedAt, @@ -109,9 +111,11 @@ describe('ユーザー', () => { usePasswordLessLogin: user.usePasswordLessLogin, securityKeys: user.securityKeys, roles: user.roles, + memo: user.memo, }); }; + // Relations関連のキーが過不足なく入っている? const userDetailedNotMeWithRelations = (user: User): Partial => { return stripUndefined({ ...userDetailedNotMe(user), @@ -126,6 +130,7 @@ describe('ユーザー', () => { }); }; + // MeDetailedのキーが過不足なく入っている? const meDetailed = (user: User, security = false): Partial => { return stripUndefined({ ...userDetailedNotMe(user), @@ -216,8 +221,8 @@ describe('ユーザー', () => { }, 1000 * 60 * 2); beforeAll(async () => { - root = await signup({ username: 'alice' }); - alice = root; + root = await signup({ username: 'root' }); + alice = await signup({ username: 'alice' }); aliceNote = await post(alice, { text: 'test' }) as any; alicePage = await page(alice); aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body; @@ -343,7 +348,7 @@ describe('ユーザー', () => { // UserDetailedNotMeOnly assert.strictEqual(response.url, null); assert.strictEqual(response.uri, null); - assert.strictEqual(response.movedToUri, null); + assert.strictEqual(response.movedTo, null); assert.strictEqual(response.alsoKnownAs, null); assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString()); assert.strictEqual(response.updatedAt, null); @@ -371,6 +376,7 @@ describe('ユーザー', () => { assert.strictEqual(response.usePasswordLessLogin, false); assert.strictEqual(response.securityKeys, false); assert.deepStrictEqual(response.roles, []); + assert.strictEqual(response.memo, null); // MeDetailedOnly assert.strictEqual(response.avatarId, null); @@ -410,7 +416,7 @@ describe('ユーザー', () => { //#endregion //#region 自分の情報(i) - test('を読み取ることができる。(自分)', async () => { + test('を読み取ることができること(自分)、キーが過不足なく入っていること。', async () => { const response = await successfulApiCall({ endpoint: 'i', parameters: {}, @@ -502,7 +508,6 @@ describe('ユーザー', () => { }; assert.deepStrictEqual(response, expected, inspect(parameters)); - if (1) return; // BUG 521eb95 以降アバターのリセットができない。 const parameters2 = { avatarId: null }; const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); const expected2 = { @@ -528,7 +533,6 @@ describe('ユーザー', () => { }; assert.deepStrictEqual(response, expected, inspect(parameters)); - if (1) return; // BUG 521eb95 以降バナーのリセットができない。 const parameters2 = { bannerId: null }; const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); const expected2 = { @@ -554,6 +558,21 @@ describe('ユーザー', () => { assert.deepStrictEqual(response2, expected2); }); + //#endregion + //#region メモの更新(users/update-memo) + + test.each([ + { label: '最大長', memo: 'x'.repeat(2048) }, + { label: '空文字', memo: '', expects: null }, + { label: 'null', memo: null }, + ])('を書き換えることができる(メモを$labelに)', async ({ memo, expects }) => { + const expected = { ...await show(bob.id, alice), memo: expects === undefined ? memo : expects }; + const parameters = { userId: bob.id, memo }; + await successfulApiCall({ endpoint: 'users/update-memo', parameters, user: alice }); + const response = await show(bob.id, alice); + assert.deepStrictEqual(response, expected); + }); + //#endregion //#region ユーザー(users) @@ -569,7 +588,7 @@ describe('ユーザー', () => { const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); // 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する - const users = await Promise.all(response.map(u => show(u.id))); + const users = await Promise.all(response.map(u => show(u.id, alice))); const expected = users.sort((x, y) => { const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; return index * (parameters.sort?.startsWith('+') ? -1 : 1); @@ -583,13 +602,13 @@ describe('ユーザー', () => { { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true }, { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, ] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => { const parameters = { limit: 100 }; const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id)]; + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected); }); test.todo('をリスト形式で取得することができる(リモート, hostname指定)'); @@ -616,7 +635,7 @@ describe('ユーザー', () => { { label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator }, { label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined }, { label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced }, - { label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended }, + //{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended }, { label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted }, { label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, { label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted }, @@ -698,13 +717,13 @@ describe('ユーザー', () => { test('を検索することができる', async () => { const parameters = { query: 'carol', limit: 10 }; const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); - const expected = [await show(carol.id)]; + const expected = [await show(carol.id, alice)]; assert.deepStrictEqual(response, expected); }); test('を検索することができる(UserLite)', async () => { const parameters = { query: 'carol', detail: false, limit: 10 }; const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); - const expected = [userLite(await show(carol.id))]; + const expected = [userLite(await show(carol.id, alice))]; assert.deepStrictEqual(response, expected); }); test.each([ @@ -720,7 +739,7 @@ describe('ユーザー', () => { ] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => { const parameters = { query: user().username, limit: 1 }; const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id)]; + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; assert.deepStrictEqual(response, expected); }); test.todo('を検索することができる(リモート)'); @@ -741,7 +760,7 @@ describe('ユーザー', () => { { label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] }, ])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => { const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); - const expected = await Promise.all(user().map(u => show(u.id))); + const expected = await Promise.all(user().map(u => show(u.id, alice))); assert.deepStrictEqual(response, expected); }); test.each([ @@ -757,7 +776,7 @@ describe('ユーザー', () => { ] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => { const parameters = { username: user().username }; const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id)]; + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; assert.deepStrictEqual(response, expected); }); test.todo('をID&ホスト指定で検索できる(リモート)'); @@ -769,7 +788,7 @@ describe('ユーザー', () => { const parameters = { userId: alice.id, limit: 5 }; const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({ - user: await show(s.id), + user: await show(s.id, alice), weight: (usersReplying.length - i) / usersReplying.length, }))); assert.deepStrictEqual(response, expected); @@ -781,7 +800,7 @@ describe('ユーザー', () => { { label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true }, { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended }, + //{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, ] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => { @@ -789,7 +808,7 @@ describe('ユーザー', () => { await post(alice, { text: `@${user().username} test`, replyId: replyTo.id }); const parameters = { userId: alice.id, limit: 100 }; const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); - const expected = (excluded ?? false) ? [] : [await show(user().id)]; + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected); }); @@ -808,7 +827,7 @@ describe('ユーザー', () => { await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice }); const parameters = { tag: hashtag, limit: 5, ...sort }; const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); - const users = await Promise.all(response.map(u => show(u.id))); + const users = await Promise.all(response.map(u => show(u.id, alice))); const expected = users.sort((x, y) => { const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; return index * (parameters.sort.startsWith('+') ? -1 : 1); @@ -822,10 +841,10 @@ describe('ユーザー', () => { { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, - { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, - ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => { + ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user, excluded }) => { const hashtag = `user_test${user().username}`; if (user() !== userSuspended) { // サスペンドユーザーはupdateできない。 @@ -833,7 +852,7 @@ describe('ユーザー', () => { } const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const; const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); - const expected = [await show(user().id)]; + const expected = (excluded ?? false) ? [] : [await show(user().id, alice)]; assert.deepStrictEqual(response, expected); }); test.todo('をハッシュタグ指定で取得することができる(リモート)'); @@ -856,7 +875,7 @@ describe('ユーザー', () => { await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root }); const parameters = {} as const; const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice }); - const expected = await Promise.all([bob, carol].map(u => show(u.id))); + const expected = await Promise.all([bob, carol].map(u => show(u.id, alice))); assert.deepStrictEqual(response, expected); }); diff --git a/packages/backend/test/resources/kick_gaba7.aac b/packages/backend/test/resources/kick_gaba7.aac new file mode 100644 index 0000000000..4644542f96 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.aac differ diff --git a/packages/backend/test/resources/kick_gaba7.flac b/packages/backend/test/resources/kick_gaba7.flac new file mode 100644 index 0000000000..7512812018 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.flac differ diff --git a/packages/backend/test/resources/kick_gaba7.mp3 b/packages/backend/test/resources/kick_gaba7.mp3 new file mode 100644 index 0000000000..6ba317deb1 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.mp3 differ diff --git a/packages/backend/test/resources/kick_gaba7.wav b/packages/backend/test/resources/kick_gaba7.wav new file mode 100644 index 0000000000..2cd280148e Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.wav differ diff --git a/packages/backend/test/resources/kick_gaba7.webm b/packages/backend/test/resources/kick_gaba7.webm new file mode 100644 index 0000000000..82c5349cd4 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.webm differ diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index d05833560d..f378184c74 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -7,10 +7,10 @@ import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { FileInfoService } from '@/core/FileInfoService.js'; -import { DI } from '@/di-symbols.js'; +//import { DI } from '@/di-symbols.js'; import { AiService } from '@/core/AiService.js'; import type { TestingModule } from '@nestjs/testing'; -import type { jest } from '@jest/globals'; +import { describe, beforeAll, afterAll, test } from '@jest/globals'; import type { MockFunctionMetadata } from 'jest-mock'; const _filename = fileURLToPath(import.meta.url); @@ -74,164 +74,271 @@ describe('FileInfoService', () => { }); }); - test('Generic JPEG', async () => { - const path = `${resources}/Lenna.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 25360, - md5: '091b3f259662aa31e2ffef4519951168', - type: { - mime: 'image/jpeg', - ext: 'jpg', - }, - width: 512, - height: 512, - orientation: undefined, + describe('IMAGE', () => { + test('Generic JPEG', async () => { + const path = `${resources}/Lenna.jpg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 25360, + md5: '091b3f259662aa31e2ffef4519951168', + type: { + mime: 'image/jpeg', + ext: 'jpg', + }, + width: 512, + height: 512, + orientation: undefined, + }); + }); + + test('Generic APNG', async () => { + const path = `${resources}/anime.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 1868, + md5: '08189c607bea3b952704676bb3c979e0', + type: { + mime: 'image/apng', + ext: 'apng', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Generic AGIF', async () => { + const path = `${resources}/anime.gif`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 2248, + md5: '32c47a11555675d9267aee1a86571e7e', + type: { + mime: 'image/gif', + ext: 'gif', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('PNG with alpha', async () => { + const path = `${resources}/with-alpha.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 3772, + md5: 'f73535c3e1e27508885b69b10cf6e991', + type: { + mime: 'image/png', + ext: 'png', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Generic SVG', async () => { + const path = `${resources}/image.svg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 505, + md5: 'b6f52b4b021e7b92cdd04509c7267965', + type: { + mime: 'image/svg+xml', + ext: 'svg', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('SVG with XML definition', async () => { + // https://github.com/misskey-dev/misskey/issues/4413 + const path = `${resources}/with-xml-def.svg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 544, + md5: '4b7a346cde9ccbeb267e812567e33397', + type: { + mime: 'image/svg+xml', + ext: 'svg', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Dimension limit', async () => { + const path = `${resources}/25000x25000.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 75933, + md5: '268c5dde99e17cf8fe09f1ab3f97df56', + type: { + mime: 'application/octet-stream', // do not treat as image + ext: null, + }, + width: 25000, + height: 25000, + orientation: undefined, + }); + }); + + test('Rotate JPEG', async () => { + const path = `${resources}/rotate.jpg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 12624, + md5: '68d5b2d8d1d1acbbce99203e3ec3857e', + type: { + mime: 'image/jpeg', + ext: 'jpg', + }, + width: 512, + height: 256, + orientation: 8, + }); }); }); - test('Generic APNG', async () => { - const path = `${resources}/anime.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 1868, - md5: '08189c607bea3b952704676bb3c979e0', - type: { - mime: 'image/apng', - ext: 'apng', - }, - width: 256, - height: 256, - orientation: undefined, + describe('AUDIO', () => { + test('MP3', async () => { + const path = `${resources}/kick_gaba7.mp3`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 19853, + md5: '4f557df8548bc3cecc794c652f690446', + type: { + mime: 'audio/mpeg', + ext: 'mp3', + }, + }); }); - }); - - test('Generic AGIF', async () => { - const path = `${resources}/anime.gif`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 2248, - md5: '32c47a11555675d9267aee1a86571e7e', - type: { - mime: 'image/gif', - ext: 'gif', - }, - width: 256, - height: 256, - orientation: undefined, + + test('WAV', async () => { + const path = `${resources}/kick_gaba7.wav`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 87630, + md5: '8bc9bb4fe5e77bb1871448209be635c1', + type: { + mime: 'audio/wav', + ext: 'wav', + }, + }); }); - }); - - test('PNG with alpha', async () => { - const path = `${resources}/with-alpha.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 3772, - md5: 'f73535c3e1e27508885b69b10cf6e991', - type: { - mime: 'image/png', - ext: 'png', - }, - width: 256, - height: 256, - orientation: undefined, + + test('AAC', async () => { + const path = `${resources}/kick_gaba7.aac`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 7291, + md5: '2789323f05e3392b648066f50be6a2a6', + type: { + mime: 'audio/aac', + ext: 'aac', + }, + }); }); - }); - - test('Generic SVG', async () => { - const path = `${resources}/image.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 505, - md5: 'b6f52b4b021e7b92cdd04509c7267965', - type: { - mime: 'image/svg+xml', - ext: 'svg', - }, - width: 256, - height: 256, - orientation: undefined, + + test('FLAC', async () => { + const path = `${resources}/kick_gaba7.flac`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 108793, + md5: 'bc0f3adfe0e1ca99ae6c7528c46b3173', + type: { + mime: 'audio/flac', + ext: 'flac', + }, + }); }); - }); - - test('SVG with XML definition', async () => { - // https://github.com/misskey-dev/misskey/issues/4413 - const path = `${resources}/with-xml-def.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 544, - md5: '4b7a346cde9ccbeb267e812567e33397', - type: { - mime: 'image/svg+xml', - ext: 'svg', - }, - width: 256, - height: 256, - orientation: undefined, - }); - }); - - test('Dimension limit', async () => { - const path = `${resources}/25000x25000.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 75933, - md5: '268c5dde99e17cf8fe09f1ab3f97df56', - type: { - mime: 'application/octet-stream', // do not treat as image - ext: null, - }, - width: 25000, - height: 25000, - orientation: undefined, - }); - }); - - test('Rotate JPEG', async () => { - const path = `${resources}/rotate.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 12624, - md5: '68d5b2d8d1d1acbbce99203e3ec3857e', - type: { - mime: 'image/jpeg', - ext: 'jpg', - }, - width: 512, - height: 256, - orientation: 8, + + /* + * video/webmとして検出されてしまう + test('WEBM AUDIO', async () => { + const path = `${resources}/kick_gaba7.webm`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 8879, + md5: '3350083dec312419cfdc06c16413aca7', + type: { + mime: 'audio/webm', + ext: 'webm', + }, + }); }); + */ }); }); diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 529e923b2c..c2280142a6 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -7,6 +7,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { RelayService } from '@/core/RelayService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; import { IdService } from '@/core/IdService.js'; import type { RelaysRepository } from '@/models/index.js'; @@ -21,6 +22,7 @@ describe('RelayService', () => { let relayService: RelayService; let queueService: jest.Mocked; let relaysRepository: RelaysRepository; + let userEntityService: UserEntityService; beforeAll(async () => { app = await Test.createTestingModule({ @@ -32,6 +34,7 @@ describe('RelayService', () => { CreateSystemUserService, ApRendererService, RelayService, + UserEntityService, ], }) .useMocker((token) => { @@ -51,6 +54,7 @@ describe('RelayService', () => { relayService = app.get(RelayService); queueService = app.get(QueueService) as jest.Mocked; relaysRepository = app.get(DI.relaysRepository); + userEntityService = app.get(UserEntityService); }); afterAll(async () => { @@ -63,7 +67,7 @@ describe('RelayService', () => { expect(result.inbox).toBe('https://example.com'); expect(result.status).toBe('requesting'); expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1].type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Follow'); expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); }); @@ -80,8 +84,8 @@ describe('RelayService', () => { await relayService.removeRelay('https://example.com'); expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1].type).toBe('Undo'); - expect(queueService.deliver.mock.lastCall![1].object.type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Undo'); + expect(queueService.deliver.mock.lastCall![1]?.object.type).toBe('Follow'); expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); diff --git a/packages/backend/test/unit/misc/check-word-mute.ts b/packages/backend/test/unit/misc/check-word-mute.ts new file mode 100644 index 0000000000..7ab838bdee --- /dev/null +++ b/packages/backend/test/unit/misc/check-word-mute.ts @@ -0,0 +1,49 @@ +import { checkWordMute } from '@/misc/check-word-mute.js'; + +describe(checkWordMute, () => { + describe('Slacc boost mode', () => { + it('should return false if mutedWords is empty', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [])).toBe(false); + }); + it('should return true if mutedWords is not empty and text contains muted word', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo']])).toBe(true); + }); + it('should return false if mutedWords is not empty and text does not contain muted word', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['bar']])).toBe(false); + }); + it('should return false when the note is written by me even if mutedWords is not empty and text contains muted word', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, { id: '1' }, [['foo']])).toBe(false); + }); + it('should return true if mutedWords is not empty and text contains muted word in CW', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['bar']])).toBe(true); + }); + it('should return true if mutedWords is not empty and text contains muted word in both CW and text', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['bar']])).toBe(true); + }); + it('should return true if mutedWords is not empty and text does not contain muted word in both CW and text', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['baz']])).toBe(true); + }); + }); + describe('normal mode', () => { + it('should return false if text does not contain muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo', 'bar']])).toBe(false); + }); + it('should return true if text contains muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, [['foo', 'bar']])).toBe(true); + }); + it('should return false when the note is written by me even if text contains muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, [['foo', 'bar']])).toBe(false); + }); + }); + describe('RegExp mode', () => { + it('should return false if text does not contain muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo' }, null, ['/bar/'])).toBe(false); + }); + it('should return true if text contains muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, ['/bar/'])).toBe(true); + }); + it('should return false when the note is written by me even if text contains muted words', async () => { + expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, ['/bar/'])).toBe(false); + }); + }); +}); diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts index f0827331f7..755bec6869 100644 --- a/packages/frontend/.storybook/changes.ts +++ b/packages/frontend/.storybook/changes.ts @@ -38,6 +38,7 @@ fs.readFile( path.resolve(__dirname, '../../..', arg) ) ) + .map((path) => path.replace(/(?:(?<=\.stories)\.(?:impl|meta)|\.msw)(?=\.ts$)/g, '')) .map((path) => (path.startsWith('.') ? path : `./${path}`)) ); if ( diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index dd40bac2cc..7c51d4c00c 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -118,7 +118,7 @@ function toStories(component: string): string { .replace(/[-.]|^(?=\d)/g, '_') .replace(/(?<=^[^A-Z_]*$)/, '_')} /> as estree.Identifier; - const parameters = ( + const parameters = - ) as estree.ObjectExpression; - const program = ( + /> as estree.ObjectExpression; + const program = ) as estree.Identifier} /> as estree.ExportDefaultDeclaration, ]} - /> - ) as estree.Program; + /> as estree.Program; return format( '/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' + '/* eslint-disable import/no-default-export */\n' + + '/* eslint-disable import/no-duplicates */\n' + generate(program, { generator }) + (hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''), { @@ -397,7 +396,11 @@ function toStories(component: string): string { // glob('src/{components,pages,ui,widgets}/**/*.vue') Promise.all([ glob('src/components/global/*.vue'), + glob('src/components/Mk{A,B}*.vue'), glob('src/components/MkGalleryPostPreview.vue'), + glob('src/components/MkSignupServerRules.vue'), + glob('src/components/MkUserSetupDialog.vue'), + glob('src/components/MkUserSetupDialog.*.vue'), glob('src/pages/user/home.vue'), ]) .then((globs) => globs.flat()) diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts index 45db48fa1d..1d0ce5ab63 100644 --- a/packages/frontend/.storybook/main.ts +++ b/packages/frontend/.storybook/main.ts @@ -1,6 +1,6 @@ import { resolve } from 'node:path'; import type { StorybookConfig } from '@storybook/vue3-vite'; -import { mergeConfig } from 'vite'; +import { type Plugin, mergeConfig } from 'vite'; import turbosnap from 'vite-plugin-turbosnap'; const config = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], @@ -22,6 +22,10 @@ const config = { disableTelemetry: true, }, async viteFinal(config) { + const replacePluginForIsChromatic = config.plugins?.findIndex((plugin) => plugin && (plugin as Partial)?.name === 'replace') ?? -1; + if (~replacePluginForIsChromatic) { + config.plugins?.splice(replacePluginForIsChromatic, 1); + } return mergeConfig(config, { plugins: [ turbosnap({ diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts index 41c3c5c4d9..4091e39686 100644 --- a/packages/frontend/.storybook/mocks.ts +++ b/packages/frontend/.storybook/mocks.ts @@ -8,6 +8,16 @@ export const onUnhandledRequest = ((req, print) => { }) satisfies SharedOptions['onUnhandledRequest']; export const commonHandlers = [ + rest.get('/fluent-emoji/:codepoints.png', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); + }), + rest.get('/fluent-emojis/:codepoints.png', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); + }), rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => { const { codepoints } = req.params; const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob()); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html index 64e537b931..ab694f64fb 100644 --- a/packages/frontend/.storybook/preview-head.html +++ b/packages/frontend/.storybook/preview-head.html @@ -1,3 +1,5 @@ + + diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index a6372b7b6f..d03331a6eb 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -1,6 +1,6 @@ - diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 93c1f89199..9f5404ce15 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -9,7 +9,7 @@ - +
@@ -32,8 +32,8 @@
- {{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} - {{ cancelText ?? i18n.ts.cancel }} + {{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} + {{ cancelText ?? i18n.ts.cancel }}
{{ action.text }} @@ -183,7 +183,7 @@ onBeforeUnmount(() => { box-sizing: border-box; text-align: center; background: var(--panel); - border-radius: var(--radius); + border-radius: 16px; } .icon { diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 58cc0de5c8..10eee6aab1 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -1,8 +1,8 @@ @@ -29,8 +29,8 @@ const props = withDefaults(defineProps<{ }); - diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts new file mode 100644 index 0000000000..f4930aa26b --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog_Profile, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue new file mode 100644 index 0000000000..adb8d43349 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts new file mode 100644 index 0000000000..7413f4884b --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../.storybook/fakes'; +import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog_User, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue new file mode 100644 index 0000000000..d66f34f165 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts new file mode 100644 index 0000000000..55790602d5 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks'; +import { userDetailed } from '../../.storybook/fakes'; +import MkUserSetupDialog from './MkUserSetupDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44'), + userDetailed('49'), + ])); + }), + rest.post('/api/pinned-users', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44'), + userDetailed('49'), + ])); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue new file mode 100644 index 0000000000..096b88c309 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue new file mode 100644 index 0000000000..fb705786cf --- /dev/null +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue new file mode 100644 index 0000000000..6226768127 --- /dev/null +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index d074fdd150..33e594acd8 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -2,11 +2,11 @@