Merge branch 'develop' into pag-back
This commit is contained in:
commit
4abe7e79a9
271 changed files with 6761 additions and 8577 deletions
|
@ -114,6 +114,7 @@ redis:
|
|||
|
||||
# Available methods:
|
||||
# aid ... Short, Millisecond accuracy
|
||||
# aidx ... Millisecond accuracy
|
||||
# meid ... Similar to ObjectID, Millisecond accuracy
|
||||
# ulid ... Millisecond accuracy
|
||||
# objectid ... This is left for backward compatibility
|
||||
|
@ -121,7 +122,7 @@ redis:
|
|||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# ID SETTINGS AFTER THAT!
|
||||
|
||||
id: 'aid'
|
||||
id: 'aidx'
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Other configuration └─────────────────────────────────────
|
||||
|
|
|
@ -125,6 +125,7 @@ redis:
|
|||
|
||||
# Available methods:
|
||||
# aid ... Short, Millisecond accuracy
|
||||
# aidx ... Millisecond accuracy
|
||||
# meid ... Similar to ObjectID, Millisecond accuracy
|
||||
# ulid ... Millisecond accuracy
|
||||
# objectid ... This is left for backward compatibility
|
||||
|
@ -132,7 +133,7 @@ redis:
|
|||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# ID SETTINGS AFTER THAT!
|
||||
|
||||
id: 'aid'
|
||||
id: 'aidx'
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Other configuration └─────────────────────────────────────
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
"features": {
|
||||
"ghcr.io/devcontainers-contrib/features/pnpm:2": {},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20.5.0"
|
||||
"version": "20.5.1"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
|
|
|
@ -114,6 +114,7 @@ redis:
|
|||
|
||||
# Available methods:
|
||||
# aid ... Short, Millisecond accuracy
|
||||
# aidx ... Millisecond accuracy
|
||||
# meid ... Similar to ObjectID, Millisecond accuracy
|
||||
# ulid ... Millisecond accuracy
|
||||
# objectid ... This is left for backward compatibility
|
||||
|
@ -121,7 +122,7 @@ redis:
|
|||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# ID SETTINGS AFTER THAT!
|
||||
|
||||
id: 'aid'
|
||||
id: 'aidx'
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Other configuration └─────────────────────────────────────
|
||||
|
|
2
.github/misskey/test.yml
vendored
2
.github/misskey/test.yml
vendored
|
@ -12,4 +12,4 @@ db:
|
|||
redis:
|
||||
host: 127.0.0.1
|
||||
port: 56312
|
||||
id: aid
|
||||
id: aidx
|
||||
|
|
2
.github/workflows/api-misskey-js.yml
vendored
2
.github/workflows/api-misskey-js.yml
vendored
|
@ -9,7 +9,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v4.0.0
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
|
|
2
.github/workflows/check_copyright_year.yml
vendored
2
.github/workflows/check_copyright_year.yml
vendored
|
@ -10,7 +10,7 @@ jobs:
|
|||
check_copyright_year:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
- run: |
|
||||
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
|
||||
echo "Please change copyright year!"
|
||||
|
|
4
.github/workflows/docker-develop.yml
vendored
4
.github/workflows/docker-develop.yml
vendored
|
@ -13,10 +13,10 @@ jobs:
|
|||
if: github.repository == 'misskey-dev/misskey'
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.9.1
|
||||
uses: docker/setup-buildx-action@v2.10.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Docker meta
|
||||
|
|
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
|
@ -12,10 +12,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2.9.1
|
||||
uses: docker/setup-buildx-action@v2.10.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Docker meta
|
||||
|
|
2
.github/workflows/dockle.yml
vendored
2
.github/workflows/dockle.yml
vendored
|
@ -14,7 +14,7 @@ jobs:
|
|||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
- run: |
|
||||
curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v0.4.10/dockle_0.4.10_Linux-64bit.deb"
|
||||
sudo dpkg -i dockle.deb
|
||||
|
|
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
|
@ -11,7 +11,7 @@ jobs:
|
|||
pnpm_install:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
@ -38,7 +38,7 @@ jobs:
|
|||
- sw
|
||||
- misskey-js
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
@ -64,7 +64,7 @@ jobs:
|
|||
- backend
|
||||
- misskey-js
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
|
2
.github/workflows/pr-preview-deploy.yml
vendored
2
.github/workflows/pr-preview-deploy.yml
vendored
|
@ -53,7 +53,7 @@ jobs:
|
|||
|
||||
# Check out merge commit
|
||||
- name: Fork based /deploy checkout
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
ref: 'refs/pull/${{ github.event.client_payload.pull_request.number }}/merge'
|
||||
|
||||
|
|
13
.github/workflows/reviewer_lottery.yml
vendored
13
.github/workflows/reviewer_lottery.yml
vendored
|
@ -1,13 +0,0 @@
|
|||
name: "Reviewer lottery"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review, reopened]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: uesteibar/reviewer-lottery@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
112
.github/workflows/storybook.yml
vendored
112
.github/workflows/storybook.yml
vendored
|
@ -1,112 +0,0 @@
|
|||
name: Storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
pull_request_target:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
if: github.event_name != 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- uses: actions/checkout@v3.5.3
|
||||
if: github.event_name == 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
ref: "refs/pull/${{ github.event.number }}/merge"
|
||||
- name: Checkout actual HEAD
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: rev
|
||||
run: |
|
||||
echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT
|
||||
git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3)
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v3.8.1
|
||||
with:
|
||||
node-version-file: '.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: Build misskey-js
|
||||
run: pnpm --filter misskey-js build
|
||||
- name: Build storybook
|
||||
run: pnpm --filter frontend build-storybook
|
||||
- name: Publish to Chromatic
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master'
|
||||
run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static
|
||||
env:
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
- name: Publish to Chromatic
|
||||
if: github.event_name != 'pull_request_target' && github.ref != 'refs/heads/master'
|
||||
id: chromatic_push
|
||||
run: |
|
||||
DIFF="${{ github.event.before }} HEAD"
|
||||
if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then
|
||||
DIFF="HEAD"
|
||||
fi
|
||||
CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))"
|
||||
if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if pnpm --filter frontend chromatic -d storybook-static $(echo "$CHROMATIC_PARAMETER"); then
|
||||
echo "success=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "success=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
- name: Publish to Chromatic
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: chromatic_pull_request
|
||||
run: |
|
||||
DIFF="${{ steps.rev.outputs.base }} HEAD"
|
||||
if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then
|
||||
DIFF="HEAD"
|
||||
fi
|
||||
CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))"
|
||||
if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
BRANCH="${{ github.event.pull_request.head.user.login }}:${{ github.event.pull_request.head.ref }}"
|
||||
if [ "$BRANCH" = "misskey-dev:${{ github.event.pull_request.head.ref }}" ]; then
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
fi
|
||||
pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static --branch-name $BRANCH $(echo "$CHROMATIC_PARAMETER")
|
||||
env:
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
- name: Notify that Chromatic detects changes
|
||||
uses: actions/github-script@v6.4.0
|
||||
if: github.event_name != 'pull_request_target' && steps.chromatic_push.outputs.success == 'false'
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.repos.createCommitComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.sha,
|
||||
body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).'
|
||||
})
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storybook
|
||||
path: packages/frontend/storybook-static
|
4
.github/workflows/test-backend.yml
vendored
4
.github/workflows/test-backend.yml
vendored
|
@ -13,7 +13,7 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [20.5.1]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
@ -29,7 +29,7 @@ jobs:
|
|||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install pnpm
|
||||
|
|
8
.github/workflows/test-frontend.yml
vendored
8
.github/workflows/test-frontend.yml
vendored
|
@ -13,10 +13,10 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [20.5.1]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install pnpm
|
||||
|
@ -51,7 +51,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [20.5.1]
|
||||
browser: [chrome]
|
||||
|
||||
services:
|
||||
|
@ -68,7 +68,7 @@ jobs:
|
|||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
submodules: true
|
||||
# https://github.com/cypress-io/cypress-docker-images/issues/150
|
||||
|
|
4
.github/workflows/test-misskey-js.yml
vendored
4
.github/workflows/test-misskey-js.yml
vendored
|
@ -16,12 +16,12 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [20.5.1]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v4.0.0
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
|
|
4
.github/workflows/test-production.yml
vendored
4
.github/workflows/test-production.yml
vendored
|
@ -16,10 +16,10 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [20.5.1]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/checkout@v4.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install pnpm
|
||||
|
|
|
@ -1 +1 @@
|
|||
20.5.0
|
||||
20.5.1
|
||||
|
|
19
CHANGELOG.md
19
CHANGELOG.md
|
@ -21,18 +21,31 @@
|
|||
- お知らせのバナー表示やダイアログ表示が可能に
|
||||
- お知らせのアイコンを設定可能に
|
||||
- チャンネルをセンシティブ指定できるようになりました
|
||||
- センシティブチャンネルのNoteのReNoteはデフォルトでHome TLに流れるようになりました
|
||||
- センシティブチャンネルのノートはユーザープロフィールに表示されません
|
||||
- 二要素認証のバックアップコードが生成されるようになりました ref. https://github.com/MisskeyIO/misskey/pull/121
|
||||
- 二要素認証でパスキーをサポートするようになりました
|
||||
|
||||
### Client
|
||||
- プロフィールにその人が作ったPlayの一覧出せるように
|
||||
- メニューのスイッチの動作を改善
|
||||
- 絵文字ピッカーの検索の表示件数を100件に増加
|
||||
- 投稿フォームのプレビューの表示状態を記憶するように
|
||||
- AiScriptからMisskeyサーバーAPIを呼び出す際の制限を撤廃
|
||||
- Playで直接投稿フォームを埋め込めるように(`Ui:C:postForm`)
|
||||
- 通知をテストできるように
|
||||
- Enhance: ユーザーメニューでスイッチでユーザーリストに追加・削除できるように
|
||||
- Enhance: 自分が押したリアクションのデザインを改善
|
||||
- Enhance: ノート検索にローカルのみ検索可能なオプションの追加
|
||||
- Enhance: AiScriptで`LOCALE`として現在の設定言語を取得できるように
|
||||
- Enhance: Renote自体を通報できるように
|
||||
- Enhance: データセーバーモードの強化
|
||||
- Enhance: Renoteを管理者権限で削除可能に
|
||||
- `$[rainbow ]`記法が、動きのあるMFMが無効になっていても使用できるようになりました
|
||||
- Playの操作を行うAPI TokenをAPIコンソールから発行できるように
|
||||
- リアクションの表示サイズをより大きくできるように
|
||||
- ノート詳細ページ読み込み時のパフォーマンスを改善
|
||||
- タイムラインでリスト/アンテナ選択時のパフォーマンスを改善
|
||||
- Fix: サーバー情報画面(`/instance-info/{domain}`)でブロックができないのを修正
|
||||
- Fix: 未読のお知らせの「わかった」をクリック・タップしてもその場で「わかった」が消えない問題を修正
|
||||
- Fix: iOSで画面を回転させるとテキストサイズが変わる問題を修正
|
||||
|
@ -42,10 +55,14 @@
|
|||
- Fix: 他のサーバーのユーザーへ「メッセージを送信」した時の初期テキストのメンションが間違っている問題を修正
|
||||
|
||||
### Server
|
||||
- Fix: ノート検索 `notes/search` にてhostを指定した際に検索結果に反映されるように
|
||||
- cacheRemoteFilesの初期値はfalseになりました
|
||||
- ファイルアップロード時等にファイル名の拡張子を修正する関数(correctFilename)の挙動を改善
|
||||
- Webhookのペイロードにサーバーのurlが含まれるようになりました
|
||||
- Webhook設定でsecretを空に出来るように
|
||||
- 使われていないアンテナの自動停止を設定可能に
|
||||
- nodeinfo 2.1対応
|
||||
- 自分へのメンション一覧を取得する際のパフォーマンスを向上
|
||||
- Fix: ノート検索 `notes/search` にてhostを指定した際に検索結果に反映されるように
|
||||
- Fix: 一部のfeatured noteを照会できない問題を修正
|
||||
- Fix: muteがapiからのuser list timeline取得で機能しない問題を修正
|
||||
- Fix: ジョブキュー管理画面の認証を回避できる問題を修正
|
||||
|
|
|
@ -301,6 +301,12 @@ export const handlers = [
|
|||
Don't forget to re-run the `.storybook/generate.js` script after adding, editing, or removing the above files.
|
||||
|
||||
## Notes
|
||||
|
||||
### Misskeyのドメイン固有の概念は`Mi`をprefixする
|
||||
例えばGoogleが自社サービスをMap、Earth、DriveではなくGoogle Map、Google Earth、Google Driveのように命名するのと同じ
|
||||
コード上でMisskeyのドメイン固有の概念には`Mi`をprefixすることで、他のドメインの同様の概念と区別できるほか、名前の衝突を防ぐ。
|
||||
ただし、文脈上Misskeyのものを指すことが明らかであり、名前の衝突の恐れがない場合は、一時的なローカル変数に限って`Mi`を省略してもよい。
|
||||
|
||||
### How to resolve conflictions occurred at pnpm-lock.yaml?
|
||||
|
||||
Just execute `pnpm` to fix it.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# syntax = docker/dockerfile:1.4
|
||||
|
||||
ARG NODE_VERSION=20.5.0-bullseye
|
||||
ARG NODE_VERSION=20.5.1-bullseye
|
||||
|
||||
# build assets & compile TypeScript
|
||||
|
||||
|
|
|
@ -135,6 +135,7 @@ redis:
|
|||
|
||||
# Available methods:
|
||||
# aid ... Short, Millisecond accuracy
|
||||
# aidx ... Millisecond accuracy
|
||||
# meid ... Similar to ObjectID, Millisecond accuracy
|
||||
# ulid ... Millisecond accuracy
|
||||
# objectid ... This is left for backward compatibility
|
||||
|
@ -142,7 +143,7 @@ redis:
|
|||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# ID SETTINGS AFTER THAT!
|
||||
|
||||
id: "aid"
|
||||
id: "aidx"
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Other configuration └─────────────────────────────────────
|
||||
|
||||
|
|
65
gulpfile.mjs
65
gulpfile.mjs
|
@ -1,65 +0,0 @@
|
|||
/**
|
||||
* Gulp tasks
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import gulp from 'gulp';
|
||||
import replace from 'gulp-replace';
|
||||
import terser from 'gulp-terser';
|
||||
import cssnano from 'gulp-cssnano';
|
||||
|
||||
import locales from './locales/index.js';
|
||||
import meta from './package.json' assert { type: "json" };
|
||||
|
||||
gulp.task('copy:backend:views', () =>
|
||||
gulp.src('./packages/backend/src/server/web/views/**/*').pipe(gulp.dest('./packages/backend/built/server/web/views'))
|
||||
);
|
||||
|
||||
gulp.task('copy:frontend:fonts', () =>
|
||||
gulp.src('./packages/frontend/node_modules/three/examples/fonts/**/*').pipe(gulp.dest('./built/_frontend_dist_/fonts/'))
|
||||
);
|
||||
|
||||
gulp.task('copy:frontend:tabler-icons', () =>
|
||||
gulp.src('./packages/frontend/node_modules/@tabler/icons-webfont/**/*').pipe(gulp.dest('./built/_frontend_dist_/tabler-icons/'))
|
||||
);
|
||||
|
||||
gulp.task('copy:frontend:locales', cb => {
|
||||
fs.mkdirSync('./built/_frontend_dist_/locales', { recursive: true });
|
||||
|
||||
const v = { '_version_': meta.version };
|
||||
|
||||
for (const [lang, locale] of Object.entries(locales)) {
|
||||
fs.writeFileSync(`./built/_frontend_dist_/locales/${lang}.${meta.version}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8');
|
||||
}
|
||||
|
||||
cb();
|
||||
});
|
||||
|
||||
gulp.task('build:backend:script', () => {
|
||||
return gulp.src(['./packages/backend/src/server/web/boot.js', './packages/backend/src/server/web/bios.js', './packages/backend/src/server/web/cli.js'])
|
||||
.pipe(replace('LANGS', JSON.stringify(Object.keys(locales))))
|
||||
.pipe(terser({
|
||||
toplevel: true
|
||||
}))
|
||||
.pipe(gulp.dest('./packages/backend/built/server/web/'));
|
||||
});
|
||||
|
||||
gulp.task('build:backend:style', () => {
|
||||
return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css'])
|
||||
.pipe(cssnano({
|
||||
zindex: false
|
||||
}))
|
||||
.pipe(gulp.dest('./packages/backend/built/server/web/'));
|
||||
});
|
||||
|
||||
gulp.task('build', gulp.parallel(
|
||||
'copy:frontend:locales', 'copy:backend:views', 'build:backend:script', 'build:backend:style', 'copy:frontend:fonts', 'copy:frontend:tabler-icons'
|
||||
));
|
||||
|
||||
gulp.task('default', gulp.task('build'));
|
||||
|
||||
gulp.task('watch', () => {
|
||||
gulp.watch([
|
||||
'./packages/*/src/**/*',
|
||||
], { ignoreInitial: false }, gulp.task('build'));
|
||||
});
|
|
@ -799,6 +799,7 @@ accountDeletionInProgress: "حذف الحساب جارٍ"
|
|||
usernameInfo: "الاسم الذي يميزك عن بافي مستخدمي هذا الخادم، يمكنك استخدام الحروف اللاتينية (a~z, A~Z) والأرقام (0~9) والشرطة السفلية (_). لا يمكنك تغييره بعد تسجيله."
|
||||
devMode: "وضع المُطوّر"
|
||||
keepCw: "أبقِ على تحذيرات المحتوى"
|
||||
pubSub: "حسابات Pub/Sub"
|
||||
lastCommunication: "آخر تواصل"
|
||||
resolved: "عولج"
|
||||
unresolved: "لم يعالج"
|
||||
|
@ -807,6 +808,7 @@ breakFollowConfirm: "أمتأكد من إزالة المتابِع ؟"
|
|||
itsOn: "مفعّل"
|
||||
itsOff: "معطّل"
|
||||
on: "مفعل"
|
||||
off: "معطل"
|
||||
emailRequiredForSignup: "عنوان البريد الإلكتروني إلزامي للتسجيل"
|
||||
unread: "غير مقروءة"
|
||||
filter: "رشّح"
|
||||
|
@ -853,6 +855,7 @@ recentNDays: "آخر {n} أيام"
|
|||
noEmailServerWarning: "خادم البريد غير مضبوط."
|
||||
thereIsUnresolvedAbuseReportWarning: "توجد بلاغات غير معالجة."
|
||||
recommended: "مقترح"
|
||||
check: "التحقق"
|
||||
driveCapOverrideLabel: "غيّر حجم قرص التخزين لهذا المستخدم"
|
||||
driveCapOverrideCaption: "أعد الحجم إلى القيمة الافتراضية بإدخال 0 أو أقل."
|
||||
requireAdminForView: "لاستعراض هذه الصفحة وجب عليك الولوج كمدير."
|
||||
|
@ -876,6 +879,7 @@ slow: "بطيء"
|
|||
fast: "سريع"
|
||||
sensitiveMediaDetection: "التعرف على المحتوى الحساس"
|
||||
localOnly: "المحلي فقط"
|
||||
remoteOnly: "بُعدي فقط"
|
||||
failedToUpload: "فشل الرفع"
|
||||
cannotUploadBecauseInappropriate: "تعذر رفع الملف لوجود محتوى حساس فيه."
|
||||
cannotUploadBecauseNoFreeSpace: "تعذر رفع الملف لنقص مساحة التخزين."
|
||||
|
@ -895,6 +899,7 @@ pushNotificationAlreadySubscribed: "إرسال الإشعارات مفعل سل
|
|||
pushNotificationNotSupported: "متصفحك لا يدعم إرسال الإشعارات أو المثيل لا يدعمها."
|
||||
sendPushNotificationReadMessage: "احذف الإشعارات فور قراءتها"
|
||||
sendPushNotificationReadMessageCaption: "هذا قد يزيد من معدل استهلاك الطاقة لجهازك."
|
||||
windowMaximize: "املأ الشاشة"
|
||||
windowRestore: "استرجاع"
|
||||
caption: "التعليق التوضيحي"
|
||||
loggedInAsBot: "والج كآلي"
|
||||
|
@ -952,6 +957,8 @@ accountMoved: "نقل هذا المستخدم حسابه:"
|
|||
accountMovedShort: "رُحل هذا الحساب."
|
||||
operationForbidden: "عملية ممنوعة"
|
||||
forceShowAds: "أظهر الإعلانات التجارية دائما"
|
||||
reactionsList: "التفاعلات"
|
||||
renotesList: "إعادات النشر"
|
||||
leftTop: "أعلى اليسار"
|
||||
rightTop: "أعلى اليمين"
|
||||
leftBottom: "أسفل اليسار"
|
||||
|
@ -987,6 +994,9 @@ later: "لاحقاً"
|
|||
goToMisskey: "لميسكي"
|
||||
additionalEmojiDictionary: "قواميس إيموجي إضافية"
|
||||
installed: "مُثبت"
|
||||
expirationDate: "تاريخ انتهاء الصلاحية"
|
||||
unused: "غير مستعمَل"
|
||||
expired: "منتهية صلاحيته"
|
||||
icon: "الصورة الرمزية"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "نجح إنشاء حسابك!"
|
||||
|
@ -1073,6 +1083,7 @@ _role:
|
|||
description: "وصف الدور"
|
||||
permission: "أذونات الدور"
|
||||
assignTarget: "نوع الإسناد"
|
||||
condition: "الشرط"
|
||||
options: "خيارات"
|
||||
policies: "السياسة العامة"
|
||||
priority: "الأولوية"
|
||||
|
@ -1128,6 +1139,9 @@ _plugin:
|
|||
install: "ثبّت إضافات"
|
||||
installWarn: "رجاءً لا تثبت إضافات غير موثوقة."
|
||||
manage: "إدارة الإضافات"
|
||||
_preferencesBackups:
|
||||
createdAt: "تم إنشاؤه: {date} {time}"
|
||||
updatedAt: "آخر تحديث: {date} {time}"
|
||||
_registry:
|
||||
scope: "الحيّز"
|
||||
key: "مفتاح"
|
||||
|
@ -1252,6 +1266,9 @@ _time:
|
|||
minute: "د"
|
||||
hour: "سا"
|
||||
day: "ي"
|
||||
_timelineTutorial:
|
||||
title: "كيف تستخدم Misskey"
|
||||
step3_1: "هل نشرت ملاحظتك الأولى؟"
|
||||
_2fa:
|
||||
alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين."
|
||||
step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})."
|
||||
|
@ -1520,6 +1537,8 @@ _deck:
|
|||
swapUp: "التحريك إلى الأعلى"
|
||||
swapDown: "التحريك إلى الأسفل"
|
||||
profile: "حسابي الشخصي"
|
||||
newProfile: "ملف تعريفي جديد"
|
||||
deleteProfile: "حذف الملف التعريفي"
|
||||
_columns:
|
||||
main: "الرئيسية"
|
||||
widgets: "التطبيقات المُصغّرة"
|
||||
|
|
|
@ -1042,7 +1042,6 @@ _2fa:
|
|||
alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷"
|
||||
step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷"
|
||||
step2: "এরপরে, অ্যাপের সাহায্যে প্রদর্শিত QR কোডটি স্ক্যান করুন।"
|
||||
step2Url: "ডেস্কটপ অ্যাপে, নিম্নলিখিত URL লিখুন:"
|
||||
step3: "অ্যাপে প্রদর্শিত টোকেনটি লিখুন এবং আপনার কাজ শেষ।"
|
||||
step4: "আপনাকে এখন থেকে লগ ইন করার সময়, এইভাবে টোকেন লিখতে হবে।"
|
||||
securityKeyInfo: "আপনি একটি হার্ডওয়্যার সিকিউরিটি কী ব্যবহার করে লগ ইন করতে পারেন যা FIDO2 বা ডিভাইসের ফিঙ্গারপ্রিন্ট সেন্সর বা পিন সমর্থন করে৷"
|
||||
|
|
|
@ -399,7 +399,6 @@ _sfx:
|
|||
chat: "Xat"
|
||||
antenna: "Antenes"
|
||||
_2fa:
|
||||
step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:"
|
||||
renewTOTPCancel: "No, gràcies"
|
||||
_antennaSources:
|
||||
all: "Totes les publicacions"
|
||||
|
|
|
@ -1681,7 +1681,6 @@ _2fa:
|
|||
step1: "Nejprve si do zařízení nainstalujte aplikaci pro ověřování (například {a} nebo {b})."
|
||||
step2: "Poté naskenujte QR kód zobrazený na této obrazovce."
|
||||
step2Click: "Kliknutím na tento QR kód můžete zaregistrovat 2FA do bezpečnostního klíče nebo aplikace autentizace telefonu."
|
||||
step2Url: "Tuto adresu URL můžete zadat také v případě, že používáte program pro stolní počítače:"
|
||||
step3Title: "Zadejte ověřovací kód"
|
||||
step3: "Pro dokončení nastavení zadejte token poskytnutý vaší aplikací."
|
||||
step4: "Od této chvíle budou všechny budoucí pokusy o přihlášení vyžadovat tento přihlašovací token."
|
||||
|
|
|
@ -45,6 +45,7 @@ pin: "An dein Profil anheften"
|
|||
unpin: "Von deinem Profil lösen"
|
||||
copyContent: "Inhalt kopieren"
|
||||
copyLink: "Link kopieren"
|
||||
copyLinkRenote: "Renote-Link kopieren"
|
||||
delete: "Löschen"
|
||||
deleteAndEdit: "Löschen und Bearbeiten"
|
||||
deleteAndEditConfirm: "Möchtest du diese Notiz wirklich löschen und bearbeiten? Alle Reaktionen, Renotes und Antworten dieser Notiz werden verloren gehen."
|
||||
|
@ -411,6 +412,7 @@ aboutMisskey: "Über Misskey"
|
|||
administrator: "Administrator"
|
||||
token: "Token"
|
||||
2fa: "Zwei-Faktor-Authentifizierung"
|
||||
setupOf2fa: "Zweifaktorauthentifizierung einrichten"
|
||||
totp: "Authentifizierungs-App"
|
||||
totpDescription: "Logge dich via Authentifizierungs-App mit Einmalpasswort ein"
|
||||
moderator: "Moderator"
|
||||
|
@ -654,6 +656,7 @@ behavior: "Verhalten"
|
|||
sample: "Beispiel"
|
||||
abuseReports: "Meldungen"
|
||||
reportAbuse: "Melden"
|
||||
reportAbuseRenote: "Renote melden"
|
||||
reportAbuseOf: "{name} melden"
|
||||
fillAbuseReportDescription: "Bitte gib zusätzliche Informationen zu dieser Meldung an. Falls es sich um eine spezielle Notiz handelt, bitte gib dessen URL an."
|
||||
abuseReported: "Deine Meldung wurde versendet. Vielen Dank."
|
||||
|
@ -1696,9 +1699,10 @@ _2fa:
|
|||
step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem Gerät."
|
||||
step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät."
|
||||
step2Click: "Durch Klicken dieses QR-Codes kannst du Verifikation mit deinem Security-Token oder einer App registrieren."
|
||||
step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:"
|
||||
step2Uri: "Nutzt du ein Desktopprogramm, gib folgende URI eingeben"
|
||||
step3Title: "Authentifizierungsscode eingeben"
|
||||
step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird."
|
||||
setupCompleted: "Einrichtung abgeschlossen"
|
||||
step4: "Alle folgenden Anmeldeversuche werden ab sofort die Eingabe eines solchen Tokens benötigen."
|
||||
securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens."
|
||||
registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren."
|
||||
|
@ -1714,6 +1718,11 @@ _2fa:
|
|||
renewTOTPConfirm: "Codes der bisherigen App werden hierdurch nutzlos"
|
||||
renewTOTPOk: "Neu einrichten"
|
||||
renewTOTPCancel: "Abbrechen"
|
||||
checkBackupCodesBeforeCloseThisWizard: "Notiere bitte deine Backup-Codes, bevor du dieses Fenster schließt."
|
||||
backupCodes: "Backup-Codes"
|
||||
backupCodesDescription: "Verwende diese Codes, falls du nicht mehr auf deine App zur Zweifaktorauthentifizierung zugreifen kannst. Jeder Code kann nur einmal verwendet werden. Bewahre sie an einem sicheren Ort auf."
|
||||
backupCodeUsedWarning: "Ein Backup-Code wurde verwendet. Falls du den Zugriff zu deiner Zweifaktorauthentifizierungsapp verloren hast, konfiguriere diese bitte möglichst bald erneut."
|
||||
backupCodesExhaustedWarning: "Alle Backup-Codes wurden verwendet. Falls du den Zugang zu deiner Zweifaktorauthentifizierungsapp verlierst, wirst du dich nicht mehr in dieses Konto einloggen können. Bitte konfiguriere diese App erneut."
|
||||
_permissions:
|
||||
"read:account": "Deine Benutzerkontoinformationen lesen"
|
||||
"write:account": "Deine Benutzerkontoinformationen bearbeiten"
|
||||
|
@ -2021,6 +2030,8 @@ _deck:
|
|||
introduction2: "Klicke auf das + rechts um wann immer du möchtest neue Spalten hinzuzufügen."
|
||||
widgetsIntroduction: "Drücke bitte \"Widgets bearbeiten\" im Spaltenmenü und füge ein Widget hinzu."
|
||||
useSimpleUiForNonRootPages: "Simple Benutzeroberfläche für navigierte Seiten verwenden"
|
||||
usedAsMinWidthWhenFlexible: "Ist \"Automatische Breitenanpassung\" aktiviert, wird hierfür die minimale Breite verwendet"
|
||||
flexible: "Automatische Breitenanpassung"
|
||||
_columns:
|
||||
main: "Hauptspalte"
|
||||
widgets: "Widgets"
|
||||
|
|
|
@ -45,6 +45,7 @@ pin: "Pin to profile"
|
|||
unpin: "Unpin from profile"
|
||||
copyContent: "Copy contents"
|
||||
copyLink: "Copy link"
|
||||
copyLinkRenote: "Copy renote link"
|
||||
delete: "Delete"
|
||||
deleteAndEdit: "Delete and edit"
|
||||
deleteAndEditConfirm: "Are you sure you want to delete this note and edit it? You will lose all reactions, renotes and replies to it."
|
||||
|
@ -411,6 +412,7 @@ aboutMisskey: "About Misskey"
|
|||
administrator: "Administrator"
|
||||
token: "Token"
|
||||
2fa: "Two-factor authentication"
|
||||
setupOf2fa: "Setup two-factor authentification"
|
||||
totp: "Authenticator App"
|
||||
totpDescription: "Use an authenticator app to enter one-time passwords"
|
||||
moderator: "Moderator"
|
||||
|
@ -654,6 +656,7 @@ behavior: "Behavior"
|
|||
sample: "Sample"
|
||||
abuseReports: "Reports"
|
||||
reportAbuse: "Report"
|
||||
reportAbuseRenote: "Report renote"
|
||||
reportAbuseOf: "Report {name}"
|
||||
fillAbuseReportDescription: "Please fill in details regarding this report. If it is about a specific note, please include its URL."
|
||||
abuseReported: "Your report has been sent. Thank you very much."
|
||||
|
@ -1696,9 +1699,10 @@ _2fa:
|
|||
step1: "First, install an authentication app (such as {a} or {b}) on your device."
|
||||
step2: "Then, scan the QR code displayed on this screen."
|
||||
step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app."
|
||||
step2Url: "You can also enter this URL if you're using a desktop program:"
|
||||
step2Uri: "Enter the following URI if you are using a desktop program"
|
||||
step3Title: "Enter an authentication code"
|
||||
step3: "Enter the token provided by your app to finish setup."
|
||||
setupCompleted: "Setup complete"
|
||||
step4: "From now on, any future login attempts will ask for such a login token."
|
||||
securityKeyNotSupported: "Your browser does not support security keys."
|
||||
registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key."
|
||||
|
@ -1714,6 +1718,11 @@ _2fa:
|
|||
renewTOTPConfirm: "This will cause verification codes from your previous app to stop working"
|
||||
renewTOTPOk: "Reconfigure"
|
||||
renewTOTPCancel: "Cancel"
|
||||
checkBackupCodesBeforeCloseThisWizard: "Before you close this window, please note the following backup codes."
|
||||
backupCodes: "Backup codes"
|
||||
backupCodesDescription: "You can use these codes to gain access to your account in case of becoming unable to use your two-factor authentificator app. Each can only be used once. Please keep them in a safe place."
|
||||
backupCodeUsedWarning: "A backup code has been used. Please reconfigure two-factor authentification as soon as possible if you are no longer able to use it."
|
||||
backupCodesExhaustedWarning: "All backup codes have been used. Should you lose access to your two-factor authentification app, you will be unable to access this account. Please reconfigure two-factor authentification."
|
||||
_permissions:
|
||||
"read:account": "View your account information"
|
||||
"write:account": "Edit your account information"
|
||||
|
@ -2021,6 +2030,8 @@ _deck:
|
|||
introduction2: "Click on the + on the right of the screen to add new colums whenever you want."
|
||||
widgetsIntroduction: "Please select \"Edit widgets\" in the column menu and add a widget."
|
||||
useSimpleUiForNonRootPages: "Use simplified UI to navigated pages"
|
||||
usedAsMinWidthWhenFlexible: "Minimum width will be used for this when the \"Auto-adjust width\" option is enabled"
|
||||
flexible: "Auto-adjust width"
|
||||
_columns:
|
||||
main: "Main"
|
||||
widgets: "Widgets"
|
||||
|
|
|
@ -45,6 +45,7 @@ pin: "Fijar al perfil"
|
|||
unpin: "Desfijar"
|
||||
copyContent: "Copiar contenido"
|
||||
copyLink: "Copiar enlace"
|
||||
copyLinkRenote: "Copiar enlace de renota"
|
||||
delete: "Borrar"
|
||||
deleteAndEdit: "Borrar y editar"
|
||||
deleteAndEditConfirm: "¿Estás seguro de que quieres borrar esta nota y editarla? Perderás todas las reacciones, renotas y respuestas."
|
||||
|
@ -411,6 +412,7 @@ aboutMisskey: "Sobre Misskey"
|
|||
administrator: "Administrador"
|
||||
token: "Token"
|
||||
2fa: "Autenticación de doble factor"
|
||||
setupOf2fa: "Configurar la autenticación de dos factores"
|
||||
totp: "Aplicación autentícadora"
|
||||
totpDescription: "Ingresa una contaseña de un sólo uso usando la aplicación autenticadora"
|
||||
moderator: "Moderador"
|
||||
|
@ -654,6 +656,7 @@ behavior: "Comportamiento"
|
|||
sample: "Muestra"
|
||||
abuseReports: "Reportes"
|
||||
reportAbuse: "Reportar"
|
||||
reportAbuseRenote: "Reportar renota"
|
||||
reportAbuseOf: "Reportar a {name}"
|
||||
fillAbuseReportDescription: "Ingrese los detalles del reporte. Si hay una nota en particular, ingrese la URL de esta."
|
||||
abuseReported: "Se ha enviado el reporte. Muchas gracias."
|
||||
|
@ -1696,9 +1699,10 @@ _2fa:
|
|||
step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o {b} u otra."
|
||||
step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla."
|
||||
step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app.\nTocar este código QR te permitirá registrar la autenticación 2FA a tu llave de seguridad o aplicación autenticadora."
|
||||
step2Url: "En una aplicación de escritorio se puede ingresar la siguiente URL:"
|
||||
step2Uri: "Si usas una aplicación de escritorio, introduce en ella la siguiente URL."
|
||||
step3Title: "Ingresa un código de autenticación"
|
||||
step3: "Para terminar, ingrese el token mostrado en la aplicación."
|
||||
setupCompleted: "Configuración completada"
|
||||
step4: "Ahora cuando inicie sesión, ingrese el mismo token"
|
||||
securityKeyNotSupported: "Tu navegador no soporta claves de autenticación."
|
||||
registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key.\npor favor. configura una aplicación de autenticación para registrar una llave de seguridad."
|
||||
|
@ -1714,6 +1718,11 @@ _2fa:
|
|||
renewTOTPConfirm: "This will cause verification codes from your previous app to stop working\nEsto hará que los códigos de verificación de la aplicación anterior dejen de funcionar"
|
||||
renewTOTPOk: "Reconfigurar"
|
||||
renewTOTPCancel: "No gracias"
|
||||
checkBackupCodesBeforeCloseThisWizard: "Por favor, copia los siguientes códigos de respaldo antes de finalizar el asistente."
|
||||
backupCodes: "Códigos de Respaldo"
|
||||
backupCodesDescription: "En caso de que no puedas usar tu aplicación de autenticación, podrás usar los códigos de respaldo que figuran abajo para acceder a tu cuenta. Asegúrate de guardar en lugar seguro los códigos de respaldo. Cada uno de los códigos de respaldo es de un solo uso."
|
||||
backupCodeUsedWarning: "Has usado todos los códigos de respaldo. Si dejas de tener acceso a tu aplicación de autenticación, no podrás volver a iniciar sesión en tu cuenta. Por favor, reconfigura tu aplicación de autenticación lo antes posible."
|
||||
backupCodesExhaustedWarning: "Has usado todos los códigos de respaldo. Si dejas de tener acceso a tu aplicación de autenticación, no podrás volver a iniciar sesión en la cuenta que figura arriba. Por favor, reconfigura tu aplicación de autenticación lo antes posible."
|
||||
_permissions:
|
||||
"read:account": "Ver información de la cuenta"
|
||||
"write:account": "Editar información de la cuenta"
|
||||
|
@ -1747,6 +1756,10 @@ _permissions:
|
|||
"write:gallery": "Editar galería"
|
||||
"read:gallery-likes": "Ver favoritos de la galería"
|
||||
"write:gallery-likes": "Editar favoritos de la galería"
|
||||
"read:flash": "Ver Play"
|
||||
"write:flash": "Editar Plays"
|
||||
"read:flash-likes": "Ver los Play que me gustan"
|
||||
"write:flash-likes": "Editar lista de Play que me gustan"
|
||||
_auth:
|
||||
shareAccessTitle: "Permisos de la aplicación"
|
||||
shareAccess: "¿Desea permitir el acceso a la cuenta \"{name}\"?"
|
||||
|
@ -2017,6 +2030,8 @@ _deck:
|
|||
introduction2: "Presiona en la + de la derecha de la pantalla para añadir nuevas columnas donde quieras."
|
||||
widgetsIntroduction: "Por favor selecciona \"Editar Widgets\" en el menú columna y agrega un widget."
|
||||
useSimpleUiForNonRootPages: "Mostrar páginas no pertenecientes a la raíz con la interfaz simple"
|
||||
usedAsMinWidthWhenFlexible: "Se usará el ancho mínimo cuando la opción \"Autoajustar ancho\" esté habilitada"
|
||||
flexible: "Autoajustar ancho"
|
||||
_columns:
|
||||
main: "Principal"
|
||||
widgets: "Widgets"
|
||||
|
|
|
@ -251,7 +251,7 @@ announcements: "Annonces"
|
|||
imageUrl: "URL de l’image"
|
||||
remove: "Supprimer"
|
||||
removed: "Supprimé"
|
||||
removeAreYouSure: "Êtes-vous sûr·e de vouloir supprimer「{x}」?"
|
||||
removeAreYouSure: "Êtes-vous sûr·e de vouloir supprimer « {x} » ?"
|
||||
deleteAreYouSure: "Êtes-vous sûr·e de vouloir supprimer「{x}」?"
|
||||
resetAreYouSure: "Voulez-vous réinitialiser ?"
|
||||
saved: "Enregistré"
|
||||
|
@ -600,7 +600,7 @@ tokenRequested: "Autoriser l'accès au compte"
|
|||
pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies ici."
|
||||
notificationType: "Type de notifications"
|
||||
edit: "Editer"
|
||||
emailServer: "Serveur mail"
|
||||
emailServer: "Serveur de messagerie"
|
||||
enableEmail: "Activer la distribution de courriel"
|
||||
emailConfigInfo: "Utilisé pour confirmer votre adresse de courriel et la réinitialisation de votre mot de passe en cas d’oubli."
|
||||
email: "E-mail "
|
||||
|
@ -835,7 +835,7 @@ off: "Désactivé"
|
|||
emailRequiredForSignup: "Une adresse e-mail est nécessaire pour créer un compte"
|
||||
unread: "Non lu"
|
||||
filter: "Filtre"
|
||||
controlPanel: "Panneau de contrôle"
|
||||
controlPanel: "Panneau de configuration"
|
||||
manageAccounts: "Gérer les comptes"
|
||||
makeReactionsPublic: "Rendre les réactions publiques"
|
||||
makeReactionsPublicDescription: "Ceci rendra la liste de toutes vos réactions données publique."
|
||||
|
@ -945,9 +945,12 @@ color: "Couleur"
|
|||
manageCustomEmojis: "Gestion des émojis personnalisés"
|
||||
preset: "Préréglage"
|
||||
selectFromPresets: "Sélectionner à partir des préréglages"
|
||||
achievements: "Accomplissements"
|
||||
thisPostMayBeAnnoying: "Cette note peut gêner d'autres personnes."
|
||||
thisPostMayBeAnnoyingCancel: "Annuler"
|
||||
thisPostMayBeAnnoyingIgnore: "Publier quand-même"
|
||||
internalServerError: "Erreur interne du serveur"
|
||||
disableFederationOk: "Désactiver"
|
||||
license: "Licence"
|
||||
video: "Vidéo"
|
||||
videos: "Vidéos"
|
||||
|
@ -964,14 +967,33 @@ vertical: "Vertical"
|
|||
horizontal: "Latéral"
|
||||
serverRules: "Règles du serveur"
|
||||
youFollowing: "Abonné·e"
|
||||
later: "Plus tard"
|
||||
goToMisskey: "Retour vers Misskey"
|
||||
expirationDate: "Date d’expiration"
|
||||
usedAt: "Utilisé le"
|
||||
unused: "Non-utilisé"
|
||||
used: "Utilisé"
|
||||
expired: "Expiré"
|
||||
doYouAgree: "Êtes-vous d’accord ?"
|
||||
icon: "Avatar"
|
||||
forYou: "Pour vous"
|
||||
_announcement:
|
||||
readConfirmTitle: "Marquer comme lu ?"
|
||||
_initialAccountSetting:
|
||||
profileSetting: "Paramètres du profil"
|
||||
privacySetting: "Paramètres de confidentialité"
|
||||
_accountMigration:
|
||||
moveToLabel: "Compte vers lequel vous migrez :"
|
||||
startMigration: "Migrer"
|
||||
movedTo: "Compte vers lequel vous migrez :"
|
||||
_achievements:
|
||||
_types:
|
||||
_notes1:
|
||||
title: "Je viens tout juste de configurer mon msky"
|
||||
description: "Publiez votre première note"
|
||||
flavor: "Passez un bon moment avec Misskey !"
|
||||
_notes10:
|
||||
title: "Quelques notes"
|
||||
_notes100:
|
||||
title: "Beaucoup de notes"
|
||||
_notes100000:
|
||||
|
@ -986,16 +1008,23 @@ _achievements:
|
|||
title: "Débutant Ⅲ"
|
||||
description: "Se connecter pour un total de 15 jours"
|
||||
_login30:
|
||||
title: "Misskeynaute I"
|
||||
description: "Se connecter pour un total de 30 jours"
|
||||
_login60:
|
||||
title: "Misskeynaute II"
|
||||
description: "Se connecter pour un total de 60 jours"
|
||||
_login100:
|
||||
title: "Misskeynaute III"
|
||||
description: "Se connecter pour un total de 100 jours"
|
||||
flavor: "Misskeynaute acharné·e"
|
||||
_login200:
|
||||
title: "Régulier I"
|
||||
description: "Se connecter pour un total de 200 jours"
|
||||
_login300:
|
||||
title: "Régulier II"
|
||||
description: "Se connecter pour un total de 300 jours"
|
||||
_login400:
|
||||
title: "Régulier III"
|
||||
description: "Se connecter pour un total de 400 jours"
|
||||
_login500:
|
||||
description: "Se connecter pour un total de 500 jours"
|
||||
|
@ -1009,6 +1038,8 @@ _achievements:
|
|||
description: "Se connecter pour un total de 900 jours"
|
||||
_login1000:
|
||||
flavor: "Merci d'utiliser Misskey !"
|
||||
_profileFilled:
|
||||
description: "Configuration de votre profil"
|
||||
_markedAsCat:
|
||||
title: "Je suis un chat"
|
||||
flavor: "Je n'ai pas encore de nom"
|
||||
|
@ -1018,6 +1049,16 @@ _achievements:
|
|||
title: "Abonnez-moi !"
|
||||
_iLoveMisskey:
|
||||
title: "J’adore Misskey"
|
||||
description: "Publication « J’❤ #Misskey »"
|
||||
_foundTreasure:
|
||||
title: "Chasse au trésor"
|
||||
description: "Vous avez trouvé le trésor caché"
|
||||
_postedAtLateNight:
|
||||
flavor: "C’est l’heure d’aller au lit."
|
||||
_postedAt0min0sec:
|
||||
title: "Horloge parlante"
|
||||
description: "Publication d’une note à 00:00"
|
||||
flavor: "Tic tac, tic tac, tic tac, ding !"
|
||||
_viewInstanceChart:
|
||||
title: "Analyste"
|
||||
_loggedInOnBirthday:
|
||||
|
@ -1027,7 +1068,11 @@ _achievements:
|
|||
_cookieClicked:
|
||||
flavor: "Attendez une minute, vous êtes sur le mauvais site web ?"
|
||||
_role:
|
||||
name: "Nom du rôle"
|
||||
description: "Description du rôle"
|
||||
permission: "Rôle et autorisations"
|
||||
assignTarget: "Attribuer"
|
||||
condition: "Condition"
|
||||
priority: "Priorité"
|
||||
_priority:
|
||||
low: "Basse"
|
||||
|
@ -1119,6 +1164,8 @@ _aboutMisskey:
|
|||
donate: "Soutenir Misskey"
|
||||
morePatrons: "Nous apprécions vraiment le soutien de nombreuses autres personnes non mentionnées ici. Merci à toutes et à tous ! 🥰"
|
||||
patrons: "Contributeurs"
|
||||
_displayOfSensitiveMedia:
|
||||
force: "Masquer tous les médias"
|
||||
_instanceTicker:
|
||||
none: "Cacher "
|
||||
remote: "Montrer pour les utilisateur·ice·s distant·e·s"
|
||||
|
@ -1137,6 +1184,8 @@ _channel:
|
|||
following: "Abonné·e"
|
||||
usersCount: "{n} Participant·e·s"
|
||||
notesCount: "{n} Notes"
|
||||
nameAndDescription: "Nom et description"
|
||||
nameOnly: "Nom seulement"
|
||||
_menuDisplay:
|
||||
sideFull: "Latéral"
|
||||
sideIcon: "Latéral (icônes)"
|
||||
|
@ -1254,16 +1303,24 @@ _time:
|
|||
minute: "min"
|
||||
hour: "h"
|
||||
day: "j"
|
||||
_timelineTutorial:
|
||||
title: "Comment utiliser Misskey"
|
||||
step3_1: "Avez-vous publié votre première note ?"
|
||||
_2fa:
|
||||
alreadyRegistered: "Configuration déjà achevée."
|
||||
step1: "Tout d'abord, installez une application d'authentification, telle que {a} ou {b}, sur votre appareil."
|
||||
step2: "Ensuite, scannez le code QR affiché sur l’écran."
|
||||
step2Url: "Vous pouvez également saisir cette URL si vous utilisez un programme de bureau :"
|
||||
step3Title: "Veuillez saisir le code d’authentification"
|
||||
step3: "Entrez le jeton affiché sur votre application pour compléter la configuration."
|
||||
setupCompleted: "Configuration terminée avec succès !"
|
||||
step4: "À partir de maintenant, ce même jeton vous sera demandé à chacune de vos connexions."
|
||||
securityKeyNotSupported: "Votre navigateur ne prend pas en charge les clés de sécurité."
|
||||
securityKeyInfo: "Vous pouvez configurer l'authentification WebAuthN pour sécuriser davantage le processus de connexion grâce à une clé de sécurité matérielle qui prend en charge FIDO2, ou bien en configurant l'authentification par empreinte digitale ou par code PIN sur votre appareil."
|
||||
securityKeyName: "Nom de la clé"
|
||||
removeKeyConfirm: "Voulez-vous supprimer {name} ?"
|
||||
renewTOTPOk: "Reconfigurer"
|
||||
renewTOTPCancel: "Pas maintenant"
|
||||
backupCodes: "Codes de Secours"
|
||||
_permissions:
|
||||
"read:account": "Afficher les informations du compte"
|
||||
"write:account": "Mettre à jour les informations de votre compte"
|
||||
|
@ -1480,7 +1537,7 @@ _pages:
|
|||
fontSerif: "Serif"
|
||||
fontSansSerif: "Sans Serif"
|
||||
eyeCatchingImageSet: "Définir une image attractive"
|
||||
eyeCatchingImageRemove: "Supprimer l'image attractive"
|
||||
eyeCatchingImageRemove: "Supprimer la miniature"
|
||||
chooseBlock: "Ajouter un bloc"
|
||||
selectType: "Choisir un type"
|
||||
contentBlocks: "Contenu"
|
||||
|
@ -1513,6 +1570,7 @@ _notification:
|
|||
pollEnded: "Les résultats du sondage sont disponibles"
|
||||
unreadAntennaNote: "Antenne {name}"
|
||||
emptyPushNotificationMessage: "Les notifications push ont été mises à jour"
|
||||
achievementEarned: "Accomplissement"
|
||||
_types:
|
||||
all: "Toutes"
|
||||
follow: "Nouvel·le abonné·e"
|
||||
|
@ -1524,6 +1582,7 @@ _notification:
|
|||
pollEnded: "Sondages se cloturant"
|
||||
receiveFollowRequest: "Demande d'abonnement reçue"
|
||||
followRequestAccepted: "Demande d'abonnement acceptée"
|
||||
achievementEarned: "Accomplissement"
|
||||
app: "Notifications provenant des apps"
|
||||
_actions:
|
||||
followBack: "Suivre"
|
||||
|
@ -1545,6 +1604,7 @@ _deck:
|
|||
deleteProfile: "Supprimer le profil"
|
||||
introduction: "Créez l’interface parfaite qui vous sied en arrangeant librement les colonnes !"
|
||||
introduction2: "Cliquez sur le + à droite de l'écran pour ajouter de nouvelles colonnes quand vous le souhaitez."
|
||||
flexible: "Ajuster automatiquement la largeur"
|
||||
_columns:
|
||||
main: "Principale"
|
||||
widgets: "Widgets"
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import * as ts from 'typescript';
|
||||
import ts from 'typescript';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createMembers(record) {
|
||||
return Object.entries(record)
|
||||
|
|
|
@ -1095,7 +1095,11 @@ expired: "Kedaluwarsa"
|
|||
doYouAgree: "Apa kamu setuju?"
|
||||
beSureToReadThisAsItIsImportant: "Mohon baca informasi penting berikut."
|
||||
iHaveReadXCarefullyAndAgree: "Saya telah membaca \"{x}\" dan menyetujui."
|
||||
dialog: "Dialog"
|
||||
icon: "Avatar"
|
||||
forYou: "Untuk Anda"
|
||||
currentAnnouncements: "Pengumuman Saat Ini"
|
||||
pastAnnouncements: "Pengumuman Terdahulu"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "Akun kamu telah sukses dibuat!"
|
||||
letsStartAccountSetup: "Untuk pemula, ayo atur profilmu dulu."
|
||||
|
@ -1681,7 +1685,6 @@ _2fa:
|
|||
step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat kamu."
|
||||
step2: "Lalu, pindai kode QR yang ada di layar."
|
||||
step2Click: "Mengeklik kode QR ini akan membolehkanmu untuk mendaftarkan 2FA ke security-key atau aplikasi autentikator ponsel."
|
||||
step2Url: "Di aplikasi desktop, masukkan URL berikut:"
|
||||
step3Title: "Masukkan kode autentikasi"
|
||||
step3: "Masukkan token yang telah disediakan oleh aplikasimu untuk menyelesaikan pemasangan."
|
||||
step4: "Mulai sekarang, upaya login apapun akan meminta token login dari aplikasi otentikasi kamu."
|
||||
|
@ -1699,6 +1702,7 @@ _2fa:
|
|||
renewTOTPConfirm: "Hal ini akan menyebabkan kode verifikasi dari aplikasi autentikator sebelumnya berhenti bekerja"
|
||||
renewTOTPOk: "Atur ulang"
|
||||
renewTOTPCancel: "Tidak sekarang."
|
||||
backupCodes: "Kode Pencadangan"
|
||||
_permissions:
|
||||
"read:account": "Lihat informasi akun"
|
||||
"write:account": "Sunting informasi akun"
|
||||
|
|
21
locales/index.d.ts
vendored
21
locales/index.d.ts
vendored
|
@ -48,6 +48,7 @@ export interface Locale {
|
|||
"unpin": string;
|
||||
"copyContent": string;
|
||||
"copyLink": string;
|
||||
"copyLinkRenote": string;
|
||||
"delete": string;
|
||||
"deleteAndEdit": string;
|
||||
"deleteAndEditConfirm": string;
|
||||
|
@ -414,6 +415,7 @@ export interface Locale {
|
|||
"administrator": string;
|
||||
"token": string;
|
||||
"2fa": string;
|
||||
"setupOf2fa": string;
|
||||
"totp": string;
|
||||
"totpDescription": string;
|
||||
"moderator": string;
|
||||
|
@ -657,6 +659,7 @@ export interface Locale {
|
|||
"sample": string;
|
||||
"abuseReports": string;
|
||||
"reportAbuse": string;
|
||||
"reportAbuseRenote": string;
|
||||
"reportAbuseOf": string;
|
||||
"fillAbuseReportDescription": string;
|
||||
"abuseReported": string;
|
||||
|
@ -1023,7 +1026,7 @@ export interface Locale {
|
|||
"enableChartsForRemoteUser": string;
|
||||
"enableChartsForFederatedInstances": string;
|
||||
"showClipButtonInNoteFooter": string;
|
||||
"largeNoteReactions": string;
|
||||
"reactionsDisplaySize": string;
|
||||
"noteIdOrUrl": string;
|
||||
"video": string;
|
||||
"videos": string;
|
||||
|
@ -1106,6 +1109,7 @@ export interface Locale {
|
|||
"currentAnnouncements": string;
|
||||
"pastAnnouncements": string;
|
||||
"youHaveUnreadAnnouncements": string;
|
||||
"useSecurityKey": string;
|
||||
"_announcement": {
|
||||
"forExistingUsers": string;
|
||||
"forExistingUsersDescription": string;
|
||||
|
@ -1816,14 +1820,14 @@ export interface Locale {
|
|||
"step1": string;
|
||||
"step2": string;
|
||||
"step2Click": string;
|
||||
"step2Url": string;
|
||||
"step2Uri": string;
|
||||
"step3Title": string;
|
||||
"step3": string;
|
||||
"setupCompleted": string;
|
||||
"step4": string;
|
||||
"securityKeyNotSupported": string;
|
||||
"registerTOTPBeforeKey": string;
|
||||
"securityKeyInfo": string;
|
||||
"chromePasskeyNotSupported": string;
|
||||
"registerSecurityKey": string;
|
||||
"securityKeyName": string;
|
||||
"tapSecurityKey": string;
|
||||
|
@ -1834,6 +1838,11 @@ export interface Locale {
|
|||
"renewTOTPConfirm": string;
|
||||
"renewTOTPOk": string;
|
||||
"renewTOTPCancel": string;
|
||||
"checkBackupCodesBeforeCloseThisWizard": string;
|
||||
"backupCodes": string;
|
||||
"backupCodesDescription": string;
|
||||
"backupCodeUsedWarning": string;
|
||||
"backupCodesExhaustedWarning": string;
|
||||
};
|
||||
"_permissions": {
|
||||
"read:account": string;
|
||||
|
@ -2128,6 +2137,10 @@ export interface Locale {
|
|||
"unreadAntennaNote": string;
|
||||
"emptyPushNotificationMessage": string;
|
||||
"achievementEarned": string;
|
||||
"testNotification": string;
|
||||
"checkNotificationBehavior": string;
|
||||
"sendTestNotification": string;
|
||||
"notificationWillBeDisplayedLikeThis": string;
|
||||
"_types": {
|
||||
"all": string;
|
||||
"follow": string;
|
||||
|
@ -2166,6 +2179,8 @@ export interface Locale {
|
|||
"introduction2": string;
|
||||
"widgetsIntroduction": string;
|
||||
"useSimpleUiForNonRootPages": string;
|
||||
"usedAsMinWidthWhenFlexible": string;
|
||||
"flexible": string;
|
||||
"_columns": {
|
||||
"main": string;
|
||||
"widgets": string;
|
||||
|
|
|
@ -45,6 +45,7 @@ pin: "Fissa sul profilo"
|
|||
unpin: "Non fissare sul profilo"
|
||||
copyContent: "Copia il contenuto"
|
||||
copyLink: "Copia il link"
|
||||
copyLinkRenote: "Copia collegamento alla Rinota"
|
||||
delete: "Elimina"
|
||||
deleteAndEdit: "Elimina e modifica"
|
||||
deleteAndEditConfirm: "Vuoi davvero cancellare questa nota e scriverla di nuovo? Verranno eliminate anche tutte le reazioni, rinote e risposte collegate."
|
||||
|
@ -63,7 +64,7 @@ reply: "Rispondi"
|
|||
loadMore: "Mostra di più"
|
||||
showMore: "Espandi"
|
||||
showLess: "Comprimi"
|
||||
youGotNewFollower: "Ha iniziato a seguirti"
|
||||
youGotNewFollower: "Ti sta seguendo"
|
||||
receiveFollowRequest: "Hai ricevuto una richiesta di follow"
|
||||
followRequestAccepted: "Ha accettato la tua richiesta di follow"
|
||||
mention: "Menzioni"
|
||||
|
@ -74,8 +75,8 @@ import: "Importa"
|
|||
export: "Esporta"
|
||||
files: "Allegati"
|
||||
download: "Scarica"
|
||||
driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\"? Anche gli allegati verranno eliminati."
|
||||
unfollowConfirm: "Vuoi smettere di seguire {name}?"
|
||||
driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\", e le Note a cui è stato allegato?"
|
||||
unfollowConfirm: "Vuoi davvero smettere di seguire {name}?"
|
||||
exportRequested: "Hai richiesto un'esportazione, e potrebbe volerci tempo. Quando sarà compiuta, il file verrà aggiunto direttamente al Drive."
|
||||
importRequested: "Hai richiesto un'importazione. Può volerci tempo. "
|
||||
lists: "Liste"
|
||||
|
@ -84,7 +85,7 @@ note: "Nota"
|
|||
notes: "Note"
|
||||
following: "Follow"
|
||||
followers: "Follower"
|
||||
followsYou: "Ti segue"
|
||||
followsYou: "Segue"
|
||||
createList: "Aggiungi una nuova lista"
|
||||
manageLists: "Gestisci liste"
|
||||
error: "Errore"
|
||||
|
@ -137,7 +138,7 @@ suspend: "Sospendi"
|
|||
unsuspend: "Revoca la sospensione"
|
||||
blockConfirm: "Vuoi davvero bloccare il profilo?"
|
||||
unblockConfirm: "Vuoi davvero sbloccare il profilo?"
|
||||
suspendConfirm: "Vuoi sospendere questo profilo?"
|
||||
suspendConfirm: "Vuoi davvero sospendere questo profilo?"
|
||||
unsuspendConfirm: "Vuoi revocare la sospensione si questo profilo?"
|
||||
selectList: "Seleziona una lista"
|
||||
editList: "Modifica Lista"
|
||||
|
@ -165,7 +166,7 @@ flagAsCat: "Sono un gatto"
|
|||
flagAsCatDescription: "La modalità \"sono un gatto\" aggiunge le orecchie al tuo profilo"
|
||||
flagShowTimelineReplies: "Mostra le risposte alle note sulla timeline."
|
||||
flagShowTimelineRepliesDescription: "Attivando, la timeline mostra le Note del profilo ed anche le risposte ad altre Note"
|
||||
autoAcceptFollowed: "Accetta automaticamente le richieste di follow da utenti che già segui"
|
||||
autoAcceptFollowed: "Accetta automaticamente le richieste di follow da profili che già segui"
|
||||
addAccount: "Aggiungi profilo"
|
||||
reloadAccountsList: "Ricarica l'elenco dei profili"
|
||||
loginFailed: "Accesso non riuscito"
|
||||
|
@ -255,7 +256,7 @@ imageUrl: "URL dell'immagine"
|
|||
remove: "Elimina"
|
||||
removed: "Eliminato con successo"
|
||||
removeAreYouSure: "Vuoi davvero eliminare \"{x}\"?"
|
||||
deleteAreYouSure: "Eliminare \"{x}\"?"
|
||||
deleteAreYouSure: "Vuoi davvero eliminare \"{x}\"?"
|
||||
resetAreYouSure: "Ripristinare?"
|
||||
saved: "Salvato"
|
||||
messaging: "Messaggi"
|
||||
|
@ -395,9 +396,9 @@ connectedTo: "Connessione ai seguenti profili:"
|
|||
notesAndReplies: "Note e risposte"
|
||||
withFiles: "Con file in allegato"
|
||||
silence: "Silenzia"
|
||||
silenceConfirm: "Vuoi davvero silenziare l'utente?"
|
||||
silenceConfirm: "Vuoi davvero silenziare questo profilo?"
|
||||
unsilence: "Riattiva"
|
||||
unsilenceConfirm: "Vuoi davvero riattivare l'utente?"
|
||||
unsilenceConfirm: "Vuoi davvero riattivare questo profilo?"
|
||||
popularUsers: "Utenti popolari"
|
||||
recentlyUpdatedUsers: "Utenti attivi di recente"
|
||||
recentlyRegisteredUsers: "Utenti registrati di recente"
|
||||
|
@ -411,6 +412,7 @@ aboutMisskey: "Informazioni di Misskey"
|
|||
administrator: "Amministratore"
|
||||
token: "Token"
|
||||
2fa: "Autenticazione a due fattori"
|
||||
setupOf2fa: "Impostare l'autenticazione a due fattori"
|
||||
totp: "App di autenticazione"
|
||||
totpDescription: "Inserisci un codice OTP tramite un'app di autenticazione"
|
||||
moderator: "Moderatore"
|
||||
|
@ -558,7 +560,7 @@ disablePagesScript: "Disabilita AiScript nelle pagine"
|
|||
updateRemoteUser: "Aggiornare le informazioni di utente remot@"
|
||||
deleteAllFiles: "Elimina tutti i file"
|
||||
deleteAllFilesConfirm: "Vuoi davvero eliminare tutti i file?"
|
||||
removeAllFollowing: "Cancella tutti i follows"
|
||||
removeAllFollowing: "Annulla tutti i follow"
|
||||
removeAllFollowingDescription: "Cancella tutti i follows del server {host}. Per favore, esegui se, ad esempio, l'istanza non esiste più."
|
||||
userSuspended: "L'utente è in sospensione"
|
||||
userSilenced: "L'utente è silenziat@."
|
||||
|
@ -653,7 +655,8 @@ fileIdOrUrl: "ID o URL del file"
|
|||
behavior: "Comportamento"
|
||||
sample: "Esempio"
|
||||
abuseReports: "Segnalazioni"
|
||||
reportAbuse: "Segnalazioni"
|
||||
reportAbuse: "Segnala"
|
||||
reportAbuseRenote: "Segnala la Rinota"
|
||||
reportAbuseOf: "Segnala {name}"
|
||||
fillAbuseReportDescription: "Per favore, spiegaci il motivo della segnalazione. Se riguarda una Nota precisa, indica anche l'indirizzo URL."
|
||||
abuseReported: "La segnalazione è stata inviata. Grazie."
|
||||
|
@ -681,7 +684,7 @@ createNewClip: "Crea una Clip"
|
|||
unclip: "Togli Nota dalla Clip"
|
||||
confirmToUnclipAlreadyClippedNote: "Questa nota è già inclusa in \"{name}\". Si desidera escludere la nota?"
|
||||
public: "Pubblica"
|
||||
private: "Invisibile"
|
||||
private: "Privato"
|
||||
i18nInfo: "Misskey è tradotto in diverse lingue da volontari. Anche tu puoi contribuire su {link}."
|
||||
manageAccessTokens: "Gestisci token di accesso"
|
||||
accountInfo: "Informazioni profilo"
|
||||
|
@ -762,7 +765,7 @@ editCode: "Modifica codice"
|
|||
apply: "Applica"
|
||||
receiveAnnouncementFromInstance: "Ricevi i messaggi informativi dall'istanza"
|
||||
emailNotification: "Eventi per notifiche via mail"
|
||||
publish: "Pubblico"
|
||||
publish: "Pubblicare"
|
||||
inChannelSearch: "Cerca in canale"
|
||||
useReactionPickerForContextMenu: "Cliccare sul tasto destro per aprire il pannello di reazioni"
|
||||
typingUsers: "{users} sta(nno) scrivendo"
|
||||
|
@ -842,8 +845,8 @@ pubSub: "Publish/Subscribe del profilo"
|
|||
lastCommunication: "La comunicazione più recente"
|
||||
resolved: "Risolto"
|
||||
unresolved: "Non risolto"
|
||||
breakFollow: "Non seguire"
|
||||
breakFollowConfirm: "Vuoi davvero togliere follower?"
|
||||
breakFollow: "Non farti più seguire"
|
||||
breakFollowConfirm: "Vuoi davvero smettere di seguire questo profilo?"
|
||||
itsOn: "Abilitato"
|
||||
itsOff: "Disabilitato"
|
||||
on: "Acceso"
|
||||
|
@ -1097,7 +1100,7 @@ doYouAgree: "Accetti le condizioni?"
|
|||
beSureToReadThisAsItIsImportant: "Si prega di leggere attentamente perché è importante."
|
||||
iHaveReadXCarefullyAndAgree: "Dichiaro di aver letto attentamente \"{x}\" e accettarne le condizioni."
|
||||
dialog: "Dialogo"
|
||||
icon: "Foto del profilo"
|
||||
icon: "Ritratto"
|
||||
forYou: "Per te"
|
||||
currentAnnouncements: "Annunci attuali"
|
||||
pastAnnouncements: "Annunci precedenti"
|
||||
|
@ -1468,7 +1471,7 @@ _emailUnavailable:
|
|||
mx: "Server email non corretto"
|
||||
smtp: "Il server email non risponde"
|
||||
_ffVisibility:
|
||||
public: "Pubblico"
|
||||
public: "Pubblica"
|
||||
followers: "Mostra solo ai follower"
|
||||
private: "Invisibile"
|
||||
_signup:
|
||||
|
@ -1696,9 +1699,10 @@ _2fa:
|
|||
step1: "Innanzitutto, installare sul dispositivo un'applicazione di autenticazione come {a} o {b}."
|
||||
step2: "Quindi, scansionare il codice QR visualizzato con l'app."
|
||||
step2Click: "Cliccando sul codice QR, puoi registrarlo con l'app di autenticazione o il portachiavi installato sul tuo dispositivo."
|
||||
step2Url: "Nell'applicazione desktop inserire il seguente URL: "
|
||||
step2Uri: "Inserisci il seguente URL se desideri utilizzare una App per PC"
|
||||
step3Title: "Inserisci il codice di verifica"
|
||||
step3: "Inserite il token visualizzato nell'app e il gioco è fatto."
|
||||
setupCompleted: "Impostazione completata"
|
||||
step4: "D'ora in poi, quando si accede, si inserisce il token nello stesso modo."
|
||||
securityKeyNotSupported: "Il tuo browser non supporta le chiavi di sicurezza."
|
||||
registerTOTPBeforeKey: "Ti occorre un'app di autenticazione con OTP, prima di registrare la chiave di sicurezza."
|
||||
|
@ -1714,6 +1718,11 @@ _2fa:
|
|||
renewTOTPConfirm: "I codici di verifica nelle app di autenticazione esistenti smetteranno di funzionare"
|
||||
renewTOTPOk: "Ripristina"
|
||||
renewTOTPCancel: "No grazie"
|
||||
checkBackupCodesBeforeCloseThisWizard: "Prima di chiudere questa procedura guidata, salva i tuoi codici usa-e-getta in un posto sicuro."
|
||||
backupCodes: "Codici usa-e-getta"
|
||||
backupCodesDescription: "Puoi usare questi codici usa-e-getta per ottenere l'accesso al tuo profilo in caso sia impossibile usare l'App col codice OTP. Salvali in un posto sicuro."
|
||||
backupCodeUsedWarning: "È stato usato un codice usa-e-getta. Per favore, riconfigura l'autenticazione a due fattori il prima possibile, nel caso la configurazione precedente abbia smesso di funzionare."
|
||||
backupCodesExhaustedWarning: "Hai esaurito i codici usa-e-getta. Se l'App che genera il codice OTP non è più disponibile, non potrai più accedere al tuo profilo. Ripeti la configurazione per l'autenticazione a due fattori."
|
||||
_permissions:
|
||||
"read:account": "Visualizza le informazioni sul profilo"
|
||||
"write:account": "Modifica le informazioni sul profilo"
|
||||
|
@ -1747,6 +1756,10 @@ _permissions:
|
|||
"write:gallery": "Gestione della galleria"
|
||||
"read:gallery-likes": "Visualizza i contenuti della galleria."
|
||||
"write:gallery-likes": "Manipolazione dei \"Mi piace\" della galleria."
|
||||
"read:flash": "Visualizza Play"
|
||||
"write:flash": "Modifica Play"
|
||||
"read:flash-likes": "Visualizza lista di Play piaciuti"
|
||||
"write:flash-likes": "Modifica lista di Play piaciuti"
|
||||
_auth:
|
||||
shareAccessTitle: "Permessi dell'applicazione"
|
||||
shareAccess: "Vuoi autorizzare {name} ad accedere al tuo profilo?"
|
||||
|
@ -2017,6 +2030,8 @@ _deck:
|
|||
introduction2: "È possibile aggiungere colonne in qualsiasi momento premendo + sulla destra dello schermo."
|
||||
widgetsIntroduction: "Dal menu della colonna, selezionare \"Modifica i riquadri\" per aggiungere un un riquadro con funzionalità"
|
||||
useSimpleUiForNonRootPages: "Visualizza sotto pagine con interfaccia web semplice"
|
||||
usedAsMinWidthWhenFlexible: "Se \"larghezza flessibile\" è abilitato, questa diventa la larghezza minima"
|
||||
flexible: "Larghezza flessibile"
|
||||
_columns:
|
||||
main: "Principale"
|
||||
widgets: "Riquadri"
|
||||
|
|
|
@ -45,6 +45,7 @@ pin: "ピン留め"
|
|||
unpin: "ピン留め解除"
|
||||
copyContent: "内容をコピー"
|
||||
copyLink: "リンクをコピー"
|
||||
copyLinkRenote: "Renoteのリンクをコピー"
|
||||
delete: "削除"
|
||||
deleteAndEdit: "削除して編集"
|
||||
deleteAndEditConfirm: "このノートを削除してもう一度編集しますか?このノートへのリアクション、Renote、返信も全て削除されます。"
|
||||
|
@ -411,6 +412,7 @@ aboutMisskey: "Misskeyについて"
|
|||
administrator: "管理者"
|
||||
token: "確認コード"
|
||||
2fa: "二要素認証"
|
||||
setupOf2fa: "二要素認証のセットアップ"
|
||||
totp: "認証アプリ"
|
||||
totpDescription: "認証アプリを使ってワンタイムパスワードを入力"
|
||||
moderator: "モデレーター"
|
||||
|
@ -654,6 +656,7 @@ behavior: "動作"
|
|||
sample: "サンプル"
|
||||
abuseReports: "通報"
|
||||
reportAbuse: "通報"
|
||||
reportAbuseRenote: "Renoteを通報"
|
||||
reportAbuseOf: "{name}を通報する"
|
||||
fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。"
|
||||
abuseReported: "内容が送信されました。ご報告ありがとうございました。"
|
||||
|
@ -1020,7 +1023,7 @@ retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大するこ
|
|||
enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
|
||||
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
|
||||
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
|
||||
largeNoteReactions: "ノートのリアクションを大きく表示"
|
||||
reactionsDisplaySize: "リアクションの表示サイズ"
|
||||
noteIdOrUrl: "ノートIDまたはURL"
|
||||
video: "動画"
|
||||
videos: "動画"
|
||||
|
@ -1103,6 +1106,7 @@ forYou: "あなたへ"
|
|||
currentAnnouncements: "現在のお知らせ"
|
||||
pastAnnouncements: "過去のお知らせ"
|
||||
youHaveUnreadAnnouncements: "未読のお知らせがあります。"
|
||||
useSecurityKey: "ブラウザまたはデバイスの指示に従って、セキュリティキーまたはパスキーを使用してください。"
|
||||
|
||||
_announcement:
|
||||
forExistingUsers: "既存ユーザーのみ"
|
||||
|
@ -1734,14 +1738,14 @@ _2fa:
|
|||
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
||||
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
||||
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
||||
step2Url: "デスクトップアプリでは次のURIを入力します:"
|
||||
step2Uri: "デスクトップアプリを使用する場合は次のURIを入力します"
|
||||
step3Title: "確認コードを入力"
|
||||
step3: "アプリに表示されている確認コード(トークン)を入力して完了です。"
|
||||
step4: "これからログインするときも、同じように確認コードを入力します。"
|
||||
step3: "アプリに表示されている確認コード(トークン)を入力します。"
|
||||
setupCompleted: "設定が完了しました"
|
||||
step4: "これからログインするときも、同じようにコードを入力します。"
|
||||
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
|
||||
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
|
||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
|
||||
chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
|
||||
registerSecurityKey: "セキュリティキー・パスキーを登録する"
|
||||
securityKeyName: "キーの名前を入力"
|
||||
tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
|
||||
|
@ -1749,9 +1753,14 @@ _2fa:
|
|||
removeKeyConfirm: "{name}を削除しますか?"
|
||||
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
|
||||
renewTOTP: "認証アプリを再設定"
|
||||
renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
|
||||
renewTOTPConfirm: "今までの認証アプリの確認コードおよびバックアップコードは使用できなくなります"
|
||||
renewTOTPOk: "再設定する"
|
||||
renewTOTPCancel: "やめておく"
|
||||
checkBackupCodesBeforeCloseThisWizard: "このウィザードを閉じる前に、以下のバックアップコードを確認してください。"
|
||||
backupCodes: "バックアップコード"
|
||||
backupCodesDescription: "認証アプリが使用できなくなった場合、以下のバックアップコードを使ってアカウントにアクセスできます。これらのコードは必ず安全な場所に保管してください。各コードは一回だけ使用できます。"
|
||||
backupCodeUsedWarning: "バックアップコードが使用されました。認証アプリが使えなくなっている場合、なるべく早く認証アプリを再設定してください。"
|
||||
backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
|
||||
|
||||
_permissions:
|
||||
"read:account": "アカウントの情報を見る"
|
||||
|
@ -2043,6 +2052,10 @@ _notification:
|
|||
unreadAntennaNote: "アンテナ {name}"
|
||||
emptyPushNotificationMessage: "プッシュ通知の更新をしました"
|
||||
achievementEarned: "実績を獲得"
|
||||
testNotification: "通知テスト"
|
||||
checkNotificationBehavior: "通知の表示を確かめる"
|
||||
sendTestNotification: "テスト通知を送信する"
|
||||
notificationWillBeDisplayedLikeThis: "通知はこのように表示されます"
|
||||
|
||||
_types:
|
||||
all: "すべて"
|
||||
|
@ -2081,6 +2094,8 @@ _deck:
|
|||
introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。"
|
||||
widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選択してウィジェットを追加してください"
|
||||
useSimpleUiForNonRootPages: "非ルートページは簡易UIで表示"
|
||||
usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となります"
|
||||
flexible: "幅を自動調整"
|
||||
|
||||
_columns:
|
||||
main: "メイン"
|
||||
|
|
|
@ -1099,9 +1099,18 @@ iHaveReadXCarefullyAndAgree: "「{x}」の内容をよう読んで、同意す
|
|||
dialog: "ダイアログ"
|
||||
icon: "アイコン"
|
||||
forYou: "あんたへ"
|
||||
currentAnnouncements: "現在のお知らせやで"
|
||||
pastAnnouncements: "過去のお知らせやで"
|
||||
youHaveUnreadAnnouncements: "あんたまだこのお知らせ読んどらんやろ。"
|
||||
_announcement:
|
||||
forExistingUsers: "もうおるユーザーのみ"
|
||||
forExistingUsersDescription: "有効にすると、このお知らせ作成時点でおるユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。"
|
||||
needConfirmationToRead: "既読にするのに確認が必要やで"
|
||||
needConfirmationToReadDescription: "有効にすると、このお知らせを既読にする際に確認ダイアログが表示されます。また、一括既読操作の対象にもなりません。"
|
||||
end: "お知らせを終了"
|
||||
tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討した方がええよ。"
|
||||
readConfirmTitle: "既読にしてええんやな?"
|
||||
readConfirmText: "「{title}」の内容を読み、既読にします。"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "アカウント作り終わったで。"
|
||||
letsStartAccountSetup: "アカウントの初期設定をしよか。"
|
||||
|
@ -1687,7 +1696,6 @@ _2fa:
|
|||
step1: "ほんなら、{a}や{b}とかの認証アプリを使っとるデバイスにインストールしてな。"
|
||||
step2: "次に、ここにあるQRコードをアプリでスキャンしてな~。"
|
||||
step2Click: "QRコードをクリックすると、今使とる端末に入っとる認証アプリとかキーリングに登録できるで。"
|
||||
step2Url: "デスクトップアプリやったら次のURLを入力してや:"
|
||||
step3Title: "確認コードを入れてーや"
|
||||
step3: "アプリに表示されているトークンを入力して終わりや。"
|
||||
step4: "これからログインするときも、同じようにトークンを入力するんやで"
|
||||
|
@ -1738,6 +1746,10 @@ _permissions:
|
|||
"write:gallery": "ギャラリーを操作するで"
|
||||
"read:gallery-likes": "ギャラリーのいいねを見るで"
|
||||
"write:gallery-likes": "ギャラリーのいいねを操作するで"
|
||||
"read:flash": "Playを見る"
|
||||
"write:flash": "Playを操作する"
|
||||
"read:flash-likes": "Playのええやん!を見る"
|
||||
"write:flash-likes": "Playのええやん!を見る"
|
||||
_auth:
|
||||
shareAccessTitle: "アプリへのアクセス許してやったらどうや"
|
||||
shareAccess: "「{name}」がアカウントにアクセスすることを許可してええか?"
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
---
|
||||
_lang_: "la .lojban."
|
||||
headlineMisskey: "lo se tcana noi jorne fi loi notci"
|
||||
|
|
|
@ -2,20 +2,20 @@
|
|||
_lang_: "한국어"
|
||||
headlineMisskey: "노트로 연결되는 네트워크"
|
||||
introMisskey: "환영합니다! Misskey는 오픈 소스 분산형 마이크로 블로그 서비스입니다.\n'노트'를 작성해서 지금 일어나고 있는 일을 공유하거나, 당신만의 이야기를 모두에게 발신하세요📡\n'리액션' 기능으로 친구의 노트에 총알같이 반응을 추가할 수도 있습니다👍\n새로운 세계를 탐험해 보세요🚀"
|
||||
poweredByMisskeyDescription: "{name}은(는) 오픈소스 플랫폼 <b>Misskey</b>를 사용한 서버 가운데 하나입니다."
|
||||
poweredByMisskeyDescription: "{name}은(는) 오픈소스 플랫폼<b>Misskey</b>를 사용한 서비스(Misskey 인스턴스라고 불립니다) 중 하나입니다."
|
||||
monthAndDay: "{month}월 {day}일"
|
||||
search: "검색"
|
||||
notifications: "알림"
|
||||
username: "유저명"
|
||||
password: "비밀번호"
|
||||
forgotPassword: "비밀번호 재설정"
|
||||
fetchingAsApObject: "연합에서 조회 중"
|
||||
fetchingAsApObject: "연합에 조회 중"
|
||||
ok: "확인"
|
||||
gotIt: "알겠어요"
|
||||
cancel: "취소"
|
||||
noThankYou: "나중에"
|
||||
enterUsername: "유저명 입력"
|
||||
renotedBy: "{user}님의 리노트"
|
||||
renotedBy: "{user}님이 리노트"
|
||||
noNotes: "노트가 없습니다"
|
||||
noNotifications: "표시할 알림이 없습니다"
|
||||
instance: "서버"
|
||||
|
@ -155,7 +155,7 @@ emojiUrl: "이모지 URL"
|
|||
addEmoji: "이모지 추가"
|
||||
settingGuide: "추천 설정"
|
||||
cacheRemoteFiles: "리모트 파일을 캐시"
|
||||
cacheRemoteFilesDescription: "이 설정을 해지하면 리모트 파일을 캐시하지 않고 해당 파일을 직접 링크하게 됩니다. 그에 따라 서버의 저장 공간을 절약할 수 있지만, 썸네일이 생성되지 않기 때문에 통신량이 증가합니다."
|
||||
cacheRemoteFilesDescription: "이 설정을 활성화하면 리모트 파일을 이 서버의 스토리지에 캐시합니다. 미디어의 표시가 빨라지지만, 서버의 저장 용량을 크게 소모합니다. 리모트 유저의 미디어를 얼마나 보관할 지는 역할의 드라이브 용량 제한에 따라 결정되며, 정해진 용량을 넘길 경우 오래된 파일부터 차례대로 삭제한 뒤 링크로 전환합니다. \n비활성화하면 리모트 파일을 직접 링크하며, 이 경우 이미지 썸네일 생성 및 유저 프라이버시 보호를 위해 default.yml에서 proxyRemoteFiles를 true로 설정하는 것을 권장합니다."
|
||||
youCanCleanRemoteFilesCache: "파일 관리 화면의 🗑️ 버튼을 눌러 모든 캐시를 삭제할 수 있습니다."
|
||||
cacheRemoteSensitiveFiles: "리모트의 민감한 파일을 캐시"
|
||||
cacheRemoteSensitiveFilesDescription: "이 설정을 비활성화하면 리모트의 민감한 파일은 캐시하지 않고 리모트에서 직접 가져오도록 합니다."
|
||||
|
@ -411,6 +411,7 @@ aboutMisskey: "Misskey에 대하여"
|
|||
administrator: "관리자"
|
||||
token: "토큰"
|
||||
2fa: "2단계 인증"
|
||||
setupOf2fa: "2단계 인증 설정"
|
||||
totp: "인증 앱"
|
||||
totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력"
|
||||
moderator: "모더레이터"
|
||||
|
@ -878,7 +879,7 @@ numberOfColumn: "한 줄에 보일 리액션의 수"
|
|||
searchByGoogle: "검색"
|
||||
instanceDefaultLightTheme: "서버 기본 라이트 테마"
|
||||
instanceDefaultDarkTheme: "서버 기본 다크 테마"
|
||||
instanceDefaultThemeDescription: "객체 형식의 테마 코드를 입력해 주세요."
|
||||
instanceDefaultThemeDescription: "객체 형식({}로 감싼 형태)의 테마 코드를 입력해 주세요."
|
||||
mutePeriod: "뮤트할 기간"
|
||||
period: "기간"
|
||||
indefinitely: "무기한"
|
||||
|
@ -1696,9 +1697,10 @@ _2fa:
|
|||
step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다."
|
||||
step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다."
|
||||
step2Click: "QR 코드를 클릭하면 기기에 설치된 인증 앱에 등록할 수 있습니다."
|
||||
step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:"
|
||||
step2Uri: "데스크톱 앱을 사용하려면 다음 URI를 입력하십시오"
|
||||
step3Title: "인증 코드 입력"
|
||||
step3: "앱에 표시된 토큰을 입력하시면 완료됩니다."
|
||||
setupCompleted: "설정 완료했습니다"
|
||||
step4: "다음 로그인부터는 토큰을 입력해야 합니다."
|
||||
securityKeyNotSupported: "이 브라우저는 보안 키를 지원하지 않습니다."
|
||||
registerTOTPBeforeKey: "보안 키 또는 패스키를 등록하려면 인증 앱을 등록하십시오."
|
||||
|
@ -1714,6 +1716,11 @@ _2fa:
|
|||
renewTOTPConfirm: "기존에 등록되어 있던 인증 키는 사용하지 못하게 됩니다."
|
||||
renewTOTPOk: "재설정"
|
||||
renewTOTPCancel: "취소"
|
||||
checkBackupCodesBeforeCloseThisWizard: "이 위자드를 닫기 전에 아래 백업 코드를 확인하십시오"
|
||||
backupCodes: "백업 코드"
|
||||
backupCodesDescription: "인증 앱을 사용할 수 없게 된 경우 아래 백업 코드를 사용하여 계정에 액세스 할 수 있습니다.이 코드들은 반드시 안전한 장소에 보관하십시오.각 코드는 한 번만 사용할 수 있습니다."
|
||||
backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오."
|
||||
backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요."
|
||||
_permissions:
|
||||
"read:account": "계정의 정보를 봅니다"
|
||||
"write:account": "계정의 정보를 변경합니다"
|
||||
|
@ -1747,6 +1754,10 @@ _permissions:
|
|||
"write:gallery": "갤러리를 추가하거나 삭제합니다"
|
||||
"read:gallery-likes": "갤러리의 좋아요를 확인합니다"
|
||||
"write:gallery-likes": "갤러리에 좋아요를 추가하거나 취소합니다"
|
||||
"read:flash": "Play를 봅니다"
|
||||
"write:flash": "Play를 조작합니다"
|
||||
"read:flash-likes": "Play의 좋아요를 봅니다"
|
||||
"write:flash-likes": "Play의 좋아요를 조작합니다"
|
||||
_auth:
|
||||
shareAccessTitle: "어플리케이션의 접근 허가"
|
||||
shareAccess: "\"{name}\" 이 계정에 접근하는 것을 허용하시겠습니까?"
|
||||
|
|
|
@ -45,6 +45,7 @@ pin: "Fixar no perfil"
|
|||
unpin: "Desafixar do perfil"
|
||||
copyContent: "Copiar conteúdos"
|
||||
copyLink: "Copiar link"
|
||||
copyLinkRenote: "Copiar o link da repostagem"
|
||||
delete: "Excluir"
|
||||
deleteAndEdit: "Excluir e editar"
|
||||
deleteAndEditConfirm: "Deseja excluir esta nota e editá-la novamente? Todas as reações, compartilhamentos e respostas a esta nota também serão excluídas."
|
||||
|
@ -654,6 +655,7 @@ behavior: "Comportamento"
|
|||
sample: "Exemplo"
|
||||
abuseReports: "Denúncias"
|
||||
reportAbuse: "Denúncias"
|
||||
reportAbuseRenote: "Reportar repostagem"
|
||||
reportAbuseOf: "Denunciar {name}"
|
||||
fillAbuseReportDescription: "Por favor, forneça detalhes sobre o motivo da denúncia. Se houver uma nota específica envolvida, inclua também a URL dela."
|
||||
abuseReported: "Denúncia enviada. Obrigado por sua ajuda."
|
||||
|
@ -916,18 +918,43 @@ statusbar: "Barra de status"
|
|||
pleaseSelect: "Por favor, selecione."
|
||||
reverse: "Inversão"
|
||||
colored: "Colorido"
|
||||
refreshInterval: "Intervalo de atualização"
|
||||
type: "Tipo"
|
||||
speed: "Velocidade"
|
||||
slow: "Lento"
|
||||
fast: "Rápido"
|
||||
sensitiveMediaDetection: "Detecção de conteúdo sensível"
|
||||
localOnly: "Apenas local"
|
||||
remoteOnly: "Apenas remoto"
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "Não é possível realizar o upload deste arquivo porque ele excede o tamanho máximo permitido."
|
||||
beta: "Beta"
|
||||
enableAutoSensitive: "Marcar automaticamente como conteúdo sensível"
|
||||
enableAutoSensitiveDescription: "Quando disponível, a marcação de mídia sensível será automaticamente atribuído ao conteúdo de mídia usando aprendizado de máquina. Mesmo que você desative essa função, em alguns servidores, isso pode ser configurado automaticamente."
|
||||
activeEmailValidationDescription: "A validação do endereço de e-mail do usuário será realizada de forma mais rigorosa, considerando se é um endereço descartável ou se é possível realizar comunicação efetiva. Se desativado, apenas a validade do formato do endereço será verificada como uma sequência de caracteres."
|
||||
shuffle: "Aleatório"
|
||||
account: "Contas"
|
||||
move: "Mover"
|
||||
pushNotification: "Notificações Push"
|
||||
subscribePushNotification: "Ativar notificações push"
|
||||
unsubscribePushNotification: "Desativar notificações push"
|
||||
windowMinimize: "Minimizar"
|
||||
windowRestore: "Restaurar"
|
||||
caption: "legenda"
|
||||
tools: "Ferramentas"
|
||||
like: "Curtir"
|
||||
unlike: "Remover curtida"
|
||||
numberOfLikes: "Número de curtidas"
|
||||
show: "Visualizar"
|
||||
neverShow: "Não exibir novamente"
|
||||
remindMeLater: "Lembrar mais tarde"
|
||||
didYouLikeMisskey: "Você gostou do Misskey?"
|
||||
pleaseDonate: "O Misskey é um software gratuito utilizado por {host}. Para que possamos continuar o desenvolvimento, pedimos que considerem fazer doações. A sua contribuição é muito importante!"
|
||||
roles: "Cargos"
|
||||
role: "Cargo"
|
||||
noRole: "Nenhum cargo"
|
||||
normalUser: "Usuários padrão"
|
||||
undefined: "Indefinido"
|
||||
assign: "Atribuir"
|
||||
unassign: "Remover"
|
||||
color: "Cor"
|
||||
manageCustomEmojis: "Gerenciar Emojis customizados"
|
||||
|
@ -947,7 +974,7 @@ thisPostMayBeAnnoying: "Esta nota pode incomodar outras pessoas."
|
|||
thisPostMayBeAnnoyingHome: "Postar na linha do tempo inicial"
|
||||
thisPostMayBeAnnoyingCancel: "Cancelar"
|
||||
thisPostMayBeAnnoyingIgnore: "Postar mesmo assim"
|
||||
collapseRenotes: "Ocultar Renotes já visualizadas"
|
||||
collapseRenotes: "Ocultar repostagens já visualizadas"
|
||||
internalServerError: "Erro interno de servidor"
|
||||
emailNotSupported: "O envio de e-mails não é suportado nesta instância"
|
||||
likeOnly: "Apenas curtidas"
|
||||
|
@ -957,8 +984,19 @@ rolesAssignedToMe: "Cargos atribuídos a mim"
|
|||
unfavoriteConfirm: "Deseja realmente remover dos favoritos?"
|
||||
drivecleaner: "Limpeza do drive"
|
||||
retryAllQueuesConfirmTitle: "Gostaria de tentar novamente agora?"
|
||||
reactionsList: "Reações"
|
||||
renotesList: "Repostagens"
|
||||
leftTop: "Superior esquerdo"
|
||||
rightTop: "Superior direito"
|
||||
leftBottom: "Inferior esquerdo"
|
||||
rightBottom: "Inferior direito"
|
||||
vertical: "Vertical"
|
||||
horizontal: "Exibir painel lateral inteiro"
|
||||
position: "Posição"
|
||||
serverRules: "Regras do servidor"
|
||||
continue: "Continuar"
|
||||
preservedUsernamesDescription: "Liste os nomes de usuário que deseja reservar, separando-os por quebras de linha. Os nomes de usuário especificados aqui não poderão ser utilizados durante a criação de contas. No entanto, esta restrição não se aplica quando a conta é criada por um administrador. Além disso, as contas que já existem não serão afetadas."
|
||||
archive: "Arquivo"
|
||||
channelArchiveConfirmTitle: "Deseja realmente arquivar {name}?"
|
||||
youFollowing: "Seguindo"
|
||||
preventAiLearningDescription: "Solicita-se que o conteúdo de notas e imagens enviadas não seja usado como objeto de aprendizado por sistemas externos de geração de texto ou imagens. Isso é alcançado incluindo a flag 'noai' na resposta HTML. No entanto, o cumprimento dessa solicitação depende do próprio sistema de IA, portanto, não é garantia total de prevenção de aprendizado."
|
||||
|
@ -1262,6 +1300,8 @@ _menuDisplay:
|
|||
sideFull: "Exibir painel lateral inteiro"
|
||||
top: "Exibir barra superior"
|
||||
hide: "Ocultar"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Todas as notas e repostagens do servidor configurado serão silenciados, incluindo respostas aos usuários do servidor mutado."
|
||||
_theme:
|
||||
description: "Descrição"
|
||||
alpha: "Opacidade"
|
||||
|
@ -1396,6 +1436,7 @@ _notification:
|
|||
youGotMention: "{name} te mencionou"
|
||||
youGotReply: "{name} te respondeu"
|
||||
youGotQuote: "{name} te citou"
|
||||
youRenoted: "Repostagens de {name}"
|
||||
youWereFollowed: "Você tem um novo seguidor"
|
||||
youReceivedFollowRequest: "Você recebeu um pedido de seguidor"
|
||||
yourFollowRequestAccepted: "Seu pedido de seguidor foi aceito"
|
||||
|
@ -1448,3 +1489,4 @@ _webhookSettings:
|
|||
_events:
|
||||
follow: "Quando seguindo um usuário"
|
||||
followed: "Quando sendo seguido"
|
||||
renote: "Quando repostado"
|
||||
|
|
|
@ -1610,7 +1610,6 @@ _2fa:
|
|||
step1: "Прежде всего, установите на устройство приложение для аутентификации, например, {a} или {b}."
|
||||
step2: "Далее отсканируйте отображаемый QR-код при помощи приложения."
|
||||
step2Click: "Нажав на QR-код, вы можете зарегистрироваться с помощью приложения для аутентификации или брелка для ключей, установленного на вашем устройстве."
|
||||
step2Url: "Если пользуетесь приложением на компьютере, можете ввести в него эту строку (URL):"
|
||||
step3Title: "Введите проверочный код"
|
||||
step3: "И наконец, введите код, который покажет приложение."
|
||||
step4: "Теперь при каждом входе на сайт вам нужно будет вводить код из приложения аналогичным образом."
|
||||
|
|
|
@ -1149,7 +1149,6 @@ _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."
|
||||
step2: "Potom, naskenujte QR kód zobrazený na obrazovke."
|
||||
step2Url: "Do aplikácie zadajte nasledujúcu URL adresu:"
|
||||
step3: "Nastavenie dokončíte zadaním tokenu z vašej aplikácie."
|
||||
step4: "Od teraz, všetky ďalšie prihlásenia budú vyžadovať prihlasovací token."
|
||||
securityKeyInfo: "Okrem odtlačku prsta alebo PIN autentifikácie si môžete nastaviť autentifikáciu cez hardvérový bezpečnostný kľúč podporujúci FIDO2 a tak ešte viac zabezpečiť svoj účet."
|
||||
|
|
|
@ -1691,7 +1691,6 @@ _2fa:
|
|||
step1: "ขั้นตอนแรก ติดตั้งแอปยืนยันตัวตน (เช่น {a} หรือ {b}) บนอุปกรณ์ของคุณ"
|
||||
step2: "จากนั้นสแกนรหัส QR ที่แสดงบนหน้าจอนี้"
|
||||
step2Click: "การคลิกที่รหัส QR นี้จะช่วยให้คุณนั้นสามารถลงทะเบียน 2FA กับคีย์ความปลอดภัยหรือแอปตรวจสอบความถูกต้องของโทรศัพท์ได้"
|
||||
step2Url: "คุณยังสามารถป้อนบน URL นี้หากคุณใช้โปรแกรมเดสก์ท็อป:"
|
||||
step3Title: "ป้อนรหัสยืนยัน"
|
||||
step3: "ป้อนโทเค็นที่แอปของคุณให้มาเพื่อเสร็จสิ้นการตั้งค่า"
|
||||
step4: "นับจากนี้เป็นต้นไปการพยายามเข้าสู่ระบบในอนาคตนั้น อาจจะต้องขอโทเค็นในการเข้าสู่ระบบดังกล่าว"
|
||||
|
|
|
@ -1337,7 +1337,6 @@ _2fa:
|
|||
alreadyRegistered: "Двофакторна автентифікація вже налаштована."
|
||||
step1: "Спершу встановіть на свій пристрій програму автентифікації (наприклад {a} або {b})."
|
||||
step2: "Потім відскануйте QR-код, який відображається на цьому екрані."
|
||||
step2Url: "Ви також можете ввести цю URL-адресу, якщо використовуєте програму для ПК:"
|
||||
step3: "Щоб завершити налаштування, введіть токен, наданий вашою програмою."
|
||||
step4: "Відтепер будь-які майбутні спроби входу вимагатимуть такого токена."
|
||||
renewTOTPCancel: "Не зараз"
|
||||
|
|
|
@ -699,6 +699,7 @@ myTheme: "Mening rang sxemam"
|
|||
backgroundColor: "Fon"
|
||||
accentColor: "Urg'u"
|
||||
textColor: "Matn"
|
||||
saveAs: "Boshqacha saqlash"
|
||||
advanced: "Murakkab"
|
||||
advancedSettings: "Qo'shimcha sozlashlar"
|
||||
value: "Qiymati"
|
||||
|
@ -725,6 +726,7 @@ inChannelSearch: "Kanal qidirish"
|
|||
useReactionPickerForContextMenu: "kontekst menyusi uchun reaktsiya tanlash vositasidan foydalaning"
|
||||
typingUsers: "{users} yozmoqda"
|
||||
jumpToSpecifiedDate: "Muayyan sanaga o'tish"
|
||||
showingPastTimeline: "O'tgan vaqt jadvallarini ko'rsatish"
|
||||
clear: "aniq"
|
||||
markAllAsRead: "hammasini o'qilgan deb belgilang"
|
||||
goBack: "qaytish"
|
||||
|
|
|
@ -1404,7 +1404,6 @@ _2fa:
|
|||
step1: "Trước tiên, hãy cài đặt một ứng dụng xác minh (chẳng hạn như {a} hoặc {b}) trên thiết bị của bạn."
|
||||
step2: "Sau đó, quét mã QR hiển thị trên màn hình này."
|
||||
step2Click: "Quét mã QR trên ứng dụng xác thực (Authy, Google authenticator, v.v.)"
|
||||
step2Url: "Bạn cũng có thể nhập URL này nếu sử dụng một chương trình máy tính:"
|
||||
step3Title: "Nhập mã xác thực"
|
||||
step3: "Nhập mã token do ứng dụng của bạn cung cấp để hoàn tất thiết lập."
|
||||
step4: "Kể từ bây giờ, những lần đăng nhập trong tương lai sẽ yêu cầu mã token đăng nhập đó."
|
||||
|
|
|
@ -45,6 +45,7 @@ pin: "置顶"
|
|||
unpin: "取消置顶"
|
||||
copyContent: "复制内容"
|
||||
copyLink: "复制链接"
|
||||
copyLinkRenote: "复制转帖链接"
|
||||
delete: "删除"
|
||||
deleteAndEdit: "删除并编辑"
|
||||
deleteAndEditConfirm: "要删除此帖并再次编辑吗?对此帖的所有回应、转发和回复也将被删除。"
|
||||
|
@ -155,7 +156,7 @@ emojiUrl: "emoji 地址"
|
|||
addEmoji: "添加表情符号"
|
||||
settingGuide: "推荐配置"
|
||||
cacheRemoteFiles: "缓存远程文件"
|
||||
cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。"
|
||||
cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 default.yml 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。"
|
||||
youCanCleanRemoteFilesCache: "可以使用文件管理的🗑️按钮来删除所有的缓存。"
|
||||
cacheRemoteSensitiveFiles: "缓存远程敏感媒体文件"
|
||||
cacheRemoteSensitiveFilesDescription: "如果禁用这项设定,远程服务器的敏感媒体将不会被缓存,而是直接链接。"
|
||||
|
@ -178,7 +179,7 @@ searchWith: "搜索:{q}"
|
|||
youHaveNoLists: "列表为空"
|
||||
followConfirm: "你确定要关注 {name} 吗?"
|
||||
proxyAccount: "代理账户"
|
||||
proxyAccountDescription: "代理账户是在某些情况下充当用户的远程关注者的账户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该服务器,因此将代之以代理账户。"
|
||||
proxyAccountDescription: "代理账户是在某些情况下替代用户进行远程关注用的账户。 例如说,当用户将一位远程用户放入一个列表中时,如果本地服务器上没有任何人关注这位远程用户,则这位远程用户的账户活动将不会被送到本地服务器上。作为替代,此时将使用代理账户进行关注。"
|
||||
host: "主机名"
|
||||
selectUser: "选择用户"
|
||||
recipient: "收件人"
|
||||
|
@ -212,7 +213,7 @@ clearQueueConfirmText: "未送达的帖子将不会投递。 通常,您不需
|
|||
clearCachedFiles: "清除缓存"
|
||||
clearCachedFilesConfirm: "确定要清除缓存文件?"
|
||||
blockedInstances: "被封锁的服务器"
|
||||
blockedInstancesDescription: "设定要封锁的服务器,以换行来进行分割。被封锁的服务器将无法与本服务器进行交换通讯。"
|
||||
blockedInstancesDescription: "设定要封锁的服务器,以换行来进行分割。被封锁的服务器将无法与本服务器进行交换通讯。子域名也同样会被封锁。"
|
||||
muteAndBlock: "屏蔽/拉黑"
|
||||
mutedUsers: "已屏蔽用户"
|
||||
blockedUsers: "已拉黑的用户"
|
||||
|
@ -411,6 +412,7 @@ aboutMisskey: "关于 Misskey"
|
|||
administrator: "管理员"
|
||||
token: "Token (令牌)"
|
||||
2fa: "双因素认证"
|
||||
setupOf2fa: "设置双因素认证"
|
||||
totp: "身份验证应用"
|
||||
totpDescription: "使用认证应用输入一次性密码。"
|
||||
moderator: "监察员"
|
||||
|
@ -654,6 +656,7 @@ behavior: "行为"
|
|||
sample: "示例"
|
||||
abuseReports: "举报"
|
||||
reportAbuse: "举报"
|
||||
reportAbuseRenote: "举报转帖"
|
||||
reportAbuseOf: "举报 {name}"
|
||||
fillAbuseReportDescription: "请填写举报的详细原因。如果有对方发的帖子,请同时填写 URL 地址。"
|
||||
abuseReported: "内容已发送。感谢您提交信息。"
|
||||
|
@ -1696,9 +1699,10 @@ _2fa:
|
|||
step1: "首先,在您的设备上安装验证应用,例如 {a} 或 {b}。"
|
||||
step2: "然后,扫描屏幕上显示的二维码。"
|
||||
step2Click: "通过点击二维码,您可以使用设备上安装的身份验证器应用程序或密钥环进行注册"
|
||||
step2Url: "在桌面应用程序中输入以下 URL:"
|
||||
step2Uri: "如果使用桌面应用程序的话,请输入下面的 URI"
|
||||
step3Title: "输入验证码"
|
||||
step3: "输入您的应用提供的动态口令以完成设置。"
|
||||
setupCompleted: "设置完成"
|
||||
step4: "从现在开始,任何登录操作都将要求您提供动态口令。"
|
||||
securityKeyNotSupported: "您的浏览器不支持安全密钥。"
|
||||
registerTOTPBeforeKey: "要注册安全密钥或 Passkey,请先设置验证器应用程序。"
|
||||
|
@ -1714,6 +1718,11 @@ _2fa:
|
|||
renewTOTPConfirm: "当前验证器应用程序的验证码将不再有效"
|
||||
renewTOTPOk: "重新配置"
|
||||
renewTOTPCancel: "不用,谢谢"
|
||||
checkBackupCodesBeforeCloseThisWizard: "在关闭此窗口前,请确认下面的备用代码"
|
||||
backupCodes: "备用代码"
|
||||
backupCodesDescription: "如果无法使用认证应用,可以使用以下的备用代码来访问账户。请务必将这些代码保存在安全的地方。每个代码仅可使用一次。"
|
||||
backupCodeUsedWarning: "已使用备用代码。如果无法使用认证应用,请尽快重新设定。"
|
||||
backupCodesExhaustedWarning: "已使用完所有的备用代码。如果无法使用认证应用,将无法再访问您的账户。请再次设定认证应用。"
|
||||
_permissions:
|
||||
"read:account": "查看账户信息"
|
||||
"write:account": "更改帐户信息"
|
||||
|
@ -1747,6 +1756,10 @@ _permissions:
|
|||
"write:gallery": "操作图库"
|
||||
"read:gallery-likes": "读取喜欢的图片"
|
||||
"write:gallery-likes": "操作喜欢的图片"
|
||||
"read:flash": "查看 Play"
|
||||
"write:flash": "编辑 Play"
|
||||
"read:flash-likes": "查看 Play 的点赞"
|
||||
"write:flash-likes": "编辑 Play 的点赞列表"
|
||||
_auth:
|
||||
shareAccessTitle: "应用程序授权许可"
|
||||
shareAccess: "您要授权允许 “{name}” 访问您的帐户吗?"
|
||||
|
@ -2017,6 +2030,8 @@ _deck:
|
|||
introduction2: "您可以随时通过屏幕右侧的 + 来添加列"
|
||||
widgetsIntroduction: "从列菜单中,选择“小工具编辑”来添加小工具"
|
||||
useSimpleUiForNonRootPages: "用简易UI表示非根页面"
|
||||
usedAsMinWidthWhenFlexible: "「自适应宽度」被启用的时候,这就是最小的宽度"
|
||||
flexible: "自适应宽度"
|
||||
_columns:
|
||||
main: "主列"
|
||||
widgets: "小工具"
|
||||
|
|
|
@ -164,7 +164,7 @@ flagAsBotDescription: "標記本帳戶由程式控制,防止其他程式與本
|
|||
flagAsCat: "此帳戶是一隻貓,喵~~~!!!"
|
||||
flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示"
|
||||
flagShowTimelineReplies: "在時間軸上顯示貼文的回覆"
|
||||
flagShowTimelineRepliesDescription: "啟用時,時間線除了顯示使用者的貼文以外,還會顯示使用者對其他貼文的回覆。"
|
||||
flagShowTimelineRepliesDescription: "啟用時,時間軸除了顯示使用者的貼文以外,還會顯示使用者對其他貼文的回覆。"
|
||||
autoAcceptFollowed: "自動允許來自追隨中使用者的追隨請求"
|
||||
addAccount: "新增帳戶"
|
||||
reloadAccountsList: "更新帳戶清單的資訊"
|
||||
|
@ -348,7 +348,7 @@ connectService: "已連結"
|
|||
disconnectService: "已斷開 "
|
||||
enableLocalTimeline: "啟用本地時間軸"
|
||||
enableGlobalTimeline: "啟用全域時間軸"
|
||||
disablingTimelinesInfo: "為了方便,即使您關閉了時間線功能,管理員和審查員仍可以繼續使用。"
|
||||
disablingTimelinesInfo: "為了方便,即使您關閉了時間軸功能,管理員和審查員仍可以繼續使用。"
|
||||
registration: "註冊"
|
||||
enableRegistration: "開放新使用者註冊"
|
||||
invite: "邀請"
|
||||
|
@ -411,6 +411,7 @@ aboutMisskey: "關於 Misskey"
|
|||
administrator: "管理員"
|
||||
token: "權杖"
|
||||
2fa: "雙重驗證"
|
||||
setupOf2fa: "設定雙重驗證"
|
||||
totp: "驗證應用程式"
|
||||
totpDescription: "以驗證應用程式輸入一次性密碼"
|
||||
moderator: "審查員"
|
||||
|
@ -721,7 +722,7 @@ thisIsExperimentalFeature: "這是實驗性的功能。可能會有變更規格
|
|||
developer: "開發者"
|
||||
makeExplorable: "使自己的帳戶能夠在「探索」頁面中顯示"
|
||||
makeExplorableDescription: "如果關閉,帳戶將不會被顯示在「探索」頁面中。"
|
||||
showGapBetweenNotesInTimeline: "分開顯示時間線上的貼文。"
|
||||
showGapBetweenNotesInTimeline: "分開顯示時間軸上的貼文。"
|
||||
duplicate: "複製"
|
||||
left: "左"
|
||||
center: "置中"
|
||||
|
@ -767,7 +768,7 @@ inChannelSearch: "頻道内搜尋"
|
|||
useReactionPickerForContextMenu: "點擊右鍵開啟反應工具欄"
|
||||
typingUsers: "{users}輸入中"
|
||||
jumpToSpecifiedDate: "跳轉到特定日期"
|
||||
showingPastTimeline: "顯示過往的時間線"
|
||||
showingPastTimeline: "顯示過往的時間軸"
|
||||
clear: "清除"
|
||||
markAllAsRead: "全部標示為已讀"
|
||||
goBack: "返回"
|
||||
|
@ -1696,9 +1697,10 @@ _2fa:
|
|||
step1: "首先,在您的裝置上安裝驗證程式,例如 {a} 或 {b}。"
|
||||
step2: "然後,掃描螢幕上的 QR 碼。"
|
||||
step2Click: "您可以點擊 QR 碼,以使用裝置上的驗證應用程式或金鑰環註冊。"
|
||||
step2Url: "請在桌面版應用程式中輸入以下的 URL:"
|
||||
step2Uri: "使用桌面版應用程式時,請輸入以下的 URI"
|
||||
step3Title: "輸入驗證碼"
|
||||
step3: "輸入應用程式所提供的權杖以完成設定。"
|
||||
setupCompleted: "設定完成"
|
||||
step4: "從現在開始,任何登入操作都將要求您提供權杖。"
|
||||
securityKeyNotSupported: "您的瀏覽器不支援安全金鑰。"
|
||||
registerTOTPBeforeKey: "如要註冊安全金鑰或 Passkey,請先設定驗證應用程式。"
|
||||
|
@ -1714,6 +1716,11 @@ _2fa:
|
|||
renewTOTPConfirm: "目前驗證應用程式的驗證碼將無法使用。"
|
||||
renewTOTPOk: "重設"
|
||||
renewTOTPCancel: "現在不要"
|
||||
checkBackupCodesBeforeCloseThisWizard: "請先確認下列備用驗證碼,再關閉此精靈視窗。"
|
||||
backupCodes: "備用驗證碼"
|
||||
backupCodesDescription: "如果驗證應用程式不能用了,可以使用以下的備用驗證碼存取您的帳戶。請務必妥善保管這個驗證碼。每個驗證碼只能使用一次。"
|
||||
backupCodeUsedWarning: "已使用備用驗證碼。如果無法使用驗證應用程式,請盡快重新設定。"
|
||||
backupCodesExhaustedWarning: "已使用所有備用驗證碼。如果無法使用驗證應用程式,則將無法再存取您的帳戶。請重新設定您的驗證應用程式。"
|
||||
_permissions:
|
||||
"read:account": "查看我的帳戶資訊"
|
||||
"write:account": "更改我的帳戶資訊"
|
||||
|
@ -1748,9 +1755,9 @@ _permissions:
|
|||
"read:gallery-likes": "讀取喜歡的圖片"
|
||||
"write:gallery-likes": "操作喜歡的圖片"
|
||||
"read:flash": "檢視 Play"
|
||||
"write:flash": "操作 Play"
|
||||
"write:flash": "編輯 Play"
|
||||
"read:flash-likes": "檢視 Play 的讚"
|
||||
"write:flash-likes": "對 Play 的讚進行操作"
|
||||
"write:flash-likes": "編輯 Play 的讚"
|
||||
_auth:
|
||||
shareAccessTitle: "應用程式的存取權限"
|
||||
shareAccess: "要授權「“{name}”」存取您的帳戶嗎?"
|
||||
|
@ -2020,7 +2027,9 @@ _deck:
|
|||
introduction: "組合多個欄位,製作屬於自己的介面吧!"
|
||||
introduction2: "您可以隨時按畫面右方的「+」新增欄位。"
|
||||
widgetsIntroduction: "請從欄位選單中選擇「編輯小工具」新增小工具。"
|
||||
useSimpleUiForNonRootPages: "用簡易 UI 顯示非根頁面"
|
||||
useSimpleUiForNonRootPages: "用簡易介面顯示非根頁面"
|
||||
usedAsMinWidthWhenFlexible: "如果啟用「自動調整寬度」,此為最小寬度"
|
||||
flexible: "自動調整寬度"
|
||||
_columns:
|
||||
main: "主列"
|
||||
widgets: "小工具"
|
||||
|
|
31
package.json
31
package.json
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2023.9.0-beta.2",
|
||||
"version": "2023.9.0-beta.5",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@8.6.10",
|
||||
"packageManager": "pnpm@8.7.4",
|
||||
"workspaces": [
|
||||
"packages/frontend",
|
||||
"packages/backend",
|
||||
|
@ -15,7 +15,8 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"build-pre": "node ./scripts/build-pre.js",
|
||||
"build": "pnpm build-pre && pnpm -r build && pnpm gulp",
|
||||
"build-assets": "node ./scripts/build-assets.mjs",
|
||||
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
|
||||
"build-storybook": "pnpm --filter frontend build-storybook",
|
||||
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/index.js",
|
||||
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/index.js",
|
||||
|
@ -23,7 +24,6 @@
|
|||
"migrate": "cd packages/backend && pnpm migrate",
|
||||
"check:connect": "cd packages/backend && pnpm check:connect",
|
||||
"migrateandstart": "pnpm migrate && pnpm start",
|
||||
"gulp": "pnpm exec gulp build",
|
||||
"watch": "pnpm dev",
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"lint": "pnpm -r lint",
|
||||
|
@ -34,7 +34,6 @@
|
|||
"jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage",
|
||||
"test": "pnpm -r test",
|
||||
"test-and-coverage": "pnpm -r test-and-coverage",
|
||||
"format": "pnpm exec gulp format",
|
||||
"clean": "node ./scripts/clean.js",
|
||||
"clean-all": "node ./scripts/clean-all.js",
|
||||
"cleanall": "pnpm clean-all"
|
||||
|
@ -44,23 +43,19 @@
|
|||
"lodash": "4.17.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"execa": "7.2.0",
|
||||
"gulp": "4.0.2",
|
||||
"gulp-cssnano": "2.1.3",
|
||||
"gulp-rename": "2.0.0",
|
||||
"gulp-replace": "1.1.4",
|
||||
"gulp-terser": "2.1.0",
|
||||
"execa": "8.0.1",
|
||||
"cssnano": "6.0.1",
|
||||
"js-yaml": "4.1.0",
|
||||
"typescript": "5.1.6"
|
||||
"postcss": "8.4.27",
|
||||
"terser": "5.19.2",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/gulp": "4.0.13",
|
||||
"@types/gulp-rename": "2.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "6.2.0",
|
||||
"@typescript-eslint/parser": "6.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.6.0",
|
||||
"@typescript-eslint/parser": "6.6.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.17.2",
|
||||
"eslint": "8.46.0",
|
||||
"cypress": "13.1.0",
|
||||
"eslint": "8.48.0",
|
||||
"start-server-and-test": "2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
export class User2faBackupCodes1690569881926 {
|
||||
name = 'User2faBackupCodes1690569881926'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ADD "twoFactorBackupSecret" character varying array`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "twoFactorBackupSecret"`);
|
||||
}
|
||||
}
|
49
packages/backend/migration/1691959191872-passkey-support.js
Normal file
49
packages/backend/migration/1691959191872-passkey-support.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class PasskeySupport1691959191872 {
|
||||
name = 'PasskeySupport1691959191872'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "counter" bigint NOT NULL DEFAULT '0'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."counter" IS 'The number of times the UserSecurityKey was validated.'`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "credentialDeviceType" character varying(32)`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialDeviceType" IS 'The type of Backup Eligibility in authenticator data'`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "credentialBackedUp" boolean`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialBackedUp" IS 'Whether or not the credential has been backed up'`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "transports" character varying(32) array`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."transports" IS 'The type of the credential returned by the browser'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."publicKey" IS 'The public key of the UserSecurityKey, hex-encoded.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'Timestamp of the last time the UserSecurityKey was used.'`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" ALTER COLUMN "lastUsed" SET DEFAULT now()`);
|
||||
await queryRunner.query(`UPDATE "user_security_key" SET "id" = REPLACE(REPLACE(REPLACE(REPLACE(ENCODE(DECODE("id", 'hex'), 'base64'), E'\\n', ''), '+', '-'), '/', '_'), '=', ''), "publicKey" = REPLACE(REPLACE(REPLACE(REPLACE(ENCODE(DECODE("publicKey", 'hex'), 'base64'), E'\\n', ''), '+', '-'), '/', '_'), '=', '')`);
|
||||
await queryRunner.query(`ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`);
|
||||
await queryRunner.query(`DROP TABLE "attestation_challenge"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "attestation_challenge" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "challenge" character varying(64) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "registrationChallenge" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_d0ba6786e093f1bcb497572a6b5" PRIMARY KEY ("id", "userId"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f1a461a618fa1755692d0e0d59" ON "attestation_challenge" ("userId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_47efb914aed1f72dd39a306c7b" ON "attestation_challenge" ("challenge") `);
|
||||
await queryRunner.query(`ALTER TABLE "attestation_challenge" ADD CONSTRAINT "FK_f1a461a618fa1755692d0e0d592" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."challenge" IS 'Hex-encoded sha256 hash of the challenge.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."createdAt" IS 'The date challenge was created for expiry purposes.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."registrationChallenge" IS 'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.'`);
|
||||
await queryRunner.query(`UPDATE "user_security_key" SET "id" = ENCODE(DECODE(REPLACE(REPLACE("id" || CASE WHEN LENGTH("id") % 4 = 2 THEN '==' WHEN LENGTH("id") % 4 = 3 THEN '=' ELSE '' END, '-', '+'), '_', '/'), 'base64'), 'hex'), "publicKey" = ENCODE(DECODE(REPLACE(REPLACE("publicKey" || CASE WHEN LENGTH("publicKey") % 4 = 2 THEN '==' WHEN LENGTH("publicKey") % 4 = 3 THEN '=' ELSE '' END, '-', '+'), '_', '/'), 'base64'), 'hex')`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" ALTER COLUMN "lastUsed" DROP DEFAULT`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'The date of the last time the UserSecurityKey was successfully validated.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."publicKey" IS 'Variable-length public key used to verify attestations (hex-encoded).'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."transports" IS 'The type of the credential returned by the browser'`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "transports"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialBackedUp" IS 'Whether or not the credential has been backed up'`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "credentialBackedUp"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialDeviceType" IS 'The type of Backup Eligibility in authenticator data'`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "credentialDeviceType"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."counter" IS 'The number of times the UserSecurityKey was validated.'`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "counter"`);
|
||||
}
|
||||
}
|
|
@ -56,36 +56,37 @@
|
|||
"utf-8-validate": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.367.0",
|
||||
"@aws-sdk/lib-storage": "3.367.0",
|
||||
"@aws-sdk/node-http-handler": "3.360.0",
|
||||
"@bull-board/api": "5.7.1",
|
||||
"@bull-board/fastify": "5.7.1",
|
||||
"@bull-board/ui": "5.7.1",
|
||||
"@aws-sdk/client-s3": "3.400.0",
|
||||
"@aws-sdk/lib-storage": "3.400.0",
|
||||
"@aws-sdk/node-http-handler": "3.374.0",
|
||||
"@bull-board/api": "5.8.1",
|
||||
"@bull-board/fastify": "5.8.1",
|
||||
"@bull-board/ui": "5.8.1",
|
||||
"@discordapp/twemoji": "14.1.2",
|
||||
"@fastify/accepts": "4.2.0",
|
||||
"@fastify/cookie": "8.3.0",
|
||||
"@fastify/cookie": "9.0.4",
|
||||
"@fastify/cors": "8.3.0",
|
||||
"@fastify/express": "2.3.0",
|
||||
"@fastify/http-proxy": "9.2.1",
|
||||
"@fastify/multipart": "7.7.3",
|
||||
"@fastify/static": "6.10.2",
|
||||
"@fastify/static": "6.11.0",
|
||||
"@fastify/view": "8.0.0",
|
||||
"@nestjs/common": "10.1.2",
|
||||
"@nestjs/core": "10.1.2",
|
||||
"@nestjs/testing": "10.1.2",
|
||||
"@nestjs/common": "10.2.4",
|
||||
"@nestjs/core": "10.2.4",
|
||||
"@nestjs/testing": "10.2.4",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sinonjs/fake-timers": "10.3.0",
|
||||
"@simplewebauthn/server": "8.1.1",
|
||||
"@sinonjs/fake-timers": "11.1.0",
|
||||
"@swc/cli": "0.1.62",
|
||||
"@swc/core": "1.3.72",
|
||||
"@swc/core": "1.3.82",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "5.3.1",
|
||||
"async-mutex": "^0.4.0",
|
||||
"archiver": "6.0.1",
|
||||
"async-mutex": "0.4.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.2",
|
||||
"bullmq": "4.6.3",
|
||||
"bullmq": "4.8.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.1",
|
||||
"chalk": "5.3.0",
|
||||
|
@ -96,7 +97,7 @@
|
|||
"content-disposition": "0.5.4",
|
||||
"date-fns": "2.30.0",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"fastify": "4.21.0",
|
||||
"fastify": "4.22.2",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "18.5.0",
|
||||
"fluent-ffmpeg": "2.1.2",
|
||||
|
@ -112,14 +113,15 @@
|
|||
"js-yaml": "4.1.0",
|
||||
"jsdom": "22.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "8.2.0",
|
||||
"jsonld": "8.2.1",
|
||||
"jsrsasign": "10.8.6",
|
||||
"meilisearch": "0.33.0",
|
||||
"meilisearch": "0.34.1",
|
||||
"mfm-js": "0.23.3",
|
||||
"microformats-parser": "1.4.1",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"nanoid": "4.0.2",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.4",
|
||||
|
@ -130,7 +132,7 @@
|
|||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.1.4",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.11.1",
|
||||
"pg": "8.11.3",
|
||||
"pkce-challenge": "4.0.1",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
|
@ -140,84 +142,85 @@
|
|||
"qrcode": "1.5.3",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.20.1",
|
||||
"re2": "1.20.3",
|
||||
"redis-lock": "0.1.4",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.1",
|
||||
"sanitize-html": "2.11.0",
|
||||
"sharp": "0.32.4",
|
||||
"sharp": "0.32.5",
|
||||
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"systeminformation": "5.18.9",
|
||||
"systeminformation": "5.21.4",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.1",
|
||||
"tsc-alias": "1.8.7",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typeorm": "0.3.17",
|
||||
"typescript": "5.1.6",
|
||||
"typescript": "5.2.2",
|
||||
"ulid": "2.3.0",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.4",
|
||||
"web-push": "3.6.5",
|
||||
"ws": "8.13.0",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.6.2",
|
||||
"@swc/jest": "0.2.27",
|
||||
"@jest/globals": "29.6.4",
|
||||
"@simplewebauthn/typescript-types": "8.0.0",
|
||||
"@swc/jest": "0.2.29",
|
||||
"@types/accepts": "1.3.5",
|
||||
"@types/archiver": "5.3.2",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/bcryptjs": "2.4.3",
|
||||
"@types/body-parser": "1.19.2",
|
||||
"@types/cbor": "6.0.0",
|
||||
"@types/color-convert": "2.0.0",
|
||||
"@types/content-disposition": "0.5.5",
|
||||
"@types/color-convert": "2.0.1",
|
||||
"@types/content-disposition": "0.5.6",
|
||||
"@types/fluent-ffmpeg": "2.1.21",
|
||||
"@types/http-link-header": "1.0.3",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/jsdom": "21.1.1",
|
||||
"@types/jsdom": "21.1.2",
|
||||
"@types/jsonld": "1.5.9",
|
||||
"@types/jsrsasign": "10.5.8",
|
||||
"@types/mime-types": "2.1.1",
|
||||
"@types/ms": "0.7.31",
|
||||
"@types/node": "20.4.5",
|
||||
"@types/node": "20.5.9",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.9",
|
||||
"@types/oauth": "0.9.1",
|
||||
"@types/oauth2orize": "1.11.0",
|
||||
"@types/oauth": "0.9.2",
|
||||
"@types/oauth2orize": "1.11.1",
|
||||
"@types/oauth2orize-pkce": "0.1.0",
|
||||
"@types/pg": "8.10.2",
|
||||
"@types/pug": "2.0.6",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/qrcode": "1.5.1",
|
||||
"@types/qrcode": "1.5.2",
|
||||
"@types/random-seed": "0.3.3",
|
||||
"@types/ratelimiter": "3.4.4",
|
||||
"@types/rename": "1.0.4",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/semver": "7.5.0",
|
||||
"@types/semver": "7.5.1",
|
||||
"@types/sharp": "0.32.0",
|
||||
"@types/simple-oauth2": "5.0.4",
|
||||
"@types/sinonjs__fake-timers": "8.1.2",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/vary": "1.1.0",
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/web-push": "3.6.0",
|
||||
"@types/ws": "8.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "6.2.0",
|
||||
"@typescript-eslint/parser": "6.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.6.0",
|
||||
"@typescript-eslint/parser": "6.6.0",
|
||||
"aws-sdk-client-mock": "3.0.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.46.0",
|
||||
"eslint-plugin-import": "2.28.0",
|
||||
"execa": "7.2.0",
|
||||
"jest": "29.6.2",
|
||||
"jest-mock": "29.6.2",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"execa": "8.0.1",
|
||||
"jest": "29.6.4",
|
||||
"jest-mock": "29.6.3",
|
||||
"simple-oauth2": "5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
|
|||
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
|
||||
import { NestLogger } from '@/NestLogger.js';
|
||||
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
|
||||
import { JanitorService } from '@/daemons/JanitorService.js';
|
||||
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
|
||||
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
|
||||
import { ServerService } from '@/server/ServerService.js';
|
||||
|
@ -25,7 +24,6 @@ export async function server() {
|
|||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.get(ChartManagementService).start();
|
||||
app.get(JanitorService).start();
|
||||
app.get(QueueStatsService).start();
|
||||
app.get(ServerStatsService).start();
|
||||
}
|
||||
|
|
|
@ -3,10 +3,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Config loader
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
@ -23,11 +19,9 @@ type RedisOptionsSource = Partial<RedisOptions> & {
|
|||
};
|
||||
|
||||
/**
|
||||
* ユーザーが設定する必要のある情報
|
||||
* 設定ファイルの型
|
||||
*/
|
||||
export type Source = {
|
||||
repository_url?: string;
|
||||
feedback_url?: string;
|
||||
type Source = {
|
||||
url: string;
|
||||
port?: number;
|
||||
socket?: string;
|
||||
|
@ -70,8 +64,6 @@ export type Source = {
|
|||
|
||||
maxFileSize?: number;
|
||||
|
||||
accesslog?: string;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
id: string;
|
||||
|
@ -93,12 +85,63 @@ export type Source = {
|
|||
videoThumbnailGenerator?: string;
|
||||
|
||||
signToActivityPubGet?: boolean;
|
||||
|
||||
perChannelMaxNoteCacheCount?: number;
|
||||
perUserNotificationsMaxCount?: number;
|
||||
deactivateAntennaThreshold?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報
|
||||
*/
|
||||
export type Mixin = {
|
||||
export type Config = {
|
||||
url: string;
|
||||
port: number;
|
||||
socket: string | undefined;
|
||||
chmodSocket: string | undefined;
|
||||
disableHsts: boolean | undefined;
|
||||
db: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
dbReplications: boolean | undefined;
|
||||
dbSlaves: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
}[] | undefined;
|
||||
meilisearch: {
|
||||
host: string;
|
||||
port: string;
|
||||
apiKey: string;
|
||||
ssl?: boolean;
|
||||
index: string;
|
||||
scope?: 'local' | 'global' | string[];
|
||||
} | undefined;
|
||||
proxy: string | undefined;
|
||||
proxySmtp: string | undefined;
|
||||
proxyBypassHosts: string[] | undefined;
|
||||
allowedPrivateNetworks: string[] | undefined;
|
||||
maxFileSize: number | undefined;
|
||||
clusterLimit: number | undefined;
|
||||
id: string;
|
||||
outgoingAddress: string | undefined;
|
||||
outgoingAddressFamily: 'ipv4' | 'ipv6' | 'dual' | undefined;
|
||||
deliverJobConcurrency: number | undefined;
|
||||
inboxJobConcurrency: number | undefined;
|
||||
relashionshipJobConcurrency: number | undefined;
|
||||
deliverJobPerSec: number | undefined;
|
||||
inboxJobPerSec: number | undefined;
|
||||
relashionshipJobPerSec: number | undefined;
|
||||
deliverJobMaxAttempts: number | undefined;
|
||||
inboxJobMaxAttempts: number | undefined;
|
||||
proxyRemoteFiles: boolean | undefined;
|
||||
signToActivityPubGet: boolean | undefined;
|
||||
|
||||
version: string;
|
||||
host: string;
|
||||
hostname: string;
|
||||
|
@ -117,10 +160,11 @@ export type Mixin = {
|
|||
redis: RedisOptions & RedisOptionsSource;
|
||||
redisForPubsub: RedisOptions & RedisOptionsSource;
|
||||
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
||||
perChannelMaxNoteCacheCount: number;
|
||||
perUserNotificationsMaxCount: number;
|
||||
deactivateAntennaThreshold: number;
|
||||
};
|
||||
|
||||
export type Config = Source & Mixin;
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
|
@ -138,7 +182,7 @@ const path = process.env.MISSKEY_CONFIG_YML
|
|||
? resolve(dir, 'test.yml')
|
||||
: resolve(dir, 'default.yml');
|
||||
|
||||
export function loadConfig() {
|
||||
export function loadConfig(): Config {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
|
||||
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
|
||||
const clientManifest = clientManifestExists ?
|
||||
|
@ -146,43 +190,72 @@ export function loadConfig() {
|
|||
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
const mixin = {} as Mixin;
|
||||
|
||||
const url = tryCreateUrl(config.url);
|
||||
|
||||
config.url = url.origin;
|
||||
|
||||
config.port = config.port ?? parseInt(process.env.PORT ?? '', 10);
|
||||
|
||||
mixin.version = meta.version;
|
||||
mixin.host = url.host;
|
||||
mixin.hostname = url.hostname;
|
||||
mixin.scheme = url.protocol.replace(/:$/, '');
|
||||
mixin.wsScheme = mixin.scheme.replace('http', 'ws');
|
||||
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
|
||||
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
|
||||
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
|
||||
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
|
||||
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
|
||||
mixin.clientEntry = clientManifest['src/_boot_.ts'];
|
||||
mixin.clientManifestExists = clientManifestExists;
|
||||
const version = meta.version;
|
||||
const host = url.host;
|
||||
const hostname = url.hostname;
|
||||
const scheme = url.protocol.replace(/:$/, '');
|
||||
const wsScheme = scheme.replace('http', 'ws');
|
||||
|
||||
const externalMediaProxy = config.mediaProxy ?
|
||||
config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy
|
||||
: null;
|
||||
const internalMediaProxy = `${mixin.scheme}://${mixin.host}/proxy`;
|
||||
mixin.mediaProxy = externalMediaProxy ?? internalMediaProxy;
|
||||
mixin.externalMediaProxyEnabled = externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy;
|
||||
const internalMediaProxy = `${scheme}://${host}/proxy`;
|
||||
const redis = convertRedisOptions(config.redis, host);
|
||||
|
||||
mixin.videoThumbnailGenerator = config.videoThumbnailGenerator ?
|
||||
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
|
||||
: null;
|
||||
|
||||
mixin.redis = convertRedisOptions(config.redis, mixin.host);
|
||||
mixin.redisForPubsub = config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, mixin.host) : mixin.redis;
|
||||
mixin.redisForJobQueue = config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, mixin.host) : mixin.redis;
|
||||
|
||||
return Object.assign(config, mixin);
|
||||
return {
|
||||
version,
|
||||
url: url.origin,
|
||||
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
|
||||
socket: config.socket,
|
||||
chmodSocket: config.chmodSocket,
|
||||
disableHsts: config.disableHsts,
|
||||
host,
|
||||
hostname,
|
||||
scheme,
|
||||
wsScheme,
|
||||
wsUrl: `${wsScheme}://${host}`,
|
||||
apiUrl: `${scheme}://${host}/api`,
|
||||
authUrl: `${scheme}://${host}/auth`,
|
||||
driveUrl: `${scheme}://${host}/files`,
|
||||
db: config.db,
|
||||
dbReplications: config.dbReplications,
|
||||
dbSlaves: config.dbSlaves,
|
||||
meilisearch: config.meilisearch,
|
||||
redis,
|
||||
redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis,
|
||||
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
|
||||
id: config.id,
|
||||
proxy: config.proxy,
|
||||
proxySmtp: config.proxySmtp,
|
||||
proxyBypassHosts: config.proxyBypassHosts,
|
||||
allowedPrivateNetworks: config.allowedPrivateNetworks,
|
||||
maxFileSize: config.maxFileSize,
|
||||
clusterLimit: config.clusterLimit,
|
||||
outgoingAddress: config.outgoingAddress,
|
||||
outgoingAddressFamily: config.outgoingAddressFamily,
|
||||
deliverJobConcurrency: config.deliverJobConcurrency,
|
||||
inboxJobConcurrency: config.inboxJobConcurrency,
|
||||
relashionshipJobConcurrency: config.relashionshipJobConcurrency,
|
||||
deliverJobPerSec: config.deliverJobPerSec,
|
||||
inboxJobPerSec: config.inboxJobPerSec,
|
||||
relashionshipJobPerSec: config.relashionshipJobPerSec,
|
||||
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
|
||||
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
|
||||
proxyRemoteFiles: config.proxyRemoteFiles,
|
||||
signToActivityPubGet: config.signToActivityPubGet,
|
||||
mediaProxy: externalMediaProxy ?? internalMediaProxy,
|
||||
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
|
||||
videoThumbnailGenerator: config.videoThumbnailGenerator ?
|
||||
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
|
||||
: null,
|
||||
userAgent: `Misskey/${version} (${config.url})`,
|
||||
clientEntry: clientManifest['src/_boot_.ts'],
|
||||
clientManifestExists: clientManifestExists,
|
||||
perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000,
|
||||
perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 300,
|
||||
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
|
||||
};
|
||||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
|
|
|
@ -43,7 +43,7 @@ import { RelayService } from './RelayService.js';
|
|||
import { RoleService } from './RoleService.js';
|
||||
import { S3Service } from './S3Service.js';
|
||||
import { SignupService } from './SignupService.js';
|
||||
import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
|
||||
import { WebAuthnService } from './WebAuthnService.js';
|
||||
import { UserBlockingService } from './UserBlockingService.js';
|
||||
import { CacheService } from './CacheService.js';
|
||||
import { UserFollowingService } from './UserFollowingService.js';
|
||||
|
@ -168,7 +168,7 @@ const $RelayService: Provider = { provide: 'RelayService', useExisting: RelaySer
|
|||
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
|
||||
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
|
||||
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
|
||||
const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
|
||||
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
|
||||
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
||||
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
|
||||
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
|
||||
|
@ -296,7 +296,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
RoleService,
|
||||
S3Service,
|
||||
SignupService,
|
||||
TwoFactorAuthenticationService,
|
||||
WebAuthnService,
|
||||
UserBlockingService,
|
||||
CacheService,
|
||||
UserFollowingService,
|
||||
|
@ -417,7 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$RoleService,
|
||||
$S3Service,
|
||||
$SignupService,
|
||||
$TwoFactorAuthenticationService,
|
||||
$WebAuthnService,
|
||||
$UserBlockingService,
|
||||
$CacheService,
|
||||
$UserFollowingService,
|
||||
|
@ -539,7 +539,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
RoleService,
|
||||
S3Service,
|
||||
SignupService,
|
||||
TwoFactorAuthenticationService,
|
||||
WebAuthnService,
|
||||
UserBlockingService,
|
||||
CacheService,
|
||||
UserFollowingService,
|
||||
|
@ -659,7 +659,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$RoleService,
|
||||
$S3Service,
|
||||
$SignupService,
|
||||
$TwoFactorAuthenticationService,
|
||||
$WebAuthnService,
|
||||
$UserBlockingService,
|
||||
$CacheService,
|
||||
$UserFollowingService,
|
||||
|
|
|
@ -8,6 +8,7 @@ import { ulid } from 'ulid';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { genAid, parseAid } from '@/misc/id/aid.js';
|
||||
import { genAidx, parseAidx } from '@/misc/id/aidx.js';
|
||||
import { genMeid, parseMeid } from '@/misc/id/meid.js';
|
||||
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
|
||||
import { genObjectId, parseObjectId } from '@/misc/id/object-id.js';
|
||||
|
@ -31,6 +32,7 @@ export class IdService {
|
|||
|
||||
switch (this.method) {
|
||||
case 'aid': return genAid(date);
|
||||
case 'aidx': return genAidx(date);
|
||||
case 'meid': return genMeid(date);
|
||||
case 'meidg': return genMeidg(date);
|
||||
case 'ulid': return ulid(date.getTime());
|
||||
|
@ -43,6 +45,7 @@ export class IdService {
|
|||
public parse(id: string): { date: Date; } {
|
||||
switch (this.method) {
|
||||
case 'aid': return parseAid(id);
|
||||
case 'aidx': return parseAidx(id);
|
||||
case 'objectid': return parseObjectId(id);
|
||||
case 'meid': return parseMeid(id);
|
||||
case 'meidg': return parseMeidg(id);
|
||||
|
|
|
@ -16,7 +16,7 @@ import type { OnApplicationShutdown } from '@nestjs/common';
|
|||
@Injectable()
|
||||
export class MetaService implements OnApplicationShutdown {
|
||||
private cache: MiMeta | undefined;
|
||||
private intervalId: NodeJS.Timer;
|
||||
private intervalId: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisForSub)
|
||||
|
|
|
@ -334,7 +334,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
if (data.channel) {
|
||||
this.redisClient.xadd(
|
||||
`channelTimeline:${data.channel.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
|
|
@ -17,12 +17,16 @@ import { PushNotificationService } from '@/core/PushNotificationService.js';
|
|||
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
|
@ -96,7 +100,7 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
|
||||
const redisIdPromise = this.redisClient.xadd(
|
||||
`notificationTimeline:${notifieeId}`,
|
||||
'MAXLEN', '~', '300',
|
||||
'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(),
|
||||
'*',
|
||||
'data', JSON.stringify(notification));
|
||||
|
||||
|
|
|
@ -1,446 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as jsrsasign from 'jsrsasign';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
const ECC_PRELUDE = Buffer.from([0x04]);
|
||||
const NULL_BYTE = Buffer.from([0]);
|
||||
const PEM_PRELUDE = Buffer.from(
|
||||
'3059301306072a8648ce3d020106082a8648ce3d030107034200',
|
||||
'hex',
|
||||
);
|
||||
|
||||
// Android Safetynet attestations are signed with this cert:
|
||||
const GSR2 = `-----BEGIN CERTIFICATE-----
|
||||
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
|
||||
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
|
||||
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
|
||||
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
|
||||
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
|
||||
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
|
||||
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
|
||||
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
|
||||
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
|
||||
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
|
||||
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
|
||||
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
|
||||
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
|
||||
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
|
||||
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
|
||||
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
|
||||
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
|
||||
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
|
||||
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
|
||||
-----END CERTIFICATE-----\n`;
|
||||
|
||||
function base64URLDecode(source: string) {
|
||||
return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function getCertSubject(certificate: string) {
|
||||
const subjectCert = new jsrsasign.X509();
|
||||
subjectCert.readCertPEM(certificate);
|
||||
|
||||
const subjectString = subjectCert.getSubjectString();
|
||||
const subjectFields = subjectString.slice(1).split('/');
|
||||
|
||||
const fields = {} as Record<string, string>;
|
||||
for (const field of subjectFields) {
|
||||
const eqIndex = field.indexOf('=');
|
||||
fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function verifyCertificateChain(certificates: string[]) {
|
||||
let valid = true;
|
||||
|
||||
for (let i = 0; i < certificates.length; i++) {
|
||||
const Cert = certificates[i];
|
||||
const certificate = new jsrsasign.X509();
|
||||
certificate.readCertPEM(Cert);
|
||||
|
||||
const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
|
||||
|
||||
const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]);
|
||||
if (certStruct == null) throw new Error('certStruct is null');
|
||||
|
||||
const algorithm = certificate.getSignatureAlgorithmField();
|
||||
const signatureHex = certificate.getSignatureValueHex();
|
||||
|
||||
// Verify against CA
|
||||
const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm });
|
||||
Signature.init(CACert);
|
||||
Signature.updateHex(certStruct);
|
||||
valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
|
||||
if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) {
|
||||
pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
|
||||
type = 'PUBLIC KEY';
|
||||
}
|
||||
const cert = pemBuffer.toString('base64');
|
||||
|
||||
const keyParts = [];
|
||||
const max = Math.ceil(cert.length / 64);
|
||||
let start = 0;
|
||||
for (let i = 0; i < max; i++) {
|
||||
keyParts.push(cert.substring(start, start + 64));
|
||||
start += 64;
|
||||
}
|
||||
|
||||
return (
|
||||
`-----BEGIN ${type}-----\n` +
|
||||
keyParts.join('\n') +
|
||||
`\n-----END ${type}-----\n`
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorAuthenticationService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public hash(data: Buffer) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(data)
|
||||
.digest();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public verifySignin({
|
||||
publicKey,
|
||||
authenticatorData,
|
||||
clientDataJSON,
|
||||
clientData,
|
||||
signature,
|
||||
challenge,
|
||||
}: {
|
||||
publicKey: Buffer,
|
||||
authenticatorData: Buffer,
|
||||
clientDataJSON: Buffer,
|
||||
clientData: any,
|
||||
signature: Buffer,
|
||||
challenge: string
|
||||
}) {
|
||||
if (clientData.type !== 'webauthn.get') {
|
||||
throw new Error('type is not webauthn.get');
|
||||
}
|
||||
|
||||
if (this.hash(clientData.challenge).toString('hex') !== challenge) {
|
||||
throw new Error('challenge mismatch');
|
||||
}
|
||||
if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
|
||||
throw new Error('origin mismatch');
|
||||
}
|
||||
|
||||
const verificationData = Buffer.concat(
|
||||
[authenticatorData, this.hash(clientDataJSON)],
|
||||
32 + authenticatorData.length,
|
||||
);
|
||||
|
||||
return crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(publicKey), signature);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getProcedures() {
|
||||
return {
|
||||
none: {
|
||||
verify({ publicKey }: { publicKey: Map<number, Buffer> }) {
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyU2F = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
return {
|
||||
publicKey: publicKeyU2F,
|
||||
valid: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
'android-key': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
if (attStmt.alg !== -7) {
|
||||
throw new Error('alg mismatch');
|
||||
}
|
||||
|
||||
const verificationData = Buffer.concat([
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
]);
|
||||
|
||||
const attCert: Buffer = attStmt.x5c[0];
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
if (!attCert.equals(publicKeyData)) {
|
||||
throw new Error('public key mismatch');
|
||||
}
|
||||
|
||||
const isValid = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
|
||||
|
||||
return {
|
||||
valid: isValid,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
},
|
||||
},
|
||||
// what a stupid attestation
|
||||
'android-safetynet': {
|
||||
verify: ({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) => {
|
||||
const verificationData = this.hash(
|
||||
Buffer.concat([authenticatorData, clientDataHash]),
|
||||
);
|
||||
|
||||
const jwsParts = attStmt.response.toString('utf-8').split('.');
|
||||
|
||||
const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
|
||||
const response = JSON.parse(
|
||||
base64URLDecode(jwsParts[1]).toString('utf-8'),
|
||||
);
|
||||
const signature = jwsParts[2];
|
||||
|
||||
if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
|
||||
throw new Error('invalid nonce');
|
||||
}
|
||||
|
||||
const certificateChain = header.x5c
|
||||
.map((key: any) => PEMString(key))
|
||||
.concat([GSR2]);
|
||||
|
||||
if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') {
|
||||
throw new Error('invalid common name');
|
||||
}
|
||||
|
||||
if (!verifyCertificateChain(certificateChain)) {
|
||||
throw new Error('Invalid certificate chain!');
|
||||
}
|
||||
|
||||
const signatureBase = Buffer.from(
|
||||
jwsParts[0] + '.' + jwsParts[1],
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const valid = crypto
|
||||
.createVerify('sha256')
|
||||
.update(signatureBase)
|
||||
.verify(certificateChain[0], base64URLDecode(signature));
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
return {
|
||||
valid,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
},
|
||||
},
|
||||
packed: {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
const verificationData = Buffer.concat([
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
]);
|
||||
|
||||
if (attStmt.x5c) {
|
||||
const attCert = attStmt.x5c[0];
|
||||
|
||||
const validSignature = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
return {
|
||||
valid: validSignature,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
} else if (attStmt.ecdaaKeyId) {
|
||||
// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
|
||||
throw new Error('ECDAA-Verify is not supported');
|
||||
} else {
|
||||
if (attStmt.alg !== -7) throw new Error('alg mismatch');
|
||||
|
||||
throw new Error('self attestation is not supported');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'fido-u2f': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>,
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer
|
||||
}) {
|
||||
const x5c: Buffer[] = attStmt.x5c;
|
||||
if (x5c.length !== 1) {
|
||||
throw new Error('x5c length does not match expectation');
|
||||
}
|
||||
|
||||
const attCert = x5c[0];
|
||||
|
||||
// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
|
||||
|
||||
const negTwo: Buffer = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree: Buffer = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyU2F = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
const verificationData = Buffer.concat([
|
||||
NULL_BYTE,
|
||||
rpIdHash,
|
||||
clientDataHash,
|
||||
credentialId,
|
||||
publicKeyU2F,
|
||||
]);
|
||||
|
||||
const validSignature = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
return {
|
||||
valid: validSignature,
|
||||
publicKey: publicKeyU2F,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
252
packages/backend/src/core/WebAuthnService.ts
Normal file
252
packages/backend/src/core/WebAuthnService.ts
Normal file
|
@ -0,0 +1,252 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions, verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UserSecurityKeysRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { MiUser } from '@/models/index.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
CredentialDeviceType,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialDescriptorFuture,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/typescript-types';
|
||||
|
||||
@Injectable()
|
||||
export class WebAuthnService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getRelyingParty(): Promise<{ origin: string; rpId: string; rpName: string; rpIcon?: string; }> {
|
||||
const instance = await this.metaService.fetch();
|
||||
return {
|
||||
origin: this.config.url,
|
||||
rpId: this.config.host,
|
||||
rpName: instance.name ?? this.config.host,
|
||||
rpIcon: instance.iconUrl ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async initiateRegistration(userId: MiUser['id'], userName: string, userDisplayName?: string): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
const keys = await this.userSecurityKeysRepository.findBy({
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
const registrationOptions = await generateRegistrationOptions({
|
||||
rpName: relyingParty.rpName,
|
||||
rpID: relyingParty.rpId,
|
||||
userID: userId,
|
||||
userName: userName,
|
||||
userDisplayName: userDisplayName,
|
||||
attestationType: 'indirect',
|
||||
excludeCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
|
||||
id: Buffer.from(key.id, 'base64url'),
|
||||
type: 'public-key',
|
||||
transports: key.transports ?? undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
|
||||
await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, registrationOptions.challenge);
|
||||
|
||||
return registrationOptions;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{
|
||||
credentialID: Uint8Array;
|
||||
credentialPublicKey: Uint8Array;
|
||||
attestationObject: Uint8Array;
|
||||
fmt: AttestationFormat;
|
||||
counter: number;
|
||||
userVerified: boolean;
|
||||
credentialDeviceType: CredentialDeviceType;
|
||||
credentialBackedUp: boolean;
|
||||
transports?: AuthenticatorTransportFuture[];
|
||||
}> {
|
||||
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
|
||||
|
||||
if (!challenge) {
|
||||
throw new IdentifiableError('7dbfb66c-9216-4e2b-9c27-cef2ac8efb84', 'challenge not found');
|
||||
}
|
||||
|
||||
await this.redisClient.del(`webauthn:challenge:${userId}`);
|
||||
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: relyingParty.origin,
|
||||
expectedRPID: relyingParty.rpId,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed');
|
||||
}
|
||||
|
||||
const { verified } = verification;
|
||||
|
||||
if (!verified || !verification.registrationInfo) {
|
||||
throw new IdentifiableError('bb333667-3832-4a80-8bb5-c505be7d710d', 'verification failed');
|
||||
}
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
|
||||
return {
|
||||
credentialID: registrationInfo.credentialID,
|
||||
credentialPublicKey: registrationInfo.credentialPublicKey,
|
||||
attestationObject: registrationInfo.attestationObject,
|
||||
fmt: registrationInfo.fmt,
|
||||
counter: registrationInfo.counter,
|
||||
userVerified: registrationInfo.userVerified,
|
||||
credentialDeviceType: registrationInfo.credentialDeviceType,
|
||||
credentialBackedUp: registrationInfo.credentialBackedUp,
|
||||
transports: response.response.transports,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async initiateAuthentication(userId: MiUser['id']): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const keys = await this.userSecurityKeysRepository.findBy({
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
if (keys.length === 0) {
|
||||
throw new IdentifiableError('f27fd449-9af4-4841-9249-1f989b9fa4a4', 'no keys found');
|
||||
}
|
||||
|
||||
const authenticationOptions = await generateAuthenticationOptions({
|
||||
allowCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
|
||||
id: Buffer.from(key.id, 'base64url'),
|
||||
type: 'public-key',
|
||||
transports: key.transports ?? undefined,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, authenticationOptions.challenge);
|
||||
|
||||
return authenticationOptions;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
|
||||
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
|
||||
|
||||
if (!challenge) {
|
||||
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
|
||||
}
|
||||
|
||||
await this.redisClient.del(`webauthn:challenge:${userId}`);
|
||||
|
||||
const key = await this.userSecurityKeysRepository.findOneBy({
|
||||
id: response.id,
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'unknown key');
|
||||
}
|
||||
|
||||
// マイグレーション
|
||||
if (key.counter === 0 && key.publicKey.length === 87) {
|
||||
const cert = new Uint8Array(Buffer.from(key.publicKey, 'base64url'));
|
||||
if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた
|
||||
const halfLength = (cert.length - 1) / 2;
|
||||
|
||||
const cborMap = new Map<number, number | ArrayBufferLike>();
|
||||
cborMap.set(1, 2); // kty, EC2
|
||||
cborMap.set(3, -7); // alg, ES256
|
||||
cborMap.set(-1, 1); // crv, P256
|
||||
cborMap.set(-2, cert.slice(1, halfLength + 1)); // x
|
||||
cborMap.set(-3, cert.slice(halfLength + 1)); // y
|
||||
|
||||
const cborPubKey = Buffer.from(isoCBOR.encode(cborMap)).toString('base64url');
|
||||
await this.userSecurityKeysRepository.update({
|
||||
id: response.id,
|
||||
userId: userId,
|
||||
}, {
|
||||
publicKey: cborPubKey,
|
||||
});
|
||||
key.publicKey = cborPubKey;
|
||||
}
|
||||
}
|
||||
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: relyingParty.origin,
|
||||
expectedRPID: relyingParty.rpId,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(key.id, 'base64url'),
|
||||
credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
|
||||
counter: key.counter,
|
||||
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
|
||||
},
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed');
|
||||
}
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (!verified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.userSecurityKeysRepository.update({
|
||||
id: response.id,
|
||||
userId: userId,
|
||||
}, {
|
||||
lastUsed: new Date(),
|
||||
counter: authenticationInfo.newCounter,
|
||||
credentialDeviceType: authenticationInfo.credentialDeviceType,
|
||||
credentialBackedUp: authenticationInfo.credentialBackedUp,
|
||||
});
|
||||
|
||||
return verified;
|
||||
}
|
||||
}
|
|
@ -23,7 +23,7 @@ import type { OnApplicationShutdown } from '@nestjs/common';
|
|||
@Injectable()
|
||||
export class ChartManagementService implements OnApplicationShutdown {
|
||||
private charts;
|
||||
private saveIntervalId: NodeJS.Timer;
|
||||
private saveIntervalId: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private federationChart: FederationChart,
|
||||
|
|
|
@ -434,6 +434,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
preventAiLearning: profile!.preventAiLearning,
|
||||
isExplorable: user.isExplorable,
|
||||
isDeleted: user.isDeleted,
|
||||
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
|
||||
where: { userId: user.id, isSpecified: true },
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { JanitorService } from './JanitorService.js';
|
||||
import { QueueStatsService } from './QueueStatsService.js';
|
||||
import { ServerStatsService } from './ServerStatsService.js';
|
||||
|
||||
|
@ -16,12 +15,10 @@ import { ServerStatsService } from './ServerStatsService.js';
|
|||
CoreModule,
|
||||
],
|
||||
providers: [
|
||||
JanitorService,
|
||||
QueueStatsService,
|
||||
ServerStatsService,
|
||||
],
|
||||
exports: [
|
||||
JanitorService,
|
||||
QueueStatsService,
|
||||
ServerStatsService,
|
||||
],
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { LessThan } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { AttestationChallengesRepository } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const interval = 30 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class JanitorService implements OnApplicationShutdown {
|
||||
private intervalId: NodeJS.Timer;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.attestationChallengesRepository)
|
||||
private attestationChallengesRepository: AttestationChallengesRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up database occasionally
|
||||
*/
|
||||
@bindThis
|
||||
public start(): void {
|
||||
const tick = async () => {
|
||||
await this.attestationChallengesRepository.delete({
|
||||
createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)),
|
||||
});
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
this.intervalId = setInterval(tick, interval);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
|
@ -19,7 +19,7 @@ const interval = 10000;
|
|||
|
||||
@Injectable()
|
||||
export class QueueStatsService implements OnApplicationShutdown {
|
||||
private intervalId: NodeJS.Timer;
|
||||
private intervalId: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
|
|
@ -20,7 +20,7 @@ const round = (num: number) => Math.round(num * 10) / 10;
|
|||
|
||||
@Injectable()
|
||||
export class ServerStatsService implements OnApplicationShutdown {
|
||||
private intervalId: NodeJS.Timer | null = null;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
|
|
|
@ -26,7 +26,6 @@ export const DI = {
|
|||
userProfilesRepository: Symbol('userProfilesRepository'),
|
||||
userKeypairsRepository: Symbol('userKeypairsRepository'),
|
||||
userPendingsRepository: Symbol('userPendingsRepository'),
|
||||
attestationChallengesRepository: Symbol('attestationChallengesRepository'),
|
||||
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
|
||||
userPublickeysRepository: Symbol('userPublickeysRepository'),
|
||||
userListsRepository: Symbol('userListsRepository'),
|
||||
|
|
|
@ -193,7 +193,7 @@ function nothingToDo<T, V = T>(value: T): V {
|
|||
export class MemoryKVCache<T, V = T> {
|
||||
public cache: Map<string, { date: number; value: V; }>;
|
||||
private lifetime: number;
|
||||
private gcIntervalHandle: NodeJS.Timer;
|
||||
private gcIntervalHandle: NodeJS.Timeout;
|
||||
private toMapConverter: (value: T) => V;
|
||||
private fromMapConverter: (cached: V) => T | undefined;
|
||||
|
||||
|
|
44
packages/backend/src/misc/id/aidx.ts
Normal file
44
packages/backend/src/misc/id/aidx.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// AIDX
|
||||
// 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ4の[個体ID] + 長さ4の[カウンタ]
|
||||
// (c) mei23
|
||||
// https://misskey.m544.net/notes/71899acdcc9859ec5708ac24
|
||||
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
export const aidxRegExp = /^[0-9a-z]{16}$/;
|
||||
|
||||
const TIME2000 = 946684800000;
|
||||
const TIME_LENGTH = 8;
|
||||
const NODE_LENGTH = 4;
|
||||
const NOISE_LENGTH = 4;
|
||||
|
||||
const nodeId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', NODE_LENGTH)();
|
||||
let counter = 0;
|
||||
|
||||
function getTime(time: number): string {
|
||||
time = time - TIME2000;
|
||||
if (time < 0) time = 0;
|
||||
|
||||
return time.toString(36).padStart(TIME_LENGTH, '0').slice(-TIME_LENGTH);
|
||||
}
|
||||
|
||||
function getNoise(): string {
|
||||
return counter.toString(36).padStart(NOISE_LENGTH, '0').slice(-NOISE_LENGTH);
|
||||
}
|
||||
|
||||
export function genAidx(date: Date): string {
|
||||
const t = date.getTime();
|
||||
if (isNaN(t)) throw new Error('Failed to create AIDX: Invalid Date');
|
||||
counter++;
|
||||
return getTime(t) + nodeId + getNoise();
|
||||
}
|
||||
|
||||
export function parseAidx(id: string): { date: Date; } {
|
||||
const time = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000;
|
||||
return { date: new Date(time) };
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAttestationChallenge, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './index.js';
|
||||
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './index.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
|
@ -93,12 +93,6 @@ const $userPendingsRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $attestationChallengesRepository: Provider = {
|
||||
provide: DI.attestationChallengesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiAttestationChallenge),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userSecurityKeysRepository: Provider = {
|
||||
provide: DI.userSecurityKeysRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey),
|
||||
|
@ -423,7 +417,6 @@ const $userMemosRepository: Provider = {
|
|||
$userProfilesRepository,
|
||||
$userKeypairsRepository,
|
||||
$userPendingsRepository,
|
||||
$attestationChallengesRepository,
|
||||
$userSecurityKeysRepository,
|
||||
$userPublickeysRepository,
|
||||
$userListsRepository,
|
||||
|
@ -491,7 +484,6 @@ const $userMemosRepository: Provider = {
|
|||
$userProfilesRepository,
|
||||
$userKeypairsRepository,
|
||||
$userPendingsRepository,
|
||||
$attestationChallengesRepository,
|
||||
$userSecurityKeysRepository,
|
||||
$userPublickeysRepository,
|
||||
$userListsRepository,
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
|
||||
import { id } from '../id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
@Entity('attestation_challenge')
|
||||
export class MiAttestationChallenge {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@PrimaryColumn(id())
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@ManyToOne(type => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 64,
|
||||
comment: 'Hex-encoded sha256 hash of the challenge.',
|
||||
})
|
||||
public challenge: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The date challenge was created for expiry purposes.',
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('boolean', {
|
||||
comment:
|
||||
'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.',
|
||||
default: false,
|
||||
})
|
||||
public registrationChallenge: boolean;
|
||||
|
||||
constructor(data: Partial<MiAttestationChallenge>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,6 +33,7 @@ export type MiNotification = {
|
|||
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
|
||||
* achievementEarned - 実績を獲得
|
||||
* app - アプリ通知
|
||||
* test - テスト通知(サーバー側)
|
||||
*/
|
||||
type: typeof notificationTypes[number];
|
||||
|
||||
|
|
|
@ -101,6 +101,11 @@ export class MiUserProfile {
|
|||
})
|
||||
public twoFactorSecret: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
nullable: true, array: true,
|
||||
})
|
||||
public twoFactorBackupSecret: string[] | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
|
@ -24,25 +24,48 @@ export class MiUserSecurityKey {
|
|||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
comment:
|
||||
'Variable-length public key used to verify attestations (hex-encoded).',
|
||||
})
|
||||
public publicKey: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment:
|
||||
'The date of the last time the UserSecurityKey was successfully validated.',
|
||||
})
|
||||
public lastUsed: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
comment: 'User-defined name for this key',
|
||||
length: 30,
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
comment: 'The public key of the UserSecurityKey, hex-encoded.',
|
||||
})
|
||||
public publicKey: string;
|
||||
|
||||
@Column('bigint', {
|
||||
comment: 'The number of times the UserSecurityKey was validated.',
|
||||
default: 0,
|
||||
})
|
||||
public counter: number;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'Timestamp of the last time the UserSecurityKey was used.',
|
||||
default: () => 'now()',
|
||||
})
|
||||
public lastUsed: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
comment: 'The type of Backup Eligibility in authenticator data',
|
||||
length: 32, nullable: true,
|
||||
})
|
||||
public credentialDeviceType: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
comment: 'Whether or not the credential has been backed up',
|
||||
nullable: true,
|
||||
})
|
||||
public credentialBackedUp: boolean | null;
|
||||
|
||||
@Column('varchar', {
|
||||
comment: 'The type of the credential returned by the browser',
|
||||
length: 32, array: true, nullable: true,
|
||||
})
|
||||
public transports: string[] | null;
|
||||
|
||||
constructor(data: Partial<MiUserSecurityKey>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
|
|
@ -10,7 +10,6 @@ import { MiAnnouncement } from '@/models/entities/Announcement.js';
|
|||
import { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
|
||||
import { MiAntenna } from '@/models/entities/Antenna.js';
|
||||
import { MiApp } from '@/models/entities/App.js';
|
||||
import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
|
||||
import { MiAuthSession } from '@/models/entities/AuthSession.js';
|
||||
import { MiBlocking } from '@/models/entities/Blocking.js';
|
||||
import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
|
||||
|
@ -79,7 +78,6 @@ export {
|
|||
MiAnnouncementRead,
|
||||
MiAntenna,
|
||||
MiApp,
|
||||
MiAttestationChallenge,
|
||||
MiAuthSession,
|
||||
MiBlocking,
|
||||
MiChannelFollowing,
|
||||
|
@ -147,7 +145,6 @@ export type AnnouncementsRepository = Repository<MiAnnouncement>;
|
|||
export type AnnouncementReadsRepository = Repository<MiAnnouncementRead>;
|
||||
export type AntennasRepository = Repository<MiAntenna>;
|
||||
export type AppsRepository = Repository<MiApp>;
|
||||
export type AttestationChallengesRepository = Repository<MiAttestationChallenge>;
|
||||
export type AuthSessionsRepository = Repository<MiAuthSession>;
|
||||
export type BlockingsRepository = Repository<MiBlocking>;
|
||||
export type ChannelFollowingsRepository = Repository<MiChannelFollowing>;
|
||||
|
|
|
@ -321,6 +321,11 @@ export const packedMeDetailedOnlySchema = {
|
|||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
twoFactorBackupCodesStock: {
|
||||
type: 'string',
|
||||
enum: ['full', 'partial', 'none'],
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
hideOnlineStatus: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
|
|
|
@ -18,7 +18,6 @@ import { MiAnnouncement } from '@/models/entities/Announcement.js';
|
|||
import { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
|
||||
import { MiAntenna } from '@/models/entities/Antenna.js';
|
||||
import { MiApp } from '@/models/entities/App.js';
|
||||
import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
|
||||
import { MiAuthSession } from '@/models/entities/AuthSession.js';
|
||||
import { MiBlocking } from '@/models/entities/Blocking.js';
|
||||
import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
|
||||
|
@ -143,7 +142,6 @@ export const entities = [
|
|||
MiUserNotePining,
|
||||
MiUserSecurityKey,
|
||||
MiUsedUsername,
|
||||
MiAttestationChallenge,
|
||||
MiFollowing,
|
||||
MiFollowRequest,
|
||||
MiMuting,
|
||||
|
|
|
@ -10,6 +10,7 @@ import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepositor
|
|||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
|
@ -18,6 +19,9 @@ export class CleanProcessorService {
|
|||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.userIpsRepository)
|
||||
private userIpsRepository: UserIpsRepository,
|
||||
|
||||
|
@ -54,12 +58,14 @@ export class CleanProcessorService {
|
|||
reason: 'word',
|
||||
});
|
||||
|
||||
// 7日以上使われてないアンテナを停止
|
||||
this.antennasRepository.update({
|
||||
lastUsedAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 7))),
|
||||
}, {
|
||||
isActive: false,
|
||||
});
|
||||
// 使われてないアンテナを停止
|
||||
if (this.config.deactivateAntennaThreshold > 0) {
|
||||
this.antennasRepository.update({
|
||||
lastUsedAt: LessThan(new Date(Date.now() - this.config.deactivateAntennaThreshold)),
|
||||
}, {
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign')
|
||||
.where('assign.expiresAt IS NOT NULL')
|
||||
|
|
|
@ -35,10 +35,10 @@ export class NodeinfoServerService {
|
|||
|
||||
@bindThis
|
||||
public getLinks() {
|
||||
return [/* (awaiting release) {
|
||||
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
||||
href: config.url + nodeinfo2_1path
|
||||
}, */{
|
||||
return [{
|
||||
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
||||
href: this.config.url + nodeinfo2_1path
|
||||
}, {
|
||||
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0',
|
||||
href: this.config.url + nodeinfo2_0path,
|
||||
}];
|
||||
|
@ -46,7 +46,7 @@ export class NodeinfoServerService {
|
|||
|
||||
@bindThis
|
||||
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
const nodeinfo2 = async () => {
|
||||
const nodeinfo2 = async (version: number) => {
|
||||
const now = Date.now();
|
||||
|
||||
const notesChart = await this.notesChart.getChart('hour', 1, null);
|
||||
|
@ -73,11 +73,11 @@ export class NodeinfoServerService {
|
|||
|
||||
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const document: any = {
|
||||
software: {
|
||||
name: 'misskey',
|
||||
version: this.config.version,
|
||||
repository: meta.repositoryUrl,
|
||||
},
|
||||
protocols: ['activitypub'],
|
||||
services: {
|
||||
|
@ -114,23 +114,36 @@ export class NodeinfoServerService {
|
|||
themeColor: meta.themeColor ?? '#86b300',
|
||||
},
|
||||
};
|
||||
if (version >= 21) {
|
||||
document.software.repository = meta.repositoryUrl;
|
||||
document.software.homepage = meta.repositoryUrl;
|
||||
}
|
||||
return document;
|
||||
};
|
||||
|
||||
const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
|
||||
|
||||
fastify.get(nodeinfo2_1path, async (request, reply) => {
|
||||
const base = await cache.fetch(() => nodeinfo2());
|
||||
const base = await cache.fetch(() => nodeinfo2(21));
|
||||
|
||||
reply.header('Cache-Control', 'public, max-age=600');
|
||||
reply
|
||||
.type(
|
||||
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"',
|
||||
)
|
||||
.header('Cache-Control', 'public, max-age=600');
|
||||
return { version: '2.1', ...base };
|
||||
});
|
||||
|
||||
fastify.get(nodeinfo2_0path, async (request, reply) => {
|
||||
const base = await cache.fetch(() => nodeinfo2());
|
||||
const base = await cache.fetch(() => nodeinfo2(20));
|
||||
|
||||
delete (base as any).software.repository;
|
||||
|
||||
reply.header('Cache-Control', 'public, max-age=600');
|
||||
reply
|
||||
.type(
|
||||
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"',
|
||||
)
|
||||
.header('Cache-Control', 'public, max-age=600');
|
||||
return { version: '2.0', ...base };
|
||||
});
|
||||
|
||||
|
|
|
@ -73,7 +73,7 @@ export class WellKnownServerService {
|
|||
});
|
||||
|
||||
fastify.get('/.well-known/host-meta.json', async (request, reply) => {
|
||||
reply.header('Content-Type', jrd);
|
||||
reply.header('Content-Type', 'application/json');
|
||||
return {
|
||||
links: [{
|
||||
rel: 'lrdd',
|
||||
|
|
|
@ -35,7 +35,7 @@ const accessDenied = {
|
|||
export class ApiCallService implements OnApplicationShutdown {
|
||||
private logger: Logger;
|
||||
private userIpHistories: Map<MiUser['id'], Set<string>>;
|
||||
private userIpHistoriesClearIntervalId: NodeJS.Timer;
|
||||
private userIpHistoriesClearIntervalId: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userIpsRepository)
|
||||
|
|
|
@ -283,6 +283,7 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
|
|||
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
||||
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
||||
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
||||
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
|
||||
import * as ep___pagePush from './endpoints/page-push.js';
|
||||
import * as ep___pages_create from './endpoints/pages/create.js';
|
||||
import * as ep___pages_delete from './endpoints/pages/delete.js';
|
||||
|
@ -629,6 +630,7 @@ const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep__
|
|||
const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default };
|
||||
const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default };
|
||||
const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default };
|
||||
const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default };
|
||||
const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default };
|
||||
const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default };
|
||||
const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default };
|
||||
|
@ -979,6 +981,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$notes_userListTimeline,
|
||||
$notifications_create,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_testNotification,
|
||||
$pagePush,
|
||||
$pages_create,
|
||||
$pages_delete,
|
||||
|
|
|
@ -3,22 +3,26 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type {
|
||||
SigninsRepository,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import type { MiLocalUser } from '@/models/entities/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class SigninApiService {
|
||||
|
@ -29,22 +33,16 @@ export class SigninApiService {
|
|||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.attestationChallengesRepository)
|
||||
private attestationChallengesRepository: AttestationChallengesRepository,
|
||||
|
||||
@Inject(DI.signinsRepository)
|
||||
private signinsRepository: SigninsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private rateLimiterService: RateLimiterService,
|
||||
private signinService: SigninService,
|
||||
private twoFactorAuthenticationService: TwoFactorAuthenticationService,
|
||||
private webAuthnService: WebAuthnService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -55,11 +53,7 @@ export class SigninApiService {
|
|||
username: string;
|
||||
password: string;
|
||||
token?: string;
|
||||
signature?: string;
|
||||
authenticatorData?: string;
|
||||
clientDataJSON?: string;
|
||||
credentialId?: string;
|
||||
challengeId?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
|
@ -160,6 +154,13 @@ export class SigninApiService {
|
|||
});
|
||||
}
|
||||
|
||||
if (profile.twoFactorBackupSecret?.includes(token)) {
|
||||
await this.userProfilesRepository.update({ userId: profile.userId }, {
|
||||
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
|
||||
});
|
||||
return this.signinService.signin(request, reply, user);
|
||||
}
|
||||
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
|
||||
digits: 6,
|
||||
|
@ -174,64 +175,16 @@ export class SigninApiService {
|
|||
} else {
|
||||
return this.signinService.signin(request, reply, user);
|
||||
}
|
||||
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
|
||||
} else if (body.credential) {
|
||||
if (!same && !profile.usePasswordLessLogin) {
|
||||
return await fail(403, {
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
});
|
||||
}
|
||||
|
||||
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
|
||||
const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
|
||||
const challenge = await this.attestationChallengesRepository.findOneBy({
|
||||
userId: user.id,
|
||||
id: body.challengeId,
|
||||
registrationChallenge: false,
|
||||
challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'),
|
||||
});
|
||||
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
|
||||
|
||||
if (!challenge) {
|
||||
return await fail(403, {
|
||||
id: '2715a88a-2125-4013-932f-aa6fe72792da',
|
||||
});
|
||||
}
|
||||
|
||||
await this.attestationChallengesRepository.delete({
|
||||
userId: user.id,
|
||||
id: body.challengeId,
|
||||
});
|
||||
|
||||
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
|
||||
return await fail(403, {
|
||||
id: '2715a88a-2125-4013-932f-aa6fe72792da',
|
||||
});
|
||||
}
|
||||
|
||||
const securityKey = await this.userSecurityKeysRepository.findOneBy({
|
||||
id: Buffer.from(
|
||||
body.credentialId
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/'),
|
||||
'base64',
|
||||
).toString('hex'),
|
||||
});
|
||||
|
||||
if (!securityKey) {
|
||||
return await fail(403, {
|
||||
id: '66269679-aeaf-4474-862b-eb761197e046',
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = this.twoFactorAuthenticationService.verifySignin({
|
||||
publicKey: Buffer.from(securityKey.publicKey, 'hex'),
|
||||
authenticatorData: Buffer.from(body.authenticatorData, 'hex'),
|
||||
clientDataJSON,
|
||||
clientData,
|
||||
signature: Buffer.from(body.signature, 'hex'),
|
||||
challenge: challenge.challenge,
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
if (authorized) {
|
||||
return this.signinService.signin(request, reply, user);
|
||||
} else {
|
||||
return await fail(403, {
|
||||
|
@ -245,42 +198,11 @@ export class SigninApiService {
|
|||
});
|
||||
}
|
||||
|
||||
const keys = await this.userSecurityKeysRepository.findBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (keys.length === 0) {
|
||||
return await fail(403, {
|
||||
id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
|
||||
});
|
||||
}
|
||||
|
||||
// 32 byte challenge
|
||||
const challenge = randomBytes(32).toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
|
||||
const challengeId = this.idService.genId();
|
||||
|
||||
await this.attestationChallengesRepository.insert({
|
||||
userId: user.id,
|
||||
id: challengeId,
|
||||
challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
|
||||
createdAt: new Date(),
|
||||
registrationChallenge: false,
|
||||
});
|
||||
const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
|
||||
|
||||
reply.code(200);
|
||||
return {
|
||||
challenge,
|
||||
challengeId,
|
||||
securityKeys: keys.map(key => ({
|
||||
id: key.id,
|
||||
})),
|
||||
};
|
||||
return authRequest;
|
||||
}
|
||||
// never get here
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -283,6 +283,7 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
|
|||
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
||||
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
||||
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
||||
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
|
||||
import * as ep___pagePush from './endpoints/page-push.js';
|
||||
import * as ep___pages_create from './endpoints/pages/create.js';
|
||||
import * as ep___pages_delete from './endpoints/pages/delete.js';
|
||||
|
@ -627,6 +628,7 @@ const eps = [
|
|||
['notes/user-list-timeline', ep___notes_userListTimeline],
|
||||
['notifications/create', ep___notifications_create],
|
||||
['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
|
||||
['notifications/test-notification', ep___notifications_testNotification],
|
||||
['page-push', ep___pagePush],
|
||||
['pages/create', ep___pages_create],
|
||||
['pages/delete', ep___pages_delete],
|
||||
|
|
|
@ -54,8 +54,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new Error('not verified');
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({ length: 5 }, () => new OTPAuth.Secret().base32);
|
||||
|
||||
await this.userProfilesRepository.update(me.id, {
|
||||
twoFactorSecret: profile.twoFactorTempSecret,
|
||||
twoFactorBackupSecret: backupCodes,
|
||||
twoFactorEnabled: true,
|
||||
});
|
||||
|
||||
|
@ -64,6 +67,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
return {
|
||||
backupCodes: backupCodes,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,155 +3,86 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { promisify } from 'node:util';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import cbor from 'cbor';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
|
||||
import type { AttestationChallengesRepository, UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
|
||||
|
||||
const cborDecodeFirst = promisify(cbor.decodeFirst) as any;
|
||||
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
|
||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
incorrectPassword: {
|
||||
message: 'Incorrect password.',
|
||||
code: 'INCORRECT_PASSWORD',
|
||||
id: '0d7ec6d2-e652-443e-a7bf-9ee9a0cd77b0',
|
||||
},
|
||||
|
||||
twoFactorNotEnabled: {
|
||||
message: '2fa not enabled.',
|
||||
code: 'TWO_FACTOR_NOT_ENABLED',
|
||||
id: '798d6847-b1ed-4f9c-b1f9-163c42655995',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
clientDataJSON: { type: 'string' },
|
||||
attestationObject: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
challengeId: { type: 'string' },
|
||||
name: { type: 'string', minLength: 1, maxLength: 30 },
|
||||
credential: { type: 'object' },
|
||||
},
|
||||
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
|
||||
required: ['password', 'name', 'credential'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
@Inject(DI.attestationChallengesRepository)
|
||||
private attestationChallengesRepository: AttestationChallengesRepository,
|
||||
|
||||
private webAuthnService: WebAuthnService,
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private twoFactorAuthenticationService: TwoFactorAuthenticationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const rpIdHashReal = this.twoFactorAuthenticationService.hash(Buffer.from(this.config.hostname, 'utf-8'));
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
throw new ApiError(meta.errors.incorrectPassword);
|
||||
}
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
throw new Error('2fa not enabled');
|
||||
throw new ApiError(meta.errors.twoFactorNotEnabled);
|
||||
}
|
||||
|
||||
const clientData = JSON.parse(ps.clientDataJSON);
|
||||
|
||||
if (clientData.type !== 'webauthn.create') {
|
||||
throw new Error('not a creation attestation');
|
||||
}
|
||||
if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
|
||||
throw new Error('origin mismatch');
|
||||
}
|
||||
|
||||
const clientDataJSONHash = this.twoFactorAuthenticationService.hash(Buffer.from(ps.clientDataJSON, 'utf-8'));
|
||||
|
||||
const attestation = await cborDecodeFirst(ps.attestationObject);
|
||||
|
||||
const rpIdHash = attestation.authData.slice(0, 32);
|
||||
if (!rpIdHashReal.equals(rpIdHash)) {
|
||||
throw new Error('rpIdHash mismatch');
|
||||
}
|
||||
|
||||
const flags = attestation.authData[32];
|
||||
|
||||
// eslint:disable-next-line:no-bitwise
|
||||
if (!(flags & 1)) {
|
||||
throw new Error('user not present');
|
||||
}
|
||||
|
||||
const authData = Buffer.from(attestation.authData);
|
||||
const credentialIdLength = authData.readUInt16BE(53);
|
||||
const credentialId = authData.slice(55, 55 + credentialIdLength);
|
||||
const publicKeyData = authData.slice(55 + credentialIdLength);
|
||||
const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData);
|
||||
if (publicKey.get(3) !== -7) {
|
||||
throw new Error('alg mismatch');
|
||||
}
|
||||
|
||||
const procedures = this.twoFactorAuthenticationService.getProcedures();
|
||||
|
||||
if (!(procedures as any)[attestation.fmt]) {
|
||||
throw new Error(`unsupported fmt: ${attestation.fmt}. Supported ones: ${Object.keys(procedures)}`);
|
||||
}
|
||||
|
||||
const verificationData = (procedures as any)[attestation.fmt].verify({
|
||||
attStmt: attestation.attStmt,
|
||||
authenticatorData: authData,
|
||||
clientDataHash: clientDataJSONHash,
|
||||
credentialId,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
});
|
||||
if (!verificationData.valid) throw new Error('signature invalid');
|
||||
|
||||
const attestationChallenge = await this.attestationChallengesRepository.findOneBy({
|
||||
userId: me.id,
|
||||
id: ps.challengeId,
|
||||
registrationChallenge: true,
|
||||
challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'),
|
||||
});
|
||||
|
||||
if (!attestationChallenge) {
|
||||
throw new Error('non-existent challenge');
|
||||
}
|
||||
|
||||
await this.attestationChallengesRepository.delete({
|
||||
userId: me.id,
|
||||
id: ps.challengeId,
|
||||
});
|
||||
|
||||
// Expired challenge (> 5min old)
|
||||
if (
|
||||
new Date().getTime() - attestationChallenge.createdAt.getTime() >=
|
||||
5 * 60 * 1000
|
||||
) {
|
||||
throw new Error('expired challenge');
|
||||
}
|
||||
|
||||
const credentialIdString = credentialId.toString('hex');
|
||||
const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential);
|
||||
|
||||
const credentialId = Buffer.from(keyInfo.credentialID).toString('base64url');
|
||||
await this.userSecurityKeysRepository.insert({
|
||||
id: credentialId,
|
||||
userId: me.id,
|
||||
id: credentialIdString,
|
||||
lastUsed: new Date(),
|
||||
name: ps.name,
|
||||
publicKey: verificationData.publicKey.toString('hex'),
|
||||
publicKey: Buffer.from(keyInfo.credentialPublicKey).toString('base64url'),
|
||||
counter: keyInfo.counter,
|
||||
credentialDeviceType: keyInfo.credentialDeviceType,
|
||||
credentialBackedUp: keyInfo.credentialBackedUp,
|
||||
transports: keyInfo.transports,
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
|
@ -161,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}));
|
||||
|
||||
return {
|
||||
id: credentialIdString,
|
||||
id: credentialId,
|
||||
name: ps.name,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -3,22 +3,38 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { promisify } from 'node:util';
|
||||
import * as crypto from 'node:crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UserProfilesRepository, AttestationChallengesRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
|
||||
import type { UserProfilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
const randomBytes = promisify(crypto.randomBytes);
|
||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
userNotFound: {
|
||||
message: 'User not found.',
|
||||
code: 'USER_NOT_FOUND',
|
||||
id: '652f899f-66d4-490e-993e-6606c8ec04c3',
|
||||
},
|
||||
|
||||
incorrectPassword: {
|
||||
message: 'Incorrect password.',
|
||||
code: 'INCORRECT_PASSWORD',
|
||||
id: '38769596-efe2-4faf-9bec-abbb3f2cd9ba',
|
||||
},
|
||||
|
||||
twoFactorNotEnabled: {
|
||||
message: '2fa not enabled.',
|
||||
code: 'TWO_FACTOR_NOT_ENABLED',
|
||||
id: 'bf32b864-449b-47b8-974e-f9a5468546f1',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -29,53 +45,43 @@ export const paramDef = {
|
|||
required: ['password'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.attestationChallengesRepository)
|
||||
private attestationChallengesRepository: AttestationChallengesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private twoFactorAuthenticationService: TwoFactorAuthenticationService,
|
||||
private webAuthnService: WebAuthnService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||
const profile = await this.userProfilesRepository.findOne({
|
||||
where: {
|
||||
userId: me.id,
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (profile == null) {
|
||||
throw new ApiError(meta.errors.userNotFound);
|
||||
}
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
throw new ApiError(meta.errors.incorrectPassword);
|
||||
}
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
throw new Error('2fa not enabled');
|
||||
throw new ApiError(meta.errors.twoFactorNotEnabled);
|
||||
}
|
||||
|
||||
// 32 byte challenge
|
||||
const entropy = await randomBytes(32);
|
||||
const challenge = entropy.toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
|
||||
const challengeId = this.idService.genId();
|
||||
|
||||
await this.attestationChallengesRepository.insert({
|
||||
userId: me.id,
|
||||
id: challengeId,
|
||||
challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
|
||||
createdAt: new Date(),
|
||||
registrationChallenge: true,
|
||||
});
|
||||
|
||||
return {
|
||||
challengeId,
|
||||
challenge,
|
||||
};
|
||||
return await this.webAuthnService.initiateRegistration(
|
||||
me.id,
|
||||
profile.user?.username ?? me.id,
|
||||
profile.user?.name ?? undefined,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,11 +11,20 @@ import type { UserProfilesRepository } from '@/models/index.js';
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
incorrectPassword: {
|
||||
message: 'Incorrect password.',
|
||||
code: 'INCORRECT_PASSWORD',
|
||||
id: '78d6c839-20c9-4c66-b90a-fc0542168b48',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -39,10 +48,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
throw new ApiError(meta.errors.incorrectPassword);
|
||||
}
|
||||
|
||||
// Generate user's secret key
|
||||
|
|
|
@ -10,11 +10,20 @@ import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/model
|
|||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
incorrectPassword: {
|
||||
message: 'Incorrect password.',
|
||||
code: 'INCORRECT_PASSWORD',
|
||||
id: '141c598d-a825-44c8-9173-cfb9d92be493',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -42,10 +51,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
throw new ApiError(meta.errors.incorrectPassword);
|
||||
}
|
||||
|
||||
// Make sure we only delete the user's own creds
|
||||
|
|
|
@ -10,11 +10,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import type { UserProfilesRepository } from '@/models/index.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
incorrectPassword: {
|
||||
message: 'Incorrect password.',
|
||||
code: 'INCORRECT_PASSWORD',
|
||||
id: '7add0395-9901-4098-82f9-4f67af65f775',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -38,14 +47,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
throw new ApiError(meta.errors.incorrectPassword);
|
||||
}
|
||||
|
||||
await this.userProfilesRepository.update(me.id, {
|
||||
twoFactorSecret: null,
|
||||
twoFactorBackupSecret: null,
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
|
|
|
@ -25,7 +25,7 @@ export const meta = {
|
|||
},
|
||||
|
||||
accessDenied: {
|
||||
message: 'You do not have edit privilege of the channel.',
|
||||
message: 'You do not have edit privilege of this key.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
|
||||
},
|
||||
|
|
|
@ -34,12 +34,12 @@ export const paramDef = {
|
|||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
url: { type: 'string', minLength: 1, maxLength: 1024 },
|
||||
secret: { type: 'string', minLength: 1, maxLength: 1024 },
|
||||
secret: { type: 'string', maxLength: 1024, default: '' },
|
||||
on: { type: 'array', items: {
|
||||
type: 'string', enum: webhookEventTypes,
|
||||
} },
|
||||
},
|
||||
required: ['name', 'url', 'secret', 'on'],
|
||||
required: ['name', 'url', 'on'],
|
||||
} as const;
|
||||
|
||||
// TODO: ロジックをサービスに切り出す
|
||||
|
|
|
@ -34,13 +34,13 @@ export const paramDef = {
|
|||
webhookId: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
url: { type: 'string', minLength: 1, maxLength: 1024 },
|
||||
secret: { type: 'string', minLength: 1, maxLength: 1024 },
|
||||
secret: { type: 'string', maxLength: 1024, default: '' },
|
||||
on: { type: 'array', items: {
|
||||
type: 'string', enum: webhookEventTypes,
|
||||
} },
|
||||
active: { type: 'boolean' },
|
||||
},
|
||||
required: ['webhookId', 'name', 'url', 'secret', 'on', 'active'],
|
||||
required: ['webhookId', 'name', 'url', 'on', 'active'],
|
||||
} as const;
|
||||
|
||||
// TODO: ロジックをサービスに切り出す
|
||||
|
|
|
@ -63,6 +63,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.where(`'{"${me.id}"}' <@ note.mentions`)
|
||||
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
|
||||
}))
|
||||
// Avoid scanning primary key index
|
||||
.orderBy('CONCAT(note.id)', 'DESC')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
|
|
|
@ -69,7 +69,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
|
||||
// パフォーマンス上の利点が無さそう?
|
||||
//.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notifications'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:notifications',
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private notificationService: NotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, user) => {
|
||||
this.notificationService.createNotification(user.id, 'test', {});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -80,9 +80,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
qb.orWhere('channel.isSensitive = false');
|
||||
}));
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me, user);
|
||||
|
|
|
@ -19,7 +19,7 @@ class UserListChannel extends Channel {
|
|||
public static requireCredential = false;
|
||||
private listId: string;
|
||||
public listUsers: MiUser['id'][] = [];
|
||||
private listUsersClock: NodeJS.Timer;
|
||||
private listUsersClock: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue