diff --git a/.config/example.yml b/.config/example.yml index c17939596..b996a83fb 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -30,6 +30,10 @@ url: https://example.tld/ # The port that your Misskey server should listen on. port: 3000 +# You can also use UNIX domain socket. +# socket: /path/to/misskey.sock +# chmodSocket: '777' + # ┌──────────────────────────┐ #───┘ PostgreSQL configuration └──────────────────────────────── @@ -78,6 +82,8 @@ redis: #pass: example-pass #prefix: example-prefix #db: 1 + # You can specify more ioredis options... + #username: example-username #redisForPubsub: # host: localhost @@ -86,6 +92,8 @@ redis: # #pass: example-pass # #prefix: example-prefix # #db: 1 +# # You can specify more ioredis options... +# #username: example-username #redisForJobQueue: # host: localhost @@ -94,6 +102,8 @@ redis: # #pass: example-pass # #prefix: example-prefix # #db: 1 +# # You can specify more ioredis options... +# #username: example-username # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── @@ -104,6 +114,7 @@ redis: # apiKey: '' # ssl: true # index: '' +# scope: local # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a47804ab0..0664ecd11 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,7 +6,7 @@ "features": { "ghcr.io/devcontainers-contrib/features/pnpm:2": {}, "ghcr.io/devcontainers/features/node:1": { - "version": "18.16.0" + "version": "20.3.1" } }, "forwardPorts": [3000], diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 8f8c5a13a..2809cd2ca 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: app: - build: + build: context: . dockerfile: Dockerfile diff --git a/.editorconfig b/.editorconfig index a6f988f8d..def7baa1a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,10 @@ indent_size = 2 charset = utf-8 insert_final_newline = true end_of_line = lf +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false [*.{yml,yaml}] indent_style = space diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.md b/.github/ISSUE_TEMPLATE/01_bug-report.md index 25e7fc8b2..b889d96eb 100644 --- a/.github/ISSUE_TEMPLATE/01_bug-report.md +++ b/.github/ISSUE_TEMPLATE/01_bug-report.md @@ -54,7 +54,7 @@ Please include errors from the developer console and/or server log files if you * Installation Method or Hosting Service: * Misskey: 13.x.x -* Node: 18.x.x +* Node: 20.x.x * PostgreSQL: 15.x.x * Redis: 7.x.x * OS and Architecture: diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 6cb1b3499..1aea8b545 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -37,7 +37,7 @@ jobs: with: version: 8 run_install: false - - name: Use Node.js 18.x + - name: Use Node.js 20.x uses: actions/setup-node@v3.6.0 with: node-version-file: '.node-version' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index d7be15bd4..96e64c322 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [18.x] + node-version: [20.x] services: postgres: diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 4ea4ba462..eef68aa0d 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [18.x] + node-version: [20.x] steps: - uses: actions/checkout@v3.3.0 @@ -51,7 +51,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [18.x] + node-version: [20.x] browser: [chrome] services: diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index b15e704c7..213657ce1 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x] + node-version: [20.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 5243a8377..8429465b5 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x] + node-version: [20.x] steps: - uses: actions/checkout@v3.3.0 diff --git a/.gitignore b/.gitignore index 537232d37..a66e527db 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ temp *.blend3 *.blend4 *.blend5 + +# VSCode addon +.favorites.json diff --git a/.node-version b/.node-version index 6d80269a4..dd0fe95cc 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -18.16.0 +20.3.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 45dc0e3c9..438436f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,70 @@ --> +## 13.x.x (unreleased) + +### General +- 招待機能を改善しました + * 過去に発行した招待コードを確認できるようになりました + * ロールごとに招待コードの発行数制限と制限対象期間、有効期限を設定できるようになりました + * 招待コードを作成したユーザーと使用したユーザーを確認できるようになりました +- ユーザーにロールが期限付きでアサインされている場合、その期限をユーザーのモデレーションページで確認できるようになりました +- identicon生成を無効にしてパフォーマンスを向上させることができるようになりました +- サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました + +### Client +- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように +- ドライブファイルのメニューで画像をクロップできるように +- 画像を動画と同様に簡単に隠せるように +- Enhance: ノートの埋め込みが複数画像と動画を表示されるように +- オリジナル画像を保持せずにアップロードする場合webpでアップロードされるように(Safari以外) +- 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように +- フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように +- 引用対象を「もっと見る」で展開した場合、「閉じる」で畳めるように +- プロフィールURLをコピーできるボタンを追加 #11190 +- `CURRENT_URL`で現在表示中のURLを取得できるように(AiScript) +- ユーザーのContextMenuに「アンテナに追加」ボタンを追加 +- フォローやお気に入り登録をしていないチャンネルを開く時は概要ページを開くように +- 画面ビューワをタップした場合、マウスクリックと同様に画像ビューワを閉じるように +- オフライン時の画面にリロードボタンを追加 +- Renote時に公開範囲のデフォルト設定が適用されるように +- Deckで非ルートページにアクセスした際に簡易UIで表示しない設定を追加 +- ロール設定画面でロールIDを確認できるように +- コンテキストメニュー表示時のパフォーマンスを改善 +- フォロー/フォロワー非公開時の表示を改善 +- 本文にMFMが含まれている場合に自動でたたまれる機能が、返信先や引用RNにも適用されるように + - position は対象外になりました +- AiScriptを0.15.0に更新 +- Fix: サーバーメトリクスが90度傾いている +- Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正 +- Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正 +- Fix: ZenUIでポップアップの表示位置がおかしい問題を修正 +- Fix: ページ遷移でスクロール位置が保持されない問題を修正 +- Fix: フォルダーのページネーションが機能しない #11180 +- Fix: 長い文章を投稿する際、プレビューが画面からはみ出る問題を修正 +- Fix: システムフォント設定が正しく反映されない問題を修正 +- Fix: アンケート終了時のプッシュ通知が正しく表示されない問題を修正 +- Fix: MasterVolumeが0の時だけでなく各通知音の音量設定が0のときも、HTMLAudioElement.playが実行されないように変更 + +### Server +- JSON.parse の回数を削減することで、ストリーミングのパフォーマンスを向上しました +- nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように +- 連合の配送ジョブのパフォーマンスを向上(ロック機構の見直し、Redisキャッシュの活用) +- featuredノートのsignedGet回数を減らしました +- ActivityPubの署名用鍵長を2048bitに変更しパフォーマンスを向上(新規アカウントのみ) +- リモートサーバーのセンシティブなファイルのキャッシュだけを無効化できるオプションを追加 +- MeilisearchにIndexするノートの範囲を設定できるように +- Export notes with file detail +- Add unix socket support +- 設定ファイルでioredisの全てのオプションを指定可能に +- Fix: エクスポートしたカスタム絵文字のzipが大きいと読み込めない問題を修正 +- Fix: リモートサーバーに無意味なActivityPubの配信を行うことがあるのを修正 +- Fix: Remove Meilisearch index when notes are deleted +- Fix: 非英語環境でのPostgreSQLのエラーハンドリングを修正 +- Fix: インスタンスのアイコンがbase64の場合の挙動を修正 +- Fix: ローカルの `Person` を指す `acct` URI を解析するときのバグを修正しました +- Fix: 無効化されたアンテナが再度有効化されないことがある問題を修正 + ## 13.13.2 ### General diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6b3804f8..62bc11cd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,7 +106,7 @@ If your language is not listed in Crowdin, please open an issue. ![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg) ## Development -During development, it is useful to use the +During development, it is useful to use the ``` pnpm dev @@ -150,7 +150,7 @@ Prepare DB/Redis for testing. ``` docker compose -f packages/backend/test/docker-compose.yml up ``` -Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. +Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. Run all test. ``` @@ -214,30 +214,13 @@ Misskey uses [Storybook](https://storybook.js.org/) for UI development. ### Setup & Run -#### Universal - -##### Setup - -```bash -pnpm --filter misskey-js build -pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js) -``` - -##### Run - -```bash -node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev -``` - -#### macOS & Linux - -##### Setup +#### Setup ```bash pnpm --filter misskey-js build ``` -##### Run +#### Run ```bash pnpm --filter frontend storybook-dev diff --git a/Dockerfile b/Dockerfile index fb389659b..5431c28aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.4 -ARG NODE_VERSION=18.16.0-bullseye +ARG NODE_VERSION=20.3.1-bullseye # build assets & compile TypeScript diff --git a/README.md b/README.md index 2aae4bb86..ab4388c2e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Misskey logo - + **🌎 **[Misskey](https://misskey-hub.net/)** is an open source, decentralized social media platform that's free forever! 🚀** - + --- @@ -21,7 +21,7 @@ become a patron - + --- [![codecov](https://codecov.io/gh/misskey-dev/misskey/branch/develop/graph/badge.svg?token=R6IQZ3QJOL)](https://codecov.io/gh/misskey-dev/misskey) diff --git a/assets/title_float.svg b/assets/title_float.svg index 43205ac1c..ed1749e32 100644 Binary files a/assets/title_float.svg and b/assets/title_float.svg differ diff --git a/cypress/e2e/basic.cy.js b/cypress/e2e/basic.cy.js index 2515c14ad..5ab07c748 100644 --- a/cypress/e2e/basic.cy.js +++ b/cypress/e2e/basic.cy.js @@ -54,9 +54,10 @@ describe('After setup instance', () => { cy.get('[data-cy-signup]').click(); cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); + cy.get('[data-cy-modal-dialog-ok]').click(); cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); cy.get('[data-cy-signup-rules-continue]').click(); - + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-username] input').type('alice'); cy.get('[data-cy-signup-submit]').should('be.disabled'); @@ -78,6 +79,7 @@ describe('After setup instance', () => { cy.get('[data-cy-signup]').click(); cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); + cy.get('[data-cy-modal-dialog-ok]').click(); cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); cy.get('[data-cy-signup-rules-continue]').click(); diff --git a/gulpfile.js b/gulpfile.mjs similarity index 88% rename from gulpfile.js rename to gulpfile.mjs index 6507aad60..9556eb795 100644 --- a/gulpfile.js +++ b/gulpfile.mjs @@ -2,14 +2,14 @@ * Gulp tasks */ -const fs = require('fs'); -const gulp = require('gulp'); -const replace = require('gulp-replace'); -const terser = require('gulp-terser'); -const cssnano = require('gulp-cssnano'); +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'; -const locales = require('./locales'); -const meta = require('./package.json'); +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')) diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 5d0fd201b..116973a4e 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -41,17 +41,23 @@ unfavorite: "إزالة من المفضلة" favorited: "أُضيف إلى المفضلة." alreadyFavorited: "تمت إضافته بالفعل إلى المفضلة." cantFavorite: "تعذرت الإضافة إلى المفضلة." -pin: "دبّسها على الصفحة الشخصية" -unpin: "ألغ تدبيسها من ملفك الشخصي" +pin: "ثبتها على الصفحة الشخصية" +unpin: "فكها من ملفك الشخصي" copyContent: "انسخ المحتوى" copyLink: "انسخ الرابط" delete: "حذف" deleteAndEdit: "إزالة وإعادة الصياغة" deleteAndEditConfirm: "أمتأكد من حذف الملاحظة؟ ستفقد كل مشاركاتها، والتفاعلات، والردود عليها." addToList: "أضفه إلى قائمة" +addToAntenna: "أضف إلى هوائي" sendMessage: "أرسل رسالة" copyRSS: "انسخ رابط RSS" copyUsername: "انسخ اسم المستخدم" +copyUserId: "انسخ معرف المستخدم" +copyNoteId: "انسخ معرف الملاحظة" +copyFileId: "انسخ معرّف الملف" +copyFolderId: "انسخ معرّف المجلد" +copyProfileUrl: "انسخ رابط الملف الشخصي" searchUser: "ابحث عن مستخدمين" reply: "رد" loadMore: "عرض المزيد" @@ -106,8 +112,8 @@ cantReRenote: "لا يمكنك إعادة نشر ملاحظة معاد نشره quote: "اقتبس" inChannelRenote: "إعادة نشر في قناة" inChannelQuote: "اقتباس في قناة" -pinnedNote: "ملاحظة مدبسة" -pinned: "دبّسها على الصفحة الشخصية" +pinnedNote: "ملاحظة مثبتة" +pinned: "ثبتها على الصفحة الشخصية" you: "أنت" clickToShow: "اضغط للعرض" sensitive: "محتوى حساس" @@ -134,8 +140,10 @@ unblockConfirm: "أمتأكد من إلغاء حجب هذا الحساب؟" suspendConfirm: "أمتأكد من تعليق الحساب؟" unsuspendConfirm: "أمتأكد من إلغاء تعليق؟" selectList: "اختر قائمة" +editList: "عدّل القائمة" selectChannel: "اختر قناة" selectAntenna: "اختر هوائيًا" +editAntenna: "عدّل الهوائي" selectWidget: "اختر ودجة" editWidgets: "عدّل الودجات" editWidgetsExit: "تم" @@ -206,7 +214,7 @@ blockedUsers: "الحسابات المحجوبة" noUsers: "ليس هناك مستخدمون" editProfile: "تعديل الملف التعريفي" noteDeleteConfirm: "هل تريد حذف هذه الملاحظة؟" -pinLimitExceeded: "لا يمكنك تدبيس الملاحظات بعد الآن." +pinLimitExceeded: "لا يمكنك تثبيت الملاحظات بعد الآن." intro: "لقد انتهت عملية تنصيب Misskey. الرجاء إنشاء حساب إداري." done: "تمّ" processing: "المعالجة جارية" @@ -305,7 +313,7 @@ copyUrl: "انسخ الرابط" rename: "إعادة التسمية" avatar: "الصورة الرمزية" banner: "الصورة الرأسية" -nsfw: "محتوى حساس" +displayOfSensitiveMedia: "عرض المحتوى الحساس" whenServerDisconnected: "عند فقدان الاتصال بالخادم" disconnectedFromServer: "قُطِع الإتصال بالخادم" reload: "انعش" @@ -344,12 +352,12 @@ iconUrl: "رابط الأيقونة" bannerUrl: "رابط صورة اللافتة" backgroundImageUrl: "رابط صورة الخلفية" basicInfo: "المعلومات الأساسية " -pinnedUsers: "المستخدمون المدبسون" -pinnedUsersDescription: "قائمة المستخدمين المدبسين في لسان \"استكشف\" ، اجعل كل اسم مستخدم في سطر لوحده." -pinnedPages: "الصفحات المدبسة" -pinnedPagesDescription: "أدخل مسار الصفحات التي تريد تدبيسها في أعلى هذا الموقع، اجعل كل مسار في سطر لوحده." -pinnedClipId: "معرّف المشبك المدبس" -pinnedNotes: "ملاحظة مدبسة" +pinnedUsers: "المستخدمون المثبتون" +pinnedUsersDescription: "قائمة المستخدمين المثبتين في لسان \"استكشف\" ، اجعل كل اسم مستخدم في سطر لوحده." +pinnedPages: "الصفحات المثبتة" +pinnedPagesDescription: "أدخل مسار الصفحات التي تريد تثبيتها في أعلى هذا الموقع، اجعل كل مسار في سطر لوحده." +pinnedClipId: "معرّف المشبك المثبت" +pinnedNotes: "ملاحظة مثبتة" hcaptcha: "hCaptcha" enableHcaptcha: "فعّل hCaptcha" hcaptchaSiteKey: "مفتاح الموقع" @@ -398,6 +406,7 @@ totp: "تطبيق استيثاق" moderator: "مشرِف" moderation: "الإشراف" nUsersMentioned: "{n} مستخدمين أُشير إليهم" +securityKeyAndPasskey: "الأمن ومفاتيح الأمان" securityKey: "مفتاح الأمان" lastUsed: "آخر استخدام" lastUsedAt: "آخر استخدام: {t}" @@ -452,6 +461,7 @@ language: "اللغة" uiLanguage: "لغة واجهة المستخدم" aboutX: "عن {x}" emojiStyle: "نمط الوجوه التعبيرية" +showNoteActionsOnlyHover: "أظهر الإجراءات عند التمرير فوق الملاحظة" noHistory: "السجل فارغ" signinHistory: "تاريخ تسجيل الدخول" doing: "انتظر لحظة" @@ -484,10 +494,12 @@ objectStoragePrefix: "البادئة" objectStoragePrefixDesc: "ستُحفظ الملفات في مجلدات تحوي اسماءها هذه البادئة." objectStorageEndpoint: "نقطة النهاية" objectStorageRegion: "المنطقة" +objectStorageRegionDesc: "حدد منطقة مثل \"xx-east-1\". إذا كانت خدمتك لا تميز بين المناطق استخدم \"us-east-1\" أو اتركها فارغة إذا كنت تستخدم متغيرات البيئة أو ملفات ضبط AWS." objectStorageUseSSL: "استخدم SSL" objectStorageUseSSLDesc: "عطل هذا الخيار إذا لم ترد استخدام API عبر HTTPS" objectStorageUseProxy: "اتصل عبر وكيل" objectStorageUseProxyDesc: "عطل هذا الخيار إذا لم ترد استخدام API عبر وكيل" +objectStorageSetPublicRead: "عينها ك\"علنية\" عند الرفع" serverLogs: "سجلات الخادم" deleteAll: "حذف الكل" showFixedPostForm: "أظهر نموذج الكتابة في أعلى الصفحة" @@ -531,6 +543,7 @@ accountDeletedDescription: "حُذف هذا الحساب." menu: "القائمة" divider: "فاصل" addItem: "إضافة عنصر" +rearrange: "أعد الترتيب" relays: "المُرَحلات" addRelay: "إضافة مُرحّل" inboxUrl: "رابط صندوق الوارد" @@ -554,6 +567,7 @@ leaveConfirm: "لديك تغييرات غير محفوظة. أتريد المت manage: "إدارة " plugins: "الإضافات" preferencesBackups: "النُسخ الاحتياطية للإعدادات" +useBlurEffectForModal: "استخدم تأثير الطمس في المشروط" useFullReactionPicker: "استخدم الحجم الكامل لمنتقي التفاعلات" width: "العرض" height: "الإرتفاع" @@ -629,6 +643,7 @@ clip: "مِشبك" createNew: "أنشِئ جديد" optional: "اختياري" createNewClip: "أنشئ مِشبكَا جديدًا" +confirmToUnclipAlreadyClippedNote: "هذه الملاحظة تنتمي للمشبك {name} سلفًا، أتريد حذفها منه⸮" public: "علني" i18nInfo: "يترجم متطوعون ميسكي إلى عدة لغات، يمكنك المساعدة عبر {link}" manageAccessTokens: "إدارة رموز الوصول" @@ -650,6 +665,7 @@ driveFilesCount: "عدد الملفات في قرص التخزين" driveUsage: "المستغل من قرص التخزين" noCrawle: "ارفض فهرسة زاحف الويب" noCrawleDescription: "يطلب من محركات البحث ألّا يُفهرسوا ملفك الشخصي وملاحظات وصفحاتك وما شابه." +lockedAccountInfo: "ستكون هذه الملاحظة مرئية للجميع مالم تحدد مرئتيها إلى \"للمتابعين فقط\"" alwaysMarkSensitive: "علّم افتراضيًا جميع ملاحظاتي كذات محتوى حساس" loadRawImages: "حمّل الصور الأصلية بدلًا من المصغرات" disableShowingAnimatedImages: "لا تشغّل الصور المتحركة" @@ -668,6 +684,8 @@ developer: "المطور" makeExplorable: "أظهر الحساب في صفحة \"استكشاف\"" makeExplorableDescription: "بتعطيل هذا الخيار لن يظهر حسابك في صفحة \"استكشاف\"" showGapBetweenNotesInTimeline: "أظهر فجوات بين المشاركات في الخيط الزمني" +left: "يسار" +center: "وسط" wide: "عريض" narrow: "رفيع" reloadToApplySetting: "سيُطبق هذا الإعداد بعد إعادة تحميل الصفحة، أتريد إعادة تحميلها الآن؟" @@ -705,6 +723,7 @@ editCode: "حرر الشفرة" apply: "تطبيق" receiveAnnouncementFromInstance: "استلم إشعارات من هذا المثيل" emailNotification: "إشعارات البريد الكتروني" +publish: "علني" inChannelSearch: "ابحث عن قناة" useReactionPickerForContextMenu: "افتح منتقي التفاعلات عند النقر بالزر الأيمن" typingUsers: "{users} يكتب(ون)..." @@ -717,7 +736,7 @@ unlikeConfirm: "أتريد إلغاء إعجابك؟" fullView: "ملء الشاشة" quitFullView: "اخرج من وضع ملء للشاشة" addDescription: "أضف وصفًا" -userPagePinTip: "لعرض ملاحظة هنا اختر \"دبسها على الصفحة الشخصية\" من قائمة تلك الملاحظة." +userPagePinTip: "لعرض ملاحظة هنا اختر \"ثبتها على الصفحة الشخصية\" من قائمة تلك الملاحظة." notSpecifiedMentionWarning: "في الملاحظة ذكر لمستخدمين لن يستلموها." info: "عن" userInfo: "معلومات المستخدم" @@ -744,6 +763,7 @@ noMaintainerInformationWarning: "لم تُضبط معلومات المدير" noBotProtectionWarning: "لم تضبط الحماية من الحسابات الآلية" configure: "اضبط" postToGallery: "انشر في المعرض" +postToHashtag: "انشر بهذا الوسم" gallery: "المعرض" recentPosts: "المشاركات الحديثة" popularPosts: "المشاركات المتداولة" @@ -776,6 +796,7 @@ translate: "ترجم" translatedFrom: "تُرجم من {x}" accountDeletionInProgress: "حذف الحساب جارٍ" usernameInfo: "الاسم الذي يميزك عن بافي مستخدمي هذا الخادم، يمكنك استخدام الحروف اللاتينية (a~z, A~Z) والأرقام (0~9) والشرطة السفلية (_). لا يمكنك تغييره بعد تسجيله." +devMode: "وضع المُطوّر" keepCw: "أبقِ على تحذيرات المحتوى" lastCommunication: "آخر تواصل" resolved: "عولج" @@ -784,6 +805,7 @@ breakFollow: "إلغاء الاشتراك" breakFollowConfirm: "أمتأكد من إزالة المتابِع ؟" itsOn: "مفعّل" itsOff: "معطّل" +on: "مفعل" emailRequiredForSignup: "عنوان البريد الإلكتروني إلزامي للتسجيل" unread: "غير مقروءة" filter: "رشّح" @@ -820,6 +842,9 @@ oneDay: "يوم" oneWeek: "أسبوع" oneMonth: "شهر" failedToFetchAccountInformation: "تعذر جلب معلومات الحساب" +cropImage: "اقتصاص الصورة" +cropImageAsk: "أتريد اقتصاص هذه الصورة" +cropYes: "اقتص" cropNo: "استخدمها كما هي" file: "الملفات" recentNHours: "آخر {n} ساعة" @@ -830,16 +855,19 @@ recommended: "مقترح" driveCapOverrideLabel: "غيّر حجم قرص التخزين لهذا المستخدم" driveCapOverrideCaption: "أعد الحجم إلى القيمة الافتراضية بإدخال 0 أو أقل." requireAdminForView: "لاستعراض هذه الصفحة وجب عليك الولوج كمدير." +isSystemAccount: "حساب أنشأه النظام ويُدار من قِبله." typeToConfirm: "أدخل {x} للتأكيد" deleteAccount: "احذف الحساب" document: "التوثيق" numberOfPageCache: "عدد الصفحات المخزنة مؤقتًا" +numberOfPageCacheDescription: "رفع الرقم سيسحن تجربة المستخدم لكن سيرفع استهلاك الذاكرة." logoutConfirm: "أتريد الخروج؟" lastActiveDate: "آخر استخدام" statusbar: "شريط الحالة" pleaseSelect: "حدد خيارًا" reverse: "اقلب" colored: "ملوّن" +refreshInterval: "مهلة التحديث" label: "التسمية" type: "نوع" speed: "سرعة" @@ -852,6 +880,9 @@ cannotUploadBecauseInappropriate: "تعذر رفع الملف لوجود محت cannotUploadBecauseNoFreeSpace: "تعذر رفع الملف لنقص مساحة التخزين." cannotUploadBecauseExceedsFileSizeLimit: "تعذر رفع الملف بسبب تجاوز حجمه للحد المسموح" beta: "بيتا" +enableAutoSensitive: "تعيين تلقائي كمحتوى حساس NSFW" +enableAutoSensitiveDescription: "عند الاستطاعة يسمح باكتشاف المحتوى حساس NSFW تلقائيًا في الوسائط باستخدام تعلم الآلة ووسمها تبعًا لذلك. قد يكون هذا الخيار مفعلا من جهة الخادم وسيعمل حتى وان عُطل." +activeEmailValidationDescription: "يتحقق من صحة عنوان البريد الإلكتروني بشكل أكثر حزمًا وذلك عبر تحديد ما إذا كان عنوان بريد إلكتروني مؤقت وإمكانية التواصل معه. إذا لم يحدد هذا الخيار فسيتحقق من نسق عنوان البريد الإلكتروني." navbar: "شريط التنقل" shuffle: "خلط" account: "الحسابات" @@ -863,22 +894,32 @@ pushNotificationAlreadySubscribed: "إرسال الإشعارات مفعل سل pushNotificationNotSupported: "متصفحك لا يدعم إرسال الإشعارات أو المثيل لا يدعمها." sendPushNotificationReadMessage: "احذف الإشعارات فور قراءتها" sendPushNotificationReadMessageCaption: "هذا قد يزيد من معدل استهلاك الطاقة لجهازك." +windowRestore: "استرجاع" caption: "التعليق التوضيحي" +loggedInAsBot: "والج كآلي" tools: "أدوات" cannotLoad: "تعذر التحميل" +numberOfProfileView: "مشاهدات الملف الشخصي" like: "أعجبني" unlike: "ألغِ الإعجاب" +numberOfLikes: "الإعجابات" show: "المظهر" neverShow: "لا تظهره مجددًا" +remindMeLater: "ربما لاحقا" didYouLikeMisskey: "هل أعجبك ميسكي؟" +pleaseDonate: "يستخدم {host} البرمجية الحرة ميسكي. نتمنى أن تتبرعوا للمشروع مما سيسمح لنا متابعة تطويره!" roles: "الأدوار" role: "الدور" noRole: "لم يُعثر على دور" normalUser: "مستخدم عادي" undefined: "غير معرّف" +assign: "أسند" +unassign: "ألغ الإسناد" color: "اللون" manageCustomEmojis: "إدارة الإيموجي المخصصة" +youCannotCreateAnymore: "وصلت لسقف الإنشاء." cannotPerformTemporary: "غير متاح مؤقتاً" +invalidParamError: "معاملات غير صالحة" permissionDeniedError: "رُفضة العملية" preset: "إعدادات مسبقة" selectFromPresets: "اختر من الإعدادات المسبقة" @@ -900,6 +941,8 @@ cannotBeChangedLater: "لا يمكن تغييره لاحقًا." reactionAcceptance: "قبول التفاعلات" rolesAssignedToMe: "الأدوار المسندة إلي" resetPasswordConfirm: "هل تريد إعادة تعيين كلمة السر؟" +license: "الرخصة" +unfavoriteConfirm: "أتريد إزالتها من المفضلة؟" noteIdOrUrl: "معرف الملاحظة أو رابطها" video: "فيديو" videos: "فيديوهات" @@ -908,6 +951,11 @@ accountMoved: "نقل هذا المستخدم حسابه:" accountMovedShort: "رُحل هذا الحساب." operationForbidden: "عملية ممنوعة" forceShowAds: "أظهر الإعلانات التجارية دائما" +leftTop: "أعلى اليسار" +rightTop: "أعلى اليمين" +leftBottom: "أسفل اليسار" +rightBottom: "أسفل اليمين" +stackAxis: "اتجاه التكديس" vertical: "عمودي" horizontal: "جانبي" position: "الموضع" @@ -917,9 +965,105 @@ pleaseAgreeAllToContinue: "للمتابعة وافق على الحقول أعل continue: "متابعة" preservedUsernames: "أسماء المستخدمين المحجوزة" preservedUsernamesDescription: "قائمة بأسماء المستخدمين المحجوزة كلٌ في سطر. لن يُقبل التسجيل بهذه الأسماء وستبقى محصورة على التسجيل اليدوي بواسطة المديرين. لن يتأثر المستخدمون الذين يملكون هذه الأسماء سلفًا." +createNoteFromTheFile: "أنشئ ملاحظة من هذا الملف" archive: "الأرشيف" +channelArchiveConfirmTitle: "أتريد أرشفت {name}؟" +channelArchiveConfirmDescription: "لن يمكنك نشر ملاحظات في القناة المأرشفة ولن تظهر في قائمة القنوات ولا في نتائج البحث." +thisChannelArchived: "أُرشفت هذه القناة." +displayOfNote: "عرض الملاحظة" +initialAccountSetting: "إعداد الملف الشخصي" youFollowing: "متابَع" options: "خيارات" +specifyUser: "مستخدم محدد" +failedToPreviewUrl: "تتعذر المعاينة" +update: "حدِّث" +rolesThatCanBeUsedThisEmojiAsReaction: "الأدوار التي يُسمح لأصحابها استخدام هذا اإيموجي في اللتفاعل" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "إذا لم تحدد دورًا يمكن للجميع استخدام هذا الإيموجي في التفاعل." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "يجب أن تكون الأدوار علنية." +cancelReactionConfirm: "أتريد حذف تفاعلك؟" +changeReactionConfirm: "أتريد تعديل تفاعلك؟" +later: "لاحقاً" +goToMisskey: "لميسكي" +additionalEmojiDictionary: "قواميس إيموجي إضافية" +installed: "مُثبت" +_initialAccountSetting: + accountCreated: "نجح إنشاء حسابك!" + letsStartAccountSetup: "إذا كنت جديدًا لنعدّ حسابك الشخصي." + letsFillYourProfile: "أولًا لنعد ملفك الشخصي." + profileSetting: "إعدادات الملف الشخصي" + privacySetting: "إعدادات الخصوصية" + theseSettingsCanEditLater: "يمكنك تغيير هذه الإعدادات لاحقًا." + skipAreYouSure: "أتريد تخطي إعداد الملف الشخصي؟" + laterAreYouSure: "أتريد إعداد الملف الشخصي لاحقًا؟" +_serverRules: + description: "مجموعة من القواعد لعرضها عند التسجيل، من المستحسن كتابة ملخصٍ للشروط الخدمة." +_accountMigration: + moveFrom: "انقل حسابًا آخر لهذا الحساب" + moveFromLabel: "الحساب الأصلي #{n}" + moveTo: "انقل هذا الحساب لحساب آخر" + moveToLabel: "الحساب الوجهة:" + moveCannotBeUndone: "لا يمكن التراجع عن نقل الحساب." + movedTo: "الحساب الوجهة:" +_achievements: + _types: + _notes1: + description: "انشر ملاحظتك الأولى" + flavor: "تمتع باستخدام ميسكي!" + _notes10: + title: "بعض الملاحظات" + description: "انشر 10 ملاحظات" + _notes100: + title: "كثير من الملاحظات" + description: "انشر 100 ملاحظة" + _notes500: + description: "انشر 500 ملاحظة" + _notes1000: + title: "جبل ملاحظات" + description: "انشر 1000 ملاحظة" + _notes5000: + description: "انشر 5000 ملاحظة" + _notes10000: + description: "انشر 10000 ملاحظة" + _notes20000: + title: "أريد...ملاحظات...أكثر" + description: "انشر 20000 ملاحظة" + _notes30000: + title: "ملاحظات وملاحظات وملاحظات" + description: "انشر 30000 ملاحظة" + _notes40000: + title: "مصنع ملاحظات" + description: "انشر 40000 ملاحظة" + _notes50000: + title: "كوكب ملاحظات" + description: "انشر 50000 ملاحظة" + _notes60000: + title: "نجم ملاحظات" + description: "انشر 60000 ملاحظة" + _notes70000: + title: "ثقب أسود للملاحظات" + description: "انشر 70000 ملاحظة" + _notes80000: + title: "مجرة ملاحظات" + description: "انشر 80000 ملاحظة" + _notes90000: + title: "كوْن ملاحظات" + description: "انشر 90000 ملاحظة" + _notes100000: + title: "كل ملاحظاتك لنا" + description: "انشر 100000 ملاحظة" + flavor: "حقًا لديك الكثير من القصص" + _login3: + title: "مبتدأ I" + _noteFavorited1: + description: "فضًِل ملاحظتك الأولى" + _myNoteFavorited1: + title: "ساعٍ للنجوم" + description: "أعجب شخص آخر بإحدى ملاحظاتك" + _profileFilled: + title: "مستعد" + description: "أعدّ حسابك" + _markedAsCat: + title: "أنا قط" _role: new: "دور جديد" edit: "حرر الأدوار" @@ -936,6 +1080,7 @@ _role: high: "عالية" _options: canManageCustomEmojis: "إدارة الإيموجي المخصصة" + pinMax: "حد عدد الملاحظات المثبتة" _condition: isLocal: "مستخدم محلي" isRemote: "مستخدم بعيد" @@ -996,10 +1141,6 @@ _aboutMisskey: donate: "تبرع لميسكي" morePatrons: "نحن نقدر الدعم الذي قدمه العديد من الأشخاص الذين لم نذكرهم. شكرًا لكم 🥰" patrons: "الداعمون" -_nsfw: - respect: "اخف الوسائط ذات المحتوى الحساس" - ignore: "اعرض الوسائط ذات المحتوى الحساس" - force: "اخف كل الوسائط" _instanceTicker: none: "لا تظهره بتاتًا" remote: "أظهر للمستخدمين البِعاد" @@ -1316,7 +1457,7 @@ _pages: url: "رابط الصفحة" summary: "ملخص الصفحة" alignCenter: "توسيط العناصر" - hideTitleWhenPinned: "اخف عنوان الصفحة عند تدبيسها في ملف الشخصي" + hideTitleWhenPinned: "اخف عنوان الصفحة عند تثبيتها في ملف الشخصي" font: "الخط" fontSerif: "Serif" fontSansSerif: "Sans Serif" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 4b1a9cb75..78dbd77eb 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -294,7 +294,6 @@ copyUrl: "URL কপি করুন" rename: "পুনঃনামকরণ" avatar: "প্রোফাইল ছবি" banner: "ব্যানার" -nsfw: "সংবেদনশীল বিষয়বস্তু" whenServerDisconnected: "সার্ভারের সাথে সংযোগ বিচ্ছিন্ন হয়ে গেলে" disconnectedFromServer: "সার্ভার থেকে সংযোগ বিচ্ছিন্ন হয়েছে" reload: "আবার লোড করুন" @@ -902,10 +901,6 @@ _aboutMisskey: donate: "Misskey তে দান করুন" morePatrons: "আরও অনেকে আমাদের সাহায্য করছেন। তাদের সবাইকে ধন্যবাদ 🥰" patrons: "সমর্থনকারী" -_nsfw: - respect: "স্পর্শকাতর মিডিয়া লুকান" - ignore: "স্পর্শকাতর মিডিয়া লুকাবেন না" - force: "সকল মিডিয়া লুকান" _instanceTicker: none: "দেখাবেন না" remote: "রিমোট ব্যাবহারকারীদের জন্য দেখান" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 588a07fd8..e3a6ec11b 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -263,7 +263,6 @@ emptyFolder: "La carpeta està buida" unableToDelete: "No es pot eliminar" copyUrl: "Copia l'URL" rename: "Canvia el nom" -nsfw: "NSFW" reload: "Actualitza" doNothing: "Ignora" accept: "Accepta" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index d055fde38..ceab50075 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -276,7 +276,6 @@ copyUrl: "Kopírovat URL" rename: "Přejmenovat" avatar: "Avatar" banner: "Baner" -nsfw: "NSFW" disconnectedFromServer: "Spojení bylo přerušeno" reload: "Aktualizovat" doNothing: "Ignorovat" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 9b52a5069..040e8836e 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -49,11 +49,15 @@ 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." addToList: "Zu Liste hinzufügen" +addToAntenna: "Zu Antenne hinzufügen" sendMessage: "Nachricht senden" copyRSS: "RSS kopieren" copyUsername: "Benutzernamen kopieren" copyUserId: "Benutzer-ID kopieren" copyNoteId: "Notiz-ID kopieren" +copyFileId: "Datei-ID kopieren" +copyFolderId: "Ordner-ID kopieren" +copyProfileUrl: "Profil-URL kopieren" searchUser: "Nach einem Benutzer suchen" reply: "Antworten" loadMore: "Mehr laden" @@ -112,7 +116,7 @@ pinnedNote: "Angeheftete Notiz" pinned: "Angeheftet" you: "Du" clickToShow: "Zum Anzeigen anklicken" -sensitive: "NSFW" +sensitive: "Sensibel" add: "Hinzufügen" reaction: "Reaktionen" reactions: "Reaktionen" @@ -120,8 +124,8 @@ reactionSetting: "In der Reaktionsauswahl anzuzeigende Reaktionen" reactionSettingDescription2: "Ziehe um Anzuordnen, klicke um zu löschen, drücke „+“ um hinzuzufügen" rememberNoteVisibility: "Notizsichtbarkeit merken" attachCancel: "Anhang entfernen" -markAsSensitive: "Als NSFW markieren" -unmarkAsSensitive: "Als nicht NSFW markieren" +markAsSensitive: "Als sensibel markieren" +unmarkAsSensitive: "Als nicht sensibel markieren" enterFileName: "Dateinamen eingeben" mute: "Stummschalten" unmute: "Stummschaltung aufheben" @@ -136,8 +140,10 @@ unblockConfirm: "Möchtest du diese Blockierung wirklich aufheben?" suspendConfirm: "Möchtest du diesen Benutzer wirklich sperren?" unsuspendConfirm: "Möchtest du diesen Benutzer wirklich entsperren?" selectList: "Liste auswählen" +editList: "Liste bearbeiten" selectChannel: "Kanal auswählen" selectAntenna: "Antenne auswählen" +editAntenna: "Antenne bearbeiten" selectWidget: "Widget auswählen" editWidgets: "Widgets bearbeiten" editWidgetsExit: "Fertig" @@ -150,6 +156,8 @@ addEmoji: "Emoji hinzufügen" settingGuide: "Empfohlene Einstellung" cacheRemoteFiles: "Dateien von fremden Instanzen im Cache speichern" cacheRemoteFilesDescription: "Ist diese Einstellung deaktiviert, so werden Dateien fremder Instanzen direkt von dort geladen. Hierdurch wird Speicherplatz auf diesem Server gespart, aber durch fehlende Generierung von Vorschaubildern mehr Bandbreite verwendet." +cacheRemoteSensitiveFiles: "Sensitive Dateien von fremden Instanzen im Cache speichern" +cacheRemoteSensitiveFilesDescription: "Ist diese Einstellung deaktiviert, so werden sensitive Dateien fremder Instanzen direkt von dort ohne Zwischenspeicherung geladen." flagAsBot: "Als Bot markieren" flagAsBotDescription: "Aktiviere diese Option, falls dieses Benutzerkonto durch ein Programm gesteuert wird. Falls aktiviert, agiert es als Flag für andere Entwickler zur Verhinderung von endlosen Kettenreaktionen mit anderen Bots und lässt Misskeys interne Systeme dieses Benutzerkonto als Bot behandeln." flagAsCat: "Als Katze markieren" @@ -311,7 +319,7 @@ copyUrl: "URL kopieren" rename: "Umbenennen" avatar: "Profilbild" banner: "Banner" -nsfw: "NSFW" +displayOfSensitiveMedia: "Anzeige von sensiblen Medien" whenServerDisconnected: "Bei Verbindungsverlust zum Server" disconnectedFromServer: "Die Verbindung zum Server wurde getrennt" reload: "Aktualisieren" @@ -693,7 +701,7 @@ driveUsage: "Drive-Auslastung" noCrawle: "Crawler-Indexierung ablehnen" noCrawleDescription: "Suchmaschinen bitten, die eigene Profilseite, Notizen, Seiten usw. nicht zu indexieren." lockedAccountInfo: "Auch wenn du Follow-Anfragen auf manuelle Bestätigung setzt, wird jede deiner Notizen öffentlich sichtbar sein, sofern du ihre Notizsichtbarkeit nicht auf \"Nur Follower\" setzt." -alwaysMarkSensitive: "Medien standardmäßig als NSFW markieren" +alwaysMarkSensitive: "Medien standardmäßig als sensibel markieren" loadRawImages: "Anstatt Vorschaubilder immer Originalbilder anzeigen" disableShowingAnimatedImages: "Animierte Bilder nicht abspielen" verificationEmailSent: "Eine Bestätigungsmail wurde an deine Email-Adresse versendet. Besuche den dort enthaltenen Link, um die Verifizierung abzuschließen." @@ -912,16 +920,16 @@ type: "Art" speed: "Geschwindigkeit" slow: "Langsam" fast: "Schnell" -sensitiveMediaDetection: "Erkennung von NSFW-Medien" +sensitiveMediaDetection: "Erkennung von sensiblen Medien" localOnly: "Nur Lokal" remoteOnly: "Nur für fremde Instanzen" failedToUpload: "Hochladen fehlgeschlagen" -cannotUploadBecauseInappropriate: "Diese Datei kann nicht hochgeladen werden, da Anteile der Datei als möglicherweise NSFW festgestellt wurden." +cannotUploadBecauseInappropriate: "Diese Datei kann nicht hochgeladen werden, da Anteile der Datei als möglicherweise unangebracht festgestellt wurden." cannotUploadBecauseNoFreeSpace: "Die Datei konnte nicht hochgeladen werden, da dein Drive-Speicherplatz aufgebraucht ist." cannotUploadBecauseExceedsFileSizeLimit: "Diese Datei kann wegen Überschreitung der Maximalgröße nicht hochgeladen werden." beta: "Beta" -enableAutoSensitive: "NSFW-Automarkierung" -enableAutoSensitiveDescription: "Setzt soweit möglich durch Verwendung von Machine Learning automatisch NSFW-Markierungen für Medien, die NSFW-Anteile beinhalten. Auch wenn du diese Option deaktiviert hast, ist sie möglicherweise auf Instanzebene aktiviert." +enableAutoSensitive: "Automarkierung sensibler Medien" +enableAutoSensitiveDescription: "Setzt soweit möglich durch Verwendung von Machine Learning automatisch Markierungen für sensible Medien. Auch wenn du diese Option deaktiviert hast, ist sie möglicherweise auf Instanzebene aktiviert." activeEmailValidationDescription: "Aktivert strengere Überprüfung von E-Mail-Adressen, d.h. Testen auf Wegwerfadressen und darauf, ob mit der Adresse tatsächlich kommuniziert werden kann. Ist dies deaktiviert, so wird nur das Format der E-Mail überprüft." navbar: "Navigationsleiste" shuffle: "Mischen" @@ -1063,6 +1071,29 @@ goToMisskey: "Zu Misskey" additionalEmojiDictionary: "Zusätzliche Emoji-Wörterbücher" installed: "Installiert" branding: "Branding" +enableServerMachineStats: "Hardwareinformationen des Servers veröffentlichen" +enableIdenticonGeneration: "Generierung von Benutzer-Identicons aktivieren" +turnOffToImprovePerformance: "Deaktivierung kann zu höherer Leistung führen." +createInviteCode: "Einladung erstellen" +createWithOptions: "Einladung mit Optionen erstellen" +createCount: "Einladungsanzahl" +inviteCodeCreated: "Einladung erstellt" +inviteLimitExceeded: "Du hast das Maximum an erstellbaren Einladungen erreicht." +createLimitRemaining: "Erstellbare Einladungen: Noch {limit}" +inviteLimitResetCycle: "Am {time} wird dies auf {limit} zurückgesetzt." +expirationDate: "Ablaufdatum" +noExpirationDate: "Keins" +inviteCodeUsedAt: "Einladung verwendet am" +registeredUserUsingInviteCode: "Einladung verwendet von" +waitingForMailAuth: "Bestätigungsemail ausstehend" +inviteCodeCreator: "Einladung erstellt von" +usedAt: "Benutzt am" +unused: "Unbenutzt" +used: "Benutzt" +expired: "Abgelaufen" +doYouAgree: "Zustimmen?" +beSureToReadThisAsItIsImportant: "Lies bitte diese wichtige Informationen." +iHaveReadXCarefullyAndAgree: "Ich habe den Text \"{x}\" gelesen und stimme zu." _initialAccountSetting: accountCreated: "Dein Konto wurde erfolgreich erstellt!" letsStartAccountSetup: "Lass uns nun dein Konto einrichten." @@ -1373,6 +1404,9 @@ _role: ltlAvailable: "Kann auf die lokale Chronik zugreifen" canPublicNote: "Kann öffentliche Notizen erstellen" canInvite: "Erstellung von Einladungscodes für diese Instanz" + inviteLimit: "Maximalanzahl an Einladungen" + inviteLimitCycle: "Zyklus des Einladungslimits" + inviteExpirationTime: "Gültigkeitsdauer von Einladungen" canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten" driveCapacity: "Drive-Kapazität" alwaysMarkNsfw: "Dateien immer als NSFW markieren" @@ -1403,10 +1437,10 @@ _role: or: "ODER-Bedingung" not: "NICHT-Bedingung" _sensitiveMediaDetection: - description: "Ermöglicht eine Erleichterung der Servermoderation durch die automatische Erkennungen von NSFW-Medien unter Verwendung von Machine Learning. Hierdurch wird die Serverlast etwas erhöht." + description: "Ermöglicht eine Erleichterung der Servermoderation durch die automatische Erkennungen von sensiblen Medien unter Verwendung von Machine Learning. Hierdurch wird die Serverlast etwas erhöht." sensitivity: "Erkennungssensitivität" sensitivityDescription: "Durch das Senken der Sensitivität kann die Anzahl an Fehlerkennungen (sog. false positives) reduziert werden. Durch ein Erhöhen dieser kann die Anzahl an verpassten Erkennungen (sog. false negatives) reduziert werden." - setSensitiveFlagAutomatically: "Als NSFW markieren" + setSensitiveFlagAutomatically: "Als sensibel markieren" setSensitiveFlagAutomaticallyDescription: "Die Resultate der internen Erkennung werden beibehalten, auch wenn diese Option deaktiviert ist." analyzeVideos: "Videoanalyse aktivieren" analyzeVideosDescription: "Analysiert zusätzlich zu Bildern auch Videos. Die Last des Servers wird hierdurch etwas erhöht." @@ -1435,6 +1469,7 @@ _ad: back: "Zurück" reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen" hide: "Ausblenden" + timezoneinfo: "Der Wochentag wird durch die Serverzeitzone bestimmt." _forgotPassword: enterEmail: "Gib die Email-Adresse ein, mit der du dich registriert hast. An diese wird ein Link gesendet, mit dem du dein Passwort zurücksetzen kannst." ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, wende dich bitte an den Administrator." @@ -1486,9 +1521,9 @@ _aboutMisskey: donate: "An Misskey spenden" morePatrons: "Wir schätzen ebenso die Unterstützung vieler anderer hier nicht gelisteter Personen sehr. Danke! 🥰" patrons: "UnterstützerInnen" -_nsfw: - respect: "Als NSFW markierte Bilder verbergen" - ignore: "Als NSFW markierte Bilder nicht verbergen" +_displayOfSensitiveMedia: + respect: "Sensible Medien verbergen" + ignore: "Sensible Medien anzeigen" force: "Alle Medien verbergen" _instanceTicker: none: "Nie anzeigen" @@ -1964,6 +1999,7 @@ _deck: introduction: "Erstelle eine auf dich zugeschneiderte Benutzeroberfläche durch das Aneinanderreihen von Spalten!" 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" _columns: main: "Hauptspalte" widgets: "Widgets" diff --git a/locales/en-US.yml b/locales/en-US.yml index 893820857..ea8252551 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -49,11 +49,15 @@ 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." addToList: "Add to list" +addToAntenna: "Add to antenna" sendMessage: "Send a message" copyRSS: "Copy RSS" copyUsername: "Copy username" copyUserId: "Copy user ID" copyNoteId: "Copy note ID" +copyFileId: "Copy file ID" +copyFolderId: "Copy folder ID" +copyProfileUrl: "Copy profile URL" searchUser: "Search for a user" reply: "Reply" loadMore: "Load more" @@ -112,7 +116,7 @@ pinnedNote: "Pinned note" pinned: "Pin to profile" you: "You" clickToShow: "Click to show" -sensitive: "NSFW" +sensitive: "Sensitive" add: "Add" reaction: "Reactions" reactions: "Reactions" @@ -120,8 +124,8 @@ reactionSetting: "Reactions to show in the reaction picker" reactionSettingDescription2: "Drag to reorder, click to delete, press \"+\" to add." rememberNoteVisibility: "Remember note visibility settings" attachCancel: "Remove attachment" -markAsSensitive: "Mark as NSFW" -unmarkAsSensitive: "Unmark as NSFW" +markAsSensitive: "Mark as sensitive" +unmarkAsSensitive: "Unmark as sensitive" enterFileName: "Enter filename" mute: "Mute" unmute: "Unmute" @@ -136,8 +140,10 @@ unblockConfirm: "Are you sure that you want to unblock this account?" suspendConfirm: "Are you sure that you want to suspend this account?" unsuspendConfirm: "Are you sure that you want to unsuspend this account?" selectList: "Select a list" +editList: "Edit list" selectChannel: "Select a channel" selectAntenna: "Select an antenna" +editAntenna: "Edit antenna" selectWidget: "Select a widget" editWidgets: "Edit widgets" editWidgetsExit: "Done" @@ -150,6 +156,8 @@ addEmoji: "Add an emoji" settingGuide: "Recommended settings" cacheRemoteFiles: "Cache remote files" cacheRemoteFilesDescription: "When this setting is disabled, remote files are loaded directly from the remote instance. Disabling this will decrease storage usage, but increase traffic, as thumbnails will not be generated." +cacheRemoteSensitiveFiles: "Cache sensitive remote files" +cacheRemoteSensitiveFilesDescription: "When this setting is disabled, sensitive remote files are loaded directly from the remote instance without caching." flagAsBot: "Mark this account as a bot" flagAsBotDescription: "Enable this option if this account is controlled by a program. If enabled, it will act as a flag for other developers to prevent endless interaction chains with other bots and adjust Misskey's internal systems to treat this account as a bot." flagAsCat: "Mark this account as a cat" @@ -311,7 +319,7 @@ copyUrl: "Copy URL" rename: "Rename" avatar: "Avatar" banner: "Banner" -nsfw: "NSFW" +displayOfSensitiveMedia: "Display of sensitive media" whenServerDisconnected: "When losing connection to the server" disconnectedFromServer: "Connection to server has been lost" reload: "Refresh" @@ -693,7 +701,7 @@ driveUsage: "Drive space usage" noCrawle: "Reject crawler indexing" noCrawleDescription: "Ask search engines to not index your profile page, notes, Pages, etc." lockedAccountInfo: "Unless you set your note visiblity to \"Followers only\", your notes will be visible to anyone, even if you require followers to be manually approved." -alwaysMarkSensitive: "Mark as NSFW by default" +alwaysMarkSensitive: "Mark as sensitive by default" loadRawImages: "Load original images instead of showing thumbnails" disableShowingAnimatedImages: "Don't play animated images" verificationEmailSent: "A verification email has been sent. Please follow the included link to complete verification." @@ -912,16 +920,16 @@ type: "Type" speed: "Speed" slow: "Slow" fast: "Fast" -sensitiveMediaDetection: "Detection of NSFW media" +sensitiveMediaDetection: "Detection of sensitive media" localOnly: "Local only" remoteOnly: "Remote only" failedToUpload: "Upload failed" -cannotUploadBecauseInappropriate: "This file could not be uploaded because parts of it have been detected as potentially NSFW." +cannotUploadBecauseInappropriate: "This file could not be uploaded because parts of it have been detected as potentially inappropriate." cannotUploadBecauseNoFreeSpace: "Upload failed due to lack of Drive capacity." cannotUploadBecauseExceedsFileSizeLimit: "This file cannot be uploaded as it exceeds the file size limit." beta: "Beta" -enableAutoSensitive: "Automatic NSFW-Marking" -enableAutoSensitiveDescription: "Allows automatic detection and marking of NSFW media through Machine Learning where possible. Even if this option is disabled, it may be enabled instance-wide." +enableAutoSensitive: "Automatic marking as sensitive" +enableAutoSensitiveDescription: "Allows automatic detection and marking of sensitive media through Machine Learning where possible. Even if this option is disabled, it may be enabled instance-wide." activeEmailValidationDescription: "Enables stricter validation of email addresses, which includes checking for disposable addresses and by whether it can actually be communicated with. When unchecked, only the format of the email is validated." navbar: "Navigation bar" shuffle: "Shuffle" @@ -1063,6 +1071,29 @@ goToMisskey: "To Misskey" additionalEmojiDictionary: "Additional emoji dictionaries" installed: "Installed" branding: "Branding" +enableServerMachineStats: "Publish server hardware stats" +enableIdenticonGeneration: "Enable user identicon generation" +turnOffToImprovePerformance: "Turning this off can increase performance." +createInviteCode: "Generate invite" +createWithOptions: "Generate with options" +createCount: "Invite count" +inviteCodeCreated: "Invite generated" +inviteLimitExceeded: "You've exceeded the limit of invites you can generate." +createLimitRemaining: "Invite limit: {limit} remaining" +inviteLimitResetCycle: "This limit will reset to {limit} at {time}." +expirationDate: "Expiration date" +noExpirationDate: "No expiration" +inviteCodeUsedAt: "Invite code used at" +registeredUserUsingInviteCode: "Invite used by" +waitingForMailAuth: "Email verification pending" +inviteCodeCreator: "Invite created by" +usedAt: "Used at" +unused: "Unused" +used: "Used" +expired: "Expired" +doYouAgree: "Agree?" +beSureToReadThisAsItIsImportant: "Please read this important information." +iHaveReadXCarefullyAndAgree: "I have read the text \"{x}\" and agree." _initialAccountSetting: accountCreated: "Your account was successfully created!" letsStartAccountSetup: "For starters, let's set up your profile." @@ -1373,6 +1404,9 @@ _role: ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" canInvite: "Can create instance invite codes" + inviteLimit: "Invite limit" + inviteLimitCycle: "Invite limit cooldown" + inviteExpirationTime: "Invite expiration interval" canManageCustomEmojis: "Can manage custom emojis" driveCapacity: "Drive capacity" alwaysMarkNsfw: "Always mark files as NSFW" @@ -1403,10 +1437,10 @@ _role: or: "OR-Condition" not: "NOT-Condition" _sensitiveMediaDetection: - description: "Reduces the effort of server moderation through automatically recognizing NSFW media via Machine Learning. This will slightly increase the load on the server." + description: "Reduces the effort of server moderation through automatically recognizing sensitive media via Machine Learning. This will slightly increase the load on the server." sensitivity: "Detection sensitivity" sensitivityDescription: "Reducing the sensitivity will lead to fewer misdetections (false positives) whereas increasing it will lead to fewer missed detections (false negatives)." - setSensitiveFlagAutomatically: "Mark as NSFW" + setSensitiveFlagAutomatically: "Mark as sensitive" setSensitiveFlagAutomaticallyDescription: "The results of the internal detection will be retained even if this option is turned off." analyzeVideos: "Enable analysis of videos" analyzeVideosDescription: "Analyzes videos in addition to images. This will slightly increase the load on the server." @@ -1435,6 +1469,7 @@ _ad: back: "Back" reduceFrequencyOfThisAd: "Show this ad less" hide: "Hide" + timezoneinfo: "The day of the week is determined from the server's timezone." _forgotPassword: enterEmail: "Enter the email address you used to register. A link with which you can reset your password will then be sent to it." ifNoEmail: "If you did not use an email during registration, please contact the instance administrator instead." @@ -1486,9 +1521,9 @@ _aboutMisskey: donate: "Donate to Misskey" morePatrons: "We also appreciate the support of many other helpers not listed here. Thank you! 🥰" patrons: "Patrons" -_nsfw: - respect: "Hide NSFW media" - ignore: "Don't hide NSFW media" +_displayOfSensitiveMedia: + respect: "Hide media marked as sensitive" + ignore: "Display media marked as sensitive" force: "Hide all media" _instanceTicker: none: "Never show" @@ -1964,6 +1999,7 @@ _deck: introduction: "Create the perfect interface for you by arranging columns freely!" 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 simple UI for navigated pages" _columns: main: "Main" widgets: "Widgets" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index a5dd18e3f..127dc367e 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -49,9 +49,15 @@ 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." addToList: "Agregar a lista" +addToAntenna: "Añadir a la antena" sendMessage: "Enviar un mensaje" copyRSS: "Copiar RSS" copyUsername: "Copiar nombre de usuario" +copyUserId: "Copiar ID del usuario" +copyNoteId: "Copiar ID de la nota" +copyFileId: "Copiar un archivo ID" +copyFolderId: "Copiar carpeta ID" +copyProfileUrl: "Copiar la URL del perfil" searchUser: "Buscar un usuario" reply: "Responder" loadMore: "Ver más" @@ -134,8 +140,10 @@ unblockConfirm: "¿Quiere dejar de bloquear esta cuenta?" suspendConfirm: "¿Quiere suspender esta cuenta?" unsuspendConfirm: "¿Quiere dejar de suspender esta cuenta?" selectList: "Seleccione una lista" +editList: "Editar lista" selectChannel: "Seleccionar canal" selectAntenna: "Seleccionar antena" +editAntenna: "Editar antena" selectWidget: "Seleccionar widget" editWidgets: "Editar widgets" editWidgetsExit: "Terminar edición" @@ -148,6 +156,8 @@ addEmoji: "Agregar emoji" settingGuide: "Configuración sugerida" cacheRemoteFiles: "Mantener en cache los archivos remotos" cacheRemoteFilesDescription: "Si desactiva esta configuración, Los archivos remotos se cargarán desde el link directo sin usar la caché. Con eso se puede ahorrar almacenamiento del servidor, pero eso aumentará el tráfico al no crear miniaturas." +cacheRemoteSensitiveFiles: "Cachear archivos remotos sensibles" +cacheRemoteSensitiveFilesDescription: "Cuando esta opción está desactivada, los archivos remotos sensibles son cargador directamente de la instancia origen sin ser cacheados." flagAsBot: "Esta cuenta es un bot" flagAsBotDescription: "En caso de que esta cuenta fuera usada por un programa, active esta opción. Al hacerlo, esta opción servirá para otros desarrolladores para evitar cadenas infinitas de reacciones, y ajustará los sistemas internos de Misskey para que trate a esta cuenta como un bot." flagAsCat: "Esta cuenta es un gato" @@ -229,10 +239,10 @@ instanceFollowers: "Seguidores de la instancia" instanceUsers: "Usuarios de la instancia" changePassword: "Cambiar contraseña" security: "Seguridad" -retypedNotMatch: "No hay coincidencia" +retypedNotMatch: "La información no coincide." currentPassword: "Contraseña actual" newPassword: "Contraseña nueva" -newPasswordRetype: "Contraseña nueva (repetir)" +newPasswordRetype: "Reescribe contraseña nueva" attachFile: "Añadir archivo" more: "¡Más!" featured: "Destacados" @@ -309,7 +319,7 @@ copyUrl: "Copiar URL" rename: "Renombrar" avatar: "Avatar" banner: "Banner" -nsfw: "Marcado como sensible" +displayOfSensitiveMedia: "Mostrar contenido sensible" whenServerDisconnected: "Cuando se pierda la conexión con el servidor" disconnectedFromServer: "Desconectado del servidor" reload: "Recargar" @@ -433,7 +443,7 @@ title: "Título" text: "Texto" enable: "Activar" next: "Siguiente" -retype: "Intentar de nuevo" +retype: "Ingrese de nuevo" noteOf: "Notas de {user}" quoteAttached: "Cita añadida" quoteQuestion: "¿Quiere añadir una cita?" @@ -453,7 +463,7 @@ weakPassword: "Contraseña débil" normalPassword: "Buena contraseña" strongPassword: "Muy buena contraseña" passwordMatched: "Correcto" -passwordNotMatched: "Las contraseñas no son las mismas" +passwordNotMatched: "Las contraseñas no coinciden" signinWith: "Inicie sesión con {x}" signinFailed: "Autenticación fallida. Asegúrate de haber usado el nombre de usuario y contraseña correctos." or: "O" @@ -790,8 +800,9 @@ noMaintainerInformationWarning: "No se ha establecido la información del admini noBotProtectionWarning: "La protección contra los bots no está configurada" configure: "Configurar" postToGallery: "Crear una nueva publicación en la galería" +postToHashtag: "Publicar a este hashtag" gallery: "Galería" -recentPosts: "Posts recientes" +recentPosts: "Publicaciones recientes" popularPosts: "Más vistos" shareWithNote: "Compartir con una nota" ads: "Anuncios" @@ -823,6 +834,7 @@ translatedFrom: "Traducido de {x}" accountDeletionInProgress: "La eliminación de la cuenta está en curso" usernameInfo: "Un nombre que identifique su cuenta de otras en este servidor. Puede utilizar el alfabeto (a~z, A~Z), dígitos (0~9) o guiones bajos (_). Los nombres de usuario no se pueden cambiar posteriormente." aiChanMode: "Modo Ai" +devMode: "Modo de desarrollador" keepCw: "Mantener la advertencia de contenido" pubSub: "Cuentas Pub/Sub" lastCommunication: "Última comunicación" @@ -832,15 +844,17 @@ breakFollow: "Dejar de seguir" breakFollowConfirm: "¿Quieres dejar de seguir?" itsOn: "¡Está encendido!" itsOff: "¡Está apagado!" -emailRequiredForSignup: "Se requere una dirección de correo electrónico para el registro de la cuenta" +on: "Activado" +off: "Desactivado" +emailRequiredForSignup: "Se requiere una dirección de correo electrónico para el registro de la cuenta" unread: "No leído" -filter: "Filtro" +filter: "Filtrar" controlPanel: "Panel de control" manageAccounts: "Administrar cuenta" makeReactionsPublic: "Hacer el historial de reacciones público" makeReactionsPublicDescription: "Todas las reacciones que hayas hecho serán públicamente visibles." classic: "Clásico" -muteThread: "Ocultar hilo" +muteThread: "Silenciar hilo" unmuteThread: "Mostrar hilo" ffVisibility: "Visibilidad de seguidores y seguidos" ffVisibilityDescription: "Puedes configurar quien puede ver a quienes sigues y quienes te siguen" @@ -912,6 +926,7 @@ remoteOnly: "Sólo remoto" failedToUpload: "La subida falló" cannotUploadBecauseInappropriate: "Este archivo no se puede subir debido a que algunas partes han sido detectadas comoNSFW." cannotUploadBecauseNoFreeSpace: "La subida falló debido a falta de espacio libre en la unidad del usuario." +cannotUploadBecauseExceedsFileSizeLimit: "Este archivo supera el peso máximo y no puede ser subido." beta: "Beta" enableAutoSensitive: "Marcar automáticamente contenido NSFW" enableAutoSensitiveDescription: "Permite la detección y marcado automático de contenido NSFW usando 'Machine Learning' cuando sea posible. Incluso si esta opción está desactivada, puede ser activado para toda la instancia." @@ -942,18 +957,23 @@ show: "Apariencia" neverShow: "No mostrar de nuevo" remindMeLater: "Recordar después" didYouLikeMisskey: "¿Te gusta Misskey?" -pleaseDonate: "Misskey es software libre, y es usado por {host} . Por favor, ¡considera donar al proyecto principal para que podamos continuar!" +pleaseDonate: "{host} usa el software gratuito Misskey. Por favor ¡Considera donar al proyecto principal para que podamos continuar!" roles: "Roles" -role: "Roles" +role: "Rol" +noRole: "Rol no encontrado" normalUser: "Usuario normal" undefined: "Indefinido" assign: "Asignar" unassign: "Quitar" color: "Color" manageCustomEmojis: "Administrar emojis personalizados" -youCannotCreateAnymore: "Se alcanzó el límite de creación" -cannotPerformTemporary: "Indisponible temporalmente" +youCannotCreateAnymore: "Has llegado al límite de creaciones." +cannotPerformTemporary: "Temporalmente no disponible" cannotPerformTemporaryDescription: "Esta acción no se puede realizar porque se excedió el límite de ejecución. Espera un poco y prueba de nuevo." +invalidParamError: "Parámetros inválidos" +invalidParamErrorDescription: "Los parámetros de la solicitud son inválidos. Normalmente se trata de un error, pero también puede haberse excedido algún límite o similares." +permissionDeniedError: "Operación denegada" +permissionDeniedErrorDescription: "Esta cuenta no tiene permisos para hacer esa acción." preset: "Predefinido" selectFromPresets: "Escoger desde predefinidos" achievements: "Logros" @@ -969,7 +989,7 @@ internalServerErrorDescription: "El servidor tuvo un error inesperado." copyErrorInfo: "Copiar detalles del error" joinThisServer: "Registrarse en esta instancia" exploreOtherServers: "Buscar otra instancia" -letsLookAtTimeline: "Mirar la línea de tiempo local" +letsLookAtTimeline: "Mira la línea de tiempo" disableFederationConfirm: "¿Estas seguro que quieres desactivar la federación?" disableFederationConfirmWarn: "Aunque no exista federación los posts no serán marcados como privados. En la mayoría de los casos, no es necesario hacer los posts no federar." disableFederationOk: "Desactivar." @@ -980,10 +1000,13 @@ cannotBeChangedLater: "Esto no podrá ser cambiado después." reactionAcceptance: "Aceptación de reacciones" likeOnly: "Sólo 'me gusta'" likeOnlyForRemote: "Sólo reacciones de instancias remotas" +nonSensitiveOnly: "Solo no sensible" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "Sólo no contenido sensible (sólo me gusta en remote)" rolesAssignedToMe: "Roles asignados a mí" resetPasswordConfirm: "¿Realmente quieres cambiar la contraseña?" sensitiveWords: "Palabras sensibles" sensitiveWordsDescription: "La visibilidad de todas las notas que contienen cualquiera de las palabras configuradas serán puestas en \"Inicio\" automáticamente. Puedes enumerás varias separándolas con saltos de línea" +sensitiveWordsDescription2: "Si se usan espacios se crearán expresiones AND y las palabras subsecuentes con barras inclinadas se convertirán en expresiones regulares." notesSearchNotAvailable: "No se puede buscar una nota" license: "Licencia" unfavoriteConfirm: "¿Desea quitar de favoritos?" @@ -997,22 +1020,108 @@ enableChartsForFederatedInstances: "Generar gráficos de servidores remotos" showClipButtonInNoteFooter: "Añadir \"Clip\" al menú de notas" largeNoteReactions: "Agrandar las reacciones de las notas" noteIdOrUrl: "ID o URL de la nota" +video: "Video" +videos: "Video" +dataSaver: "Ahorro de datos" accountMigration: "Migración de cuenta" -accountMoved: "Este usuario se ha mudado a una nueva cuenta:" +accountMoved: "Este usuario se movió a una nueva cuenta:" accountMovedShort: "Esta cuenta ha sido migrada." +operationForbidden: "Operación prohibida" +forceShowAds: "Siempre mostrar anuncios" +addMemo: "Añadir nota" +editMemo: "Editar nota" +reactionsList: "Lista de reacciones" +renotesList: "Renotas" +notificationDisplay: "Notificaciones" +leftTop: "Arriba a la izquierda" +rightTop: "Arriba a la derecha" +leftBottom: "Abajo a la izquierda" +rightBottom: "Abajo a la derecha" +stackAxis: "Dirección de apilado" +vertical: "Vertical" horizontal: "Horizontal" +position: "Posición" +serverRules: "Reglas del servidor" +pleaseConfirmBelowBeforeSignup: "Por favor confirma antes de continuar el registro" +pleaseAgreeAllToContinue: "Tienes que estar de acuerdo con los campos anteriores para contnuar." +continue: "Continuar" +preservedUsernames: "Nombre de usuario reservado" +preservedUsernamesDescription: "La lista de nombres de usuario para reservar tienen que separarse con saltos de línea.\nEstos estarán indisponibles durante la creación de cuentas, pero pueden ser usados para que los administradores puedan crear esas cuentas manualmente. Las cuentas existentes con esos nombres de usuario no se verán afectadas." +createNoteFromTheFile: "Componer una nota desde éste archivo" +archive: "Archivo" +channelArchiveConfirmTitle: "¿Seguro de archivar {name}?" +channelArchiveConfirmDescription: "Un canal archivado no aparecerá en la lista de canales ni en los resultados. Las nuevas publicaciones tampoco serán añadidas." +thisChannelArchived: "El canal ha sido archivado." +displayOfNote: "Mostrar notas" +initialAccountSetting: "Configración inicial de su cuenta\nか\nConfigración de inicio" youFollowing: "Siguiendo" +preventAiLearning: "Rechazar el uso en el Aprendizaje de Máquinas. (IA Generativa)" +preventAiLearningDescription: "Pedirle a las arañas (crawlers) no usar los textos publicados o imágenes en el aprendizaje automático (IA Predictiva / Generativa). Ésto se logra añadiendo una marca respuesta HTML con la cadena \"noai\" al cantenido. Una prevención total no podría lograrse sólo usando ésta marca, ya que puede ser simplemente ignorada." options: "Opción" +specifyUser: "Especificar usuario" +failedToPreviewUrl: "No se pudo generar la vista previa" +update: "Actualizar" +rolesThatCanBeUsedThisEmojiAsReaction: "Roles que pueden usar este emoji como reacción" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Si no se especifican roles, cualquiera podrá usar éste emoji como reacción." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Éstos roles deben ser públicos." +cancelReactionConfirm: "¿Realmente quieres eliminar la reacción?" +changeReactionConfirm: "¿Realmente quieres cambiar la reacción?" +later: "Ahora no" +goToMisskey: "ir a Misskey" +additionalEmojiDictionary: "Diccionario adicional de Emoji" +installed: "Instalado" +branding: "Marca" +enableServerMachineStats: "Publicar estadísticas de hardware del servidor" +enableIdenticonGeneration: "Activar generación de identicon por usuario" +turnOffToImprovePerformance: "Desactivar esto puede aumentar el rendimiento." +createInviteCode: "Generar invitación" +createWithOptions: "Generar con opciones" +createCount: "Conteo de invitaciones" +inviteCodeCreated: "Invitación generada" +inviteLimitExceeded: "Has excedido el límite de invitaciones que puedes generar." +createLimitRemaining: "Límite de invitaciones: quedan {limit}" +inviteLimitResetCycle: "El límite ha sido reiniciado a {limit} por {time}." +expirationDate: "Fecha de caducidad" +noExpirationDate: "Sin caducidad" +inviteCodeUsedAt: "Código de invitación usado el" +registeredUserUsingInviteCode: "Invitación usada por" +waitingForMailAuth: "Verificación de correo pendiente" +inviteCodeCreator: "Invitación creada por" +usedAt: "Usada el" +unused: "Sin usar" +used: "Usada" +expired: "Caducada" _initialAccountSetting: accountCreated: "¡La cuenta ha sido creada!" + letsStartAccountSetup: "Para empezar, creemos tu perfil." + letsFillYourProfile: "Primero, creemos tu perfil." + profileSetting: "Configuración del perfil" + privacySetting: "Configuración de privacidad" + theseSettingsCanEditLater: "Puedes cambiar estos ajustes más tarde." + youCanEditMoreSettingsInSettingsPageLater: "Desde la pestaña de \"Configuración\" puedes modificar más ajustes. Asegúrate de visitarla después." + followUsers: "Comienza a seguir a usuarios que te interesen para construir tu línea de tiempo." + pushNotificationDescription: "Habilitar las notificaciones push te permitirá recibir notificaciones de {name} directamente en tu dispositivo." + initialAccountSettingCompleted: "¡Configuración del perfil completada!" + haveFun: "¡Disfruta de {name}!" + ifYouNeedLearnMore: "Si quieres aprender cómo usar {name} (Misskey), por favor, visita {link}." + skipAreYouSure: "¿Realmente quieres saltarte la configuración del perfil?" + laterAreYouSure: "¿Realmente quieres configurar tu perfil después?" +_serverRules: + description: "Un conjunto de reglas que serán mostradas antes del registro. Configurar un sumario de términos de servicio es recomendado." _accountMigration: moveFrom: "Trasladar de otra cuenta a ésta" + moveFromSub: "Crear un alias para otra cuenta." moveFromLabel: "Cuenta desde la que se realiza el traslado:" moveFromDescription: "Si quieres transferir seguidores de otra cuenta a esta cuenta y trasladarlos, tendrás que crear un alias aquí. Asegúrate de crearlo antes de realizar el traslado. Introduce la cuenta desde la que estás moviendo los seguidores así: @person@instance.com" moveTo: "Mover esta cuenta a una nueva" moveToLabel: "Cuenta destino:" + moveCannotBeUndone: "La migración de la cuenta no puede ser revertida." moveAccountDescription: "Esta operación no puede deshacerse. En primer lugar, asegúrese de haber creado un alias para esta cuenta en la cuenta a la que se va a trasladar. Después de crear el alias, introduzca la cuenta a la que se está trasladando de la siguiente manera: @person@instance.com" + moveAccountHowTo: "Para migrar, primero crea un alias para ésta cuenta en la cuenta a donde te moverás.\nDespués de crear el alias, ingresa la cuenta a mover de la siguiente forma:\n@usuario@servidor.ejempo.com" + startMigration: "Migrar" migrationConfirm: "¿Estás seguro de que quieres mover esta cuenta a {account}? Una vez trasladada, no podrás deshacer el traslado y no podrás volver a utilizar la cuenta original.\n\nAdemás, compruebe que ha configurado un alias en el destino del traslado." + movedAndCannotBeUndone: "\nLa migración decuenta ha sido completada.\nNo se puede revertir éste proceso." + postMigrationNote: "Ésta cuenta dejará de seguir a todas las cuentas en las siguientes 24 horas después de que finalice la migración.\nEl número de seguidos y seguidores serán 0. Para evitar que Para evitar que tus seguidores dejen de ver las publicaciones, todas serán marcadas como \"sólo seguidores\"." movedTo: "Cuenta destino:" _achievements: earnedAt: "Desbloqueado el" @@ -1187,6 +1296,7 @@ _achievements: description: "30 minutos dedicados a Misskey" _client60min: title: "Viendo mucho Misskey." + description: "Dejar abierto Misskey por al menos 60 minutos" _noteDeletedWithin1min: title: "Ah... Mejor no..." description: "Borrar una nota antes que de pase 1 minuto" @@ -1275,6 +1385,8 @@ _role: iconUrl: "URL del ícono" asBadge: "Mostrar como emblema" descriptionOfAsBadge: "Este ícono de rol se mostrará a lado del nombre de usuario cuando este rol se encuentre activo." + isExplorable: "Hacer el rol explorable" + descriptionOfIsExplorable: "La línea de tiempo de éste rol y la lista de usuarios serán públicos si se activa.." displayOrder: "Posición" descriptionOfDisplayOrder: "Entre más alto el número, mayor es la posición en la interfaz." canEditMembersByModerator: "Permitir a los moderadores editar los miembros" @@ -1289,8 +1401,12 @@ _role: ltlAvailable: "Explorar la línea de tiempo local" canPublicNote: "Permitir la publicación" canInvite: "Puede crear códigos de invitación" + inviteLimit: "Límite de invitaciones" + inviteLimitCycle: "Enfriamiento del límite de invitaciones" + inviteExpirationTime: "Intervalo de caducidad de invitaciones" canManageCustomEmojis: "Administrar emojis personalizados" - driveCapacity: "Capacidad de almacenamiento" + driveCapacity: "Capacidad del drive" + alwaysMarkNsfw: "Siempre marcar archivos como NSFW" pinMax: "Máximo de notas fijadas" antennaMax: "Máximo de antenas" wordMuteMax: "Máximo de caracteres en palabras silenciadas" @@ -1350,6 +1466,7 @@ _ad: back: "Deseleccionar" reduceFrequencyOfThisAd: "Mostrar menos este anuncio." hide: "No mostrar" + timezoneinfo: "El día de la semana está determidado por la zona horaria del servidor." _forgotPassword: enterEmail: "Ingrese el correo usado para registrar la cuenta. Se enviará un link para resetear la contraseña." ifNoEmail: "Si no utilizó un correo para crear la cuenta, contáctese con el administrador." @@ -1401,10 +1518,10 @@ _aboutMisskey: donate: "Donar a Misskey" morePatrons: "Muchas más personas nos apoyan. Muchas gracias🥰" patrons: "Patrocinadores" -_nsfw: - respect: "Ocultar medios NSFW" - ignore: "No esconder medios NSFW " - force: "Ocultar todos los medios" +_displayOfSensitiveMedia: + respect: "Esconder medios marcados como sensibles" + ignore: "Mostrar medios marcados como sensibles" + force: "Esconder todala multimedia" _instanceTicker: none: "No mostrar" remote: "Mostrar a usuarios remotos" @@ -1423,6 +1540,8 @@ _channel: following: "Siguiendo" usersCount: "{n} participantes" notesCount: "{n} notas" + nameAndDescription: "Nombre y descripción" + nameOnly: "Sólo nombre" _menuDisplay: sideFull: "Horizontal" sideIcon: "Horizontal (ícono)" @@ -1526,7 +1645,7 @@ _sfx: channel: "Notificaciones del canal" _ago: future: "Futuro" - justNow: "Recién ahora" + justNow: "Justo ahora" secondsAgo: "Hace {n} segundos" minutesAgo: "Hace {n} minutos" hoursAgo: "Hace {n} horas" @@ -1540,6 +1659,16 @@ _time: minute: "Minutos" hour: "Horas" day: "Días" +_timelineTutorial: + title: "Cómo usar Misskey" + step1_1: "Ésta es la \"línea de tiempo\". Todas las \"notas\" que sean publicadas en {name} serán mostradas cronológicamente aquí." + step1_2: "Hay varias líneas de tiempo. Por ejemplo, la línea temporal \"Inicio\" contiene las notas de otros usuarios que sigues, y la línea \"Local\" contandrá las notas de todos los usuarios de {name}." + step2_1: "Ahora probemos publicar una nota. Puedes hacerlo presionando el botón que tiene un ícono de lápiz." + step2_2: "¿Qué tal si escribimos una introducción? o sólo un \"¡Hola {name}!\" ¿No te apetece?" + step3_1: "¿Terminaste de publicar tu primera nota?" + step3_2: "Tu primera nota ahora se mostrará en tu línea de tiempo." + step4_1: "También puedes añadir \"Reacciones\" a notas." + step4_2: "Para añadir una reacción selecciona el botón \"+\" en la nota y escoge el emoji que quieras para reaccionar." _2fa: alreadyRegistered: "Ya has completado la configuración." registerTOTP: "Registrar aplicación autenticadora" @@ -1685,14 +1814,14 @@ _visibility: homeDescription: "Visible sólo en la linea de tiempo de inicio" followers: "Seguidores" followersDescription: "Visible sólo para tus seguidores" - specified: "Mensaje directo" + specified: "Nota directa" specifiedDescription: "Visible sólo para los usuarios elegidos" disableFederation: "No federado" disableFederationDescription: "No enviar a otras instancias" _postForm: replyPlaceholder: "Responder a esta nota" quotePlaceholder: "Citar esta nota" - channelPlaceholder: "Postear en el canal" + channelPlaceholder: "Publicar en el canal" _placeholders: a: "¿Qué haces?" b: "¿Te pasó algo?" @@ -1867,6 +1996,7 @@ _deck: introduction: "¡Crea la interfaz perfecta para tí organizando las columnas libremente!" 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" _columns: main: "Principal" widgets: "Widgets" @@ -1876,7 +2006,8 @@ _deck: list: "Listas" channel: "Canal" mentions: "Menciones" - direct: "Mensaje directo" + direct: "Notas directas" + roleTimeline: "Linea de tiempo del rol" _dialog: charactersExceeded: "¡Has excedido el límite de caracteres! Actualmente {current} de {max}." charactersBelow: "¡Estás por debajo del límite de caracteres! Actualmente {current} de {min}." @@ -1884,8 +2015,8 @@ _disabledTimeline: title: "Línea de tiempo deshabilitada" description: "No puedes usar esta línea de tiempo con tus roles actuales." _drivecleaner: - orderBySizeDesc: "Más grandes" - orderByCreatedAtAsc: "Más antiguos" + orderBySizeDesc: "Tamaño descendiente" + orderByCreatedAtAsc: "Fecha ascendente" _webhookSettings: createWebhook: "Crear Webhook" name: "Nombre" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 173380805..f76a26632 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -52,6 +52,11 @@ addToList: "Ajouter à une liste" sendMessage: "Envoyer un message" copyRSS: "Copier le RSS" copyUsername: "Copier le nom d’utilisateur·rice" +copyUserId: "Copier l'identifiant de l'utilisateur" +copyNoteId: "Copier l'identifiant de la note" +copyFileId: "Copier l'identifiant du fichier" +copyFolderId: "Copier l'identifiant du dossier" +copyProfileUrl: "Copier l'URL du profil" searchUser: "Chercher un·e utilisateur·rice" reply: "Répondre" loadMore: "Afficher plus …" @@ -134,6 +139,7 @@ unsuspendConfirm: "Êtes-vous sûr·e de vouloir annuler la suspension de ce com selectList: "Sélectionner une liste" selectChannel: "Sélectionner un canal" selectAntenna: "Sélectionner une antenne" +editAntenna: "Modifier l'antenne" selectWidget: "Sélectionner un widget" editWidgets: "Modifier les widgets" editWidgetsExit: "Valider les modifications" @@ -146,6 +152,8 @@ addEmoji: "Ajouter un émoji" settingGuide: "Configuration proposée" cacheRemoteFiles: "Mise en cache des fichiers distants" cacheRemoteFilesDescription: "Lorsque cette option est désactivée, les fichiers distants sont chargés directement depuis l’instance distante. La désactiver diminuera certes l’utilisation de l’espace de stockage local mais augmentera le trafic réseau puisque les miniatures ne seront plus générées." +cacheRemoteSensitiveFiles: "Mettre en cache les fichiers distants sensibles" +cacheRemoteSensitiveFilesDescription: "Si vous désactivez ce paramètre, les fichiers sensibles distants ne seront pas mis en cache et un lien direct sera utilisé à la place" flagAsBot: "Ce compte est un robot" flagAsBotDescription: "Si ce compte est géré de manière automatisée, choisissez cette option. Si elle est activée, elle agira comme un marqueur pour les autres développeurs afin d'éviter des chaînes d'interaction sans fin avec d'autres robots et d'ajuster les systèmes internes de Misskey pour traiter ce compte comme un robot." flagAsCat: "Ce compte est un chat" @@ -154,6 +162,7 @@ flagShowTimelineReplies: "Afficher les réponses dans le fil" flagShowTimelineRepliesDescription: "Affiche les réponses des utilisateurs aux notes des autres utilisateurs dans la timeline si cette option est activée." autoAcceptFollowed: "Accepter automatiquement les demandes d’abonnement venant d’utilisateur·rice·s que vous suivez" addAccount: "Ajouter un compte" +reloadAccountsList: "Rafraichir la liste des comptes" loginFailed: "Échec de la connexion" showOnRemote: "Voir sur l’instance distante" general: "Général" @@ -260,6 +269,8 @@ noMoreHistory: "Il n’y a plus d’historique" startMessaging: "Commencer à discuter" nUsersRead: "Lu par {n} personnes" agreeTo: "J’accepte {0}" +agree: "Accepter" +termsOfService: "Conditions d'utilisation" start: "Commencer" home: "Principal" remoteUserCaution: "Les informations de ce compte risqueraient d’être incomplètes du fait que l’utilisateur·rice provient d’une instance distante." @@ -302,7 +313,7 @@ copyUrl: "Copier l’URL" rename: "Renommer" avatar: "Avatar" banner: "Bannière" -nsfw: "Contenu sensible" +displayOfSensitiveMedia: "Afficher les médias sensibles" whenServerDisconnected: "Lorsque la connexion au serveur est perdue" disconnectedFromServer: "Déconnecté·e du serveur" reload: "Rafraîchir" @@ -392,11 +403,15 @@ about: "Informations" aboutMisskey: "À propos de Misskey" administrator: "Administrateur" token: "Jeton" +2fa: "Authentification à deux facteurs" +totp: "Application d'authentification" +totpDescription: "Entrez un mot de passe à usage unique à l'aide d'une application d'authentification" moderator: "Modérateur·rice·s" moderation: "Modérations" nUsersMentioned: "{n} utilisateur·rice·s mentionné·e·s" securityKey: "Clé de sécurité" lastUsed: "Dernier utilisé" +lastUsedAt: "Dernière utilisation : {t}" unregister: "Se désinscrire" passwordLessLogin: "Se connecter sans mot de passe" resetPassword: "Réinitialiser le mot de passe" @@ -534,9 +549,14 @@ userSuspended: "Cet·te utilisateur·rice a été suspendu·e." userSilenced: "Cette utilisateur·trice a été mis·e en sourdine." yourAccountSuspendedTitle: "Ce compte est suspendu" yourAccountSuspendedDescription: "Ce compte est suspendu car vous avez enfreint les conditions d'utilisation de l'instance, ou pour un motif similaire. Si vous souhaitez connaître en détail les raisons de cette suspension, renseignez-vous auprès de l'administrateur·rice de votre instance. Merci de ne pas créer de nouveau compte." +tokenRevoked: "Ce jeton est invalide." +tokenRevokedDescription: "Votre jeton de connexion a expiré. Veuillez vous reconnecter." +accountDeleted: "Compte supprimé" +accountDeletedDescription: "Ce compte a été supprimé." menu: "Menu" divider: "Séparateur" addItem: "Ajouter un élément" +rearrange: "Trier par" relays: "Relais" addRelay: "Ajouter un relais" inboxUrl: "Inbox URL" @@ -678,6 +698,8 @@ contact: "Contact" useSystemFont: "Utiliser la police par défaut du système" clips: "Clips" experimentalFeatures: "Fonctionnalités expérimentales" +experimental: "Expérimental" +thisIsExperimentalFeature: "Ceci est une fonctionnalité expérimentale. Il y a une possibilité que les spécifications changent ou qu'elle ne fonctionne pas correctement." developer: "Développeur" makeExplorable: "Rendre le compte visible sur la page \"Découvrir\"." makeExplorableDescription: "Si vous désactivez cette option, votre compte n'apparaîtra pas sur la page \"Découvrir\"." @@ -762,6 +784,7 @@ noMaintainerInformationWarning: "Informations administrateur non configurées." noBotProtectionWarning: "La protection contre les bots n'est pas configurée." configure: "Configurer" postToGallery: "Publier dans la galerie" +postToHashtag: "Publier avec ce hashtag" gallery: "Galerie" recentPosts: "Les plus récentes" popularPosts: "Les plus consultées" @@ -800,6 +823,7 @@ lastCommunication: "Dernière communication" resolved: "Résolu" unresolved: "En attente" breakFollow: "Ne plus suivre" +breakFollowConfirm: "Êtes-vous sûr de vouloir vous désabonner ?" itsOn: "Activé" itsOff: "Désactivé" emailRequiredForSignup: "Une adresse e-mail est nécessaire pour créer un compte" @@ -1085,10 +1109,6 @@ _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" -_nsfw: - respect: "Cacher les médias marqués comme contenu sensible" - ignore: "Afficher les médias sensibles" - force: "Cacher tous les médias" _instanceTicker: none: "Cacher " remote: "Montrer pour les utilisateur·ice·s distant·e·s" diff --git a/locales/generateDTS.js b/locales/generateDTS.js index 5949aee7c..7369dfbb4 100644 --- a/locales/generateDTS.js +++ b/locales/generateDTS.js @@ -1,6 +1,6 @@ -const fs = require('fs'); -const yaml = require('js-yaml'); -const ts = require('typescript'); +import * as fs from 'node:fs'; +import * as yaml from 'js-yaml'; +import * as ts from 'typescript'; function createMembers(record) { return Object.entries(record) @@ -14,7 +14,7 @@ function createMembers(record) { )); } -module.exports = function generateDTS() { +export default function generateDTS() { const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8')); const members = createMembers(locale); const elements = [ @@ -51,11 +51,7 @@ module.exports = function generateDTS() { ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags, ), ), - ts.factory.createExportAssignment( - undefined, - true, - ts.factory.createIdentifier('locales'), - ), + ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), ]; const printed = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 70217caa2..34c995b05 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -5,7 +5,7 @@ introMisskey: "Selamat datang! Misskey adalah perangkat mikroblog tercatu bersif poweredByMisskeyDescription: "{name} adalah sebuah layanan (instance) yang menggunakan platform sumber terbuka Misskey." monthAndDay: "{day} {month}" search: "Penelusuran" -notifications: "Pemberitahuan" +notifications: "Notifikasi" username: "Nama Pengguna" password: "Kata sandi" forgotPassword: "Lupa Kata Sandi" @@ -17,7 +17,7 @@ noThankYou: "Tidak sekarang." enterUsername: "Masukkan nama pengguna" renotedBy: "direnote oleh {user}" noNotes: "Tidak ada catatan" -noNotifications: "Tidak ada pemberitahuan" +noNotifications: "Tidak ada notifikasi" instance: "Instansi" settings: "Pengaturan" notificationSettings: "Atur Notifikasi" @@ -25,7 +25,7 @@ basicSettings: "Pengaturan umum" otherSettings: "Pengaturan lainnya" openInWindow: "Buka di jendela" profile: "Profil" -timeline: "Linimasa" +timeline: "Lini masa" noAccountDescription: "Pengguna ini belum menulis bio" login: "Masuk" loggingIn: "Sedang masuk" @@ -52,6 +52,8 @@ addToList: "Tambahkan ke daftar" sendMessage: "Kirim pesan" copyRSS: "Salin RSS" copyUsername: "Salin nama pengguna" +copyUserId: "Salin ID pengguna" +copyNoteId: "Salin ID catatan" searchUser: "Cari pengguna" reply: "Balas" loadMore: "Selebihnya" @@ -90,7 +92,7 @@ serverIsDead: "Tidak ada respon dari peladen. Mohon tunggu dan coba beberapa saa youShouldUpgradeClient: "Untuk melihat halaman ini, mohon muat ulang untuk memutakhirkan klienmu." enterListName: "Masukkan nama daftar" privacy: "Privasi" -makeFollowManuallyApprove: "Permintaan mengikuti membutuhkan persetujuan" +makeFollowManuallyApprove: "Permintaan mengikuti butuh persetujuan" defaultNoteVisibility: "Privasi bawaan catatan" follow: "Ikuti" followRequest: "Permintaan mengikuti" @@ -115,7 +117,7 @@ add: "Tambahkan" reaction: "Reaksi" reactions: "Reaksi" reactionSetting: "Reaksi untuk dimunculkan di bilah reaksi" -reactionSettingDescription2: "Geser untuk memindah urutkan, klik untuk menghapus, tekan \"+\" untuk menambahkan" +reactionSettingDescription2: "Geser untuk memindah urutan emoji, klik untuk menghapus, tekan \"+\" untuk menambahkan" rememberNoteVisibility: "Ingat pengaturan visibilitas catatan" attachCancel: "Hapus lampiran" markAsSensitive: "Tandai sebagai konten sensitif" @@ -134,8 +136,10 @@ unblockConfirm: "Apakah kamu yakin ingin membuka blokir akun ini?" suspendConfirm: "Apakah kamu yakin ingin membekukan akun ini?" unsuspendConfirm: "Apakah kamu yakin ingin membuka pembekuan akun ini?" selectList: "Pilih daftar" +editList: "Sunting daftar" selectChannel: "Pilih kanal" selectAntenna: "Pilih Antena" +editAntenna: "Sunting antena" selectWidget: "Pilih gawit" editWidgets: "Sunting gawit" editWidgetsExit: "Selesai" @@ -146,16 +150,17 @@ emojiName: "Nama emoji" emojiUrl: "URL Emoji" addEmoji: "Tambahkan emoji" settingGuide: "Pengaturan rekomendasi" -cacheRemoteFiles: "Tembolokkan berkas remote" -cacheRemoteFilesDescription: "Ketika pengaturan ini dinonaktifkan, berkas luar akan dimuat langsung dari instansi luar. Menonaktifkan ini akan mengurangi penggunaan penyimpanan, namun dapat menyebabkan meningkatkan lalu lintas bandwidth, karena thumbnail tidak dihasilkan." +cacheRemoteFiles: "Tembolokkan berkas dari instansi luar" +cacheRemoteFilesDescription: "Ketika pengaturan ini dinonaktifkan, berkas dari instansi luar akan dimuat langsung. Menonaktifkan ini akan mengurangi penggunaan penyimpanan peladen, namun dapat menyebabkan peningkatan lalu lintas bandwidth, karena keluku tidak dihasilkan." flagAsBot: "Atur akun ini sebagai Bot" flagAsBotDescription: "Jika akun ini dikendalikan oleh program, tetapkanlah opsi ini. Jika diaktifkan, ini akan berfungsi sebagai tanda bagi pengembang lain untuk mencegah interaksi berantai dengan bot lain dan menyesuaikan sistem internal Misskey untuk memperlakukan akun ini sebagai bot." flagAsCat: "Atur akun ini sebagai kucing" flagAsCatDescription: "Nyalakan tanda ini untuk menandai akun ini sebagai kucing." -flagShowTimelineReplies: "Tampilkan balasan di linimasa" -flagShowTimelineRepliesDescription: "Menampilkan balasan pengguna dari note pengguna lain di linimasa apabila dinyalakan." +flagShowTimelineReplies: "Tampilkan balasan di lini masa" +flagShowTimelineRepliesDescription: "Menampilkan balasan pengguna dari catatan pengguna lain di lini masa apabila dinyalakan." autoAcceptFollowed: "Setujui otomatis permintaan mengikuti dari pengguna yang kamu ikuti" addAccount: "Tambahkan akun" +reloadAccountsList: "Muat ulang daftar akun" loginFailed: "Gagal untuk masuk" showOnRemote: "Lihat profil asli" general: "Umum" @@ -166,7 +171,7 @@ searchWith: "Cari: {q}" youHaveNoLists: "Kamu tidak memiliki daftar apapun" followConfirm: "Apakah kamu yakin ingin mengikuti {name}?" proxyAccount: "Akun proksi" -proxyAccountDescription: "Akun proksi merupakan sebuah akun yang bertindak sebagai pengikut luar untuk pengguna dalam kondisi tertentu. Sebagai contoh, ketika pengguna menambahkan seorang pengguna luar ke dalam daftar, aktivitas dari pengguna luar tidak akan disampaikan ke instansi apabila tidak ada pengguna lokal yang mengikuti pengguna tersebut, dengan begitu akun proksilah yang akan mengikutinya." +proxyAccountDescription: "Akun proksi merupakan sebuah akun yang bertindak sebagai pengikut instansi luar untuk pengguna dalam kondisi tertentu. Sebagai contoh, ketika pengguna menambahkan seorang pengguna instansi luar ke dalam daftar, aktivitas dari pengguna instansi luar tidak akan disampaikan ke instansi apabila tidak ada pengguna lokal yang mengikuti pengguna tersebut, dengan begitu akun proksilah yang akan mengikutinya." host: "Host" selectUser: "Pilih pengguna" recipient: "Penerima" @@ -198,7 +203,7 @@ clearQueue: "Bersihkan antrian" clearQueueConfirmTitle: "Apakah kamu yakin ingin membersihkan antrian?" clearQueueConfirmText: "Seluruh sisa catatan yang tidak tersampaikan di dalam antrian tidak akan difederasi. Biasanya operasi ini TIDAK dibutuhkan." clearCachedFiles: "Hapus tembolok" -clearCachedFilesConfirm: "Apakah kamu yakin ingin menghapus seluruh tembolok berkas remote?" +clearCachedFilesConfirm: "Apakah kamu yakin ingin menghapus seluruh tembolok berkas instansi luar?" blockedInstances: "Instansi terblokir" blockedInstancesDescription: "Daftar nama host dari instansi yang diperlukan untuk diblokir. Instansi yang didaftarkan tidak akan dapat berkomunikasi dengan instansi ini." muteAndBlock: "Bisukan / Blokir" @@ -223,9 +228,9 @@ all: "Semua" subscribing: "Berlangganan" publishing: "Sedang menyiarkan langsung" notResponding: "Tidak ada respon" -instanceFollowing: "Mengikuti instance" -instanceFollowers: "Pengikut instance" -instanceUsers: "Pengguna pada instance ini" +instanceFollowing: "Mengikuti instansi" +instanceFollowers: "Pengikut instansi" +instanceUsers: "Pengguna pada instansi ini" changePassword: "Ubah kata sandi" security: "Keamanan" retypedNotMatch: "Input tidak sama" @@ -233,11 +238,11 @@ currentPassword: "Kata sandi saat ini" newPassword: "Kata sandi baru" newPasswordRetype: "Ulangi kata sandi baru" attachFile: "Lampirkan berkas" -more: "Lagi !" +more: "Lainnya" featured: "Sorotan" usernameOrUserId: "Nama pengguna atau User ID" noSuchUser: "Pengguna tidak ditemukan" -lookup: "Mencari" +lookup: "Cari" announcements: "Pengumuman" imageUrl: "URL Gambar" remove: "Hapus" @@ -268,7 +273,7 @@ basicNotesBeforeCreateAccount: "Catatan penting" termsOfService: "Syarat dan ketentuan" start: "Mulai" home: "Beranda" -remoteUserCaution: "Informasi ini mungkin tidak mutakhir, karena pengguna ini berasal dari instansi luar." +remoteUserCaution: "Informasi ini mungkin tidak mutakhir, karena pengguna ini berasal dari peladen instansi luar." activity: "Aktivitas" images: "Gambar" image: "Gambar" @@ -308,19 +313,18 @@ copyUrl: "Salin tautan" rename: "Ubah nama" avatar: "Avatar" banner: "Banner" -nsfw: "Konten sensitif" whenServerDisconnected: "Ketika kehilangan koneksi dengan peladen" disconnectedFromServer: "Terputus koneksi dari peladen" reload: "Muat ulang" doNothing: "Abaikan" -reloadConfirm: "Apakah kamu ingin memuat ulang linimasa?" +reloadConfirm: "Apakah kamu ingin memuat ulang lini masa?" watch: "Tonton" unwatch: "Batal tonton" accept: "Terima" reject: "Tolak" normal: "Normal" -instanceName: "Nama instance" -instanceDescription: "Tentang instance" +instanceName: "Nama instansi" +instanceDescription: "Tentang instansi" maintainerName: "Pengelola" maintainerEmail: "Surel pengelola" tosUrl: "URL Syarat dan Ketentuan" @@ -334,9 +338,9 @@ pages: "Halaman" integration: "Integrasi" connectService: "Sambungkan" disconnectService: "Putuskan" -enableLocalTimeline: "Nyalakan linimasa lokal" -enableGlobalTimeline: "Nyalakan linimasa global" -disablingTimelinesInfo: "Admin dan Moderator akan selalu memiliki akses ke semua linimasa meskipun linimasa tersebut tidak diaktifkan." +enableLocalTimeline: "Nyalakan lini masa lokal" +enableGlobalTimeline: "Nyalakan lini masa global" +disablingTimelinesInfo: "Admin dan Moderator akan selalu memiliki akses ke semua lini masa meskipun lini masa tersebut tidak diaktifkan." registration: "Pendaftaran" enableRegistration: "Nyalakan pendaftaran pengguna baru" invite: "Undang" @@ -379,7 +383,7 @@ enableServiceworker: "Aktifkan ServiceWorker" antennaUsersDescription: "Tuliskan satu nama pengguna per baris" caseSensitive: "Peka huruf besar dan huruf kecil" withReplies: "Termasuk balasan" -connectedTo: "Akun yang mengikuti telah terhubung" +connectedTo: "Akun berikut terhubung" notesAndReplies: "Catatan dan balasan" withFiles: "Media" silence: "Bungkam" @@ -398,6 +402,7 @@ about: "Informasi" aboutMisskey: "Tentang Misskey" administrator: "Admin" token: "Token" +2fa: "Autentikasi 2-faktor" totp: "Aplikasi autentikator" totpDescription: "Gunakan aplikasi autentikator untuk mendapatkan kata sandi sekali pakai" moderator: "Moderator" @@ -409,6 +414,7 @@ lastUsed: "Terakhir digunakan" lastUsedAt: "Penggunaan terakhir: {t}" unregister: "Batalkan pendaftaran" passwordLessLogin: "Setel login tanpa kata sandi" +passwordLessLoginDescription: "Bolehkan masuk tanpa kata sandi dengan menggunakan hanya security-key atau passkey" resetPassword: "Atur ulang kata sandi" newPasswordIs: "Kata sandi baru adalah \"{password}\"" reduceUiAnimation: "Kurangi animasi antarmuka" @@ -417,7 +423,7 @@ notFound: "Tidak dapat ditemukan" notFoundDescription: "Tidak ada halaman sesuai dengan URL yang ditentukan." uploadFolder: "Lokasi unggah folder bawaan" cacheClear: "Bersihkan tembolok" -markAsReadAllNotifications: "Tandai semua pemberitahuan telah dibaca" +markAsReadAllNotifications: "Tandai semua notifikasi telah dibaca" markAsReadAllUnreadNotes: "Tandai semua catatan telah dibaca" markAsReadAllTalkMessages: "Tandai semua pesan telah dibaca" help: "Bantuan" @@ -460,6 +466,7 @@ aboutX: "Tentang {x}" emojiStyle: "Gaya emoji" native: "Native" disableDrawer: "Jangan gunakan menu bergaya laci" +showNoteActionsOnlyHover: "Hanya tampilkan aksi catatan saat ditunjuk" noHistory: "Tidak ada riwayat" signinHistory: "Riwayat masuk" enableAdvancedMfm: "Nyalakan MFM tingkat lanjut" @@ -472,6 +479,8 @@ createAccount: "Buat akun" existingAccount: "Akun yang ada" regenerate: "Buat ulang" fontSize: "Ukuran huruf" +mediaListWithOneImageAppearance: "Tinggi daftar media dengan satu gambar saja" +limitTo: "Batasi pada {x}" noFollowRequests: "Kamu tidak memiliki permintaan mengikuti yang menunggu" openImageInNewTab: "Buka gambar di tab baru" dashboard: "Dasbor" @@ -487,7 +496,7 @@ promotion: "Promosi" promote: "Promosikan" numberOfDays: "Jumlah hari" hideThisNote: "Sembunyikan catatan ini" -showFeaturedNotesInTimeline: "Tampilkan catatan yang diunggulkan di linimasa" +showFeaturedNotesInTimeline: "Tampilkan catatan yang diunggulkan di lini masa" objectStorage: "Object Storage" useObjectStorage: "Gunakan object storage" objectStorageBaseUrl: "Base URL" @@ -505,9 +514,11 @@ objectStorageUseSSLDesc: "Matikan ini jika kamu tidak akan menggunakan HTTPS unt objectStorageUseProxy: "Hubungkan melalui Proxy" objectStorageUseProxyDesc: "Matikan ini jika kamu tidak akan menggunakan Proxy untuk koneksi ObjectStorage" objectStorageSetPublicRead: "Setel \"public-read\" disaat mengunggah" +s3ForcePathStyleDesc: "Jika s3ForcePathStyle dinyalakan, nama bucket harus dimasukkan dalam path URL dan bukan URL nama host tersebut. Kamu perlu menyalakan pengaturan ini jika menggunakan layanan seperti instansi Minio yang self-hosted." serverLogs: "Log Peladen" deleteAll: "Hapus semua" -showFixedPostForm: "Tampilkan form posting di atas linimasa." +showFixedPostForm: "Tampilkan form posting di atas lini masa." +showFixedPostFormInChannel: "Tampilkan form posting di atas lini masa (Kanal)" newNoteRecived: "Kamu mendapat catatan baru" sounds: "Bunyi" sound: "Bunyi" @@ -536,23 +547,28 @@ scratchpadDescription: "Scratchpad menyediakan lingkungan eksperimen untuk AiScr output: "Keluaran" script: "Script" disablePagesScript: "Nonaktifkan script pada halaman" -updateRemoteUser: "Perbaharui informasi pengguna luar" +updateRemoteUser: "Perbaharui informasi pengguna instansi luar" deleteAllFiles: "Hapus semua berkas" deleteAllFilesConfirm: "Apakah kamu yakin ingin menghapus semua berkas?" -removeAllFollowing: "Tahan semua mengikuti" +removeAllFollowing: "Batalkan mengikuti semua pengguna" removeAllFollowingDescription: "Batal mengikuti semua akun dari {host}. Mohon jalankan ini ketika instansi sudah tidak ada lagi." userSuspended: "Pengguna ini telah dibekukan." userSilenced: "Pengguna ini telah dibungkam." yourAccountSuspendedTitle: "Akun ini dibekukan" yourAccountSuspendedDescription: "Akun ini dibekukan karena melanggar ketentuan penggunaan layanan peladen atau semacamnya. Hubungi admin apabila ingin tahu alasan lebih lanjut. Mohon untuk tidak membuat akun baru." +tokenRevoked: "Token tidak valid" +tokenRevokedDescription: "Token ini telah kedaluwarsa. Mohon masuk lagi." +accountDeleted: "Akun telah dihapus" +accountDeletedDescription: "Akun ini telah dihapus." menu: "Menu" divider: "Pembagi" addItem: "Tambahkan item" +rearrange: "Tata ulang" relays: "Relay" addRelay: "Tambahkan relay" inboxUrl: "URL Kotak masuk" addedRelays: "Relay yang ditambahkan" -serviceworkerInfo: "Harus diaktifkan untuk pemberitahuan push." +serviceworkerInfo: "Harus diaktifkan untuk notifikasi dorong." deletedNote: "Catatan yang dihapus" invisibleNote: "Catatan yang disembunyikan" enableInfiniteScroll: "Aktifkan gulir tak terbatas" @@ -580,13 +596,13 @@ height: "Tinggi" large: "Besar" medium: "Sedang" small: "Kecil" -generateAccessToken: "Buat access token" +generateAccessToken: "Buat token akses" permission: "Izin" enableAll: "Aktifkan semua" disableAll: "Nonaktifkan semua" tokenRequested: "Berikan ijin akses ke akun" pluginTokenRequestedDescription: "Plugin ini dapat menggunakan setelan ijin disini." -notificationType: "Jenis pemberitahuan" +notificationType: "Jenis notifikasi" edit: "Sunting" emailServer: "Peladen surel" enableEmail: "Nyalakan distribusi surel" @@ -617,10 +633,10 @@ delayed: "Terlambat" database: "Basis data" channel: "Kanal" create: "Buat" -notificationSetting: "Pengaturan Pemberitahuan" -notificationSettingDesc: "Pilih tipe pemberitahuan untuk ditampilkan" +notificationSetting: "Pengaturan Notifikasi" +notificationSettingDesc: "Pilih tipe notifikasi untuk ditampilkan" useGlobalSetting: "Gunakan setelan global" -useGlobalSettingDesc: "Jika dinyalakan, setelan pemberitahuan akun kamu akan digunakan. Jika dimatikan, konfigurasi secara individu dapat dibuat." +useGlobalSettingDesc: "Jika dinyalakan, setelan notifikasi akun kamu akan digunakan. Jika dimatikan, pengaturan secara individu dapat dibuat." other: "Lainnya" regenerateLoginToken: "Perbarui token login" regenerateLoginTokenDescription: "Perbarui token yang digunakan secara internal saat login. Normalnya aksi ini tidak diperlukan. Jika diperbarui, semua perangkat akan dilogout." @@ -657,8 +673,8 @@ createNewClip: "Buat klip baru" unclip: "Batalkan klip" confirmToUnclipAlreadyClippedNote: "Catatan ini sudah disertakan di klip \"{name}\". Yakin ingin membatalkan catatan dari klip ini?" public: "Publik" -i18nInfo: "Misskey diterjemahkan ke dalam banyak bahasa oleh sukarelawan. Kamu dapat ikut membantu di {link}." -manageAccessTokens: "Kelola access token" +i18nInfo: "Misskey diterjemahkan ke dalam banyak bahasa oleh sukarelawan. Kamu juga dapat ikut membantu menerjemahkannya di {link}." +manageAccessTokens: "Kelola token akses" accountInfo: "Informasi akun" notesCount: "Jumlah catatan" repliesCount: "Jumlah balasan terkirim" @@ -675,7 +691,7 @@ yes: "Iya" no: "Tidak" driveFilesCount: "Jumlah berkas drive" driveUsage: "Penggunaan ruang penyimpanan drive" -noCrawle: "Tolak pengindeksan crawler" +noCrawle: "Tolak pengindeksan perayap web" noCrawleDescription: "Meminta mesin pencari untuk tidak mengindeks halaman profil kamu, catatan, Halaman, dll." lockedAccountInfo: "Kecuali kamu menyetel visibilitas catatan milikmu ke \"Hanya pengikut\", catatan milikmu akan dapat dilihat oleh siapa saja, bahkan jika kamu memerlukan pengikut untuk disetujui secara manual." alwaysMarkSensitive: "Tandai media dalam catatan sebagai media sensitif" @@ -691,10 +707,12 @@ contact: "Kontak" useSystemFont: "Gunakan font bawaan sistem operasi" clips: "Klip" experimentalFeatures: "Fitur eksperimental" +experimental: "Eksperimental" +thisIsExperimentalFeature: "Fitur ini eksperimental. Fungsionalitas dari fitur ini dapat berubah sewaktu-waktu dan mungkin tidak bekerja sesuai semestinya." developer: "Pengembang" makeExplorable: "Buat akun tampil di \"Jelajahi\"" -makeExplorableDescription: "Jika kamu mematikan ini, akun kamu tidak akan muncul di bagian \"Jelajahi:" -showGapBetweenNotesInTimeline: "Tampilkan jarak diantara catatan pada linimasa" +makeExplorableDescription: "Jika kamu mematikan ini, akun kamu tidak akan muncul di menu \"Jelajahi\"" +showGapBetweenNotesInTimeline: "Tampilkan jarak diantara catatan pada lini masa" duplicate: "Duplikat" left: "Kiri" center: "Tengah" @@ -733,14 +751,14 @@ capacity: "Kapasitas" inUse: "Digunakan" editCode: "Sunting kode" apply: "Terapkan" -receiveAnnouncementFromInstance: "Terima pemberitahuan surel dari instansi ini" -emailNotification: "Pemberitahuan surel" +receiveAnnouncementFromInstance: "Terima pengumuman dari instansi ini" +emailNotification: "Notifikasi surel" publish: "Terbitkan" inChannelSearch: "Cari di kanal" useReactionPickerForContextMenu: "Buka pemilih reaksi dengan klik-kanan" typingUsers: "{users} sedang mengetik..." jumpToSpecifiedDate: "Loncat ke tanggal spesifik" -showingPastTimeline: "Sedang menampilkan linimasa lama" +showingPastTimeline: "Sedang menampilkan lini masa lama" clear: "Bersihkan" markAllAsRead: "Tandai semua telah dibaca" goBack: "Kembali" @@ -755,7 +773,7 @@ userInfo: "Informasi pengguna" unknown: "Tidak diketahui" onlineStatus: "Status daring" hideOnlineStatus: "Sembunyikan status daring" -hideOnlineStatusDescription: "Menyembunyikan status daring kamu umengurangi kenyamanan untuk beberapa fungsi seperti contohnya pencarian." +hideOnlineStatusDescription: "Menyembunyikan status daring kamu akan mengurangi kenyamanan untuk beberapa fungsi, seperti contohnya pencarian." online: "Daring" active: "Aktif" offline: "Luring" @@ -775,12 +793,14 @@ noMaintainerInformationWarning: "Informasi pengelola belum disetel." noBotProtectionWarning: "Proteksi bot belum disetel." configure: "Setel" postToGallery: "Posting ke galeri" +postToHashtag: "Catat ke tagar ini" gallery: "Galeri" recentPosts: "Postingan terbaru" popularPosts: "Postingan populer" shareWithNote: "Bagikan dengan catatan" ads: "Iklan" expiration: "Batas akhir" +startingperiod: "Mulai" memo: "Memo" priority: "Prioritas" high: "Tinggi" @@ -807,14 +827,18 @@ translatedFrom: "Terjemahkan dari {x}" accountDeletionInProgress: "Penghapusan akun sedang dalam proses" usernameInfo: "Nama yang mengidentifikasikan akun kamu dari yang lain pada peladen ini. Kamu dapat menggunakan alfabet (a~z, A~Z), digit (0~9) atau garis bawah (_). Username tidak dapat diubah setelahnya." aiChanMode: "Mode Ai" -keepCw: "Biarkan Peringatan Konten" +devMode: "Mode pengembang" +keepCw: "Biarkan peringatan konten" pubSub: "Akun Pub/Sub" lastCommunication: "Komunikasi terakhir" resolved: "Selesai" unresolved: "Belum selesai" -breakFollow: "Batalkan mengikuti" +breakFollow: "Hapus pengikut" +breakFollowConfirm: "Yakin untuk menghapus pengikut ini?" itsOn: "Aktif" itsOff: "Nonaktif" +on: "Nyala" +off: "Mati" emailRequiredForSignup: "Membutuhkan alamat surel untuk mendaftar" unread: "Belum dibaca" filter: "Saring" @@ -891,25 +915,26 @@ slow: "Lambat" fast: "Cepat" sensitiveMediaDetection: "Deteksi media NSFW" localOnly: "Hanya lokal" -remoteOnly: "Hanya remot" +remoteOnly: "Hanya luar instansi" failedToUpload: "Gagal mengunggah" cannotUploadBecauseInappropriate: "Berkas ini tidak dapat diunggah karena sebagian dari berkas terdeteksi berpotensi NSFW." cannotUploadBecauseNoFreeSpace: "Gagal mengunggah karena kekurangan kapasitas Drive." +cannotUploadBecauseExceedsFileSizeLimit: "Berkas ini tidak dapat diunggah karena melebihi batas ukuran berkas." beta: "Beta" enableAutoSensitive: "Penandaan NSFW otomatis" -enableAutoSensitiveDescription: "Mendeteksi otomatis dan menandai media NSFW menggunakan Machine Learning jika memungkinkan. Meskipun opsi ini dimatikan, ada kemungkinan dinyalakan secara menyeluruh pada instansi peladen." +enableAutoSensitiveDescription: "Mendeteksi otomatis dan menandai media NSFW menggunakan Pembelajaran Mesin jika memungkinkan. Meskipun opsi ini dimatikan, ada kemungkinan dinyalakan secara menyeluruh pada instansi peladen." activeEmailValidationDescription: "Membolehkan validasi alamat surel ketat dengan mengecek apakah alamat surel tersebut temporer dan bisa berkomunikasi dengan surel tersebut. Ketidak tidak dicentang, hanya format surel yang divalidasi." navbar: "Bilah navigasi" shuffle: "Acak" account: "Akun" move: "Pindah" -pushNotification: "Pemberitahuan push" -subscribePushNotification: "Nyalakan pemberitahuan push" -unsubscribePushNotification: "Matikan pemberitahuan push" -pushNotificationAlreadySubscribed: "Pemberitahuan push telah dinyalakan" -pushNotificationNotSupported: "Browser atau instansi kamu tidak mendukung pemberitahuan push" -sendPushNotificationReadMessage: "Hapus pemberitahuan push ketika pemberitahuan relevan atau pesan telah dibaca" -sendPushNotificationReadMessageCaption: "Pemberitahuan berisi teks「{emptyPushNotificationMessage}」akan ditampilkan dalam waktu pendek. Ini mungkin dapat menambah pemakaian baterai pada perangkat kamu." +pushNotification: "Notifikasi dorong" +subscribePushNotification: "Nyalakan notifikasi dorong" +unsubscribePushNotification: "Matikan notifikasi dorong" +pushNotificationAlreadySubscribed: "Notifikasi dorong telah dinyalakan" +pushNotificationNotSupported: "Browser atau instansi kamu tidak mendukung notifikasi dorong" +sendPushNotificationReadMessage: "Hapus notifikasi dorong ketika notifikasi relevan atau pesan telah dibaca" +sendPushNotificationReadMessageCaption: "Notifikasi berisi teks「{emptyPushNotificationMessage}」akan ditampilkan dalam waktu pendek. Ini mungkin dapat menambah pemakaian baterai pada perangkat kamu." windowMaximize: "Maksimalkan" windowMinimize: "Minimalkan" windowRestore: "Kembalikan" @@ -928,6 +953,7 @@ didYouLikeMisskey: "Apakah kamu mulai menyukai Misskey?" pleaseDonate: "{host} menggunakan perangkat lunak bebas yaitu Misskey. Kami sangat mengapresiasi sekali donasi dari kamu agar pengembangan Misskey tetap dapat berlanjut!" roles: "Peran" role: "Peran" +noRole: "Peran tidak temukan" normalUser: "Pengguna umum" undefined: "Tak terdefinisi" assign: "Tetapkan\n" @@ -937,28 +963,141 @@ manageCustomEmojis: "Kelola Emoji Kustom" youCannotCreateAnymore: "Kamu melewati batas pembuatan." cannotPerformTemporary: "Sementara Tidak Tersedia" cannotPerformTemporaryDescription: "Aksi ini tidak dapat dilakukan sementara karena melewati batas eksekusi. Mohon tunggu sejenak dan coba lagi." +invalidParamError: "Parameter tidak valid" +invalidParamErrorDescription: "Parameter permintaan tidak valid. Hal ini biasanya disebabkan oleh bug, namun juga dapat terjadi karena input melebihi batas ukuran atau semacamnya." +permissionDeniedError: "Operasi ditolak" +permissionDeniedErrorDescription: "Akun ini tidak memiliki izin untuk melakukan aksi ini." preset: "Prasetel" selectFromPresets: "Pilih dari prasetel" achievements: "Pencapaian" gotInvalidResponseError: "Respon peladen tidak valid" gotInvalidResponseErrorDescription: "Peladen tidak dapat dijangkau atau sedang dalam perawatan. Mohon coba lagi nanti." thisPostMayBeAnnoying: "Catatan ini mungkin dapat mengganggu orang lain." -thisPostMayBeAnnoyingHome: "Catat ke linimasa beranda" +thisPostMayBeAnnoyingHome: "Catat ke lini masa beranda" thisPostMayBeAnnoyingCancel: "Batalkan" thisPostMayBeAnnoyingIgnore: "Tetap catat" collapseRenotes: "Tutup renote yang sudah kamu lihat" internalServerError: "Kesalahan internal peladen" internalServerErrorDescription: "Peladen sedang mengalami galat tak terduga" copyErrorInfo: "Salin detil galat" -joinThisServer: "Gabung server ini" -exploreOtherServers: "Cari server lain" +joinThisServer: "Gabung peladen ini" +exploreOtherServers: "Cari peladen lain" letsLookAtTimeline: "LIhat timeline" disableFederationConfirm: "Matikan federasi?" disableFederationConfirmWarn: "Mematikan federasi tidak membuat kiriman menjadi privat. Umumnya, mematikan federasi tidak diperlukan." disableFederationOk: "Matikan federasi" +invitationRequiredToRegister: "Instansi ini dalam mode undangan-saja. Kamu harus memasukkan kode undangan yang valid untuk mendaftar." +emailNotSupported: "Instansi ini tidak mendukung mengirim surel" +postToTheChannel: "Catat ke kanal" +cannotBeChangedLater: "Hal ini nantinya tidak dapat diubah lagi." +reactionAcceptance: "Penerimaan reaksi" +likeOnly: "Hanya suka" +likeOnlyForRemote: "Semua (Hanya suka dari instansi luar)" +nonSensitiveOnly: "Hanya non-sensitif" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "Hanya non-sensitif (Hanya suka dari instansi luar)" +rolesAssignedToMe: "Peran yang ditugaskan ke saya" +resetPasswordConfirm: "Yakin untuk mereset kata sandimu?" +sensitiveWords: "Kata sensitif" +sensitiveWordsDescription: "Visibilitas dari semua catatan mengandung kata yang telah diatur akan dijadikan \"Beranda\" secara otomatis. Kamu dapat mendaftarkan kata tersebut lebih dari satu dengan menuliskannya di baris baru." +sensitiveWordsDescription2: "Menggunakan spasi akan membuat ekspresi AND dan kata kunci disekitarnya dengan garis miring akan mengubahnya menjadi ekspresi reguler." +notesSearchNotAvailable: "Pencarian catatan tidak tersedia." +license: "Lisensi" +unfavoriteConfirm: "Yakin ingin menghapusnya dari favorit?" +myClips: "Klip saya" +drivecleaner: "Pembersih Drive" +retryAllQueuesNow: "Coba jalankan lagi semua antrian" +retryAllQueuesConfirmTitle: "Yakin ingin mencoba lagi semuanya?" +retryAllQueuesConfirmText: "Hal ini akan meningkatkan beban sementara ke peladen." +enableChartsForRemoteUser: "Buat bagan data pengguna instansi luar" +enableChartsForFederatedInstances: "Buat bagan data peladen instansi luar" +showClipButtonInNoteFooter: "Tambahkan \"Klip\" ke menu aksi catatan" +largeNoteReactions: "Besarkan reaksi yang ditampilkan" +noteIdOrUrl: "ID catatan atau URL" +video: "Video" +videos: "Video" +dataSaver: "Penghemat data" +accountMigration: "Pemindahan akun" +accountMoved: "Pengguna ini telah berpindah ke akun baru:" +accountMovedShort: "Akun ini telah dipindahkan." +operationForbidden: "Operasi dilarang" +forceShowAds: "Selalu tampilkan iklan" +addMemo: "Tambahkan memo" +editMemo: "Sunting memo" +reactionsList: "Reaksi" +renotesList: "Renote" +notificationDisplay: "Notifikasi" +leftTop: "Kiri atas" +rightTop: "Kanan atas" +leftBottom: "Kiri bawah" +rightBottom: "Kanan bawah" +stackAxis: "Arah tumpukan" +vertical: "Vertikal" horizontal: "Horisontal" +position: "Posisi" +serverRules: "Aturan peladen" +pleaseConfirmBelowBeforeSignup: "Mohon konfirmasi di bawah ini sebelum mendaftar." +pleaseAgreeAllToContinue: "Kamu harus menyetujui semua kolom di atas untuk melanjutkan." +continue: "Lanjutkan" +preservedUsernames: "Nama pengguna tercadangkan" +preservedUsernamesDescription: "Daftar nama pengguna yang dicadangkan dipisah dengan baris baru. Nama pengguna berikut akan tidak dapat dipakai pada pembuatan akun normal, namun dapat digunakan oleh admin untuk membuat akun baru. Akun yang sudah ada dengan menggunakan nama pengguna ini tidak akan terpengaruh." +createNoteFromTheFile: "Buat catatan dari berkas ini" +archive: "Arsipkan" +channelArchiveConfirmTitle: "Yakin untuk mengarsipkan {name}?" +channelArchiveConfirmDescription: "Kanal yang diarsipkan tidak akan muncul pada daftar kanal atau hasil pencarian. Postingan baru juga tidak dapat ditambahkan lagi." +thisChannelArchived: "Kanal ini telah diarsipkan." +displayOfNote: "Tampilan catatan" +initialAccountSetting: "Atur profil" youFollowing: "Mengikuti" +preventAiLearning: "Tolak penggunaan Pembelajaran Mesin (AI Generatif)" +preventAiLearningDescription: "Minta perayap web untuk tidak menggunakan materi teks atau gambar yang telah diposting ke dalam set data Pembelajaran Mesin (Prediktif / Generatif). Hal ini dicapai dengan menambahkan flag HTML-Response \"noai\" ke masing-masing konten. Pencegahan penuh mungkin tidak dapat dicapai dengan flag ini, karena juga dapat diabaikan begitu saja." options: "Opsi peran" +specifyUser: "Pengguna spesifik" +failedToPreviewUrl: "Tidak dapat dipratinjau" +update: "Perbarui" +rolesThatCanBeUsedThisEmojiAsReaction: "Peran yang dapat menggunakan emoji ini sebagai reaksi" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Jika peran tidak ditentukan, semua pengguna dapat menggunakan emoji ini sebagai reaksi." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Peran ini harus publik." +cancelReactionConfirm: "Yakin untuk menghapus reaksimu?" +changeReactionConfirm: "Yakin untuk mengganti reaksimu?" +later: "Nanti saja" +goToMisskey: "Ke Misskey" +additionalEmojiDictionary: "Kamus emoji tambahan" +installed: "Terpasang" +branding: "Merek" +enableServerMachineStats: "Tampilkan informasi mesin peladen menjadi publik" +enableIdenticonGeneration: "Nyalakan pembuatan Identicon per pengguna" +_initialAccountSetting: + accountCreated: "Akun kamu telah sukses dibuat!" + letsStartAccountSetup: "Untuk pemula, ayo atur profilmu dulu." + letsFillYourProfile: "Pertama, ayo atur profilmu dulu." + profileSetting: "Pengaturan profil" + privacySetting: "Pengaturan privasi" + theseSettingsCanEditLater: "Kamu selalu bisa mengganti pengaturan ini lain kali." + youCanEditMoreSettingsInSettingsPageLater: "Ada banyak pengaturan yang dapat kamu atur dari halaman \"Pengaturan\". Pastikan untuk mengunjungi halaman tersebut nanti." + followUsers: "Coba ikuti beberapa pengguna yang menarik bagimu untuk membangun lini masa akunmu." + pushNotificationDescription: "Menyalakan notifikasi dorong akan membuatmu menerima notifikasi dari {name} secara langsung ke perangkatmu." + initialAccountSettingCompleted: "Pengaturan profil selesai!" + haveFun: "Selamat menikmati, {name}!" + ifYouNeedLearnMore: "Kalau kamu ingin mempelajari lebih lanjut bagaimana cara menggunakan {name} (Misskey), silahkan kunjungi {link}." + skipAreYouSure: "Yakin melewati atur profil?" + laterAreYouSure: "Yakin banget untuk atur profil nanti?" +_serverRules: + description: "Daftar peraturan akan ditampilkan sebelum pendaftaran. Mengatur ringkasan dari Syarat dan Ketentuan sangat direkomendasikan." +_accountMigration: + moveFrom: "Pindahkan akun lain ke akun ini" + moveFromSub: "Buat alias ke akun lain" + moveFromLabel: "Akun asli #{n}" + moveFromDescription: "Kamu harus membuat alias untuk akun asal kamu berpindah ke akun ini\nMasukkan alias akun asal kamu berpindah ke dalam format berikut: @namapengguna@nama.server.com\nUntuk menghapus alias, kosongkan kolom ini (tidak direkomendasikan)." + moveTo: "Pindahkan akun ini ke akun lain" + moveToLabel: "Akun tujuan pindah:" + moveCannotBeUndone: "Pemindahan akun tidak dapat diurungkan." + moveAccountDescription: "Hal ini akan memindahkan akun kamu ke akun lain.\n ・Pengikut dari akun ini akan secara otomatis dipindahkan ke akun baru\n ・Akun ini akan berhenti mengikuti dari semua pengguna yang sedang kamu ikuti\n ・Kamu akan tidak dapat membuat catatan baru dan lain-lain pada akun ini\n\nMeskipun pemindahan pengikut dilakukan secara otomatis, kamu harus mempersiapkan beberapa langkah secara manual untuk memindahkan daftar pengguna yang sedang kamu ikuti. Untuk melakukan tersebut, lakukan ekspor daftar ikuti yang nantinya dapat kamu impor pada menu pengaturan di akun baru kamu. Prosedur yang sama juga dapat diterapkan pada daftar seperti pengguna yang kamu bisukan atau blokir.\n\n(Penjelasan ini hanya berlaku pada Misskey versi 13.12.0 dan setelahnya. Perangkat lunak ActivityPub lainnya seperti Mastodon berkemungkinan befungsi berbeda.)" + moveAccountHowTo: "Untuk pindah, pertama buat alias untuk akun ini pada akun tujuan kamu berpindah.\nSetelah kamu membuat alias, masukkan akun tujuan kamu berpindah ke dalam format berikut:\n@namapengguna@nama.server.com" + startMigration: "Pindahkan" + migrationConfirm: "Yakin untuk memindahkan akun ini ke {account}? Sekali dimulai, proses ini tidak dapat dihentikan atau ditarik kembali, dan kamu tidak dapat menggunakan akun ini lagi dalam keadaan asli semula." + movedAndCannotBeUndone: "\nAkun ini telah dipindahkan.\nPemindahan tidak dapat diurungkan." + postMigrationNote: "24 jam setelah pemindahan akun selesai, akun ini akan berhenti mengikuti semua akun yang sedang diikuti. Angka mengikut dan pengikut akan menjadi nol. Untuk menghindari pengikut kamu tidak dapat melihat postingan hanya pengikut saja dalam postingan ini, mereka akan tetap mengikuti akun ini." + movedTo: "Akun baru tujuan pindah:" _achievements: earnedAt: "Terbuka pada" _types: @@ -1130,6 +1269,9 @@ _achievements: _client30min: title: "Istirahat pendek" description: "Habiskan waktu 30 menit di Misskey" + _client60min: + title: "Tidak ada \"Miss\" dalam Misskey" + description: "Biarkan Misskey tetap terbuka setidaknya selama 60 menit" _noteDeletedWithin1min: title: "Eh, salah coy!" description: "Hapus catatan kurang dari semenit kamu catat" @@ -1145,8 +1287,8 @@ _achievements: title: "Rujukan mandiri" description: "Kutip catatanmu sendiri" _htl20npm: - title: "Linimasa mengalir" - description: "Memiliki linimasa beranda dengan kecepatan melebihi 20 cpm (catatan per menit)" + title: "Lini masa mengalir" + description: "Memiliki lini masa beranda dengan kecepatan melebihi 20 cpm (catatan per menit)" _viewInstanceChart: title: "Analis" description: "Lihat bagan instansimu" @@ -1218,6 +1360,10 @@ _role: iconUrl: "URL ikon" asBadge: "Tampilkan sebagai lencana" descriptionOfAsBadge: "Ikon peran ini akan ditampilkan bersebelahan dengan username pengguna yang memiliki peran ini jika dinyalakan." + isExplorable: "Buat peran dapat terjelajahi" + descriptionOfIsExplorable: "Lini masa peran ini dan daftar pengguna dengan peran ini akan dibuat publik apabila dinyalakan." + displayOrder: "Urutan" + descriptionOfDisplayOrder: "Semakin tinggi angka, semakin tinggi posisi antarmukanya." canEditMembersByModerator: "Perbolehkan moderator untuk menyunting daftar anggota untuk peran ini" descriptionOfCanEditMembersByModerator: "Ketika dinyalakan, moderator beserta administrator dapat menugaskan ataupun mencabut pengguna ke peran ini. Ketika dimatikan, hanya administrator saja yang dapat menugaskan pengguna ke peran ini." priority: "Prioritas" @@ -1226,12 +1372,13 @@ _role: middle: "Sedang" high: "Tinggi" _options: - gtlAvailable: "Dapat melihat linimasa global" - ltlAvailable: "Dapat melihat linimasa lokal" + gtlAvailable: "Dapat melihat lini masa global" + ltlAvailable: "Dapat melihat lini masa lokal" canPublicNote: "Dapat mengirim catatan publik" canInvite: "Dapat membuat kode undangan instansi" canManageCustomEmojis: "Dapat mengelola Emoji kustom" driveCapacity: "Kapasitas Drive" + alwaysMarkNsfw: "Selalu tandai berkas sebagai NSFW" pinMax: "Jumlah maksimal catatan yang disematkan" antennaMax: "Jumlah maksimum antena" wordMuteMax: "Jumlah maksimum karakter yang diperbolehkan dalam membisukan kata" @@ -1243,6 +1390,7 @@ _role: rateLimitFactor: "Batas kecepatan" descriptionOfRateLimitFactor: "Batas kecepatan yang rendah tidak begitu membatasi, batas kecepatan tinggi lebih membatasi. " canHideAds: "Dapat menyembunyikan iklan" + canSearchNotes: "Penggunaan pencarian catatan" _condition: isLocal: "Pengguna lokal" isRemote: "Pengguna remote" @@ -1252,11 +1400,13 @@ _role: followersMoreThanOrEq: "Memiliki pengikut X atau lebih dari tersebut" followingLessThanOrEq: "Mengikuti X pengguna atau kurang dari itu" followingMoreThanOrEq: "Mengikuti X pengguna atau lebih dari itu" + notesLessThanOrEq: "Jumlah postingan kurang dari sama dengan" + notesMoreThanOrEq: "Jumlah postingan lebih dari sama dengan" and: "Kondisi-AND" or: "Kondisi-OR" not: "Kondisi-NOT" _sensitiveMediaDetection: - description: "Mengurangi usaha moderasi server dengan mengenali media NSFW srcara otomatis menggunakan Machine Learning. Fungsi ini akan sedikit menaikkan beban peladen." + description: "Mengurangi usaha moderasi peladen dengan mengenali media NSFW secara otomatis menggunakan Pembelajaran Mesin. Fungsi ini akan sedikit menaikkan beban peladen." sensitivity: "Sensitivitas deteksi" sensitivityDescription: "Mengurangi sensitivitas akan mengurangi misdeteksi (false positive) sedangkan meningkatkannya akan menambah misdeteksi (false positive)." setSensitiveFlagAutomatically: "Tandai sebagai NSFW" @@ -1339,21 +1489,17 @@ _aboutMisskey: donate: "Donasi ke Misskey" morePatrons: "Kami sangat mengapresiasi dukungan dari banyak penolong lain yang tidak tercantum disini. Terima kasih! 🥰" patrons: "Pendukung" -_nsfw: - respect: "Sembunyikan media NSFW" - ignore: "Jangan sembunyikan media NSFW" - force: "Sembunyikan semua media" _instanceTicker: none: "Jangan tampilkan" - remote: "Tampilkan untuk pengguna luar" + remote: "Tampilkan untuk pengguna instansi luar" always: "Selalu tampilkan" _serverDisconnectedBehavior: reload: "Muat ulang otomatis" dialog: "Tampilkan dialog peringatan" quiet: "Tampilkan peringatan tidak mengganggu" _channel: - create: "Buat saluran" - edit: "Sunting saluran" + create: "Buat Kanal" + edit: "Sunting Kanal" setBanner: "Setel banner" removeBanner: "Hapus banner" featured: "Tren" @@ -1361,6 +1507,8 @@ _channel: following: "Mengikuti" usersCount: "{n} Partisipan" notesCount: "terdapat {n} catatan" + nameAndDescription: "Nama dan deskripsi" + nameOnly: "Hanya nama" _menuDisplay: sideFull: "Horisontal" sideIcon: "Horisontal (Ikon)" @@ -1369,9 +1517,9 @@ _menuDisplay: _wordMute: muteWords: "Kata yang dibisukan" muteWordsDescription: "Pisahkan dengan spasi untuk kondisi AND. Pisahkan dengan baris baru untuk kondisi OR." - muteWordsDescription2: "Kurung kata kunci dengan garis miring untuk menggunakan regular expressions." - softDescription: "Sembunyikan catatan yang memenuhi aturan kondisi dari linimasa." - hardDescription: "Cegah catatan memenuhi aturan kondisi dari ditambahkan ke linimasa. Dengan tambahan, catatan berikut tidak akan ditambahkan ke linimasa meskipun jika kondisi tersebut diubah." + muteWordsDescription2: "Kurung kata kunci dengan garis miring untuk menggunakan ekspresi reguler." + softDescription: "Sembunyikan catatan yang memenuhi aturan kondisi dari lini masa." + hardDescription: "Cegah catatan memenuhi aturan kondisi dari ditambahkan ke lini masa. Dengan tambahan, catatan berikut tidak akan ditambahkan ke lini masa meskipun jika kondisi tersebut diubah." soft: "Lembut" hard: "Keras" mutedNotes: "Catatan yang dibisukan" @@ -1441,8 +1589,8 @@ _theme: cwBg: "Latar belakang tombol Sembunyikan Konten" cwFg: "Teks tombol Sembunyikan Konten" cwHoverBg: "Latar belakang tombol Sembunyikan Konten (Mengambang)" - toastBg: "Latar belakang pemberitahuan" - toastFg: "Teks pemberitahuan" + toastBg: "Latar belakang notifikasi" + toastFg: "Teks notifikasi" buttonBg: "Latar belakang tombol" buttonHoverBg: "Latar belakang tombol (Mengambang)" inputBorder: "Batas bidang masukan" @@ -1457,11 +1605,11 @@ _theme: _sfx: note: "Catatan" noteMy: "Catatan (Saya)" - notification: "Pemberitahuan" + notification: "Notifikasi" chat: "Pesan" chatBg: "Obrolan (Latar Belakang)" antenna: "Penerimaan Antenna" - channel: "Pemberitahuan saluran" + channel: "Notifikasi Kanal" _ago: future: "Masa depan" justNow: "Baru saja" @@ -1478,15 +1626,40 @@ _time: minute: "menit" hour: "jam" day: "hari" +_timelineTutorial: + title: "Bagaimana cara menggunakan Misskey" + step1_1: "Ini adalah \"lini masa\". Semua \"catatan\" yang dikirimkan oleh {name} akan dimunculkan secara kronologis di sini." + step1_2: "Ada beberapa lini masa yang berbeda. Seperti contoh, \"Lini masa Beranda\" berisi catatan dari pengguna yang kamu ikuti, dan \"Lini masa lokal\" berisi catatan dari semua pengguna dari {name}." + step2_1: "Selanjutnya, mari kita coba memposting sebuah catatan. Kamu dapat melakukanya dengan menekan tombol dengan ikon pensil." + step2_2: "Bagaimana dengan menuliskan sedikit perkenalan diri, atau hanya \"Hello {name}\" kalau kamu lagi ngga feeling?" + step3_1: "Udah selesai memposting catatan pertamamu?" + step3_2: "Catatan pertamamu seharusnya sekarang sudah tampil di lini masa kamu." + step4_1: "Kamu dapat menyisipkan \"Reaksi\" ke dalam catatan." + step4_2: "Untuk menyisipkan reaksi, tekan tanda \"+\" dalam catatan dan pilih emoji yang kamu suka untuk mereaksi catatan tersebut." _2fa: alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor." + registerTOTP: "Daftarkan aplikasi autentikator" + passwordToTOTP: "Masukkan kata sandimu" 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." + securityKeyNotSupported: "Peramban kamu tidak mendukung security key." + registerTOTPBeforeKey: "Mohon atur aplikasi autentikator untuk mendaftarkan security key atau passkey." securityKeyInfo: "Kamu dapat memasang otentikasi WebAuthN untuk mengamankan proses login lebih lanjut dengan tidak hanya perangkat keras kunci keamanan yang mendukung FIDO2, namun juga sidik jari atau otentikasi PIN pada perangkatmu." + chromePasskeyNotSupported: "Passkey Chrome saat ini tidak didukung." + registerSecurityKey: "Daftarkan security key atau passkey." + securityKeyName: "Masukkan nama key." + tapSecurityKey: "Mohon ikuti peramban kamu untuk mendaftarkan security key atau passkey" + removeKey: "Hapus security key" removeKeyConfirm: "Hapus cadangan {name}?" + whyTOTPOnlyRenew: "Aplikasi autentikator tidak dapat dihapus selama security key masih terdaftar." + renewTOTP: "Atur ulang aplikasi autentikator" + renewTOTPConfirm: "Hal ini akan menyebabkan kode verifikasi dari aplikasi autentikator sebelumnya berhenti bekerja" + renewTOTPOk: "Atur ulang" renewTOTPCancel: "Tidak sekarang." _permissions: "read:account": "Lihat informasi akun" @@ -1504,8 +1677,8 @@ _permissions: "read:mutes": "Lihat daftar orang yang dibisukan" "write:mutes": "Sunting daftar orang yang dibisukan" "write:notes": "Buat atau hapus catatan" - "read:notifications": "Lihat pemberitahuan" - "write:notifications": "Sunting pemberitahuan" + "read:notifications": "Lihat notifikasi" + "write:notifications": "Sunting notifikasi" "read:reactions": "Lihat reaksi" "write:reactions": "Sunting reaksi" "write:votes": "Beri suara" @@ -1515,8 +1688,8 @@ _permissions: "write:page-likes": "Sunting suka pada Halaman" "read:user-groups": "Lihat grup pengguna" "write:user-groups": "Sunting atau hapus grup pengguna" - "read:channels": "Lihat saluran" - "write:channels": "Sunting saluran" + "read:channels": "Lihat Kanal" + "write:channels": "Sunting Kanal" "read:gallery": "Lihat galeri" "write:gallery": "Sunting galeri" "read:gallery-likes": "Lihat daftar postingan galeri yang disukai" @@ -1548,8 +1721,8 @@ _widgets: profile: "Profil" instanceInfo: "Informasi Instansi" memo: "Catatan memo" - notifications: "Pemberitahuan" - timeline: "Linimasa" + notifications: "Notifikasi" + timeline: "Lini masa" calendar: "Kalender" trends: "Tren" clock: "Jam" @@ -1603,13 +1776,15 @@ _poll: remainingSeconds: "Berakhir dalam {s} detik" _visibility: public: "Publik" - publicDescription: "Catat ke linimasa global" + publicDescription: "Catat ke lini masa global" home: "Beranda" - homeDescription: "Catat ke linimasa beranda saja" + homeDescription: "Catat ke lini masa beranda saja" followers: "Pengikut" followersDescription: "Catat ke pengikut saja" specified: "Langsung" specifiedDescription: "Catat ke pengguna yang ditentukan saja" + disableFederation: "Matikan federasi" + disableFederationDescription: "Jangan kirimkan ke instansi lain" _postForm: replyPlaceholder: "Balas ke catatan ini..." quotePlaceholder: "Kutip catatan ini..." @@ -1650,7 +1825,7 @@ _charts: activeUsers: "Pengguna aktif" notesIncDec: "Perbedaan # dalam catatan" localNotesIncDec: "Perbedaan # dalam catatan lokal" - remoteNotesIncDec: "Perbedaan # dalam catatan luar" + remoteNotesIncDec: "Perbedaan # dalam catatan instansi luar" notesTotal: "Total # catatan" filesIncDec: "Perbedaan # dalam berkas" filesTotal: "Jumlah # berkas" @@ -1765,7 +1940,8 @@ _notification: pollEnded: "Jajak pendapat berakhir" receiveFollowRequest: "Permintaan mengikuti diterima" followRequestAccepted: "Permintaan mengikuti disetujui" - app: "Pemberitahuan dari aplikasi" + achievementEarned: "Pencapaian didapatkan" + app: "Notifikasi dari aplikasi tertaut" _actions: followBack: "Ikuti Kembali" reply: "Balas" @@ -1790,13 +1966,34 @@ _deck: _columns: main: "Utama" widgets: "Widget" - notifications: "Pemberitahuan" - tl: "Linimasa" + notifications: "Notifikasi" + tl: "Lini masa" antenna: "Antena" list: "Daftar" channel: "Kanal" mentions: "Sebutan" direct: "Langsung" + roleTimeline: "Lini masa peran" +_dialog: + charactersExceeded: "Kamu telah melebihi batas karakter maksimum! Saat ini pada {current} dari {max}." + charactersBelow: "Kamu berada di bawah batas minimum karakter! Saat ini pada {current} dari {min}." +_disabledTimeline: + title: "Lini masa dinonaktifkan" + description: "Saat ini kamu tidak dapat menggunakan lini masa ini karena peran kamu saat ini." +_drivecleaner: + orderBySizeDesc: "Ukuran berkas (Turun)" + orderByCreatedAtAsc: "Tanggal (Naik)" _webhookSettings: + createWebhook: "Buat Webhook" name: "Nama" + secret: "Secret" + events: "Webhook Events" active: "Aktif" + _events: + follow: "Ketika mengikuti pengguna" + followed: "Ketika diikuti pengguna" + note: "Ketika memposting catatan" + reply: "Ketika menerima balasan" + renote: "Ketika direnote" + reaction: "Ketika menerima reaksi" + mention: "Ketika sedang disebut" diff --git a/locales/index.d.ts b/locales/index.d.ts index eed29f408..db7e3e957 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -52,11 +52,15 @@ export interface Locale { "deleteAndEdit": string; "deleteAndEditConfirm": string; "addToList": string; + "addToAntenna": string; "sendMessage": string; "copyRSS": string; "copyUsername": string; "copyUserId": string; "copyNoteId": string; + "copyFileId": string; + "copyFolderId": string; + "copyProfileUrl": string; "searchUser": string; "reply": string; "loadMore": string; @@ -139,8 +143,10 @@ export interface Locale { "suspendConfirm": string; "unsuspendConfirm": string; "selectList": string; + "editList": string; "selectChannel": string; "selectAntenna": string; + "editAntenna": string; "selectWidget": string; "editWidgets": string; "editWidgetsExit": string; @@ -153,6 +159,8 @@ export interface Locale { "settingGuide": string; "cacheRemoteFiles": string; "cacheRemoteFilesDescription": string; + "cacheRemoteSensitiveFiles": string; + "cacheRemoteSensitiveFilesDescription": string; "flagAsBot": string; "flagAsBotDescription": string; "flagAsCat": string; @@ -314,7 +322,7 @@ export interface Locale { "rename": string; "avatar": string; "banner": string; - "nsfw": string; + "displayOfSensitiveMedia": string; "whenServerDisconnected": string; "disconnectedFromServer": string; "reload": string; @@ -1066,6 +1074,29 @@ export interface Locale { "additionalEmojiDictionary": string; "installed": string; "branding": string; + "enableServerMachineStats": string; + "enableIdenticonGeneration": string; + "turnOffToImprovePerformance": string; + "createInviteCode": string; + "createWithOptions": string; + "createCount": string; + "inviteCodeCreated": string; + "inviteLimitExceeded": string; + "createLimitRemaining": string; + "inviteLimitResetCycle": string; + "expirationDate": string; + "noExpirationDate": string; + "inviteCodeUsedAt": string; + "registeredUserUsingInviteCode": string; + "waitingForMailAuth": string; + "inviteCodeCreator": string; + "usedAt": string; + "unused": string; + "used": string; + "expired": string; + "doYouAgree": string; + "beSureToReadThisAsItIsImportant": string; + "iHaveReadXCarefullyAndAgree": string; "_initialAccountSetting": { "accountCreated": string; "letsStartAccountSetup": string; @@ -1456,6 +1487,9 @@ export interface Locale { "ltlAvailable": string; "canPublicNote": string; "canInvite": string; + "inviteLimit": string; + "inviteLimitCycle": string; + "inviteExpirationTime": string; "canManageCustomEmojis": string; "driveCapacity": string; "alwaysMarkNsfw": string; @@ -1526,6 +1560,7 @@ export interface Locale { "back": string; "reduceFrequencyOfThisAd": string; "hide": string; + "timezoneinfo": string; }; "_forgotPassword": { "enterEmail": string; @@ -1587,7 +1622,7 @@ export interface Locale { "morePatrons": string; "patrons": string; }; - "_nsfw": { + "_displayOfSensitiveMedia": { "respect": string; "ignore": string; "force": string; @@ -2103,6 +2138,7 @@ export interface Locale { "introduction": string; "introduction2": string; "widgetsIntroduction": string; + "useSimpleUiForNonRootPages": string; "_columns": { "main": string; "widgets": string; @@ -2148,4 +2184,4 @@ export interface Locale { declare const locales: { [lang: string]: Locale; }; -export = locales; +export default locales; diff --git a/locales/index.js b/locales/index.js index 2248bb6ac..7801f1275 100644 --- a/locales/index.js +++ b/locales/index.js @@ -2,8 +2,8 @@ * Languages Loader */ -const fs = require('fs'); -const yaml = require('js-yaml'); +import * as fs from 'node:fs'; +import * as yaml from 'js-yaml'; const merge = (...args) => args.reduce((a, c) => ({ ...a, @@ -51,9 +51,9 @@ const primaries = { // 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); -const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(`${__dirname}/${c}.yml`, 'utf-8'))) || {}, a), {}); +const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {}); -module.exports = Object.entries(locales) +export default Object.entries(locales) .reduce((a, [k ,v]) => (a[k] = (() => { const [lang] = k.split('-'); switch (k) { diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 3c1a26e85..0df1fd64b 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -26,15 +26,15 @@ otherSettings: "Altre impostazioni" openInWindow: "Apri in una finestra" profile: "Profilo" timeline: "Timeline" -noAccountDescription: "L'utente non ha ancora scritto niente nella biografia di profilo." +noAccountDescription: "La persona non ha ancora scritto alcuna autobiografia." login: "Accedi" loggingIn: "Accesso in corso..." logout: "Uscita" signup: "Iscriviti" uploading: "Caricamento..." save: "Salva" -users: "Utente" -addUser: "Aggiungi utente" +users: "Profili" +addUser: "Aggiungi profilo" favorite: "Preferiti" favorites: "Preferiti" unfavorite: "Rimuovi nota dai preferiti" @@ -49,10 +49,16 @@ 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." addToList: "Aggiungi alla lista" +addToAntenna: "Aggiungi all'antenna" sendMessage: "Invia messaggio" copyRSS: "Copia RSS" copyUsername: "Copia nome utente" -searchUser: "Cerca utente" +copyUserId: "Copia ID del profilo" +copyNoteId: "Copia ID della Nota" +copyFileId: "Copia ID del file" +copyFolderId: "Copia ID della cartella" +copyProfileUrl: "Copia URL del profilo" +searchUser: "Cerca profilo" reply: "Rispondi" loadMore: "Mostra di più" showMore: "Espandi" @@ -134,8 +140,10 @@ unblockConfirm: "Vuoi davvero sbloccare il profilo?" suspendConfirm: "Vuoi sospendere questo profilo?" unsuspendConfirm: "Vuoi revocare la sospensione si questo profilo?" selectList: "Seleziona una lista" +editList: "Modifica Lista" selectChannel: "Seleziona canale" selectAntenna: "Scegli un'antenna" +editAntenna: "Modifica Antenna" selectWidget: "Seleziona il riquadro" editWidgets: "Modifica i riquadri" editWidgetsExit: "Conferma le modifiche" @@ -148,12 +156,14 @@ addEmoji: "Aggiungi un emoji" settingGuide: "Configurazione suggerita" cacheRemoteFiles: "Memorizza i file remoti nella cache" cacheRemoteFilesDescription: "Disabilitando questa opzione, i file remoti verranno linkati direttamente senza essere memorizzati nella cache. Sarà possibile risparmiare spazio di archiviazione sul server, ma il traffico aumenterà in quanto non verranno generate anteprime." +cacheRemoteSensitiveFiles: "Memorizza nella cache i file sensibili remoti" +cacheRemoteSensitiveFilesDescription: "Disattivando questa opzione, i file sensibili verranno caricati direttamente dall'istanza remota senza essere salvati dal server." flagAsBot: "Io sono un robot" flagAsBotDescription: "Attiva questo campo se il profilo esegue principalmente operazioni automatiche. L'attivazione segnala agli altri sviluppatori come comportarsi per evitare catene d’interazione infinite con altri bot. I sistemi interni di Misskey si adegueranno al fine di trattare questo profilo come bot." 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: "Se è attiva, la timeline mostra le risposte alle altre note dell'utente oltre a quelle dell'utente stesso." +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" addAccount: "Aggiungi profilo" reloadAccountsList: "Ricarica l'elenco dei profili" @@ -205,7 +215,7 @@ blockedInstancesDescription: "Elenca le istanze che vuoi bloccare, una per riga. muteAndBlock: "Silenziati / Bloccati" mutedUsers: "Profili silenziati" blockedUsers: "Profili bloccati" -noUsers: "Nessun utente trovato" +noUsers: "Non ci sono profili" editProfile: "Modifica profilo" noteDeleteConfirm: "Vuoi davvero eliminare questa Nota?" pinLimitExceeded: "Non puoi fissare altre note " @@ -236,8 +246,8 @@ newPasswordRetype: "Conferma password" attachFile: "Allega file" more: "Di più!" featured: "Tendenze" -usernameOrUserId: "Nome utente o ID utente" -noSuchUser: "Nessun utente trovato" +usernameOrUserId: "Nome utente o ID" +noSuchUser: "Profilo non trovato" lookup: "Ricerca remota" announcements: "Annunci" imageUrl: "URL dell'immagine" @@ -269,7 +279,7 @@ basicNotesBeforeCreateAccount: "Note importanti" termsOfService: "Informativa Privacy" start: "Inizia!" home: "Home" -remoteUserCaution: "Può darsi che le informazioni siano incomplete perché questo è un utente remoto." +remoteUserCaution: "Le informazioni potrebbero essere incomplete poiché questo profilo remoto potrebbe non essere completamente federato." activity: "Attività" images: "Immagini" image: "Immagini" @@ -309,7 +319,7 @@ copyUrl: "Copia URL" rename: "Modifica nome" avatar: "Foto del profilo" banner: "Intestazione" -nsfw: "Contenuti sensibili" +displayOfSensitiveMedia: "Visibilità dei media sensibili" whenServerDisconnected: "Quando la connessione col server è persa" disconnectedFromServer: "Il server si è disconnesso" reload: "Ricarica" @@ -444,7 +454,7 @@ signinRequired: "Occorre avere un profilo registrato su questa istanza" invitations: "Invita" invitationCode: "Codice di invito" checking: "Confermando" -available: "Consigliati" +available: "Disponibile" unavailable: "Il nome utente è già in uso" usernameInvalidFormat: "Il nome utente può contenere solo lettere, numeri e '_'" tooShort: "Troppo breve" @@ -560,6 +570,7 @@ accountDeletedDescription: "Questo profilo è stato eliminato." menu: "Menù" divider: "Linea di separazione" addItem: "Aggiungi elemento" +rearrange: "Riordina" relays: "Ripetitori" addRelay: "Aggiungi ripetitore" inboxUrl: "Inbox URL" @@ -660,7 +671,7 @@ instanceTicker: "Informazioni sull'istanza da cui vengono le note" waitingFor: "Aspettando {x}" random: "Casuale" system: "Sistema" -switchUi: "Cambiare interfaccia" +switchUi: "Cambia interfaccia grafica" desktop: "Desktop" clip: "Clip" createNew: "Crea" @@ -703,6 +714,8 @@ contact: "Contatti" useSystemFont: "Usa il carattere predefinito del sistema" clips: "Clip" experimentalFeatures: "Funzioni sperimentali" +experimental: "Sperimentale" +thisIsExperimentalFeature: "Questa è una funzionalità sperimentale. Potrebbe essere malfunzionante o cambiare in futuro." developer: "Sviluppatore" makeExplorable: "Profilo visibile pubblicamente nella pagina \"Esplora\"" makeExplorableDescription: "Disabilitando questa opzione, il tuo profilo non verrà elencato nella pagina \"Esplora\"." @@ -766,10 +779,10 @@ info: "Informazioni" userInfo: "Informazioni utente" unknown: "Sconosciuto" onlineStatus: "Stato di connessione" -hideOnlineStatus: "Stato invisibile" -hideOnlineStatusDescription: "Abilitare l'opzione di stato invisibile può guastare la praticità di singole funzioni, come la ricerca." +hideOnlineStatus: "Modalità invisibile" +hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca." online: "Online" -active: "Attiv@" +active: "Attività" offline: "Offline" notRecommended: "Sconsigliato" botProtection: "Protezione contro i bot" @@ -779,7 +792,7 @@ switchAccount: "Cambia profilo" enabled: "Attivo" disabled: "Inattivo" quickAction: "Azioni rapide" -user: "Utente" +user: "Profilo" administration: "Gestione" accounts: "Profilo" switch: "Cambia" @@ -787,6 +800,7 @@ noMaintainerInformationWarning: "Le informazioni amministratore non sono imposta noBotProtectionWarning: "Nessuna protezione impostata contro i bot." configure: "Imposta" postToGallery: "Pubblicare nella galleria" +postToHashtag: "Pubblica a questo hashtag" gallery: "Galleria" recentPosts: "Le più recenti" popularPosts: "Le più visualizzate" @@ -811,7 +825,7 @@ received: "Ricevuto" searchResult: "Risultati della Ricerca" hashtags: "Hashtag" troubleshooting: "Risoluzione problemi" -useBlurEffect: "Utilizza effetto sfocatura nell'interfaccia" +useBlurEffect: "Utilizza effetto sfocatura" learnMore: "Più dettagli" misskeyUpdated: "Misskey è stato aggiornato!" whatIsNew: "Visualizza le informazioni sull'aggiornamento" @@ -820,6 +834,7 @@ translatedFrom: "Tradotto da {x}" accountDeletionInProgress: "È in corso l'eliminazione del profilo" usernameInfo: "Un nome per identificare univocamente il tuo profilo sull'istanza. Puoi utilizzare caratteri alfanumerici maiuscoli, minuscoli e il trattino basso (_). Non potrai cambiare nome utente in seguito." aiChanMode: "Modalità Ai" +devMode: "Modalità sviluppatori" keepCw: "Mantieni il Content Warning" pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" @@ -829,6 +844,8 @@ breakFollow: "Non seguire" breakFollowConfirm: "Vuoi davvero togliere follower?" itsOn: "Abilitato" itsOff: "Disabilitato" +on: "Acceso" +off: "Spento" emailRequiredForSignup: "L'ndirizzo e-mail è obbligatorio per registrarsi" unread: "Non lette" filter: "Filtri" @@ -839,8 +856,8 @@ makeReactionsPublicDescription: "La lista delle reazioni che avete fatto è a di classic: "Classico" muteThread: "Silenzia la conversazione" unmuteThread: "Riattiva la conversazione" -ffVisibility: "Ambito pubblico del collegamento" -ffVisibilityDescription: "È possibile impostare la portata pubblica delle informazioni sui propri follower/seguaci." +ffVisibility: "Visibilità delle connessioni" +ffVisibilityDescription: "Puoi scegliere a chi mostrare le tue relazioni con altri profili nel fediverso." continueThread: "Altri thread." deleteAccountConfirm: "Così verrà eliminato il profilo. Vuoi procedere?" incorrectPassword: "La password è errata." @@ -848,7 +865,7 @@ voteConfirm: "Votare per「{choice}」?" hide: "Nascondere" useDrawerReactionPickerForMobile: "Mostra sul drawer da dispositivo mobile" welcomeBackWithName: "Ciao, {name}! Eccoti di nuovo!" -clickToFinishEmailVerification: "Fai click su [{ok}] per completare la verifica dell'indirizzo email." +clickToFinishEmailVerification: "Premi il bottone \"{ok}\" per completare la verifica dell'indirizzo email." overridedDeviceKind: "Tipo di dispositivo" smartphone: "Smartphone" tablet: "Tablet" @@ -909,6 +926,7 @@ remoteOnly: "Solo remoto" failedToUpload: "errore di caricamento" cannotUploadBecauseInappropriate: "Non è possibile caricarlo perché è stato stabilito che potrebbe contenere contenuti inappropriati." cannotUploadBecauseNoFreeSpace: "Impossibile caricare a causa della mancanza di spazio libero sul drive." +cannotUploadBecauseExceedsFileSizeLimit: "Il file non può essere caricato perché eccede le dimensioni consentite." beta: "Versione beta" enableAutoSensitive: "Determinazione automatica del NSFW" enableAutoSensitiveDescription: "Se disponibile, il flag NSFW viene impostato automaticamente sui media utilizzando l'apprendimento automatico. Anche se questa funzione è disattivata, in alcuni casi può essere impostata automaticamente." @@ -942,6 +960,7 @@ didYouLikeMisskey: "Ti piace Misskey?" pleaseDonate: "Misskey è il software libero utilizzato su {host}. Offrendo una donazione è più facile continuare a svilupparlo!" roles: "Ruoli" role: "Ruolo" +noRole: "Ruolo non trovato" normalUser: "Profilo standard" undefined: "Indefinito" assign: "Assegna" @@ -981,10 +1000,13 @@ cannotBeChangedLater: "Non sarà più modificabile" reactionAcceptance: "Reazioni consentite" likeOnly: "Solo i Like" likeOnlyForRemote: "Solo Like remoti" +nonSensitiveOnly: "Solamente non sensibili" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "Solamente non sensibili (solo Mi piace remoti)" rolesAssignedToMe: "I miei ruoli" resetPasswordConfirm: "Vuoi davvero ripristinare la password?" sensitiveWords: "Parole sensibili" sensitiveWordsDescription: "Imposta automaticamente \"Home\" alla visibilità delle Note che contengono una qualsiasi parola tra queste configurate. Puoi separarle per riga." +sensitiveWordsDescription2: "Gli spazi creano la relazione \"E\" tra parole (questo E quello). Racchiudere una parola nelle slash \"/\" la trasforma in Espressione Regolare." notesSearchNotAvailable: "Non è possibile cercare tra le Note." license: "Licenza" unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?" @@ -1003,9 +1025,13 @@ videos: "Video" dataSaver: "Risparmia dati" accountMigration: "Migrazione del profilo" accountMoved: "Questo profilo ha migrato altrove:" +accountMovedShort: "Questo profilo è stato migrato" +operationForbidden: "Operazione non consentita" forceShowAds: "Mostra sempre i banner" addMemo: "Aggiungi Memo" editMemo: "Modifica Memo" +reactionsList: "Elenco delle reazioni" +renotesList: "Elenco di Rinota" notificationDisplay: "Stile delle notifiche" leftTop: "In alto a sinistra" rightTop: "In alto a destra" @@ -1019,18 +1045,83 @@ serverRules: "Regolamento" pleaseConfirmBelowBeforeSignup: "Ai sensi del regolamento EU 679/2016 GDPR, autorizzo il trattamento dati personali come descritto nella informativa Privacy." pleaseAgreeAllToContinue: "Per continuare, occorre selezionare ed essere d'accordo su tutto." continue: "Continua" +preservedUsernames: "Nomi utente riservati" +preservedUsernamesDescription: "Elenca, uno per linea, i nomi utente che non possono essere registrati durante la creazione del profilo. La restrizione non si applica agli amministratori. Inoltre, i profili già registrati sono esenti." +createNoteFromTheFile: "Crea Nota da questo file" +archive: "Archivio" +channelArchiveConfirmTitle: "Vuoi davvero archiviare {name}?" +channelArchiveConfirmDescription: "Un canale archiviato non compare nell'elenco canali, nemmeno nei risultati di ricerca. Non può ricevere nemmeno nuove Note." +thisChannelArchived: "Questo canale è stato archiviato." +displayOfNote: "Visualizzazione delle Note" +initialAccountSetting: "Impostazioni iniziali del profilo" youFollowing: "Seguiti" +preventAiLearning: "Impedisci l'apprendimento della IA" +preventAiLearningDescription: "Aggiungendo il campo \"noai\" alla risposta HTML, si indica ai Robot esterni di non usare testi e allegati per addestrare sistemi di Machine Learning (IA predittiva/generativa). Anche se è impossibile sapere se la richiesta venga onorata o semplicemente ignorata." options: "Opzioni del ruolo" +specifyUser: "Profilo specifico" +failedToPreviewUrl: "Anteprima non disponibile" +update: "Aggiorna" +rolesThatCanBeUsedThisEmojiAsReaction: "Ruoli che possono usare questa emoji come reazione" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Se non viene specificato alcun ruolo, chiunque può reagire con questa emoji." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Questi ruoli devono essere pubblici" +cancelReactionConfirm: "Vuoi annullare la tua reazione?" +changeReactionConfirm: "Vuoi cambiare la tua reazione?" +later: "Non ora" +goToMisskey: "Vai a Misskey" +additionalEmojiDictionary: "Dizionario aggiuntivo emoji" +installed: "Installazione avvenuta" +branding: "Branding" +enableServerMachineStats: "Pubblicare le informazioni sul server" +enableIdenticonGeneration: "Generazione automatica delle Identicon" +turnOffToImprovePerformance: "Disattiva, per migliorare le prestazioni" +createInviteCode: "Genera codice di invito" +createWithOptions: "Genera con opzioni" +createCount: "Conteggio inviti" +inviteCodeCreated: "Inviti generati" +inviteLimitExceeded: "Hai raggiunto il numero massimo di codici invito generabili." +createLimitRemaining: "Inviti generabili: {limit} rimanenti" +inviteLimitResetCycle: "Alle {time}, il limite verrà ripristinato a {limit}" +expirationDate: "Scadenza" +noExpirationDate: "Perpetuo" +inviteCodeUsedAt: "Codice di invito usato alle" +registeredUserUsingInviteCode: "Codice di invito usato da" +waitingForMailAuth: "In attesa della verifica email" +inviteCodeCreator: "Codice di invito creato da" +usedAt: "Usato alle" +unused: "Inutilizzato" +used: "Utilizzato" +expired: "Scaduto" +_initialAccountSetting: + accountCreated: "Il tuo profilo è stato creato!" + letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo." + letsFillYourProfile: "Innanzitutto, compila il tuo profilo." + profileSetting: "Impostazioni del profilo" + privacySetting: "Impostazioni sulla privacy" + theseSettingsCanEditLater: "In seguito, potrai cambiare la tua scelta." + youCanEditMoreSettingsInSettingsPageLater: "Nella pagina \"Impostazioni\", è possibile personalizzare di più il tuo profilo. Dacci un'occhiata dopo!" + followUsers: "Per comporre la tua Timeline Home (personale) segui i profili delle persone che ti interessano." + pushNotificationDescription: "Attivare le notifiche push ti permettera di ricevere informazioni sulla attività di {name} direttamente sul tuo dispositivo." + initialAccountSettingCompleted: "Hai completato la configurazione iniziale!" + haveFun: "Divertiti con {name}!" + ifYouNeedLearnMore: "Per saperne di più su come usare {name} (Misskey), visita la pagina {link}" + skipAreYouSure: "Vuoi davvero saltare la configurazione iniziale?" + laterAreYouSure: "Vuoi davvero rimandare la configurazione iniziale?" _serverRules: description: "In Europa è necessario mostrare l'informativa sul trattamento dei dati personali, prima della registrazione al servizio." _accountMigration: moveFrom: "Migra un altro profilo dentro a questo" + moveFromSub: "Crea un alias verso un altro profilo remoto" moveFromLabel: "Profilo da cui migrare:" moveFromDescription: "Se desideri spostare i profili follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@istanza.it" moveTo: "Migrare questo profilo verso un un altro" moveToLabel: "Profilo verso cui migrare" + moveCannotBeUndone: "La migrazione è irreversibile, non può essere interrotta o annullata." moveAccountDescription: "Questa attività è irreversibile! Innanzitutto, assicurati di aver creato, nella istanza di destinazione, un alias con l'indirizzo di questo profilo. Successivamente, indica qui il profilo di destinazione in questo modo: @persona@istanza.it" + moveAccountHowTo: "Per migrare su un profilo remoto, crea prima un alias di questo profilo, sulla istanza di destinazione.\nDopo aver creato l'alias, inserisci l'indirizzo di destinazione, indicando ad esempio: @profilo@altra.istanza" + startMigration: "Avvia la migrazione" migrationConfirm: "Vuoi davvero migrare questo profilo su {account}? L'azione è irreversibile e non potrai più utilizzare questo profilo nel suo stato originale.\nInoltre, assicurati di aver già creato un alias sull'account a cui ti stai trasferendo." + movedAndCannotBeUndone: "Il tuo profilo è stato migrato.\nLa migrazione non può essere annullata." + postMigrationNote: "Questo profilo smetterà di seguire gli altri profili remoti a 24 ore dal termine della migrazione.\nSia i Follow che i Follower scenderanno a zero. I tuoi follower saranno comunque in grado di vedere le Note per soli follower, poiché non smetteranno di seguirti." movedTo: "Profilo verso cui migrare" _achievements: earnedAt: "Data di conseguimento" @@ -1310,8 +1401,12 @@ _role: ltlAvailable: "Disponibilità della Timeline Locale" canPublicNote: "Può scrivere Note con Visibilità Pubblica" canInvite: "Genera codici di invito all'istanza" + inviteLimit: "Limite di codici invito" + inviteLimitCycle: "Intervallo di emissione del codice di invito" + inviteExpirationTime: "Scadenza del codice di invito" canManageCustomEmojis: "Gestire le emoji personalizzate" driveCapacity: "Capienza del Drive" + alwaysMarkNsfw: "Imposta sempre come NSFW" pinMax: "Quantità massima di Note in primo piano" antennaMax: "Quantità massima di Antenne" wordMuteMax: "Lunghezza massima del filtro parole" @@ -1371,6 +1466,7 @@ _ad: back: "Indietro" reduceFrequencyOfThisAd: "Visualizza questa pubblicità meno spesso" hide: "Nascondi" + timezoneinfo: "Il giorno della settimana è determinato in base al fuso orario del server." _forgotPassword: enterEmail: "Inserisci l'indirizzo di posta elettronica che hai registrato nel tuo profilo. Il collegamento necessario per ripristinare la password verrà inviato a questo indirizzo." ifNoEmail: "Se il tuo indirizzo email non risulta registrato, contatta l'amministrazione dell'istanza." @@ -1422,10 +1518,10 @@ _aboutMisskey: donate: "Sostieni Misskey" morePatrons: "Apprezziamo sinceramente il supporto di tante altre persone. Grazie mille! 🥰" patrons: "Sostenitori" -_nsfw: - respect: "Nascondere i media segnati come sensibli" - ignore: "Visualizzare i media segnati come sensibili" - force: "Nascondere tutti i media" +_displayOfSensitiveMedia: + respect: "Nascondere i media sensibili" + ignore: "Non nascondere i media sensibili" + force: "Nascondi tutti i media" _instanceTicker: none: "Nascondi" remote: "Mostra solo per i profili remoti" @@ -1563,6 +1659,16 @@ _time: minute: "min" hour: "ore" day: "giorni" +_timelineTutorial: + title: "Come usare Misskey" + step1_1: "Questa è la \"Timeline\". tutte le \"Note\" pubblicate su {name} vengono elencate in ordine cronologico." + step1_2: "Le Timeline sono diverse, ad esempio, la \"Home\" elenca le Note dei profili che segui. Quella \"Locale\" elenca quelle di tutti i profili attivi su {name}." + step2_1: "Prova a pubblicare una Nota. Semplicemente premendo il bottone con l'icona di una matita." + step2_2: "Potresti scrivere la tua presentazione, oppure semplicemente \"Ciao da {name}!\"" + step3_1: "Hai pubblicato qualcosa?" + step3_2: "In tal caso, dovrebbe comparire subito nella tua \"Home\"" + step4_1: "Puoi reagire con un emoji alle Note." + step4_2: "To attach a reaction, press the \"+\" mark on a note and choose an emoji you'd like to react with.\nPer reagire con una emoji, premi il bottone \"+\" (più) visibile vicino ad ogni Nota e scegli dall'elenco la emoji che rappresenta la tua reazione." _2fa: alreadyRegistered: "La configurazione è stata già completata." registerTOTP: "Registra un'app di autenticazione" @@ -1710,7 +1816,7 @@ _visibility: followersDescription: "Visibile solo ai tuoi follower" specified: "Nota diretta" specifiedDescription: "Visibile solo ai profili menzionati" - disableFederation: "Federazione disabilitata" + disableFederation: "Non federare" disableFederationDescription: "Non spedire attività alle altre istanze remote" _postForm: replyPlaceholder: "Rispondi a questa nota..." @@ -1890,6 +1996,7 @@ _deck: introduction: "Combinate le colonne per creare la vostra interfaccia!" 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" _columns: main: "Principale" widgets: "Riquadri" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8004e5357..b2fa9c337 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -49,11 +49,15 @@ delete: "削除" deleteAndEdit: "削除して編集" deleteAndEditConfirm: "このノートを削除してもう一度編集しますか?このノートへのリアクション、Renote、返信も全て削除されます。" addToList: "リストに追加" +addToAntenna: "アンテナに追加" sendMessage: "メッセージを送信" copyRSS: "RSSをコピー" copyUsername: "ユーザー名をコピー" copyUserId: "ユーザーIDをコピー" copyNoteId: "ノートIDをコピー" +copyFileId: "ファイルIDをコピー" +copyFolderId: "フォルダーIDをコピー" +copyProfileUrl: "プロフィールURLをコピー" searchUser: "ユーザーを検索" reply: "返信" loadMore: "もっと見る" @@ -112,7 +116,7 @@ pinnedNote: "ピン留めされたノート" pinned: "ピン留め" you: "あなた" clickToShow: "クリックして表示" -sensitive: "閲覧注意" +sensitive: "センシティブ" add: "追加" reaction: "リアクション" reactions: "リアクション" @@ -120,8 +124,8 @@ reactionSetting: "ピッカーに表示するリアクション" reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。" rememberNoteVisibility: "公開範囲を記憶する" attachCancel: "添付取り消し" -markAsSensitive: "閲覧注意にする" -unmarkAsSensitive: "閲覧注意を解除する" +markAsSensitive: "センシティブとして設定" +unmarkAsSensitive: "センシティブを解除する" enterFileName: "ファイル名を入力" mute: "ミュート" unmute: "ミュート解除" @@ -136,8 +140,10 @@ unblockConfirm: "ブロック解除しますか?" suspendConfirm: "凍結しますか?" unsuspendConfirm: "解凍しますか?" selectList: "リストを選択" +editList: "リストを編集" selectChannel: "チャンネルを選択" selectAntenna: "アンテナを選択" +editAntenna: "アンテナを編集" selectWidget: "ウィジェットを選択" editWidgets: "ウィジェットを編集" editWidgetsExit: "編集を終了" @@ -150,6 +156,8 @@ addEmoji: "絵文字を追加" settingGuide: "おすすめ設定" cacheRemoteFiles: "リモートのファイルをキャッシュする" cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクするようになります。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。" +cacheRemoteSensitiveFiles: "リモートのセンシティブなファイルをキャッシュする" +cacheRemoteSensitiveFilesDescription: "この設定を無効にすると、リモートのセンシティブなファイルはキャッシュせず直リンクするようになります。" flagAsBot: "Botとして設定" flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。" flagAsCat: "にゃああああああああああああああ!!!!!!!!!!!!" @@ -311,7 +319,7 @@ copyUrl: "URLをコピー" rename: "名前を変更" avatar: "アイコン" banner: "バナー" -nsfw: "閲覧注意" +displayOfSensitiveMedia: "センシティブなメディアの表示" whenServerDisconnected: "サーバーとの接続が失われたとき" disconnectedFromServer: "サーバーから切断されました" reload: "リロード" @@ -693,7 +701,7 @@ driveUsage: "ドライブ使用量" noCrawle: "クローラーによるインデックスを拒否" noCrawleDescription: "外部の検索エンジンにあなたのユーザーページ、ノート、Pagesなどのコンテンツを登録(インデックス)しないよう要求します。" lockedAccountInfo: "フォローを承認制にしても、ノートの公開範囲を「フォロワー」にしない限り、誰でもあなたのノートを見ることができます。" -alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にする" +alwaysMarkSensitive: "デフォルトでメディアをセンシティブ設定にする" loadRawImages: "添付画像のサムネイルをオリジナル画質にする" disableShowingAnimatedImages: "アニメーション画像を再生しない" verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。" @@ -920,8 +928,8 @@ cannotUploadBecauseInappropriate: "不適切な内容を含む可能性がある cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。" beta: "ベータ" -enableAutoSensitive: "自動NSFW判定" -enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" +enableAutoSensitive: "自動センシティブ判定" +enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにセンシティブフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。" navbar: "ナビゲーションバー" shuffle: "シャッフル" @@ -1034,7 +1042,7 @@ vertical: "縦" horizontal: "横" position: "位置" serverRules: "サーバールール" -pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。" +pleaseConfirmBelowBeforeSignup: "このサーバーに登録するには、以下の内容を確認し同意する必要があります。" pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。" continue: "続ける" preservedUsernames: "予約ユーザー名" @@ -1063,6 +1071,29 @@ goToMisskey: "Misskeyへ" additionalEmojiDictionary: "絵文字の追加辞書" installed: "インストール済み" branding: "ブランディング" +enableServerMachineStats: "サーバーのマシン情報を公開する" +enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" +turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。" +createInviteCode: "招待コードを作成" +createWithOptions: "オプションを指定して作成" +createCount: "作成数" +inviteCodeCreated: "招待コードを作成しました" +inviteLimitExceeded: "作成できる招待コードの数が上限に達しています。" +createLimitRemaining: "作成できる招待コード: 残り {limit} 個" +inviteLimitResetCycle: "{time}で最大 {limit} 個の招待コードを作成できます。" +expirationDate: "有効期限" +noExpirationDate: "有効期限を設けない" +inviteCodeUsedAt: "招待コードが使用された日時" +registeredUserUsingInviteCode: "招待コードを使用したユーザー" +waitingForMailAuth: "メール認証待ち" +inviteCodeCreator: "招待コードを作成したユーザー" +usedAt: "使用日時" +unused: "未使用" +used: "使用済み" +expired: "期限切れ" +doYouAgree: "同意しますか?" +beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。" +iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。" _initialAccountSetting: accountCreated: "アカウントの作成が完了しました!" @@ -1378,6 +1409,9 @@ _role: ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" canInvite: "サーバー招待コードの発行" + inviteLimit: "招待コードの作成可能数" + inviteLimitCycle: "招待コードの発行間隔" + inviteExpirationTime: "招待コードの有効期限" canManageCustomEmojis: "カスタム絵文字の管理" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "ファイルにNSFWを常に付与" @@ -1412,7 +1446,7 @@ _sensitiveMediaDetection: description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" sensitivity: "検出感度" sensitivityDescription: "感度を低くすると、誤検知(偽陽性)が減ります。感度を高くすると、検知漏れ(偽陰性)が減ります。" - setSensitiveFlagAutomatically: "NSFWフラグを設定する" + setSensitiveFlagAutomatically: "センシティブフラグを設定する" setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されます。" analyzeVideos: "動画の解析を有効化" analyzeVideosDescription: "静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。" @@ -1446,6 +1480,7 @@ _ad: back: "戻る" reduceFrequencyOfThisAd: "この広告の表示頻度を下げる" hide: "表示しない" + timezoneinfo: "曜日はサーバーのタイムゾーンを元に指定されます。" _forgotPassword: enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。" @@ -1505,9 +1540,9 @@ _aboutMisskey: morePatrons: "他にも多くの方が支援してくれています。ありがとうございます🥰" patrons: "支援者" -_nsfw: - respect: "閲覧注意のメディアは隠す" - ignore: "閲覧注意のメディアを隠さない" +_displayOfSensitiveMedia: + respect: "センシティブ設定されたメディアを隠す" + ignore: "センシティブ設定されたメディアを隠さない" force: "常にメディアを隠す" _instanceTicker: @@ -2018,6 +2053,7 @@ _deck: introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!" introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。" widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選択してウィジェットを追加してください" + useSimpleUiForNonRootPages: "非ルートページは簡易UIで表示" _columns: main: "メイン" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index aa0f31fcd..e8b02f8d5 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1,7 +1,7 @@ --- _lang_: "日本語 (関西弁)" headlineMisskey: "ノートでつながるネットワーク" -introMisskey: "ようお越し!Misskeyは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「ツッコミ」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな新しい世界を探検しよか🚀" +introMisskey: "ようお越し!Misskeyは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「ツッコミ」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな、新しい世界を探検しよか🚀" poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつなんやで。" monthAndDay: "{month}月 {day}日" search: "探す" @@ -136,8 +136,10 @@ unblockConfirm: "ブロックやめたるってほんまか?" suspendConfirm: "凍結してしもうてええか?" unsuspendConfirm: "解凍するけどええか?" selectList: "リストを選ぶ" +editList: "リスト直すで" selectChannel: "チャンネルを選ぶ" selectAntenna: "アンテナを選ぶ" +editAntenna: "アンテナを編集" selectWidget: "ウィジェットを選ぶ" editWidgets: "ウィジェットをいじる" editWidgetsExit: "編集終ったで" @@ -311,7 +313,6 @@ copyUrl: "URLをコピー" rename: "名前を変えるで" avatar: "アイコン" banner: "バナー" -nsfw: "見るんは気いつけてな" whenServerDisconnected: "サーバーとの接続が失くなってしもうたとき" disconnectedFromServer: "サーバーが機嫌悪いねん" reload: "リロード" @@ -1062,6 +1063,15 @@ later: "あとで" goToMisskey: "Misskeyへ" additionalEmojiDictionary: "絵文字の追加辞書" installed: "インストール済み" +branding: "あ" +enableServerMachineStats: "サーバーのマシン情報見せびらかすで" +enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" +turnOffToImprovePerformance: "オフにしたらえらい軽うなるで。" +inviteCodeCreated: "招待コード作ったで" +inviteLimitExceeded: "招待コード作りすぎやで。" +createLimitRemaining: "作成できる招待コード: 残り {limit} 個やで" +unused: "つこてへん" +used: "もうつこてる" _initialAccountSetting: accountCreated: "アカウント作り終わったで。" letsStartAccountSetup: "アカウントの初期設定をしよか。" @@ -1485,9 +1495,9 @@ _aboutMisskey: donate: "Misskeyに寄付" morePatrons: "他にもぎょうさんの人からサポートしてもろてんねん。ほんまおおきに🥰" patrons: "支援者" -_nsfw: - respect: "閲覧注意のメディアは隠すで" - ignore: "閲覧注意のメディアは隠さへんで" +_displayOfSensitiveMedia: + respect: "きわどいのは見とうない" + ignore: "きわどいのも見たい" force: "常にメディアを隠すで" _instanceTicker: none: "表示せん" @@ -1635,7 +1645,7 @@ _timelineTutorial: step3_1: "投稿できた?" step3_2: "あんたのノートがタイムラインに出てきたら成功や。" step4_1: "ノートには、「ツッコミ」を付けれるで。" - step4_2: "ツッコむんやったら、ノートの「+」マークを押して、好きな絵文字を選ぶで。" + step4_2: "ツッコむんやったら、ノートの「+」マークを押して、好きな絵文字を選ぶんやで。" _2fa: alreadyRegistered: "もう設定終わっとるわ。" registerTOTP: "認証アプリの設定はじめる" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index fd46eef1f..42a829c12 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -40,7 +40,7 @@ favorites: "즐겨찾기" unfavorite: "즐겨찾기에서 제거" favorited: "즐겨찾기에 등록했습니다" alreadyFavorited: "이미 즐겨찾기에 등록되어 있습니다" -cantFavorite: "즐겨찾기에 등록하지 못했습니다." +cantFavorite: "즐겨찾기에 등록하지 못했습니다" pin: "프로필에 고정" unpin: "프로필에서 고정 해제" copyContent: "내용 복사" @@ -49,11 +49,15 @@ delete: "삭제" deleteAndEdit: "삭제 후 편집" deleteAndEditConfirm: "이 노트를 삭제한 뒤 다시 편집하시겠습니까? 이 노트에 대한 리액션, 리노트, 답글 또한 모두 삭제됩니다." addToList: "리스트에 추가" +addToAntenna: "안테나에 추가" sendMessage: "메시지 보내기" copyRSS: "RSS 복사" copyUsername: "유저명 복사" copyUserId: "유저 ID 복사" copyNoteId: "노트 ID 복사" +copyFileId: "파일 ID 복사" +copyFolderId: "폴더 ID 복사" +copyProfileUrl: "프로필 URL 복사" searchUser: "사용자 검색" reply: "답글" loadMore: "더 보기" @@ -104,7 +108,7 @@ renote: "리노트" unrenote: "리노트 취소" renoted: "리노트했습니다" cantRenote: "이 게시물은 리노트 할 수 없습니다." -cantReRenote: "리노트를 리노트 할 수 없습니다." +cantReRenote: "리노트를 리노트할 수 없습니다." quote: "인용" inChannelRenote: "채널 내 리노트" inChannelQuote: "채널 내 인용" @@ -112,7 +116,7 @@ pinnedNote: "고정해놓은 노트" pinned: "프로필에 고정" you: "당신" clickToShow: "클릭하여 보기" -sensitive: "열람주의" +sensitive: "열람 주의" add: "추가" reaction: "리액션" reactions: "리액션" @@ -136,8 +140,10 @@ unblockConfirm: "이 계정의 차단을 해제하시겠습니까?" suspendConfirm: "이 계정을 정지하시겠습니까?" unsuspendConfirm: "이 계정의 정지를 해제하시겠습니까?" selectList: "리스트 선택" +editList: "리스트 편집" selectChannel: "채널 선택" selectAntenna: "안테나 선택" +editAntenna: "안테나 편집" selectWidget: "위젯 선택" editWidgets: "위젯 편집" editWidgetsExit: "편집 종료" @@ -150,10 +156,12 @@ addEmoji: "이모지 추가" settingGuide: "추천 설정" cacheRemoteFiles: "리모트 파일을 캐시" cacheRemoteFilesDescription: "이 설정을 해지하면 리모트 파일을 캐시하지 않고 해당 파일을 직접 링크하게 됩니다. 그에 따라 서버의 저장 공간을 절약할 수 있지만, 썸네일이 생성되지 않기 때문에 통신량이 증가합니다." +cacheRemoteSensitiveFiles: "리모트의 민감한 파일을 캐시" +cacheRemoteSensitiveFilesDescription: "이 설정을 비활성화하면 리모트의 민감한 파일은 캐시하지 않고 리모트에서 직접 가져오도록 합니다." flagAsBot: "나는 봇입니다" flagAsBotDescription: "이 계정을 자동화된 수단으로 운용할 경우에 활성화해 주세요. 이 플래그를 활성화하면, 다른 봇이 이를 참고하여 봇 끼리의 무한 연쇄 반응을 회피하거나, 이 계정의 시스템 상에서의 취급이 Bot 운영에 최적화되는 등의 변화가 생깁니다." flagAsCat: "나는 고양이다냥" -flagAsCatDescription: "이 계정이 고양이라면 활성화 해주세요." +flagAsCatDescription: "이 계정이 고양이라면 활성화해 주세요." flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기" flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시합니다." autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" @@ -199,7 +207,7 @@ instanceInfo: "서버 정보" statistics: "통계" clearQueue: "대기열 비우기" clearQueueConfirmTitle: "대기열을 비우시겠습니까?" -clearQueueConfirmText: "대기열에 남아 있는 노트는 더이상 연합되지 않습니다. 보통의 경우 이 작업은 필요하지 않습니다." +clearQueueConfirmText: "대기열에 남아 있는 노트는 더 이상 연합되지 않습니다. 보통의 경우 이 작업은 필요하지 않습니다." clearCachedFiles: "캐시 비우기" clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제하시겠습니까?" blockedInstances: "차단된 서버" @@ -311,7 +319,7 @@ copyUrl: "URL 복사" rename: "이름 변경" avatar: "아바타" banner: "배너" -nsfw: "열람주의" +displayOfSensitiveMedia: "민감한 미디어 표시" whenServerDisconnected: "서버와의 접속이 끊겼을 때" disconnectedFromServer: "서버와의 연결이 끊어졌습니다" reload: "새로고침" @@ -992,8 +1000,8 @@ cannotBeChangedLater: "나중에 변경할 수 없습니다." reactionAcceptance: "리액션 수신" likeOnly: "좋아요만 받기" likeOnlyForRemote: "리모트에서는 좋아요만 받기" -nonSensitiveOnly: "열람 주의로 설정되지 않았을 때만 받기" -nonSensitiveOnlyForLocalLikeOnlyForRemote: "열람 주의로 설정되지 않았을 때만 받기 (리모트에서는 좋아요만 받기)" +nonSensitiveOnly: "민감한 이모지를 제외하고 받기" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "민감한 이모지를 제외하고 받기 (리모트에서는 좋아요만 받기)" rolesAssignedToMe: "나에게 할당된 역할" resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?" sensitiveWords: "민감한 단어" @@ -1062,6 +1070,27 @@ later: "나중에" goToMisskey: "Misskey로" additionalEmojiDictionary: "이모지 추가 사전" installed: "설치됨" +branding: "브랜딩" +enableServerMachineStats: "서버의 머신 사양을 공개하기" +enableIdenticonGeneration: "유저마다의 Identicon 생성 유효화" +turnOffToImprovePerformance: "이 기능을 끄면 성능이 향상될 수 있습니다." +createInviteCode: "초대 코드 생성" +createWithOptions: "옵션을 지정하여 생성" +createCount: "초대 수" +inviteCodeCreated: "초대 코드 생성됨" +inviteLimitExceeded: "초대 코드 생성 한도를 초과했습니다." +createLimitRemaining: "초대 한도: {limit}회 남음" +inviteLimitResetCycle: " {time}시간 이내에 최대 {limit}개의 초대 코드를 생성할 수 있습니다." +expirationDate: "만료 날짜" +noExpirationDate: "만료기간 없음" +inviteCodeUsedAt: "다음에 사용된 초대 코드" +registeredUserUsingInviteCode: "초대 코드 사용 대상" +waitingForMailAuth: "이메일 인증 보류 중" +inviteCodeCreator: "초대 코드 생성자" +usedAt: "사용 시각" +unused: "사용되지 않음" +used: "사용됨" +expired: "만료됨" _initialAccountSetting: accountCreated: "계정 생성이 완료되었습니다!" letsStartAccountSetup: "계정의 초기 설정을 진행합니다." @@ -1372,6 +1401,9 @@ _role: ltlAvailable: "로컬 타임라인 보이기" canPublicNote: "공개 노트 허용" canInvite: "서버 초대 코드 발행" + inviteLimit: "초대 한도" + inviteLimitCycle: "초대 발급 간격" + inviteExpirationTime: "초대 만료 기간" canManageCustomEmojis: "커스텀 이모지 관리" driveCapacity: "드라이브 용량" alwaysMarkNsfw: "파일을 항상 NSFW로 지정" @@ -1434,6 +1466,7 @@ _ad: back: "뒤로" reduceFrequencyOfThisAd: "이 광고의 표시 빈도 낮추기" hide: "보이지 않음" + timezoneinfo: "요일은 서버의 표준 시간대에 따라 결정됩니다." _forgotPassword: enterEmail: "여기에 계정에 등록한 메일 주소를 입력해 주세요. 입력한 메일 주소로 비밀번호 재설정 링크를 발송합니다." ifNoEmail: "메일 주소를 등록하지 않은 경우, 관리자에 문의해 주십시오." @@ -1485,9 +1518,9 @@ _aboutMisskey: donate: "Misskey에 기부하기" morePatrons: "이 외에도 다른 많은 분들이 도움을 주시고 계십니다. 감사합니다🥰" patrons: "후원자" -_nsfw: - respect: "열람주의로 설정된 미디어 숨기기" - ignore: "열람 주의 미디어 항상 표시" +_displayOfSensitiveMedia: + respect: "민감한 콘텐츠로 표시된 미디어 숨기기" + ignore: "민감한 콘텐츠로 표시된 미디어 보이기" force: "미디어 항상 숨기기" _instanceTicker: none: "보이지 않음" @@ -1963,6 +1996,7 @@ _deck: introduction: "칼럼을 조합해서 나만의 인터페이스를 구성해 보아요!" introduction2: "나중에라도 화면 우측의 + 버튼을 눌러 새 칼럼을 추가할 수 있습니다." widgetsIntroduction: "칼럼 메뉴의 \"위젯 편집\"에서 위젯을 추가해 주세요" + useSimpleUiForNonRootPages: "루트 이외의 페이지로 접속한 경우 UI 간략화하기" _columns: main: "메인" widgets: "위젯" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index f686b0425..4f73b7055 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -20,6 +20,7 @@ noNotes: "ບໍ່ມີຫມາຍເຫດ" noNotifications: "ບໍ່ມີການແຈ້ງເຕືອນ" instance: "ອີນສະແຕນ" settings: "ກຳນົດຄ່າ" +notificationSettings: "ຕັ້ງຄ່າການແຈ້ງເຕືອນ" basicSettings: "ການຕັ້ງຄ່າພື້ນຖານ" otherSettings: "ການຕັ້ງຄ່າອື່ນໆ" openInWindow: "ເປີດຢູ່ໃນປ່ອງຢ້ຽມ" @@ -48,9 +49,15 @@ delete: "ລຶບ" deleteAndEdit: "ລົບ​ແລະ​ແກ້​ໄຂ​" deleteAndEditConfirm: "ເຈົ້າ​ແນ່​ໃຈ​ບໍ່? ທີ່ທ່ານຕ້ອງການທີ່ຈະລຶບບັນທຶກນີ້ແລະແກ້ໄຂມັນ ທ່ານອາດຈະສູນເສຍການໂຕ້ຕອບ, ບັນທຶກ, ແລະການຕອບກັບທັງໝົດ" addToList: "ເພີ່ມໃສ່ລາຍຊື່" +addToAntenna: "ເພີ່ມໃສ່ເສົາອາກາດ" sendMessage: "ສົ່ງຂໍ້ຄວາມ" copyRSS: "ສຳເນົາ RSS" copyUsername: "ສຳເນົາຊື່ຜູ້ໃຊ້" +copyUserId: "ສຳເນົາ ID ຜູ້ໃຊ້" +copyNoteId: "ສຳເນົາ ID ບັນທຶກ" +copyFileId: "ສຳເນົາ ID ໄຟລ໌" +copyFolderId: "ສຳເນົາ ID ໂຟນເດີ" +copyProfileUrl: "ສຳເນົາ URL ໂປຣໄຟລ໌" searchUser: "ຄົ້ນຫາຜູ້ໃຊ້" reply: "ຕອບ​ໄປ​ທີ" loadMore: "ໂຫຼດເພີ່ມເຕີມ" @@ -109,6 +116,7 @@ sensitive: "NSFW" add: "ເພີ່ມ" reaction: "ປະຕິກິລິຍາ" reactions: "ປະຕິກິລິຍາ" +attachCancel: "ເອົາໄຟລ໌ແນບ" mute: "ປີດສຽງ" unmute: "ເປີດສຽງ" block: "ບ໋ອກ" @@ -116,6 +124,10 @@ unblock: "ຍົກເລີກກາຮົບລັອກ" suspend: "ລະງັບ" unsuspend: "ເຊົາ​ລະ​ງັບ" selectList: "ເລືອກບັນຊີລາຍການ" +editList: "ແກ້ໄຂລາຍຊື່" +selectChannel: "ເລືອກຊ່ອງ" +selectAntenna: "ເລືອກເສົາອາກາດ" +editAntenna: "ແກ້ໄຂເສົາອາກາດ" selectWidget: "ເລືອກວິກເຈັດ" editWidgets: "ແກ້ໄຂ Widget" editWidgetsExit: "ສຳເລັດແລ້ວ" @@ -125,6 +137,7 @@ emojis: "ອີໂມຈິ" emojiName: "ຊື່ Emoji" emojiUrl: "URL ອີໂມຈິ" addEmoji: "ຕື່ມອີໂມຈິ" +settingGuide: "ການຕັ້ງຄ່າທີ່ແນະນໍາ" flagAsBot: "ໝາຍບັນຊີນີ້ເປັນບັອດ" flagAsCat: "ໝາຍບັນຊີນີ້ເປັນແມວ" flagAsCatDescription: "ເປີດໃຊ້ຕົວເລືອກນີ້ເພື່ອໝາຍບັນຊີນີ້ເປັນແມວ" @@ -133,10 +146,13 @@ flagShowTimelineRepliesDescription: "ສະແດງການຕອບກັບ autoAcceptFollowed: "ອະນຸມັດອັດຕະໂນມັດຕາມຄຳຮ້ອງຂໍຈາກຜູ້ໃຊ້ທີ່ທ່ານກຳລັງຕິດຕາມຢູ່" addAccount: "ເພີ່ມບັນຊີ" loginFailed: "ການເຂົ້າສູ່ລະບົບບໍ່ສຳເລັດ" +showOnRemote: "ເບິ່ງຢູ່ໃນຕົວຢ່າງໄລຍະໄກ" general: "ທົ່ວໄປ" wallpaper: "ພາບພື້ນຫລັງ" setWallpaper: "ຕັ້ງເປັນພາບພື້ນຫຼັງ" +removeWallpaper: "ລຶບຮູບວໍເປເປີອອກ" searchWith: "ຊອກຫາ: {q}" +youHaveNoLists: "ທ່ານ​ບໍ່​ມີ​ລາຍ​ການ​ໃດໆ​" proxyAccount: "ບັນຊີພຣັອກຊີ" host: "ໂຮດສ" selectUser: "ເລືອກຜູ້ໃຊ້" @@ -155,7 +171,9 @@ operations: "ການດຳເນີນງານ" software: "ຊອບແວ" version: "ສະບັບ" metadata: "Metadata" +withNFiles: "{n} ໄຟລ໌(s)" monitor: "ຈໍພາບ" +jobQueue: "ຄິວວຽກ" cpuAndMemory: "CPU ແລະ ຫນ່ວຍຄວາມຈໍາ" network: "ເຄືອຂ່າຍ" disk: "ດິສກ໌" @@ -248,7 +266,6 @@ inputNewDescription: "ໃສ່ຄຳບັນຍາຍໃໝ່" inputNewFolderName: "ໃສ່ຊື່ໂຟນເດີໃໝ່" circularReferenceFolder: "ໂຟນເດີປາຍທາງແມ່ນໂຟນເດີຍ່ອຍຂອງໂຟນເດີທີ່ທ່ານຕ້ອງການຍ້າຍ" rename: "ປ່ຽນຊື່" -nsfw: "NSFW" watch: "ເບິ່ງ" unwatch: "ຢຸດເບິ່ງ" accept: "ອະນຸຍາດ" @@ -344,6 +361,7 @@ _widgets: timeline: "​ເສັ້ນກຳ​ນົດ​ເວ​ລາ​" activity: "ກິດຈະກຳ" federation: "ສະຫະພັນ" + jobQueue: "ຄິວວຽກ" _userList: chooseList: "ເລືອກບັນຊີລາຍການ" _cw: diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 6bca484f1..ae2881a7d 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -20,6 +20,7 @@ noNotes: "Geen notities" noNotifications: "Geen meldingen" instance: "Server" settings: "Instellingen" +notificationSettings: "Notificatie instellingen" basicSettings: "Basisinstellingen" otherSettings: "Overige instellingen" openInWindow: "In een venster openen" @@ -48,8 +49,15 @@ delete: "Verwijderen" deleteAndEdit: "Verwijderen en bewerken" deleteAndEditConfirm: "Weet je zeker dat je deze notitie wilt verwijderen en dan bewerken? Je verliest alle reacties, herdelingen en antwoorden erop." addToList: "Aan lijst toevoegen" +addToAntenna: "Voeg toe aan antenne" sendMessage: "Verstuur bericht" +copyRSS: "Kopieer RSS" copyUsername: "Kopiëren gebruikersnaam " +copyUserId: "Kopieer gebruiker ID" +copyNoteId: "Kopieer notitie ID" +copyFileId: "Kopieer veld ID" +copyFolderId: "Kopieer folder ID" +copyProfileUrl: "Kopieer profiel URL" searchUser: "Zoeken een gebruiker" reply: "Antwoord" loadMore: "Laad meer" @@ -296,7 +304,6 @@ copyUrl: "URL kopiëren" rename: "Hernoemen" avatar: "Avatar" banner: "Banner" -nsfw: "NSFW" whenServerDisconnected: "Wanneer de verbinding met de server wordt onderbroken" disconnectedFromServer: "Verbinding met de server onderbroken." reload: "Verversen" diff --git a/locales/package.json b/locales/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/locales/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 24eee60ba..8b6b4be7d 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -299,7 +299,6 @@ copyUrl: "Skopiuj adres URL" rename: "Zmień nazwę" avatar: "Awatar" banner: "Baner" -nsfw: "NSFW" whenServerDisconnected: "Po utracie połączenia z serwerem" disconnectedFromServer: "Utracono połączenie z serwerem." reload: "Odśwież" @@ -955,10 +954,6 @@ _aboutMisskey: donate: "Przekaż darowiznę na Misskey" morePatrons: "Naprawdę doceniam wsparcie ze strony wielu niewymienionych tu osób. Dziękuję! 🥰" patrons: "Wspierający" -_nsfw: - respect: "Ukrywaj media NSFW" - ignore: "Nie ukrywaj mediów NSFW" - force: "Ukrywaj wszystkie media" _instanceTicker: none: "Nigdy nie pokazuj" remote: "Pokaż dla zdalnych użytkowników" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index e7b43f638..29d7de19c 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -294,7 +294,6 @@ copyUrl: "Copiar URL" rename: "Renomear" avatar: "Avatar" banner: "Capa" -nsfw: "Conteúdo sensível" whenServerDisconnected: "Quando a conexão com o servidor é perdida" disconnectedFromServer: "Desconectado do servidor" reload: "Recarregar" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 6ad45d063..2d3099858 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -294,7 +294,6 @@ copyUrl: "Copiază URL" rename: "Redenumește" avatar: "Avatar" banner: "Banner" -nsfw: "NSFW" whenServerDisconnected: "Când pierzi conexiunea cu serverul" disconnectedFromServer: "Conecțiunea cu serverul a fost pierdută" reload: "Reîncarcă" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index e92449fdb..080ebf166 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -52,6 +52,11 @@ addToList: "Добавить в список" sendMessage: "Отправить сообщение" copyRSS: "Скопировать RSS" copyUsername: "Скопировать имя пользователя" +copyUserId: "Скопировать ID пользователя" +copyNoteId: "Скопировать ID заметки" +copyFileId: "Скопировать ID файла" +copyFolderId: "Скопировать ID папки" +copyProfileUrl: "Скопировать URL профиля " searchUser: "Поиск людей" reply: "Ответить" loadMore: "Показать еще" @@ -134,8 +139,10 @@ unblockConfirm: "Разблокировать этот аккаунт?" suspendConfirm: "Заморозить этот аккаунт?" unsuspendConfirm: "Разморозить этот аккаунт?" selectList: "Выберите список" +editList: "Редактировать список" selectChannel: "Выберите канал" selectAntenna: "Выберите антенну" +editAntenna: "Редактировать антенну" selectWidget: "Выберите виджет" editWidgets: "Редактировать виджеты" editWidgetsExit: "Готово" @@ -309,7 +316,6 @@ copyUrl: "Копировать ссылку" rename: "Переименовать" avatar: "Аватар" banner: "Шапка" -nsfw: "Содержимое не для всех" whenServerDisconnected: "Когда соединение с сервером потеряно" disconnectedFromServer: "Разорвано соединение с сервером" reload: "Перезагрузить" @@ -790,6 +796,7 @@ noMaintainerInformationWarning: "Не заполнены сведения об noBotProtectionWarning: "Ботозащита не настроена" configure: "Настроить" postToGallery: "Опубликовать в галерею" +postToHashtag: "Опубликовать пост с этим хештегом" gallery: "Галерея" recentPosts: "Недавние публикации" popularPosts: "Популярные публикации" @@ -1395,10 +1402,6 @@ _aboutMisskey: donate: "Пожертвование на Misskey" morePatrons: "Большое спасибо и многим другим, кто принял участие в этом проекте! 🥰" patrons: "Материальная поддержка" -_nsfw: - respect: "Скрывать содержимое не для всех" - ignore: "Показывать содержимое не для всех" - force: "Скрывать вообще все файлы" _instanceTicker: none: "Не показывать" remote: "Только для других сайтов" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 3e9611938..f4c598ac8 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -303,7 +303,6 @@ copyUrl: "Kopírovať URL" rename: "Premenovať" avatar: "Avatar" banner: "BAnner" -nsfw: "NSFW" whenServerDisconnected: "Keď sa stratí spojenie so serverom" disconnectedFromServer: "Spojenie so serverom bolo prerušené" reload: "Obnoviť" @@ -1009,10 +1008,6 @@ _aboutMisskey: donate: "Podporiť Misskey" morePatrons: "Takisto oceňujeme podporu mnoých ďalších, ktorí tu nie sú uvedení. Ďakujeme! 🥰" patrons: "Prispievatelia" -_nsfw: - respect: "Skryť NSFW médiá" - ignore: "Neskrývať NSFW médiá" - force: "Skryť všetky médiá" _instanceTicker: none: "Nikdy nezobrazovať" remote: "Zobraziť pre vzdialených používateľov" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 7a00e3fce..07f43afe2 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -20,6 +20,7 @@ noNotes: "Inga noteringar" noNotifications: "Inga notifikationer" instance: "Instanser" settings: "Inställningar" +notificationSettings: "Notifieringsinställningar" basicSettings: "Basinställningar" otherSettings: "Andra inställningar" openInWindow: "Öppna i ett fönster" @@ -51,6 +52,10 @@ addToList: "Lägg till i lista" sendMessage: "Skicka ett meddelande" copyRSS: "Kopiera RSS" copyUsername: "Kopiera användarnamn" +copyUserId: "Kopiera användar-ID" +copyNoteId: "Kopiera noter-ID" +copyFileId: "Kopiera Fil-ID" +copyFolderId: "Kopiera mapp-ID" searchUser: "Sök användare" reply: "Svara" loadMore: "Ladda mer" @@ -103,6 +108,8 @@ renoted: "Omnoterad." cantRenote: "Inlägget kunde inte bli omnoterat." cantReRenote: "En omnotering kan inte bli omnoterad." quote: "Citat" +inChannelRenote: "Omnotera inom kanalen" +inChannelQuote: "I kanal citat" pinnedNote: "Fästad not" pinned: "Fäst till profil" you: "Du" @@ -129,7 +136,10 @@ unblockConfirm: "Är du säkert att du vill avblockera kontot?" suspendConfirm: "Är du säker att du vill suspendera detta konto?" unsuspendConfirm: "Är du säker att du vill avsuspendera detta konto?" selectList: "Välj lista" +editList: "Redigera lista" +selectChannel: "Välj en kanal" selectAntenna: "Välj en antenn" +editAntenna: "Redigera en antenn" selectWidget: "Välj en widget" editWidgets: "Redigera widgets" editWidgetsExit: "Avsluta redigering" @@ -256,6 +266,9 @@ noMoreHistory: "Det finns ingen mer historik" startMessaging: "Starta en chatt" nUsersRead: "läst av {n}" agreeTo: "Jag accepterar {0}" +agree: "Överens" +termsOfService: "Användarvillkor" +start: "Kom igång" home: "Hem" remoteUserCaution: "Då denna användaren kommer från en fjärrinstans, kan informationen visad vara ofullständig." activity: "Aktivitet" @@ -297,10 +310,10 @@ copyUrl: "Kopiera URL" rename: "Byt namn" avatar: "Profilbild" banner: "Banner" -nsfw: "Känsligt innehåll" reload: "Ladda om" doNothing: "Ignorera" reloadConfirm: "Vill du ladda om tidslinjen?" +watch: "Titta" accept: "Tillåt" reject: "Neka" normal: "Normal" @@ -320,16 +333,31 @@ connectService: "Anslut" disconnectService: "Koppla från" enableLocalTimeline: "Aktivera lokal tidslinje" enableGlobalTimeline: "Aktivera global tidslinje" +registration: "Registrera" enableRegistration: "Aktivera registrering av nya användare" +invite: "Inbjudan" inMb: "I megabyte" iconUrl: "URL till profilbilden" bannerUrl: "URL till banner-bilden" +basicInfo: "Grundläggande info" +pinnedUsers: "Fästa användare" +pinnedPages: "Fästa sidor" pinnedNotes: "Fästad not" +hcaptcha: "hCaptcha" enableHcaptcha: "Aktivera hCaptcha" +hcaptchaSiteKey: "Webbplatsnyckel" +hcaptchaSecretKey: "Hemlig nyckel" +recaptcha: "reCAPTCHA" enableRecaptcha: "Aktivera reCAPTCHA" +recaptchaSiteKey: "Webbplatsnyckel" +recaptchaSecretKey: "Hemlig nyckel" +turnstile: "Turnstile" enableTurnstile: "Aktivera Turnstile" +turnstileSiteKey: "Webbplatsnyckel" +turnstileSecretKey: "Hemlig nyckel" antennas: "Antenner" manageAntennas: "Hantera Antenner" +name: "Namn" antennaSource: "Antennkälla" antennaKeywords: "Nyckelord att lyssna efter" antennaExcludeKeywords: "Nyckelord att exkludera" @@ -338,39 +366,112 @@ notifyAntenna: "Notifiera om nya noter" withFileAntenna: "Endast noter med filer" enableServiceworker: "Aktivera pushnotiser i denna webbläsaren" antennaUsersDescription: "Ange ett användarnamn per linje" +withReplies: "Med svar" +notesAndReplies: "Inlägg och svar" +silence: "Tystnad" recentlyUpdatedUsers: "Nyligen aktiva användare" recentlyRegisteredUsers: "Nyligen registrerade användare" +exploreFediverse: "Utforska Fediverse" +popularTags: "Populära taggar" userList: "Listor" +about: "Om" aboutMisskey: "Om Misskey" administrator: "Administratör" +2fa: "Tvåfaktorsautentisering" +totp: "Autentiseringsapp" +moderator: "Moderator" passwordLessLogin: "Lösenordsfri inloggning" passwordLessLoginDescription: "Tillåter lösenordsfri inloggning med endast en säkerhetsnyckel eller en passkey." resetPassword: "Återställ Lösenord" newPasswordIs: "Det nya lösenordet är \"{password}\"" share: "Dela" +help: "Hjälp" +close: "Stäng" +invites: "Inbjudan" +members: "Medlemmar" +transfer: "Överför" +text: "Text" enable: "Aktivera" +next: "Nästa" +invitations: "Inbjudan" +invitationCode: "Inbjudningskod" +available: "Tillgängligt" weakPassword: "Svagt Lösenord" normalPassword: "Medel Lösenord" strongPassword: "Starkt Lösenord" signinFailed: "Kan inte logga in. Det angivna användarnamnet eller lösenordet är felaktigt." +or: "eller" +language: "Språk" +aboutX: "Om {x}" +category: "Kategori" +tags: "Taggar" +createAccount: "Skapa ett konto" +existingAccount: "Existerande konto" +regenerate: "Regenerera" +fontSize: "Textstorlek" +openImageInNewTab: "Öppna bild i ny flik" +clientSettings: "Klientinställningar" +accountSettings: "Kontoinställningar" +numberOfDays: "Antal dagar" +deleteAll: "Radera alla" +sounds: "Ljud" +sound: "Ljud" +listen: "Lyssna" +none: "Ingen" +volume: "Volym" +chooseEmoji: "Välj en emoji" +recentUsed: "Senast använd" +install: "Installera" +uninstall: "Avinstallera" +menu: "Meny" serviceworkerInfo: "Måste vara aktiverad för pushnotiser." enableInfiniteScroll: "Ladda mer automatiskt" enablePlayer: "Öppna videospelare" +permission: "Behörigheter" enableAll: "Aktivera alla" +edit: "Ändra" enableEmail: "Aktivera epost-utskick" +email: "E-post" smtpHost: "Värd" smtpUser: "Användarnamn" smtpPass: "Lösenord" emptyToDisableSmtpAuth: "Lämna användarnamn och lösenord tomt för att avaktivera SMTP verifiering" +logs: "Logg" +channel: "kanal" +create: "Skapa" +other: "Mer" +send: "Skicka" +openInNewTab: "Öppna i ny flik" +createNew: "Skapa ny" +i18nInfo: "Misskey översätts till många olika språk av volontärer. Du kan hjälpa till med översättningen på {link}." +accountInfo: "Kontoinformation" +clips: "Klipp" +duplicate: "Duplicera" +reloadToApplySetting: "Inställningen tillämpas efter sidan laddas om. Vill du göra det nu?" clearCache: "Rensa cache" onlineUsersCount: "{n} användare är online" +nNotes: "{n} Noter" +backgroundColor: "Bakgrundsbild" +textColor: "Text" +youAreRunningUpToDateClient: "Klienten du använder är uppdaterat." +newVersionOfClientAvailable: "Ny version av klienten är tillgänglig." +publish: "Publicera" +typingUsers: "{users} skriver" +info: "Om" enabled: "Aktiverad" user: "Användare" +customCssWarn: "Den här inställningen borde bara ändrats av en som har rätta kunskaper. Om du ställer in det här fel så kan klienten sluta fungera rätt." global: "Global" squareAvatars: "Visa fyrkantiga profilbilder" +sent: "Skicka" +misskeyUpdated: "Misskey har uppdaterats!" incorrectPassword: "Fel lösenord." +welcomeBackWithName: "Välkommen tillbaka, {name}" +clickToFinishEmailVerification: "Tryck på [{ok}] för att slutföra bekräftelsen på e-postadressen." searchByGoogle: "Sök" file: "Filer" +cannotUploadBecauseNoFreeSpace: "Kan inte ladda upp filen för att det finns inget lagringsutrymme kvar." +cannotUploadBecauseExceedsFileSizeLimit: "Kan inte ladda upp filen för att den är större än filstorleksgränsen." enableAutoSensitive: "Automatisk NSFW markering" enableAutoSensitiveDescription: "Tillåter automatiskt detektering och marketing av NSFW media genom Maskininlärning när möjligt. Även om denna inställningen är avaktiverad, kan det vara aktiverat på hela instansen." pushNotification: "Pushnotiser" @@ -381,12 +482,16 @@ pushNotificationNotSupported: "Din webbläsare eller instans har inte stöd för windowMaximize: "Maximera" windowMinimize: "Minimera" windowRestore: "Återställ" +pleaseDonate: "Misskey är en gratis programvara som används på {host}. Donera gärna för att göra utvecklingen ständigt, tack!" resetPasswordConfirm: "Återställ verkligen ditt lösenord?" +dataSaver: "Databesparing" _achievements: _types: _open3windows: title: "Flera Fönster" description: "Ha minst 3 fönster öppna samtidigt" +_ffVisibility: + public: "Publicera" _email: _follow: title: "följde dig" @@ -426,6 +531,7 @@ _visibility: home: "Hem" followers: "Följare" _profile: + name: "Namn" username: "Användarnamn" changeAvatar: "Ändra profilbild" changeBanner: "Ändra banner" @@ -461,6 +567,8 @@ _deck: tl: "Tidslinje" antenna: "Antenner" list: "Listor" + channel: "kanal" mentions: "Omnämningar" _webhookSettings: + name: "Namn" active: "Aktiverad" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index d8e68202d..82032b06c 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -1,9 +1,9 @@ --- _lang_: "ภาษาไทย" -headlineMisskey: "เชื่อมต่อเครือข่ายโดยโน้ต" -introMisskey: "ยินดีต้อนรับจ้าาา! Misskey เป็นบริการไมโครบล็อกโอเพ่นซอร์ส แบบการกระจายอำนาจ\nสร้าง \"โน้ต\" เพื่อแบ่งปันความคิดของคุณกับทุกคนรอบตัวคุณกันเถอะ 📡\nด้วยการ \"รีแอคชั่นผู้คน\" คุณยังสามารถแสดงความรู้สึกของคุณเกี่ยวกับบันทึกของทุกคนได้อย่างรวดเร็ว 👍\n\nแล้วมาท่องสำรวจโลกใบใหม่กันเถอะ! 🚀" +headlineMisskey: "เชื่อมต่อระบบ Network ด้วย Note" +introMisskey: "ยินดีต้อนรับทุกคนจ้า! Misskey คือ บริการไมโครบล็อกกิ้ง (MicroBlogging) แบบกระจายศูนย์อำนาจ (Decentralized) \n\nเขียน \"โน้ต (Note)\" เพื่อส่งต่อเรื่องราวของคุณให้ทั้งโลกได้รับรู้📡\nและอย่าลืมที่จะ \"React\" กับเรื่องราวของคนอื่น ๆ ด้วย! 👍\n\nมุ่งสู่โลกใบใหม่กันเถอะ🚀" poweredByMisskeyDescription: "{name} เป็นส่วนหนึ่งในบริการที่ถูกขับเคลื่อนโดยแพลตฟอร์มโอเพ่นซอร์ส Misskey (เรียกว่า \"อินสแตนซ์ Misskey\")" -monthAndDay: "{เดือน}/{วัน}" +monthAndDay: "{month}/{day}" search: "ค้นหา" notifications: "การเเจ้งเตือน" username: "ชื่อผู้ใช้" @@ -15,7 +15,7 @@ gotIt: "เข้าใจแล้ว !" cancel: "ยกเลิก" noThankYou: "ไม่เป็นไร" enterUsername: "ใส่ชื่อผู้ใช้" -renotedBy: "รีโน้ตโดย {ผู้ใช้}" +renotedBy: "รีโน้ตโดย {user}" noNotes: "ไม่มีโน้ต" noNotifications: "ไม่มีการแจ้งเตือน" instance: "อินสแตนซ์" @@ -49,9 +49,15 @@ delete: "ลบ" deleteAndEdit: "ลบและแก้ไข" deleteAndEditConfirm: "นายแน่ใจแล้วเหรอ? ว่าต้องการลบโน้ตนี้และแก้ไข คุณอาจจะสูญเสียการโต้ตอบ, โน้ต, และการตอบกลับทั้งหมดได้นะ" addToList: "เพิ่มในลิสต์" +addToAntenna: "เพิ่มไปยังเสาอากาศ" sendMessage: "ส่งข้อความ" copyRSS: "คัดลอก RSS" copyUsername: "คัดลอกชื่อผู้ใช้" +copyUserId: "คัดลอก ID ผู้ใช้" +copyNoteId: "คัดลอก ID โน้ต " +copyFileId: "คัดลอกไฟล์ ID" +copyFolderId: "คัดลอกโฟลเดอร์ ID" +copyProfileUrl: "คัดลอกโปรไฟล์ URL" searchUser: "ค้นหาผู้ใช้งาน" reply: "ตอบกลับ" loadMore: "โหลดเพิ่มเติม" @@ -73,8 +79,8 @@ unfollowConfirm: "นายแน่ใจแล้วหรอว่าต้ exportRequested: "เมื่อคุณได้ร้องขอการส่งออก อาจจะต้องใช้เวลาสักครู่ และจะถูกเพิ่มในไดรฟ์ของคุณเมื่อเสร็จสิ้นแล้ว" importRequested: "เมื่อคุณได้ร้องขอการนำเข้า อาจจะต้องใช้เวลาสักครู่นะ" lists: "รายการ" -noLists: "คุณไม่มีลิสต์ใดๆนะ" -note: "ตัวโน้ต" +noLists: "คุณไม่มีลิสต์ใด ๆ" +note: " โน้ต" notes: "ตัวโน้ต" following: "กำลังติดตาม" followers: "ผู้ติดตาม" @@ -100,7 +106,7 @@ followRequestPending: "กำลังรอดำเนินการร้อ enterEmoji: "ใส่อีโมจิ" renote: "รีโน้ต" unrenote: "เลิกรีโน้ต" -renoted: "รีโน้ตแล้วนะ" +renoted: "รีโน้ตแล้ว" cantRenote: "โพสต์นี้ไม่สามารถรีโน้ตไว้ใหม่ได้นะ" cantReRenote: "ไม่สามารถรีโน้ตเอาไว้ใหม่ได้นะ" quote: "อ้างคำพูด" @@ -108,7 +114,7 @@ inChannelRenote: "รีโน้ตช่องแชลแนลเท่า inChannelQuote: "อ้างช่องเท่านั้น" pinnedNote: "โน้ตที่ปักหมุดเอาไว้" pinned: "ปักหมุดไปยังโปรไฟล์" -you: "ตัวเอง" +you: "คุณ" clickToShow: "คลิกเพื่อแสดง" sensitive: "เนื้อหาที่ละเอียดอ่อน NSFW" add: "เพิ่ม" @@ -134,8 +140,10 @@ unblockConfirm: "คุณแน่ใจแล้วเหรอ? ว่าต suspendConfirm: "นายแน่ใจแล้วเหรอว่าต้องการระงับบัญชีนี้อ่ะ?" unsuspendConfirm: "นายแน่ใจแล้วหรอ? ว่าต้องการยกเลิกการระงับบัญชีนี้" selectList: "เลือกรายการ" +editList: "แก้ไขรายการ" selectChannel: "เลือกแชนแนล" selectAntenna: "เลือกเสาอากาศ" +editAntenna: "แก้ไขเสาอากาศ" selectWidget: "เลือกวิดเจ็ต" editWidgets: "แก้ไขวิดเจ็ต" editWidgetsExit: "เรียบร้อย" @@ -148,6 +156,8 @@ addEmoji: "แทรกอีโมจิ" settingGuide: "การตั้งค่าที่แนะนำ" cacheRemoteFiles: "แคชไฟล์ระยะไกล" cacheRemoteFilesDescription: "เมื่อปิดใช้งานการตั้งค่านี้ ไฟล์ระยะไกลนั้นจะถูกโหลดโดยตรงจากอินสแตนซ์ระยะไกล แต่กรณีการปิดใช้งานนี้จะช่วยลดปริมาณการใช้พื้นที่จัดเก็บข้อมูล แต่เพิ่มปริมาณการใช้งาน เพราะเนื่องจากจะไม่มีการสร้างภาพขนาดย่อ" +cacheRemoteSensitiveFiles: "ไฟล์ระยะไกลที่มีความละเอียดอ่อนแคช" +cacheRemoteSensitiveFilesDescription: "เมื่อปิดการใช้งานแล้วการตั้งค่านี้ ไฟล์รีโมตที่มีความละเอียดอ่อนนั้นจะถูกโหลดโดยตรงจากอินสแตนซ์ระยะไกลโดยที่ไม่มีการแคช" flagAsBot: "ทำเครื่องหมายบอกว่าบัญชีนี้เป็นบอท" flagAsBotDescription: "การเปิดใช้งานตัวเลือกนี้หากบัญชีนี้ถูกควบคุมโดยนักเขียนโปรแกรม หรือ ถ้าหากเปิดใช้งาน มันจะทำหน้าที่เป็นแฟล็กสำหรับนักพัฒนารายอื่นๆ และเพื่อป้องกันการโต้ตอบแบบไม่มีที่สิ้นสุดกับบอทตัวอื่นๆ และยังสามารถปรับเปลี่ยนระบบภายในของ Misskey เพื่อปฏิบัติต่อบัญชีนี้เป็นบอท" flagAsCat: "ทำเครื่องหมายบอกว่าบัญชีนี้เป็นแมว" @@ -164,7 +174,7 @@ wallpaper: "วอลล์เปเปอร์" setWallpaper: "ตั้งวอลเปเปอร์" removeWallpaper: "นำวอลเปเปอร์ออก" searchWith: "ค้นหา: {q}" -youHaveNoLists: "รายการนี้ว่างเปล่า" +youHaveNoLists: "คุณไม่มีลิสต์ใด ๆ " followConfirm: "คุณแน่ใจแล้วหรอว่าต้องการที่จะติดตาม {name}?" proxyAccount: "บัญชี พร็อกซี่" proxyAccountDescription: "บัญชีพร็อกซี่ คือ บัญชีที่จะทำหน้าที่เป็นผู้ติดตามระยะไกลสำหรับผู้ใช้งานที่อยู่ภายใต้ด้วยเงื่อนไขบางอย่าง ยกตัวอย่าง เช่น เมื่อมีผู้ใช้งานนั้นได้เพิ่มผู้ใช้งานจากระยะไกลลงในรายการ แต่กิจกรรมของผู้ใช้ในระยะไกลนั้นจะไม่ถูกส่งไปยังอินสแตนซ์หากไม่มีผู้ใช้งานในพื้นที่ติดตามผู้ใช้รายนั้น ดังนั้นบัญชีพร็อกซีนี้จะติดตามแทน" @@ -186,7 +196,7 @@ blockThisInstance: "บล็อกอินสแตนซ์นี้" operations: "ดำเนินการ" software: "ซอฟต์แวร์" version: "เวอร์ชั่น" -metadata: "ข้อมูลเมตา" +metadata: "Metadata" withNFiles: "{n} ไฟล์(s)" monitor: "มอนิเตอร์" jobQueue: "คิวงาน" @@ -213,7 +223,7 @@ intro: "การติดตั้ง Misskey เสร็จสิ้นแล done: "เสร็จสิ้น" processing: "กำลังประมวลผล..." preview: "แสดงตัวอย่าง" -default: "ค่าตั้งต้น" +default: "ค่าเริ่มต้น" defaultValueIs: "ค่าเริ่มต้น: {value}" noCustomEmojis: "ไม่มีอีโมจิ" noJobs: "ไม่มีชิ้นงาน" @@ -234,10 +244,10 @@ currentPassword: "รหัสผ่านปัจจุบัน" newPassword: "รหัสผ่านใหม่" newPasswordRetype: "ใส่รหัสผ่านใหม่อีกครั้ง" attachFile: "แนบไฟล์" -more: "เพิ่มเติม!" +more: "เพิ่มเติม" featured: "ไฮไลท์" usernameOrUserId: "ชื่อผู้ใช้หรือรหัสผู้ใช้งาน" -noSuchUser: "ไม่มีผู้ใช้นี้อยู่ในระบบ" +noSuchUser: "ไม่พบผู้ใช้" lookup: "การค้นหา" announcements: "ประกาศ" imageUrl: "url รูปภาพ" @@ -282,7 +292,7 @@ themeForLightMode: "ธีมที่จะใช้ในโหมดแสง themeForDarkMode: "ธีมที่จะใช้ในโหมดมืด" light: "สว่าง" dark: "มืด" -lightThemes: "ธีมสีสว่าง" +lightThemes: "ธีมสว่าง" darkThemes: "ธีมมืด" syncDeviceDarkMode: "ซิงค์โหมดมืดด้วยการตั้งค่ากับอุปกรณ์" drive: "ไดรฟ์" @@ -309,7 +319,7 @@ copyUrl: "คัดลอก URL" rename: "เปลี่ยนชื่อ" avatar: "ไอคอน" banner: "แบนเนอร์" -nsfw: "เนื้อหาที่ละเอียดอ่อน NSFW" +displayOfSensitiveMedia: "แสดงผลสื่อละเอียดอ่อน" whenServerDisconnected: "สูญเสียการเชื่อมต่อกับเซิร์ฟเวอร์" disconnectedFromServer: "ถูกตัดการเชื่อมต่อออกจากเซิร์ฟเวอร์" reload: "รีโหลด" @@ -319,7 +329,7 @@ watch: "ดู" unwatch: "หยุดดู" accept: "ยอมรับ" reject: "ปฏิเสธ" -normal: "โหมดปกติ" +normal: "ปกติ" instanceName: "ชื่อ อินสแตนซ์" instanceDescription: "คำอธิบายอินสแตนซ์" maintainerName: "ผู้ดูแล" @@ -328,9 +338,9 @@ tosUrl: "เงื่อนไขการให้บริการ URL" thisYear: "ปีนี้" thisMonth: "เดือนนี้" today: "วันนี้" -dayX: "{วัน}" -monthX: "{เดือน}" -yearX: "{ปี}" +dayX: "{day}" +monthX: "เดือน {month}" +yearX: "{year}" pages: "หน้า" integration: "รวบรวม" connectService: "เชื่อมต่อ" @@ -790,6 +800,7 @@ noMaintainerInformationWarning: "ข้อมูลผู้ดูแลไม noBotProtectionWarning: "ไม่ได้กำหนดค่าการป้องกันบอทนะ" configure: "กำหนดค่า" postToGallery: "สร้างโพสต์แกลเลอรี่ใหม่" +postToHashtag: "โพสต์ไปที่แฮชแท็กนี้" gallery: "แกลเลอรี่" recentPosts: "โพสต์ล่าสุด" popularPosts: "โพสต์ติดอันดับ" @@ -823,6 +834,7 @@ translatedFrom: "แปลมาจาก {x}" accountDeletionInProgress: "กำลังดำเนินการลบบัญชีอยู่" usernameInfo: "ชื่อที่ระบุบัญชีของคุณจากผู้อื่นในเซิร์ฟเวอร์นี้ คุณสามารถใช้ตัวอักษร (a~z, A~Z), ตัวเลข (0~9) หรือขีดล่าง (_) ชื่อผู้ใช้ไม่สามารถเปลี่ยนแปลงได้ในภายหลัง" aiChanMode: "โหมด Ai " +devMode: "โหมดนักพัฒนา" keepCw: "เก็บคำเตือนเนื้อหา" pubSub: "บัญชีผับ/ย่อย" lastCommunication: "การสื่อสารครั้งสุดท้ายล่าสุด" @@ -832,6 +844,8 @@ breakFollow: "ลบผู้ติดตาม" breakFollowConfirm: "ลบผู้ติดตามนี้ออกจริงหรอ?" itsOn: "เปิดใช้งาน" itsOff: "ปิดใช้งาน" +on: "เปิด" +off: "ปิด" emailRequiredForSignup: "จำเป็นต้องการใช้ที่อยู่อีเมลสำหรับการสมัคร" unread: "ไม่ได้อ่าน" filter: "กรอง" @@ -850,7 +864,7 @@ incorrectPassword: "รหัสผ่านไม่ถูกต้อง" voteConfirm: "ยืนยันการโหวต \"{choice}\" มั้ย?" hide: "ซ่อน" useDrawerReactionPickerForMobile: "แสดงผล ตัวเลือกปฏิกิริยาเป็นลิ้นชักบนมือถือ" -welcomeBackWithName: "ยินดีต้อนรับการกลับมานะค่ะ, {name}" +welcomeBackWithName: "ยินดีต้อนรับการกลับมานะคะ, {name}" clickToFinishEmailVerification: "กรุณาคลิก [{ok}] เพื่อดำเนินการยืนยันอีเมลให้เสร็จสมบูรณ์นะ" overridedDeviceKind: "ประเภทอุปกรณ์" smartphone: "สมาร์ทโฟน" @@ -955,7 +969,7 @@ color: "สี" manageCustomEmojis: "จัดการอีโมจิแบบกำหนดเอง" youCannotCreateAnymore: "คุณถึงขีดจํากัดการสร้างแล้วนะ" cannotPerformTemporary: "ไม่สามารถใช้การได้ชั่วคราว" -cannotPerformTemporaryDescription: "การดําเนินการนี้ไม่สามารถดําเนินการได้ชั่วคราว เนื่องจากเกินขีดจํากัดการดําเนินการ กรุณารอสักครู่แล้วลองใหม่อีกครั้งนะค่ะ" +cannotPerformTemporaryDescription: "ไม่สามารถดําเนินการได้ชั่วคราว เนื่องจากเกินขีดจํากัดการดําเนินการ กรุณารอสักครู่แล้วลองใหม่อีกครั้ง" invalidParamError: "ข้อผิดพลาดพารามิเตอร์" invalidParamErrorDescription: "คำขอพารามิเตอร์ไม่ถูกต้อง สิ่งนี้มักจะเกิดจากข้อผิดพลาด แต่อาจเกิดจากอินพุตเกินขีดจำกัดของขนาดหรือที่คล้ายกัน" permissionDeniedError: "การดำเนินถูกปฏิเสธ" @@ -977,20 +991,23 @@ joinThisServer: "ลงชื่อสมัครใช้ในอินสแ exploreOtherServers: "มองหาอินสแตนซ์อื่น" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" disableFederationConfirm: "ปิดใช้งานสหพันธ์จริงๆหรอแน่ใจแล้วนะ?" -disableFederationConfirmWarn: "แม้ว่าจะถูกยกเลิกเอาไว้โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป เว้นแต่ว่า...จะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องทำตรงนี้หรอกนะค่ะ" +disableFederationConfirmWarn: "โพสต์จะยังคงเป็นสาธารณะต่อไป เว้นแต่จะตั้งค่าเป็นอย่างอื่น" disableFederationOk: "ปิดการใช้งาน" -invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" -emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" +invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญ เพื่องลงทะเบียนเข้าใช้งาน" +emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมล" postToTheChannel: "โพสต์ลงช่อง" cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" reactionAcceptance: "การยอมรับรีแอคชั่น" likeOnly: "ที่ชอบเท่านั้น" likeOnlyForRemote: "ไลค์สำหรับอินสแตนซ์ระยะไกลเท่านั้น" +nonSensitiveOnly: "ไม่มีความอ่อนไหวเท่านั้น" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "ไม่มีความอ่อนไหวเท่านั้น (เฉพาะไลค์จากระยะไกลเท่านั้น)" rolesAssignedToMe: "บทบาทที่ได้รับมอบหมายให้ฉัน" resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?" sensitiveWords: "คำที่ละเอียดอ่อน" sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ" -notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งานนะค่ะ" +sensitiveWordsDescription2: "การใช้ช่องว่างนั้นอาจจะสร้างนิพจน์ AND และคำหลักที่มีเครื่องหมายทับล้อมรอบจะเปลี่ยนเป็นนิพจน์ทั่วไปนะ" +notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งาน" license: "ใบอนุญาต" unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?" myClips: "คลิปของฉัน" @@ -1025,29 +1042,84 @@ vertical: "แนวตั้ง" horizontal: "ด้านข้าง" position: "ตำแหน่ง" serverRules: "กฎของเซิฟเวอร์" -pleaseConfirmBelowBeforeSignup: "โปรดยืนยันด้านล่างก่อนกำลังลงชื่อสมัครนะค่ะ" +pleaseConfirmBelowBeforeSignup: "โปรดยืนยันที่ด้านล่างก่อนสมัครใช้งาน" pleaseAgreeAllToContinue: "คุณต้องยอมรับทุกช่องตรงด้านบนเพื่อดำเนินการต่อค่ะ" continue: "ดำเนินการต่อ" preservedUsernames: "ชื่อผู้ใช้ที่สงวนไว้" preservedUsernamesDescription: "ลิสต์ชื่อผู้ใช้ที่จะสำรองโดยคั่นด้วยการแบ่งบรรทัดนั้น เพราะสิ่งเหล่านี้จะไม่สามารถทำได้ในระหว่างการสร้างบัญชีตามปกติ บัญชีที่มีอยู่แล้วนั้นโดยใช้ชื่อผู้ใช้เหล่านี้จะไม่ได้รับผลกระทบอะไร" createNoteFromTheFile: "เรียบเรียงโน้ตจากไฟล์นี้" archive: "เก็บถาวร" +channelArchiveConfirmTitle: "เก็บถาวรจริงๆ {name} มั้ย?" +channelArchiveConfirmDescription: "ช่องที่ถูกเก็บถาวรแล้วนั้นจะไม่ปรากฏในรายการช่องหรือผลการค้นหานั้นอีกต่อไปไม่สามารถเพิ่มโพสต์ใหม่ได้อีกต่อไปนะ" +thisChannelArchived: "ช่องนี้ถูกเก็บถาวรแล้วนะ" +displayOfNote: "การแสดงโน้ต" +initialAccountSetting: "ตั้งค่าโปรไฟล์" youFollowing: "ติดตามแล้ว" +preventAiLearning: "ปฏิเสธการใช้งาน ในการเรียนรู้ของเครื่อง (Generative AI)" +preventAiLearningDescription: "การส่งคำร้องขอโปรแกรมรวบรวมข้อมูลไม่ให้ใช้ข้อความที่โพสต์หรือรูปภาพ ฯลฯ ในชุดข้อมูลแมชชีนเลิร์นนิง (Predictive / Generative AI) สิ่งนี้นั้นทำได้โดยการเพิ่มแฟล็กการตอบสนอง \"noai\" HTML ให้กับเนื้อหาที่เกี่ยวข้อง แต่อย่างไรก็ตามแล้ว การป้องกันโดยสมบูรณ์นั้นไม่สามารถทำได้ผ่านแฟล็กนี้เนื่องจากอาจจะทำให้ถูกเพิกเฉยได้" options: "ตัวเลือกบทบาท" +specifyUser: "ผู้ใช้เฉพาะ" +failedToPreviewUrl: "ไม่สามารถดูตัวอย่างได้" +update: "อัปเดต" +rolesThatCanBeUsedThisEmojiAsReaction: "บทบาทที่สามารถใช้อิโมจินี้เป็นรีแอคชั่นได้" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ถ้าหากไม่ได้ระบุบทบาท ทุกคนนั้นก็สามารถใช้อิโมจินี้เป็นการแสดงความรู้สึกได้นะ" +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "บทบาทเหล่านี้ต้องเป็นสาธารณะ" +cancelReactionConfirm: "ต้องการลบรีแอคชั่นของคุณจริงๆหรอ?" +changeReactionConfirm: "ต้องการเปลี่ยนรีแอคชั่นของคุณจริงๆหรอ?" +later: "ไว้ทีหลัง" +goToMisskey: "ถึง Misskey" +additionalEmojiDictionary: "พจนานุกรมอีโมจิเพิ่มเติม" +installed: "ติดตั้งแล้ว" +branding: "แบรนดิ้ง" +enableServerMachineStats: "เผยแพร่สถานะฮาร์ดแวร์ของเซิร์ฟเวอร์" +enableIdenticonGeneration: "เปิดใช้งานผู้ใช้สร้างตัวระบุ" +turnOffToImprovePerformance: "การปิดส่วนนี้สามารถเพิ่มประสิทธิภาพได้" +createInviteCode: "สร้างคำเชิญ" +createWithOptions: "สร้างด้วยตัวเลือก" +createCount: "จำนวนการเชิญ" +inviteCodeCreated: "สร้างคำเชิญแล้ว" +inviteLimitExceeded: "คุณสร้างคำเชิญเกินถึงขีดจำกัดแล้วนะ" +createLimitRemaining: "ขีดจำกัดการเชิญ: {limit} ที่เหลืออยู่" +inviteLimitResetCycle: "ขีดจำกัดนี้จะถูกรีเซ็ตเป็น {limit} ที่ {time}." +expirationDate: "วันที่หมดอายุ" +noExpirationDate: "ไม่มีหมดอายุ" +inviteCodeUsedAt: "รหัสคำเชิญใช้แล้วที่" +registeredUserUsingInviteCode: "ใช้คำเชิญแล้วโดย" +waitingForMailAuth: "กำลังรอการยืนยันอีเมล" +inviteCodeCreator: "สร้างการเชิญแล้วโดย" +usedAt: "ใช้แล้วที่" +unused: "ไม่ใช้แล้ว" +used: "ใช้แล้ว" +expired: "หมดอายุแล้ว" +_initialAccountSetting: + accountCreated: "คุณได้สร้างบัญชีของคุณสำเร็จเรียบร้อยแล้ว!" + letsStartAccountSetup: "สำหรับผู้เริ่มต้นมาตั้งค่าโปรไฟล์ของคุณกันเถอะ" + letsFillYourProfile: "ก่อนอื่นมาตั้งค่าโปรไฟล์ของคุณ" + profileSetting: "ตั้งค่าโปรไฟล์" + privacySetting: "ตั้งค่าความเป็นส่วนตัว" + theseSettingsCanEditLater: "คุณสามารถเปลี่ยนการตั้งค่าเหล่านี้ได้ในภายหลังได้ตลอดเวลานะ" + youCanEditMoreSettingsInSettingsPageLater: "ยังมีการตั้งค่าอื่นๆ อีกมากมายที่คุณนั้นสามารถกำหนดค่าได้จาก \"การตั้งค่า\" เพื่อให้แน่ใจว่าได้เยี่ยมชมมันได้ภายหลังนะ" + followUsers: "ลองติดตามผู้ใช้บางคนที่คุณอาจจะสนใจเพื่อสร้างไทม์ไลน์ของคุณสิ !" + pushNotificationDescription: "กำลังเปิดใช้งานการแจ้งเตือนแบบพุชจะช่วยให้คุณได้รับการแจ้งเตือนจาก {name} โดยตรงบนอุปกรณ์ของคุณนะ" + initialAccountSettingCompleted: "ตั้งค่าโปรไฟล์เสร็จสมบูรณ์แล้ว!" + haveFun: "สนุกกับ {name}!" + ifYouNeedLearnMore: "ถ้าหากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับวิธีใช้ {ชื่อ} (Misskey) กรุณาไปที่ {link}" + skipAreYouSure: "ต้องการข้ามการตั้งค่าโปรไฟล์จริงๆแบบนั้นหรอ?" + laterAreYouSure: "ต้องการตั้งค่าโปรไฟล์ในภายหลังจริงๆอย่างงั้นหรอ?" _serverRules: description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ" _accountMigration: moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง" moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น" moveFromLabel: "บัญชีที่จะย้ายจาก:" - moveFromDescription: "สร้างนามแฝงสำหรับบัญชีที่จะย้ายจากบัญชีนี้ ถ้าหากคุณต้องการโอนผู้ติดตาม สิ่งนี้ต้องทำก่อนโอนก่อนนะค่ะ! หลังจากนั้น ป้อนบัญชีที่จะย้ายไปในรูปแบบต่อไปนี้: @person@instance.com" + moveFromDescription: "ถ้าหากคุณต้องการโอนข้อมูล คุณจำเป็นต้องสร้างบัญชีสำรองสำหรับการย้ายบัญชี หลังจากนั้นป้อนบัญชีที่จะย้ายไปในรูปแบบต่อไปนี้: @person@instance.com" moveTo: "ย้ายข้อมูลบัญชีนี้ไปยังบัญชีอีกหนึ่ง" moveToLabel: "บัญชีที่จะย้ายไปที่:" moveCannotBeUndone: "ไม่สามารถยกเลิกการโอนย้ายบัญชีได้" moveAccountDescription: "การกระทำนี้ไม่สามารถย้อนกลับได้นะ ขั้นตอนแรก ต้องสร้างนามแฝงสำหรับบัญชีนี้ในบัญชีที่คุณต้องการย้ายไป หลังจากนั้นแล้ว ป้อนบัญชีที่จะย้ายไปในรูปแบบดังต่อไปนี้: @person@instance.com" moveAccountHowTo: "หากต้องการย้ายข้อมูลก่อนอื่นให้สร้างชื่อแทนสำหรับบัญชีนี้ ในบัญชีที่จะต้องการย้ายไป\nหลังจากที่คุณสร้างนามแฝงนั้นแล้ว ให้ป้อนบัญชีที่ต้องการจะย้ายไปในรูปแบบดังต่อไปนี้: @username@server.example.com" startMigration: "โอนย้าย" - migrationConfirm: "ย้ายข้อมูลบัญชีนี้ไปที่ {account} จริงๆนะ เมื่อมีการเริ่มต้นแล้ว กระบวนการนี้จะไม่สามารถหยุดหรือนำกลับคืนมาได้ และคุณจะไม่สามารถใช้บัญชีนี้ในสถานะดั้งเดิมได้อีกต่อไป\n\nนอกจากนี้ เพื่อให้แน่ใจยืนยันว่าคุณได้สร้างนามแฝงในบัญชีที่จะย้ายข้อมูลนะค่ะ" + migrationConfirm: "ยืนยันการย้ายข้อมูลบัญชีนี้ไปที่ {account} เมื่อเริ่มแล้วจะไม่สามารถหยุดหรือนำกลับคืนมาได้ และคุณจะไม่สามารถใช้บัญชีนี้ในสถานะดั้งเดิมได้อีกต่อไป\n\nนอกจากนี้ คุณจำเป็นต้องสร้างบัญชีสำรองสำหรับการย้ายบัญชี" movedAndCannotBeUndone: "\nบัญชีนี้ถูกโอนย้ายไปแล้ว\nไม่สามารถย้อนกลับโอนย้ายข้อมูลได้" postMigrationNote: "บัญชีนี้จะถูกเลิกติดตามบัญชีทั้งหมดที่กำลังติดตามภายใน 24 ชั่วโมงหลังจากการย้ายข้อมูลนั้นเสร็จสิ้น ทั้งจำนวนผู้ติดตามและผู้ติดตามนั้นจะกลายเป็นศูนย์ เพื่อหลีกเลี่ยงป้องกันไม่ให้ผู้ติดตามของคุณนั้นไม่สามารถเห็นโพสต์เฉพาะผู้ติดตามของบัญชีนี้ได้ แต่อย่างไรก็ตามแล้วพวกเขาจะยังคงติดตามบัญชีนี้ต่อไป" movedTo: "บัญชีที่จะย้ายไปที่:" @@ -1223,7 +1295,7 @@ _achievements: title: "พักผ่อนสักหน่อย" description: "ใช้เวลา 30 นาทีบน Misskey" _client60min: - title: "ไม่มี \"Miss\" ใน Misskey นะค่ะ !" + title: "ไม่พบ \"Miss\" ใน Misskey " description: "เปิด Misskey ค้างไว้แล้วอย่างน้อย 60 นาที" _noteDeletedWithin1min: title: "ไม่เป็นไร" @@ -1329,6 +1401,9 @@ _role: ltlAvailable: "การดูไทม์ไลน์ในท้องถิ่น" canPublicNote: "สามารถส่งโน้ตสาธารณะ" canInvite: "สร้างรหัสเชิญอินสแตนซ์" + inviteLimit: "จำกัดการเชิญ" + inviteLimitCycle: "จำกัดการเชิญไว้คูลดาวน์" + inviteExpirationTime: "วันหมดอายุของรหัสการเชิญ" canManageCustomEmojis: "จัดการอีโมจิแบบกำหนดเอง" driveCapacity: "ความจุของไดรฟ์" alwaysMarkNsfw: "ทำเครื่องหมายไฟล์ว่าเป็น NSFW เสมอ" @@ -1391,6 +1466,7 @@ _ad: back: "ย้อนกลับ" reduceFrequencyOfThisAd: "แสดงโฆษณานี้ให้น้อยลง" hide: "ไม่ต้องแสดง" + timezoneinfo: "วันในสัปดาห์นี้จะถูกกำหนดจากโซนเวลาของเซิร์ฟเวอร์" _forgotPassword: enterEmail: "ป้อนที่อยู่อีเมลที่คุณเคยใช้ในการลงทะเบียนไว้ ลิงก์ที่คุณสามารถรีเซ็ตรหัสผ่านได้นั้นจะถูกส่งไปนะ" ifNoEmail: "ถ้าหากคุณไม่ได้ใช้อีเมลระหว่างการลงทะเบียน กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์แทนนะ" @@ -1442,9 +1518,9 @@ _aboutMisskey: donate: "บริจาคให้กับ Misskey" morePatrons: "เราขอขอบคุณสำหรับความช่วยเหลือจากผู้ช่วยอื่นๆ ที่ไม่ได้ระบุไว้ที่นี่นะ ขอขอบคุณ! 🥰" patrons: "สมาชิกพันธมิตร" -_nsfw: - respect: "ซ่อนสื่อ NSFW" - ignore: "อย่าซ่อนสื่อ NSFW" +_displayOfSensitiveMedia: + respect: "ซ่อนสื่อทำเครื่องหมายบอกว่าละเอียดอ่อน" + ignore: "แสดงผลสื่อทำเครื่องหมายบอกว่าละเอียดอ่อน" force: "ซ่อนสื่อทั้งหมด" _instanceTicker: none: "ไม่ต้องแสดง" @@ -1585,9 +1661,13 @@ _time: day: "วัน" _timelineTutorial: title: "วิธีใช้งาน Misskey" + step1_1: "นี่คือ \"ไทม์ไลน์\" \"โน้ต\" ทั้งหมดที่ส่งใน {name} จะแสดงรายการตามลำดับเวลาที่นี่นะ" + step1_2: "อาจจะมีไทม์ไลน์ที่แตกต่างกันเล็กน้อยยกตัวอย่างเช่น \"ไทม์ไลน์หน้าแรก\" จะมีโน้ตของผู้ใช้ที่คุณติดตามและ \"ไทม์ไลน์ท้องถิ่น\" จะมีโน้ตจากผู้ใช้ทั้งหมดของ {name}" + step2_1: "มาลองโพสต์โน้ตต่อไปกัน คุณสามารถทำได้โดยการกดปุ่มที่มีไอคอนดินสอ" + step2_2: "ยังไงไหนลองเขียนแนะนำตัวเองหรือแค่ \"สวัสดี {name}!\" ถ้าคุณไม่รู้สึกเหมือนมัน?" step3_1: "เสร็จสิ้นการโพสต์โน้ตย่อแรกของคุณแล้วอย่างงั้นหรอ?" step3_2: "ไชโย! ตอนนี้โน้ตย่อแรกของคุณได้ปรากฏบนไทม์ไลน์ของคุณแล้วนะ" - step4_1: "คุณยังสามารถแนบ \"ปฏิกิริยา\" ไปกับโน้ตได้อีกด้วยนะค่ะ" + step4_1: "คุณสามารถเพิ่ม \"การตอบสนอง\" ในโน้ตได้" step4_2: "หากต้องการแนบการแสดงความรู้สึก ให้กดเครื่องหมาย \"+\" บนโน้ตแล้วเลือกอิโมจิที่คุณต้องการแสดงความรู้สึกที่ตนเองชอบได้เลย" _2fa: alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว" @@ -1916,6 +1996,7 @@ _deck: introduction: "สร้างอินเทอร์เฟซที่สมบูรณ์แบบสำหรับคุณโดยจัดเรียงคอลัมน์ได้อย่างอิสระ!" introduction2: "คลิกที่เครื่องหมาย + ทางขวาของหน้าจอเพื่อเพิ่มคอลัมน์ใหม่ทุกครั้งที่คุณต้องการ" widgetsIntroduction: "กรุณาเลือก \"แก้ไขวิดเจ็ต\" ในเมนูคอลัมน์และเพิ่มวิดเจ็ต" + useSimpleUiForNonRootPages: "แสดง UI ของ Root Page อย่างง่าย " _columns: main: "หลัก" widgets: "วิดเจ็ต" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index cc402eec4..42cc7da3f 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -1,5 +1,6 @@ --- _lang_: "Türkçe" +headlineMisskey: "Notlarla bağlanmış bir ağ" introMisskey: "Açık kaynaklı bir dağıtılmış mikroblog hizmeti olan Misskey'e hoş geldiniz.\nMisskey, neler olup bittiğini paylaşmak ve herkese sizden bahsetmek için \"notlar\" oluşturmanıza olanak tanıyan, açık kaynaklı, dağıtılmış bir mikroblog hizmetidir.\nHerkesin notlarına kendi tepkilerinizi hızlıca eklemek için \"Tepkiler\" özelliğini de kullanabilirsiniz👍.\nYeni bir dünyayı keşfedin🚀." poweredByMisskeyDescription: "name}Açık kaynak bir platform\nMisskeyDünya'nın en sunucularında biri。" monthAndDay: "{month}Ay {day}Gün" @@ -11,7 +12,9 @@ forgotPassword: "şifremi unuttum" ok: "TAMAM" gotIt: "Anladım" cancel: "İptal" +noThankYou: "Hayır, teşekkürler" enterUsername: "Kullanıcı adınızı giriniz" +renotedBy: "{user} tarafından Renotelandı" noNotes: "Notlar mevcut değil." noNotifications: "Bildirim bulunmuyor" instance: "Sunucu" @@ -45,15 +48,35 @@ delete: "Sil" deleteAndEdit: "Sil ve yeniden düzenle" deleteAndEditConfirm: "Bu notu silip yeniden düzenlemek istiyor musunuz? Bu nota ilişkin tüm Tepkiler, Yeniden Notlar ve Yanıtlar da silinecektir." addToList: "Listeye ekle" +addToAntenna: "Antene ekle" sendMessage: "Mesaj Gönder" copyRSS: "RSSKopyala" copyUsername: "Kullanıcı Adını Kopyala" copyUserId: "KullanıcıyıKopyala" copyNoteId: "Kimlik notunu kopyala" +copyFileId: "Dosya ID'sini kopyala" +copyFolderId: "Klasör ID'sini kopyala" +copyProfileUrl: "Profil URL'sini kopyala" searchUser: "Kullanıcıları ara" reply: "yanıt" loadMore: "Devamını yükle" showMore: "Devamını yükle" +showLess: "Kapat" +youGotNewFollower: "seni takip etti" +receiveFollowRequest: "Takip isteği alındı" +followRequestAccepted: "Takip isteği kabul edildi" +mention: "Bahset" +mentions: "Bahsetmeler" +directNotes: "Kişisel mesajlar" +importAndExport: "İçeri/Dışarı aktar" +import: "İçeri aktar" +export: "Dışa aktar" +files: "Dosyalar" +download: "İndir" +driveFileDeleteConfirm: "\"{name}\" dosyası silinsin mi? Dosya kullanıldığı tüm notlardan kaybolacaktır." +unfollowConfirm: "{name} takipten çıkarılsın mı?" +exportRequested: "Dışa aktarım talep ettiniz. Bu biraz zaman alabilir. İşlem bitince Sürücünüze eklenecektir." +importRequested: "Dışa aktarım talep ettiniz. Bu işlem biraz zaman alabilir." lists: "Listeler" noLists: "Liste yok" note: "not" @@ -64,6 +87,16 @@ followsYou: "seni takip ediyor" createList: "Liste oluştur" manageLists: "Yönetici Listeleri" error: "hata" +somethingHappened: "Bir hata oluştu" +retry: "Tekrar dene" +pageLoadError: "Sayfa yüklenemedi." +pageLoadErrorDescription: "Bu genelde ağ veya tarayıcı ön belleği hatalarından olur. Lütfen ön belleği temizlemeyi veya birkaç dakika beklemeyi ve sayfayı yenilemeyi deneyin." +serverIsDead: "Sunucu yanıt vermiyor. Birkaç dakika sonra tekrar deneyin." +youShouldUpgradeClient: "Sayfayı görüntülemek için yenileyin." +enterListName: "Liste ismi" +privacy: "Gizlilik" +makeFollowManuallyApprove: "Takip istekleri elle onaylansın" +defaultNoteVisibility: "Varsayılan görünürlük" follow: "takipçi" followRequest: "Takip isteği" followRequests: "Takip istekleri" @@ -76,9 +109,24 @@ renoted: "yeniden adlandırılmış" cantRenote: "Ayrılamama" cantReRenote: "not alabilirmiyim" quote: "alıntı" +inChannelRenote: "Kanal içi Renote" +inChannelQuote: "Kanal içi Alıntı" pinnedNote: "Sabitlenen" pinned: "Sabitlenmiş" you: "sen" +clickToShow: "Görüntülemek için tıkla" +sensitive: "Hassas içerik" +add: "Ekle" +reaction: "Tepkiler" +reactions: "Tepkiler" +reactionSetting: "Palette görünecek tepkiler" +reactionSettingDescription2: "Sıralamak için sürükleyin, silmek için tıklayın, eklemek için \"+\" tuşuna tıklayın." +rememberNoteVisibility: "Görünürlük ayarlarını hatırla" +attachCancel: "Eki sil" +markAsSensitive: "Hassas içerik olarak işaretle" +unmarkAsSensitive: "Hassas içerik işaretini kaldır" +enterFileName: "Dosya ismini gir" +mute: "Gizle" unmute: "sesi aç" renoteMute: "sesi kapat" renoteUnmute: "sesi açmayı iptal et" @@ -88,46 +136,280 @@ suspend: "askıya al" unsuspend: "askıya alma" blockConfirm: "Onayı engelle" unblockConfirm: "engellemeyi kaldır onayla" +suspendConfirm: "Hesap askıya alınsın mı?" +unsuspendConfirm: "Hesap askıdan kaldırılsın mı" +selectList: "Bir liste seç" +editList: "Listeyi düzenle" selectChannel: "Kanal seç" +selectAntenna: "Bir anten seç" +editAntenna: "Anteni düzenle" +selectWidget: "Araç seç" +editWidgets: "Araçları düzenle" +editWidgetsExit: "Tamam" +customEmojis: "Özel Emoji" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Emoji adı" +emojiUrl: "Emoji URL'si" +addEmoji: "Emoji ekle" +settingGuide: "Önerilen ayarlar" +cacheRemoteFiles: "Uzak dosyalar ön belleğe alınsın" +cacheRemoteFilesDescription: "Bu ayar açık olduğunda diğer sitelerin dosyaları doğrudan uzak sunucudan yüklenecektir. Bu ayarı kapatmak depolama kullanımını azaltacak ama küçük resimler oluşturulmadığından trafiği arttıracaktır." +cacheRemoteSensitiveFiles: "Hassas uzak dosyalar ön belleğe alınsın" +cacheRemoteSensitiveFilesDescription: "Bu ayar kapalı olduğunda hassas uzak dosyalar ön belleğe alınmadan doğrudan uzak sunucudan yüklenecektir." flagAsBot: "Bot olarak işaretle" +flagAsBotDescription: "Bu seçeneği hesap bir program tarafından kontrol ediliyorsa işaretleyin. Bu, diğer geliştiricilerin sonsuz etkileşim zincirleri oluşturmasını engellemeye yardımcı olur ve Misskey'in iç sisteminin hesaba bir bot gibi davranmasını sağlar." +flagAsCat: "Kedi hesabı" +flagAsCatDescription: "Kedi hesabı" +flagShowTimelineReplies: "Zaman akışında notlara gelen cevapları göster" +flagShowTimelineRepliesDescription: "Açık olduğu durumda, zaman akışında kullanıcıların başkalarına verdiği cevaplar gözükür." +autoAcceptFollowed: "Takip edilen hesapların takip isteklerini kabul et" +addAccount: "Hesap ekle" +reloadAccountsList: "Hesap listesini güncelle" +loginFailed: "Giriş başarısız oldu" +showOnRemote: "Uzak sunucuda görüntüle" +general: "Genel" +wallpaper: "Duvar kağıdı" +setWallpaper: "Duvar kağıdını ayarla" +removeWallpaper: "Duvar kağıdını sil" +searchWith: "Arama: {q}" +youHaveNoLists: "Hiç listeniz yok" +followConfirm: "{name} takip edilsin mi?" +proxyAccount: "Vekil hesabı" +proxyAccountDescription: "Proxy hesabı, belirli koşullar altında kullanıcılar için uzaktan takipçi işlevi gören bir hesaptır. Örneğin, bir kullanıcı listeye bir uzak kullanıcı eklediğinde, o kullanıcıyı takip eden yerel bir kullanıcı yoksa uzak kullanıcının etkinliği örneğe teslim edilmeyecektir, dolayısıyla bunun yerine proxy hesabı takip edilecektir." +host: "Sağlayıcı" +selectUser: "Kullanıcı seç" +recipient: "Kime" +annotation: "Açıklamalar" +federation: "Federasyon" instances: "Sunucu" +registeredAt: "Katılma tarihi" +latestRequestReceivedAt: "Alınan son talep" +latestStatus: "En son durum" +storageUsage: "Depolama kullanımı" +charts: "Çizelgeler" +perHour: "Saatlik" +perDay: "Günlük" +stopActivityDelivery: "Durum güncellemelerini gönderme" +blockThisInstance: "Bu sunucuyu engelle" +operations: "İşlemler" +software: "Yazılımlar" +version: "Sürüm" +metadata: "Meta Verileri" +withNFiles: "{n} tane dosya" +monitor: "Monitör" +jobQueue: "İşlem sırası" +cpuAndMemory: "İşlemci ve Hafıza" +network: "Ağ" +disk: "Disk" +instanceInfo: "Sunucu Bilgisi" +statistics: "İstatistikler" +clearQueue: "Sırayı temizle" +clearQueueConfirmTitle: "Sıra silinsin mi?" +clearQueueConfirmText: "Sırada kalan hiçbir şey iletilmeyecek. Genelde bu işlem gerekli değildir." +clearCachedFiles: "Ön belleği temizle" +clearCachedFilesConfirm: "Ön belleğe alınmış tüm uzak sunucu dosyaları silinsin mi?" +blockedInstances: "Engellenen sunucular" +blockedInstancesDescription: "Engellemek istediğiniz sunucuların alan adlarını satır sonlarıyla ayırarak yazın. Yazılan sunucular bu sunucuyla iletişime geçemeyecek." +muteAndBlock: "Susturma ve Engelleme" +mutedUsers: "Susturulan kullanıcılar" +blockedUsers: "Engellenen kullanıcılar" +noUsers: "Kullanıcı yok" +editProfile: "Profili düzenle" +noteDeleteConfirm: "Bu notu silmek istediğinizden emin misiniz?" +pinLimitExceeded: "Daha fazla not sabitlenemez" +intro: "Misskey yüklemesi tamamlandı! Lütfen yönetici hesabını oluşturun." +done: "Tamamlandı" +preview: "Önizleme" +default: "Varsayılan" +defaultValueIs: "Varsayılan: {value}" +noCustomEmojis: "Emoji bulunamadı" +noJobs: "Hiç işlem yok" +federating: "Federe ediliyor" +blocked: "Engellenmiş" +suspended: "Askıya alınmış" +all: "Tümü" +subscribing: "Abonelik" +publishing: "Paylaşım" +notResponding: "Cevap yok" +instanceFollowing: "Sunucuda takip edenler" +instanceFollowers: "Sunucu takipçileri" +instanceUsers: "Sunucu kullanıcıları" +changePassword: "Şifreyi değiştir" +security: "Güvenlik" +retypedNotMatch: "Girişler uyuşmuyor." +currentPassword: "Geçerli şifre" +newPassword: "Yeni şifre" +newPasswordRetype: "Yeni şifre (tekrar)" +attachFile: "Dosya ekle" +more: "Daha!" +featured: "Öne Çıkan" +usernameOrUserId: "Kullanıcı adı veya ID'si" +noSuchUser: "Kullanıcı bulunamadı" +lookup: "Sorgu" +announcements: "Duyurular" +imageUrl: "Görsel URL'si" remove: "Sil" +removed: "Silindi" +removeAreYouSure: "\"{x}\" silmek istediğinizden emin misiniz?" +deleteAreYouSure: "\"{x}\" silmek istediğinizden emin misiniz?" +resetAreYouSure: "Sıfırlansın mı?" +saved: "Kaydedildi" +messaging: "Mesajlar" +upload: "Yükle" +keepOriginalUploading: "Orijinal görseli koru" +keepOriginalUploadingDescription: "Orijinal olarak yüklenen görüntüyü olduğu gibi kaydeder. Kapatılırsa, yükleme sırasında web'de görüntülenecek bir sürüm oluşturulur." +fromUrl: "Bağlantıdan" +uploadFromUrl: "Bağlantıdan yükle" +uploadFromUrlDescription: "Yüklemek istediğiniz dosyanın bağlantısı" +uploadFromUrlRequested: "Yükleme talep edildi" +uploadFromUrlMayTakeTime: "Yüklemenin tamamlanması biraz süre alabilir." +explore: "Keşfet" +messageRead: "Okundu" +noMoreHistory: "Bundan öncesi yok" +startMessaging: "Yeni bir sohbet başlat" +nUsersRead: "{n} kişi okudu" +agreeTo: "Kabul Ediyorum: {0}" +agree: "Kabul Et" +agreeBelow: "Aşağıdakileri kabul ederim" +basicNotesBeforeCreateAccount: "Önemli notlar" +termsOfService: "Şartlar ve Koşullar" +start: "Başla" +home: "Ana sayfa" +remoteUserCaution: "Bu kullanıcı bir uzak sunucudan olduğu için alınan bilgiler tam olmayabilir." +activity: "Etkinlik" +images: "Görseller" +image: "Görseller" +birthday: "Doğum günü" +yearsOld: "{age} yaşında" +registeredDate: "Kayıt tarihi" +location: "Konum" +theme: "Temalar" +themeForLightMode: "Aydınlık Tema" +themeForDarkMode: "Karanlık Tema" +light: "Aydınlık" +dark: "Karanlık" +lightThemes: "Aydınlık Temalar" +darkThemes: "Karanlık Temalar" +syncDeviceDarkMode: "Sistem Koyu Modu ile senkronize et" +drive: "Sürücü" +fileName: "Dosya adı" +selectFile: "Dosya seç" +selectFiles: "Dosya seç" +selectFolder: "Klasör seç" +selectFolders: "Klasör seç" +renameFile: "Dosyayı yeniden adlandır" +folderName: "Klasör adı" +createFolder: "Klasör oluştur" +renameFolder: "Klasörü Yeniden Adlandır" +deleteFolder: "Klasörü sil" +addFile: "Dosya ekle" +emptyDrive: "Sürücü boş" +hasChildFilesOrFolders: "Klasör boş olmadığından silinemiyor" +doNothing: "Bir şey yapma" +reloadConfirm: "Zaman akışı yenilensin mi?" +maintainerName: "Yönetici ismi" +monthX: "{month} ay" +enableRegistration: "Kayıtlara izin ver" pinnedNotes: "Sabitlenen" +manageAntennas: "Anten ayarları" userList: "Listeler" +resetPassword: "Şifre sıfırlama" +noMessagesYet: "Şimdilik mesaj yok" +details: "Detaylar" +deck: "Güverte" +smtpHost: "Sağlayıcı" smtpUser: "Kullanıcı Adı" smtpPass: "Şifre" +notificationSetting: "Bildirim ayarları" +noCrawleDescription: "Arama motorlarından profilinde, notlarında, sayfalarında vb. dolaşılmamasını ve dizine eklememesini talep et." +clearCache: "Ön belleği temizle" +onlineUsersCount: "{n} kullanıcı çevrim içi" user: "Kullanıcı" +global: "Küresel" +squareAvatars: "Kare avatarlar" searchByGoogle: "Arama" +file: "Dosyalar" +pushNotification: "Push bildirimleri" +subscribePushNotification: "Push bildirimlerini etkinleştir" +unsubscribePushNotification: "Push bildirimlerini kapat" +pushNotificationAlreadySubscribed: "Push bildirimleri zaten açık" +pushNotificationNotSupported: "Push bildirimleri sunucu veya tarayıcı tarafından desteklenmiyor" +noRole: "Rol bulunamadı" +color: "Renk" +addMemo: "Kısa not ekle" +_accountDelete: + started: "Silme işlemi başlatıldı" +_email: + _follow: + title: "seni takip etti" _theme: + color: "Renk" keys: + mention: "Bahset" renote: "vazgeçme" _sfx: note: "notlar" notification: "Bildirim" + chat: "Mesajlar" +_2fa: + renewTOTPCancel: "Hayır, teşekkürler" +_permissions: + "read:blocks": "Engellenen hesapları gör" + "write:blocks": "Engellenen hesap listesini düzenle" _widgets: profile: "Profil" + instanceInfo: "Sunucu Bilgisi" notifications: "Bildirim" timeline: "Zaman çizelgesi" + calendar: "Takvim" + clock: "Saat" + activity: "Etkinlik" + federation: "Federasyon" + jobQueue: "İşlem sırası" + _userList: + chooseList: "Bir liste seç" _cw: show: "Devamını yükle" +_poll: + vote: "Oy kullan" _visibility: + publicDescription: "Herkese açık" + home: "Ana sayfa" followers: "takipçi" _profile: username: "Kullanıcı Adı" _exportOrImport: followingList: "takipçi" + muteList: "Gizle" blockingList: "engelle" userLists: "Listeler" +_charts: + federation: "Federasyon" +_timelines: + home: "Ana sayfa" + global: "Küresel" +_pages: + blocks: + image: "Görseller" _notification: + youWereFollowed: "seni takip etti" + unreadAntennaNote: "{name} anteni" _types: follow: "takipçi" + mention: "Bahset" renote: "vazgeçme" quote: "alıntı" + reaction: "Tepkiler" + receiveFollowRequest: "Takip isteği alındı" + followRequestAccepted: "Takip isteği kabul edildi" _actions: reply: "yanıt" renote: "vazgeçme" _deck: + configureColumn: "Sütun seçenekleri" _columns: notifications: "Bildirim" tl: "Zaman çizelgesi" list: "Listeler" + mentions: "Bahsetmeler" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 4f215bc98..1ac07ff9b 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -300,7 +300,6 @@ copyUrl: "Копіювати URL" rename: "Перейменувати" avatar: "Аватар" banner: "Банер" -nsfw: "NSFW" whenServerDisconnected: "Коли зв’язок із сервером втрачено" disconnectedFromServer: "Зв’язок із сервером було перервано" reload: "Оновити" @@ -1200,10 +1199,6 @@ _aboutMisskey: donate: "Пожертвувати Misskey" morePatrons: "Ми дуже цінуємо підтримку багатьох інших помічників, не перелічених тут. Дякуємо! 🥰" patrons: "Підтримали" -_nsfw: - respect: "Приховувати NSFW медіа" - ignore: "Не приховувати NSFW медіа" - force: "Приховувати всі медіа файли" _instanceTicker: none: "Не відображати" remote: "Відображати для віддалених користувачів" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index d620d99d8..898c478bf 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -303,7 +303,6 @@ copyUrl: "Sao chép URL" rename: "Đổi tên" avatar: "Ảnh đại diện" banner: "Ảnh bìa" -nsfw: "Nhạy cảm" whenServerDisconnected: "Khi mất kết nối tới máy chủ" disconnectedFromServer: "Mất kết nối tới máy chủ" reload: "Tải lại" @@ -1212,10 +1211,6 @@ _aboutMisskey: donate: "Ủng hộ Misskey" morePatrons: "Chúng tôi cũng trân trọng sự hỗ trợ của nhiều người đóng góp khác không được liệt kê ở đây. Cảm ơn! 🥰" patrons: "Người ủng hộ" -_nsfw: - respect: "Ẩn nội dung NSFW" - ignore: "Hiện nội dung NSFW" - force: "Ẩn mọi media" _instanceTicker: none: "Không hiển thị" remote: "Hiện cho người dùng từ máy chủ khác" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 313c254c7..d1b11a354 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -49,11 +49,15 @@ delete: "删除" deleteAndEdit: "删除并编辑" deleteAndEditConfirm: "要删除此帖并再次编辑吗?对此帖的所有回应、转发和回复也将被删除。" addToList: "添加至列表" +addToAntenna: "添加到天线" sendMessage: "发送" copyRSS: "复制RSS" copyUsername: "复制用户名" -copyUserId: "复制用户ID" -copyNoteId: "复制帖子ID" +copyUserId: "复制用户 ID" +copyNoteId: "复制帖子 ID" +copyFileId: "复制文件ID" +copyFolderId: "复制文件夹ID" +copyProfileUrl: "复制配置文件URL" searchUser: "搜索用户" reply: "回复" loadMore: "查看更多" @@ -71,7 +75,7 @@ export: "导出" files: "文件" download: "下载" driveFileDeleteConfirm: "要删除「{name}」文件吗?附加此文件的帖子也会被删除。" -unfollowConfirm: "要取消对{name}的关注吗?" +unfollowConfirm: "要取消对 {name} 的关注吗?" exportRequested: "导出请求已提交,这可能需要花一些时间,导出的文件将保存到网盘中。" importRequested: "导入请求已提交,这可能需要花一点时间。" lists: "列表" @@ -136,8 +140,10 @@ unblockConfirm: "确定要解除拉黑吗?" suspendConfirm: "要冻结吗?" unsuspendConfirm: "要解除冻结吗?" selectList: "选择列表" +editList: "编辑列表" selectChannel: "选择频道" selectAntenna: "选择天线" +editAntenna: "编辑天线" selectWidget: "选择小工具" editWidgets: "编辑部件" editWidgetsExit: "完成编辑" @@ -150,8 +156,10 @@ addEmoji: "添加表情符号" settingGuide: "推荐配置" cacheRemoteFiles: "缓存远程文件" cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" +cacheRemoteSensitiveFiles: "缓存远程敏感媒体文件" +cacheRemoteSensitiveFilesDescription: "如果禁用这项设定,远程服务器的敏感媒体将不会被缓存,而是直接链接。" flagAsBot: "这是一个机器人账号" -flagAsBotDescription: "如果此账户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此账户识别为机器人。" +flagAsBotDescription: "如果此账户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让 Misskey 的内部系统将此账户识别为机器人。" flagAsCat: "将这个账户设定为一只猫" flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。\n开启后,会在您的头像上出现猫耳朵,并将你的帖子中的「na」替换为「nya」,日文同理。" flagShowTimelineReplies: "在时间线上显示帖子的回复" @@ -167,7 +175,7 @@ setWallpaper: "设置壁纸" removeWallpaper: "移除壁纸" searchWith: "搜索:{q}" youHaveNoLists: "列表为空" -followConfirm: "你确定要关注{name}吗?" +followConfirm: "你确定要关注 {name} 吗?" proxyAccount: "代理账户" proxyAccountDescription: "代理账户是在某些情况下充当用户的远程关注者的账户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该服务器,因此将代之以代理账户。" host: "主机名" @@ -189,7 +197,7 @@ operations: "操作" software: "软件" version: "版本" metadata: "元数据" -withNFiles: "{n}个文件" +withNFiles: "{n} 个文件" monitor: "服务器状态" jobQueue: "作业队列" cpuAndMemory: "CPU和内存" @@ -199,11 +207,11 @@ instanceInfo: "服务器信息" statistics: "统计" clearQueue: "清除队列" clearQueueConfirmTitle: "确定清除队列?" -clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需要这样做。" +clearQueueConfirmText: "未送达的帖子将不会投递。 通常,您不需要这样做。" clearCachedFiles: "清除缓存" clearCachedFilesConfirm: "确定要清除缓存文件?" -blockedInstances: "被阻拦的服务器" -blockedInstancesDescription: "设定要阻拦的服务器,以换行来进行分割。被阻拦的服务器将无法与本服务器进行交换通讯。" +blockedInstances: "被封锁的服务器" +blockedInstancesDescription: "设定要封锁的服务器,以换行来进行分割。被封锁的服务器将无法与本服务器进行交换通讯。" muteAndBlock: "屏蔽/拉黑" mutedUsers: "已屏蔽用户" blockedUsers: "已拉黑的用户" @@ -211,7 +219,7 @@ noUsers: "无用户" editProfile: "编辑资料" noteDeleteConfirm: "要删除该帖子吗?" pinLimitExceeded: "无法置顶更多了" -intro: "Misskey的部署结束啦!填写管理员账号吧!" +intro: "Misskey 的部署结束啦!创建管理员账号吧!" done: "完成" processing: "正在处理" preview: "预览" @@ -238,11 +246,11 @@ newPasswordRetype: "重新输入密码:" attachFile: "插入附件" more: "更多!" featured: "热门" -usernameOrUserId: "用户名或用户ID" +usernameOrUserId: "用户名或用户 ID" noSuchUser: "用户不存在" lookup: "查询" announcements: "公告" -imageUrl: "图片URL" +imageUrl: "图片 URL" remove: "删除" removed: "已删除" removeAreYouSure: "要删掉「{x}」吗?" @@ -256,15 +264,15 @@ keepOriginalUploadingDescription: "上传图片时保留原始图片。关闭时 fromDrive: "从网盘中" fromUrl: "从 URL" uploadFromUrl: "从网址上传" -uploadFromUrlDescription: "输入文件的URL" +uploadFromUrlDescription: "输入文件的 URL" uploadFromUrlRequested: "请求上传" uploadFromUrlMayTakeTime: "上传可能需要一些时间完成。" explore: "发现" messageRead: "已读" noMoreHistory: "没有更多的历史记录" startMessaging: "添加聊天" -nUsersRead: "{n}人已读" -agreeTo: "勾选则表示已阅读并同意{0}" +nUsersRead: "{n} 人已读" +agreeTo: "勾选则表示已阅读并同意 {0}" agree: "同意" agreeBelow: "同意以下内容" basicNotesBeforeCreateAccount: "基本注意事项" @@ -305,13 +313,13 @@ unableToDelete: "无法删除" inputNewFileName: "请输入新文件名" inputNewDescription: "请输入新标题" inputNewFolderName: "请输入新文件夹名" -circularReferenceFolder: "目标文件夹是您要移动的文件夹的子文件夹。" +circularReferenceFolder: "目标文件夹是要移动的文件夹的子文件夹。" hasChildFilesOrFolders: "此文件夹中有文件,无法删除。" copyUrl: "复制链接" rename: "重命名" avatar: "头像" banner: "横幅" -nsfw: "敏感内容" +displayOfSensitiveMedia: "显示敏感媒体" whenServerDisconnected: "与服务器连接中断时" disconnectedFromServer: "已和服务器断开连接" reload: "重新加载" @@ -326,7 +334,7 @@ instanceName: "服务器名称" instanceDescription: "服务器简介" maintainerName: "管理员名称" maintainerEmail: "管理员电子邮箱" -tosUrl: "服务条款URL" +tosUrl: "服务条款 URL" thisYear: "今年" thisMonth: "本月" today: "今天" @@ -337,49 +345,49 @@ pages: "页面" integration: "关联" connectService: "连接" disconnectService: "断开连接" -enableLocalTimeline: "启用本地时间线功能" +enableLocalTimeline: "启用本地时间线" enableGlobalTimeline: "启用全局时间线" -disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和数据图表也可以继续使用。" +disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和协作者也可以继续使用。" registration: "注册" enableRegistration: "允许任何人注册" invite: "邀请" -driveCapacityPerLocalAccount: "每个用户的网盘空间" +driveCapacityPerLocalAccount: "每个用户的网盘容量" driveCapacityPerRemoteAccount: "每个远程用户的网盘容量" inMb: "以兆字节(MegaByte)为单位" -iconUrl: "图标URL" -bannerUrl: "横幅URL" -backgroundImageUrl: "背景图URL" +iconUrl: "图标 URL" +bannerUrl: "横幅 URL" +backgroundImageUrl: "背景图 URL" basicInfo: "基本信息" pinnedUsers: "置顶用户" -pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。" +pinnedUsersDescription: "输入您想要固定到“发现”页面的用户,一行一个。" pinnedPages: "固定页面" -pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,以换行符分隔。" -pinnedClipId: "置顶的便签ID" +pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,一行一个。" +pinnedClipId: "置顶的便签 ID" pinnedNotes: "已置顶的帖子" hcaptcha: "hCaptcha" enableHcaptcha: "启用 hCaptcha" hcaptchaSiteKey: "网站密钥" -hcaptchaSecretKey: "密钥" +hcaptchaSecretKey: "hCaptcha 密钥(SecretKey)" recaptcha: "reCAPTCHA" enableRecaptcha: "启用 reCAPTCHA\n(请注意, 此功能在中国大陆不可用. 如果启用, 可能导致无法正常使用登录或注册等功能)" recaptchaSiteKey: "网站密钥" recaptchaSecretKey: "reCAPTCHA 密钥(SecretKey)" turnstile: "Turnstile" -enableTurnstile: "启用Turnstile" +enableTurnstile: "启用 Turnstile" turnstileSiteKey: "网站密钥" turnstileSecretKey: "Turnstile 密钥(SecretKey)" -avoidMultiCaptchaConfirm: "使用多种验证方式可能会造成干扰,您要禁用其他验证方式吗?您可以按“取消”按钮,仍然保持启用多种验证方式。" +avoidMultiCaptchaConfirm: "使用多种验证方式可能会造成干扰,您要禁用其他验证方式吗?您可以按“取消”按钮,继续保持启用多种验证方式。" antennas: "天线" manageAntennas: "天线管理" name: "名称" antennaSource: "接收来源" antennaKeywords: "包含关键字" antennaExcludeKeywords: "排除关键字" -antennaKeywordsDescription: "使用空格分隔会产生AND规范,并且使用换行符分隔会产生OR规范" +antennaKeywordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" notifyAntenna: "开启通知" withFileAntenna: "仅带有附件的帖子" -enableServiceworker: "启用ServiceWorker" -antennaUsersDescription: "指定用户名,用换行符分隔" +enableServiceworker: "启用 ServiceWorker" +antennaUsersDescription: "指定用户名,一行一个" caseSensitive: "区分大小写" withReplies: "包括回复" connectedTo: "您的账号已连到接以下第三方账号" @@ -393,7 +401,7 @@ popularUsers: "热门用户" recentlyUpdatedUsers: "最近投稿的用户" recentlyRegisteredUsers: "最近登录的用户" recentlyDiscoveredUsers: "最近发现的用户" -exploreUsersCount: "有{count}个用户" +exploreUsersCount: "有 {count} 个用户" exploreFediverse: "探索联邦宇宙" popularTags: "热门标签" userList: "列表" @@ -413,13 +421,13 @@ lastUsed: "最后使用:" lastUsedAt: "最后使用: {t}" unregister: "删除账户" passwordLessLogin: "无密码登录" -passwordLessLoginDescription: "不使用密码,仅使用安全密钥或Passkey登录" +passwordLessLoginDescription: "不使用密码,仅使用安全密钥或 Passkey 登录" resetPassword: "重置密码" newPasswordIs: "新的密码是「{password}」" reduceUiAnimation: "减少UI动画" share: "分享" notFound: "未找到" -notFoundDescription: "没有与指定URL对应的页面。" +notFoundDescription: "没有与指定 URL 对应的页面。" uploadFolder: "默认上传文件夹" cacheClear: "清空缓存" markAsReadAllNotifications: "将所有通知标为已读" @@ -436,7 +444,7 @@ text: "文本" enable: "启用" next: "下一个" retype: "重新输入" -noteOf: "{user}的帖子" +noteOf: "{user} 的帖子" quoteAttached: "已引用" quoteQuestion: "是否引用此链接内容?" noMessagesYet: "现在没有新的聊天" @@ -468,8 +476,8 @@ disableDrawer: "不显示抽屉菜单" showNoteActionsOnlyHover: "仅在悬停时显示帖子操作" noHistory: "没有历史记录" signinHistory: "登录历史" -enableAdvancedMfm: "启用扩展MFM" -enableAnimatedMfm: "启用MFM动画" +enableAdvancedMfm: "启用扩展 MFM" +enableAnimatedMfm: "启用 MFM 动画" doing: "正在进行" category: "类别" tags: "标签" @@ -479,7 +487,7 @@ existingAccount: "现有的账户" regenerate: "重新生成" fontSize: "字体大小" mediaListWithOneImageAppearance: "仅一张图片的媒体列表高度" -limitTo: "上限为{x}" +limitTo: "上限为 {x}" noFollowRequests: "没有关注申请" openImageInNewTab: "在新标签页中打开图片" dashboard: "管理面板" @@ -499,20 +507,20 @@ showFeaturedNotesInTimeline: "在时间线上显示热门推荐" objectStorage: "对象存储" useObjectStorage: "使用对象存储" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "这里是用于参考的URL,如果您正在使用CDN或反向代理,请指定其URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" +objectStorageBaseUrlDesc: "这里是用于引用的 URL,如果您正在使用 CDN 或反向代理,请指定其 URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" objectStorageBucket: "存储桶" objectStorageBucketDesc: "请指定使用的对象存储服务的存储桶名称。" objectStoragePrefix: "前缀" objectStoragePrefixDesc: "文件将存储在此前缀的目录下。" -objectStorageEndpoint: "端点" -objectStorageEndpointDesc: "如果你使用AWS S3请留空。否则请根据你使用的服务商的说明来进行设置,指定端点形式为“”或“:”。" +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "如果你使用 AWS S3 请留空。否则请根据你使用的服务商的说明来进行设置,指定 Endpoint 形式为“”或“:”。" objectStorageRegion: "可用区" -objectStorageRegionDesc: "指定一个可用区,例如“xx-east-1”。 如果您的对象存储服务没有可用区概念,请将其留空或填写“us-east-1”。" -objectStorageUseSSL: "使用SSL" -objectStorageUseSSLDesc: "如果不使用https进行API连接,请关闭。" +objectStorageRegionDesc: "指定一个可用区,例如“xx-east-1”。 如果您的对象存储服务没有可用区概念,请将其留空或填写“us-east-1”。如果引用 AWS 的配置文件或环境变量,则留空。" +objectStorageUseSSL: "使用 SSL" +objectStorageUseSSLDesc: "如果不使用 https 进行 API 连接,请关闭。" objectStorageUseProxy: "使用代理" -objectStorageUseProxyDesc: "如果您不使用代理进行API连接,请将其关闭。" -objectStorageSetPublicRead: "上传时设置为public-read" +objectStorageUseProxyDesc: "如果您不使用代理进行 API 连接,请将其关闭。" +objectStorageSetPublicRead: "上传时设置为 public-read" s3ForcePathStyleDesc: "启用 s3ForcePathStyle 会强制将存储桶名称指定为 URL 中路径的一部分,而不是主机名。使用自托管 Minio 等时可能需要启用。" serverLogs: "服务器日志" deleteAll: "全部删除" @@ -541,8 +549,8 @@ state: "状态" sort: "排序" ascendingOrder: "升序" descendingOrder: "降序" -scratchpad: "AiScript控制台" -scratchpadDescription: "AiScript控制台为AiScript提供了实验环境。您可以编写代码以与Misskey交互,运行它并查看结果。" +scratchpad: "AiScript 控制台" +scratchpadDescription: "AiScript 控制台为 AiScript 提供了实验环境。您可以编写代码与 Misskey 交互,运行并查看结果。" output: "输出" script: "脚本" disablePagesScript: "禁用页面脚本" @@ -550,7 +558,7 @@ updateRemoteUser: "更新远程用户信息" deleteAllFiles: "删除所有文件" deleteAllFilesConfirm: "要删除所有文件吗?" removeAllFollowing: "取消所有关注" -removeAllFollowingDescription: "取消{host}的所有关注者。当服务器不再存在时执行。" +removeAllFollowingDescription: "取消 {host} 的所有关注者。当服务器不再存在时执行。" userSuspended: "该用户已被冻结。" userSilenced: "该用户已被禁言。" yourAccountSuspendedTitle: "账户已被冻结" @@ -587,7 +595,7 @@ manage: "管理" plugins: "插件" preferencesBackups: "备份设置" deck: "Deck" -undeck: "取消Deck" +undeck: "取消 Deck" useBlurEffectForModal: "对话框使用模糊效果" useFullReactionPicker: "使用全功能的回应工具栏" width: "宽度" @@ -600,7 +608,7 @@ permission: "权限" enableAll: "启用全部" disableAll: "禁用全部" tokenRequested: "允许访问账户" -pluginTokenRequestedDescription: "此插件将能够拥有此处设置的权限" +pluginTokenRequestedDescription: "此插件将能够拥有这里设置的权限" notificationType: "通知类型" edit: "编辑" emailServer: "邮件服务器" @@ -608,20 +616,20 @@ enableEmail: "启用发送邮件功能" emailConfigInfo: "用于确认电子邮件和密码重置" email: "邮箱" emailAddress: "电子邮件地址" -smtpConfig: "SMTP服务器设置" +smtpConfig: "SMTP 服务器设置" smtpHost: "主机名" smtpPort: "端口" smtpUser: "用户名" smtpPass: "密码" -emptyToDisableSmtpAuth: "用户名和密码留空可以禁用SMTP验证" +emptyToDisableSmtpAuth: "用户名和密码留空可以禁用 SMTP 验证" smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS" -smtpSecureInfo: "使用STARTTLS时关闭。" +smtpSecureInfo: "使用 STARTTLS 时关闭。" testEmail: "邮件发送测试" wordMute: "文字屏蔽" regexpError: "正则表达式错误" regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:" instanceMute: "被屏蔽的服务器" -userSaysSomething: "{name}说了什么,但是被屏蔽词过滤了" +userSaysSomething: "{name} 说了什么,但是被屏蔽词过滤了" makeActive: "启用" display: "显示" copy: "复制" @@ -640,19 +648,19 @@ other: "其他" regenerateLoginToken: "重新生成登录令牌" regenerateLoginTokenDescription: "重新生成用于登录的内部令牌。通常您不需要这样做。重新生成后,您将在所有设备上登出。" setMultipleBySeparatingWithSpace: "您可以使用空格分隔多个项目。" -fileIdOrUrl: "文件ID或者URL" +fileIdOrUrl: "文件 ID 或者 URL" behavior: "行为" sample: "示例" abuseReports: "举报" reportAbuse: "举报" -reportAbuseOf: "举报{name}" -fillAbuseReportDescription: "请填写举报的详细原因。如果有对方发的帖子,请同时填写URL地址。" +reportAbuseOf: "举报 {name}" +fillAbuseReportDescription: "请填写举报的详细原因。如果有对方发的帖子,请同时填写 URL 地址。" abuseReported: "内容已发送。感谢您提交信息。" reporter: "举报者" reporteeOrigin: "举报来源" reporterOrigin: "举报者来源" forwardReport: "将该举报信息转发给远程服务器" -forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。" +forwardReportIsAnonymous: "在远程实例上显示的报告者是匿名的系统账号,而不是您的账号。" send: "发送" abuseMarkAsResolved: "处理完毕" openInNewTab: "在新标签页中打开" @@ -660,7 +668,7 @@ openInSideView: "在侧边栏中打开" defaultNavigationBehaviour: "默认导航" editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号" instanceTicker: "帖子的服务器来源" -waitingFor: "等待{x}" +waitingFor: "等待 {x}" random: "随机" system: "系统" switchUi: "切换界面" @@ -670,9 +678,9 @@ createNew: "新建" optional: "可选" createNewClip: "新建便签" unclip: "移除便签" -confirmToUnclipAlreadyClippedNote: "本帖已包含在便签\"{name}\"里。您想要将本帖从该便签中移除吗?" +confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。您想要将本帖从该便签中移除吗?" public: "公开" -i18nInfo: "Misskey已经被志愿者们翻译成了各种语言。如果你也有兴趣,可以通过{link}帮助翻译。" +i18nInfo: "Misskey 已经被志愿者们翻译成了各种语言。如果你也有兴趣,可以通过 {link} 帮助翻译。" manageAccessTokens: "管理 Access Tokens" accountInfo: "账户信息" notesCount: "帖子数量" @@ -718,14 +726,14 @@ center: "中央" wide: "宽" narrow: "窄" reloadToApplySetting: "页面刷新后设置才会生效。是否现在刷新页面?" -needReloadToApply: "重启后应用才会生效。" +needReloadToApply: "重新载入后应用才会生效。" showTitlebar: "显示标题栏" clearCache: "清除缓存" -onlineUsersCount: "{n}人在线" -nUsers: "{n}用户" -nNotes: "{n}帖子" +onlineUsersCount: "{n} 人在线" +nUsers: "{n} 用户" +nNotes: "{n} 帖子" sendErrorReports: "发送错误报告" -sendErrorReportsDescription: "启用后,如果出现问题,可以与Misskey共享详细的错误信息,从而帮助提高软件的质量。" +sendErrorReportsDescription: "启用后,如果出现问题,可以与 Misskey 共享详细的错误信息,从而帮助提高软件的质量。错误信息包括操作系统版本、浏览器类型、行为历史记录等。" myTheme: "我的主题" backgroundColor: "背景" accentColor: "强调色" @@ -755,7 +763,7 @@ emailNotification: "邮件通知" publish: "发布" inChannelSearch: "频道内搜索" useReactionPickerForContextMenu: "单击右键打开回应工具栏" -typingUsers: "{users}正在输入" +typingUsers: "{users} 正在输入" jumpToSpecifiedDate: "跳转到特定日期" showingPastTimeline: "显示过去的时间线" clear: "清除" @@ -789,7 +797,7 @@ administration: "管理" accounts: "账户" switch: "切换" noMaintainerInformationWarning: "管理人员信息未设置。" -noBotProtectionWarning: "Bot保护未设置。" +noBotProtectionWarning: "Bot 防御未设置。" configure: "设置" postToGallery: "发送到图库" postToHashtag: "投稿到这个标签" @@ -817,9 +825,9 @@ received: "收取" searchResult: "搜索结果" hashtags: "话题标签" troubleshooting: "故障排除" -useBlurEffect: "在UI上使用模糊效果" +useBlurEffect: "在 UI 上使用模糊效果" learnMore: "更多信息" -misskeyUpdated: "Misskey更新完成!" +misskeyUpdated: "Misskey 更新完成!" whatIsNew: "显示更新信息" translate: "翻译" translatedFrom: "从 {x} 翻译" @@ -828,7 +836,7 @@ usernameInfo: "在服务器上唯一标识您的帐户的名称。您可以使 aiChanMode: "小蓝模式" devMode: "开发者模式" keepCw: "回复时维持隐藏内容" -pubSub: "Pub/Sub账户" +pubSub: "Pub/Sub 账户" lastCommunication: "最近通信" resolved: "已解决" unresolved: "未解决" @@ -853,7 +861,7 @@ ffVisibilityDescription: "您可以设置您的关注/关注者信息的公开 continueThread: "查看更多帖子" deleteAccountConfirm: "将要删除账户。是否确认?" incorrectPassword: "密码错误" -voteConfirm: "确定投给“{choice}” ?" +voteConfirm: "确定投给 “{choice}” ?" hide: "隐藏" useDrawerReactionPickerForMobile: "在移动设备上使用抽屉显示" welcomeBackWithName: "欢迎回来,{name}" @@ -868,30 +876,30 @@ numberOfColumn: "列数" searchByGoogle: "Google" instanceDefaultLightTheme: "服务器默认浅色主题" instanceDefaultDarkTheme: "服务器默认深色主题" -instanceDefaultThemeDescription: "以对象格式键入主题代码" +instanceDefaultThemeDescription: "以对象格式输入主题代码" mutePeriod: "屏蔽期限" period: "截止时间" indefinitely: "永久" -tenMinutes: "10分钟" -oneHour: "1小时" -oneDay: "1天" -oneWeek: "1周" +tenMinutes: "10 分钟" +oneHour: "1 小时" +oneDay: "1 天" +oneWeek: "1 周" oneMonth: "1 个月" reflectMayTakeTime: "可能需要一些时间才能体现出效果。" failedToFetchAccountInformation: "获取账户信息失败" -rateLimitExceeded: "已超過速率限制" -cropImage: "剪裁图像" +rateLimitExceeded: "已超过速率限制" +cropImage: "裁剪图像" cropImageAsk: "是否要裁剪图像?" cropYes: "去裁剪" cropNo: "就这样吧!" file: "文件" -recentNHours: "最近{n}小时" -recentNDays: "最近{n}天" +recentNHours: "最近 {n} 小时" +recentNDays: "最近 {n} 天" noEmailServerWarning: "电子邮件服务器未设置。" thereIsUnresolvedAbuseReportWarning: "有未解决的报告" recommended: "推荐" check: "检查" -driveCapOverrideLabel: "變更此用戶的雲端硬碟容量上限" +driveCapOverrideLabel: "更改此用户的网盘容量上限" driveCapOverrideCaption: "设定为 0 以下则会解除此限制。" requireAdminForView: "需要使用管理员账户登录才能查看。" isSystemAccount: "该账号由系统自动创建和管理。" @@ -899,7 +907,7 @@ typeToConfirm: "输入 {x} 以确认操作。" deleteAccount: "删除账户" document: "文档" numberOfPageCache: "缓存页数" -numberOfPageCacheDescription: "设置较高的值会更方便用户,但服务器负载和内存使用量会增加。" +numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。" logoutConfirm: "是否确认登出?" lastActiveDate: "最后活跃时间" statusbar: "状态栏" @@ -921,7 +929,7 @@ cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。" cannotUploadBecauseExceedsFileSizeLimit: "无法上传文件,超过文件大小限制。" beta: "测试" enableAutoSensitive: "自动 NSFW 识别" -enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。" +enableAutoSensitiveDescription: "使用机器学习在可用时自动使用 NSFW 标记来标记媒体。即使您关闭此功能,根据服务器的不同,它仍然可能会自动设置。" activeEmailValidationDescription: "开启用户的电子邮件地址验证,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。" navbar: "导航栏" shuffle: "随机" @@ -938,7 +946,7 @@ windowMaximize: "最大化" windowMinimize: "最小化" windowRestore: "还原" caption: "标题" -loggedInAsBot: "以Bot账户登录" +loggedInAsBot: "以 Bot 账户登录" tools: "工具" cannotLoad: "无法加载" numberOfProfileView: "个人资料展示次数" @@ -948,8 +956,8 @@ numberOfLikes: "点赞数" show: "显示" neverShow: "不再显示" remindMeLater: "稍后提醒我" -didYouLikeMisskey: "您喜欢Misskey吗?" -pleaseDonate: "Misskey是{host}所使用的免费软件。为了今后也能够维持Misskey的开发,请在有余力的情况下进行捐助!" +didYouLikeMisskey: "您喜欢 Misskey 吗?" +pleaseDonate: "Misskey 是 {host} 所使用的免费软件。为了今后也能够维持 Misskey 的开发,请在有余力的情况下进行捐助!" roles: "角色" role: "角色" noRole: "角色不存在" @@ -963,11 +971,11 @@ youCannotCreateAnymore: "抱歉,您无法再创建更多了。" cannotPerformTemporary: "暂时不可用" cannotPerformTemporaryDescription: "因操作过于频繁,暂时不可用,请稍后再试。" invalidParamError: "参数错误" -invalidParamErrorDescription: "请求参数出现问题。通常是因为bug造成的,但也可能是输入文字数量过多之类的原因。" +invalidParamErrorDescription: "请求参数出现问题。通常是因为 bug 造成的,但也可能是输入文字数量过多之类的原因。" permissionDeniedError: "操作被拒绝" permissionDeniedErrorDescription: "本账户没有执行该操作的权限。" -preset: "預設值" -selectFromPresets: "從預設值中選擇" +preset: "预设值" +selectFromPresets: "从预设值中选择" achievements: "成就" gotInvalidResponseError: "服务器无应答" gotInvalidResponseErrorDescription: "您的网络连接可能出现了问题, 或是远程服务器暂时不可用. 请稍后重试。" @@ -998,7 +1006,7 @@ rolesAssignedToMe: "指派给自己的角色" resetPasswordConfirm: "确定重置密码?" sensitiveWords: "敏感词" sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。" -sensitiveWordsDescription2: "用空格分割关键词作为AND格式,用斜线包裹关键字来构成正则表达式。" +sensitiveWordsDescription2: "AND 条件用空格分隔,正则表达式用斜线包裹。" notesSearchNotAvailable: "帖子检索不可用" license: "许可信息" unfavoriteConfirm: "确定要取消收藏吗?" @@ -1011,7 +1019,7 @@ enableChartsForRemoteUser: "生成远程用户的图表" enableChartsForFederatedInstances: "生成远程服务器的图表" showClipButtonInNoteFooter: "在贴文下方显示便签按钮" largeNoteReactions: "使用大图标来显示回应" -noteIdOrUrl: "帖子ID或URL" +noteIdOrUrl: "帖子 ID 或 URL" video: "视频" videos: "视频" dataSaver: "省流量模式" @@ -1041,14 +1049,14 @@ preservedUsernames: "保留的用户名" preservedUsernamesDescription: "列出需要保留的用户名,使用换行来作为分割。被指定的用户名在建立账户时无法使用,但由管理员所创建的账户不受该限制。此外,现有的账户也不会受到影响。" createNoteFromTheFile: "从文件创建帖子" archive: "归档" -channelArchiveConfirmTitle: "要将{name}归档吗?" +channelArchiveConfirmTitle: "要将 {name} 归档吗?" channelArchiveConfirmDescription: "归档后,在频道列表与搜索结果中不会显示,也无法发布新的贴文。" thisChannelArchived: "该频道已被归档。" displayOfNote: "显示帖子" initialAccountSetting: "初始设置" youFollowing: "正在关注" -preventAiLearning: "拒绝接受生成式AI的学习" -preventAiLearningDescription: "要求文章生成AI或图像生成AI不能够以发布的帖子和图像等内容作为学习对象。这是通过在HTML响应中包含noai标志来实现的,这不能完全阻止AI学习你的发布内容,并不是所有AI都会遵守这类请求。" +preventAiLearning: "拒绝接受生成式 AI 的学习" +preventAiLearningDescription: "要求文章生成 AI 或图像生成 AI 不能够以发布的帖子和图像等内容作为学习对象。这是通过在 HTML 响应中包含 noai 标志来实现的,这不能完全阻止 AI 学习你的发布内容,并不是所有 AI 都会遵守这类请求。" options: "选项" specifyUser: "用户指定" failedToPreviewUrl: "无法预览" @@ -1059,9 +1067,30 @@ rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "角色必须是公开的 cancelReactionConfirm: "要取消回应吗?" changeReactionConfirm: "要更改回应吗?" later: "一会再说" -goToMisskey: "去往Misskey" +goToMisskey: "去往 Misskey" additionalEmojiDictionary: "表情符号追加字典" installed: "已安装" +branding: "品牌" +enableServerMachineStats: "公开服务器硬件统计信息" +enableIdenticonGeneration: "启用生成用户 Identicon" +turnOffToImprovePerformance: "关闭该选项可以提高性能。" +createInviteCode: "发行邀请码" +createWithOptions: "使用选项来创建" +createCount: "发行数" +inviteCodeCreated: "已创建邀请码" +inviteLimitExceeded: "可供发行的邀请码已达上限。" +createLimitRemaining: "可供发行的邀请码:剩余{limit}个" +inviteLimitResetCycle: "可以在{time}内发行最多{limit}个邀请码。" +expirationDate: "有效日期" +noExpirationDate: "不设置有效日期" +inviteCodeUsedAt: "邀请码被使用的日期和时间" +registeredUserUsingInviteCode: "使用了邀请码的用户" +waitingForMailAuth: "等待验证电子邮件" +inviteCodeCreator: "发行邀请码的用户" +usedAt: "使用时间" +unused: "未使用" +used: "已使用" +expired: "已过期" _initialAccountSetting: accountCreated: "账户创建完成了!" letsStartAccountSetup: "来进行帐户的初始设置吧。" @@ -1071,10 +1100,10 @@ _initialAccountSetting: theseSettingsCanEditLater: "也可以在稍后修改这里的设置。" youCanEditMoreSettingsInSettingsPageLater: "还可以在「设置」页面进行其它各种设置,稍后就来确认一下看看吧。" followUsers: "为了建立属于你自己的时间线,试着去关注你感兴趣的用户吧。" - pushNotificationDescription: "启用推送通知的话,就可以在设备上受到来自{name}的通知了。" + pushNotificationDescription: "启用推送通知的话,就可以在设备上接收来自 {name} 的通知了。" initialAccountSettingCompleted: "初始设定已经完成了!" - haveFun: "希望{name}在这里玩得开心!" - ifYouNeedLearnMore: "关于{name}(Misskey)的使用方法,详见{link}。" + haveFun: "希望 {name} 在这里玩得开心!" + ifYouNeedLearnMore: "关于 {name}(Misskey) 的使用方法,详见 {link}。" skipAreYouSure: "要跳过初始设置吗?" laterAreYouSure: "要稍后再进行初始设定吗?" _serverRules: @@ -1083,16 +1112,16 @@ _accountMigration: moveFrom: "从别的账号迁移到此账户" moveFromSub: "为另一个账户建立别名" moveFromLabel: "迁移前的账户" - moveFromDescription: "如果迁移时需要继承其他账户的关注者,请在此创造别名。此操作需要在实行迁移之前完成!请如已下输入需要迁移的账户:@person@instance.com" + moveFromDescription: "如果迁移时需要继承其他账户的关注者,你需要创建一个别名。此操作需要在迁移前完成!\n请像这样输入要迁移的账户:@username@server.example.com\n如果要删除,请将输入字段留空,并保存(不推荐)。" moveTo: "把这个账户迁移到新的账户" moveToLabel: "迁移后的账户" moveCannotBeUndone: "一旦迁移账户,就无法撤销。" - moveAccountDescription: "此操作无法取消。请先确认您已在迁移后的账户上,为此账户创造了别名。创造别名后,请如以下输入您的迁移后的账户:@person@instance.com" + moveAccountDescription: "\n迁移到新帐户。\n ・现有的关注者自动关注新帐户\n ・此帐户的所有关注者都将被删除\n ・您将无法再使用此帐户发帖。\n关注者迁移是自动的,但关注中迁移必须手动完成。请在迁移前在此帐户上导出关注列表,并在迁移后立即在目标帐户上执行导入。\n屏蔽列表也是如此,因此您必须手动迁移它。\n(此描述适用于该服务器(Misskey v13.12.0 或更高版本)。其他 ActivityPub 软件(例如 Mastodon)的行为可能有所不同。)" moveAccountHowTo: "要进行账户迁移,请现在目标账户中为此账户建立一个别名。\n建立别名后,请像这样输入目标账户:@username@server.example.com" startMigration: "迁移" - migrationConfirm: "确定要把此账户迁移到{account}吗?一旦确定后,此操作无法取消,此账户也无法以原来的状态使用。\n同时,请确认迁移后的账户,已创造别名。" + migrationConfirm: "确定要把此账户迁移到 {account} 吗?一旦确定后,此操作无法取消,此账户也无法以原来的状态使用。\n同时,请确认迁移后的账户,已创造别名。" movedAndCannotBeUndone: "该账户已被迁移。\n迁移操作无法撤销。" - postMigrationNote: "这个账户的关注会在迁移操作后的24小时后解除。该账户的「关注中」和「关注者」皆会变为0。由于不会解除关注关系,你的关注者仍然可以继续查看该账户发补给关注者的帖子。" + postMigrationNote: "这个账户的关注会在迁移操作后的 24 小时后解除。该账户的「关注中」和「关注者」皆会变为 0。由于不会解除关注关系,你的关注者仍然可以继续查看该账户发补给关注者的帖子。" movedTo: "迁移后的账户" _achievements: earnedAt: "达成时间" @@ -1100,103 +1129,103 @@ _achievements: _notes1: title: "初来乍到" description: "第一次发帖" - flavor: "祝您在Misskey玩的愉快~" + flavor: "祝您在 Misskey 玩的愉快~" _notes10: title: "一些帖子" - description: "发布了10篇帖子" + description: "发布了 10 篇帖子" _notes100: title: "很多帖子" - description: "发布了100篇帖子" + description: "发布了 100 篇帖子" _notes500: title: "满是帖子" - description: "发布了500篇帖子" + description: "发布了 500 篇帖子" _notes1000: title: "积帖成山" - description: "发布了1,000篇帖子" + description: "发布了 1,000 篇帖子" _notes5000: title: "帖如泉涌" - description: "发布了5,000篇帖子" + description: "发布了 5,000 篇帖子" _notes10000: title: "超级帖" - description: "发布了10,000篇帖子" + description: "发布了 10,000 篇帖子" _notes20000: title: "还想要更多帖子" - description: "发布了20,000篇帖子" + description: "发布了 20,000 篇帖子" _notes30000: title: "帖子帖子帖子" - description: "发布了30,000篇帖子" + description: "发布了 30,000 篇帖子" _notes40000: title: "帖子工厂" - description: "发布了40,000篇帖子" + description: "发布了 40,000 篇帖子" _notes50000: title: "帖子星球" - description: "发布了50,000篇帖子" + description: "发布了 50,000 篇帖子" _notes60000: title: "帖子类星体" - description: "发布了60,000篇帖子" + description: "发布了 60,000 篇帖子" _notes70000: title: "帖子黑洞" - description: "发布了70,000篇帖子" + description: "发布了 70,000 篇帖子" _notes80000: title: "帖子星系" - description: "发布了80,000篇帖子" + description: "发布了 80,000 篇帖子" _notes90000: title: "帖子起源" - description: "发布了90,000篇帖子" + description: "发布了 90,000 篇帖子" _notes100000: title: "ALL YOUR NOTE ARE BELONG TO US" - description: "发布了100,000篇帖子" + description: "发布了 100,000 篇帖子" flavor: "真的有那么多可以写的东西吗?" _login3: title: "初学者 I" - description: "连续登录3天" - flavor: "今天开始我就是Misskist!" + description: "累计登录 3 天" + flavor: "今天开始我就是 Misskist!" _login7: title: "初学者 II" - description: "连续登录7天" + description: "累计登录 7 天" flavor: "您开始习惯了吗?" _login15: title: "初学者 III" - description: "连续登录15天" + description: "累计登录 15 天" _login30: title: "Misskist Ⅰ" - description: "连续登录30天" + description: "累计登录 30 天" _login60: title: "Misskist Ⅱ" - description: "连续登录60天" + description: "累计登录 60 天" _login100: title: "Misskist Ⅲ" - description: "总登入100天" - flavor: "那个用户,是Misskist喔" + description: "累计登入 100 天" + flavor: "那个用户,是 Misskist 喔" _login200: title: "定期联系Ⅰ" - description: "总登录天数200天" + description: "累计登录 200 天" _login300: title: "定期联系Ⅱ" - description: "总登录天数300天" + description: "累计登录 300 天" _login400: title: "定期联系Ⅲ" - description: "总登录天数400天" + description: "累计登录 400 天" _login500: title: "老熟人Ⅰ" - description: "总登录天数500天" + description: "累计登录 500 天" flavor: "诸君,我喜欢贴文" _login600: title: "老熟人Ⅱ" - description: "总登录天数600天" + description: "累计登录 600 天" _login700: title: "老熟人Ⅲ" - description: "总登录天数700天" + description: "累计登录 700 天" _login800: - title: "帖子大师Ⅰ" - description: "总登录天数800天" + title: "帖子大师 Ⅰ" + description: "累计登录 800 天" _login900: - title: "帖子大师Ⅱ" - description: "总登录天数900天" + title: "帖子大师 Ⅱ" + description: "累计登录 900 天" _login1000: - title: "帖子大师Ⅲ" - description: "总登录天数1000天" - flavor: "感谢您使用Misskey!" + title: "帖子大师 Ⅲ" + description: "累计登录 1000 天" + flavor: "感谢您使用 Misskey!" _noteClipped1: title: "忍不住要收藏到便签" description: "第一次将贴文贴进便签" @@ -1218,56 +1247,56 @@ _achievements: description: "第一次关注别人" _following10: title: "关注,跟随" - description: "关注超过10人" + description: "关注超过 10 人" _following50: title: "我的朋友很多" - description: "关注超过50人" + description: "关注超过 50 人" _following100: - title: "我的朋友很多" - description: "关注超过100人" + title: "胜友如云" + description: "关注超过 100 人" _following300: title: "朋友成群" - description: "关注数超过300" + description: "关注数超过 300" _followers1: title: "最初的关注者" description: "第一次被关注" _followers10: title: "关注我吧!" - description: "拥有超过10名关注者" + description: "拥有超过 10 名关注者" _followers50: title: "三五成群" - description: "拥有超过50名关注者" + description: "拥有超过 50 名关注者" _followers100: title: "胜友如云" - description: "拥有超过100名关注者" + description: "拥有超过 100 名关注者" _followers300: title: "排列成行" - description: "拥有超过300名关注者" + description: "拥有超过 300 名关注者" _followers500: title: "信号塔" - description: "拥有超过500名关注者" + description: "拥有超过 500 名关注者" _followers1000: title: "大影响家" - description: "拥有超过1000名关注者" + description: "拥有超过 1000 名关注者" _collectAchievements30: title: "成就收藏家" - description: "获得超过30个成就" + description: "获得超过 30 个成就" _viewAchievements3min: title: "成就爱好者" description: "盯着成就看三分钟" _iLoveMisskey: title: "I Love Misskey" - description: "发布\"I ❤ #Misskey\"帖子" + description: "发布 \"I ❤ #Misskey\" 帖子" flavor: "感谢您使用 Misskey ! by 开发团队" _foundTreasure: title: "寻宝" description: "发现了隐藏的宝藏" _client30min: title: "休息一下!" - description: "启动客户端超过30分钟" + description: "启动客户端超过 30 分钟" _client60min: - title: "Misskey重度依赖" - description: "启动客户端超过60分钟" + title: "Misskey 重度依赖" + description: "启动客户端超过 60 分钟" _noteDeletedWithin1min: title: "欲言又止" description: "发帖后一分钟内就将其删除" @@ -1277,20 +1306,20 @@ _achievements: flavor: "差不多该去睡了喔。" _postedAt0min0sec: title: "报时" - description: "在0点发布一篇帖子" + description: "在 0 点发布一篇帖子" flavor: "嘣 嘣 嘣 Biu——!" _selfQuote: title: "自我引用" description: "引用了自己的帖子" _htl20npm: title: "流动的时间线" - description: "在首页时间线的流速超过20npm" + description: "在首页时间线的流速超过 20npm" _viewInstanceChart: title: "分析师" description: "查看了服务器信息中的图表" _outputHelloWorldOnScratchpad: title: "Hello, world!" - description: "在AiScript控制台中输出 hello world" + description: "在 AiScript 控制台中输出 hello world" _open3windows: title: "多窗口" description: "打开了三个或更多的窗口" @@ -1299,25 +1328,25 @@ _achievements: description: "试图对网盘中的文件夹进行循环嵌套" _reactWithoutRead: title: "有好好读过吗?" - description: "在含有100字以上的帖子被发出三秒内做出回应" + description: "在含有 100 字以上的帖子被发出三秒内做出回应" _clickedClickHere: title: "点这里" description: "点了这里" _justPlainLucky: title: "超高校级的幸运" - description: "每10秒有0.01的概率自动获得" + description: "每 10 秒有 0.01 的概率自动获得" _setNameToSyuilo: title: "像神一样呐" - description: "将名称设定为syuilo" + description: "将名称设定为 syuilo" _passedSinceAccountCreated1: title: "一周年" - description: "账户创建时间超过1年" + description: "账户创建时间超过 1 年" _passedSinceAccountCreated2: title: "二周年" - description: "账户创建时间超过2年" + description: "账户创建时间超过 2 年" _passedSinceAccountCreated3: title: "三周年" - description: "账户创建时间超过3年" + description: "账户创建时间超过 3 年" _loggedInOnBirthday: title: "生日快乐" description: "在生日当天登录" @@ -1331,7 +1360,7 @@ _achievements: flavor: "是不是软件有问题?" _brainDiver: title: "Brain Diver" - description: "发布了包含Brain Diver链接的帖子" + description: "发布了包含 Brain Diver 链接的帖子" flavor: "Misskey-Misskey La-Tu-Ma" _role: new: "创建角色" @@ -1353,7 +1382,7 @@ _role: baseRole: "基本角色" useBaseValue: "使用基本角色的值" chooseRoleToAssign: "选择要分配的角色" - iconUrl: "图标URL" + iconUrl: "图标 URL" asBadge: "作为徽章显示" descriptionOfAsBadge: "开启后,用户名旁边将会出现角色图标。" isExplorable: "公开角色时间线" @@ -1372,9 +1401,12 @@ _role: ltlAvailable: "查看本地时间线" canPublicNote: "允许公开发帖" canInvite: "发放服务器邀请码" + inviteLimit: "可发行邀请码的数量" + inviteLimitCycle: "邀请码的发行间隔" + inviteExpirationTime: "邀请码的有效日期" canManageCustomEmojis: "管理自定义表情符号" driveCapacity: "网盘容量" - alwaysMarkNsfw: "总是将文件标记为NSFW" + alwaysMarkNsfw: "总是将文件标记为 NSFW" pinMax: "帖子置顶数量限制" antennaMax: "可创建的最大天线数量" wordMuteMax: "屏蔽词的字数限制" @@ -1402,7 +1434,7 @@ _role: or: "符合以下任一条件" not: "不符合以下任何条件" _sensitiveMediaDetection: - description: "可以使用机器学习技术自动检测敏感媒体,以便进行审核。服务器负载将略微增加。" + description: "使用机器学习技术自动检测敏感媒体,以便进行审核。服务器负载将略微增加。" sensitivity: "检测敏感度" sensitivityDescription: "敏感度较低,则误检(假阳性)会减少;敏感度较高,则漏检(假阴性)会减少。" setSensitiveFlagAutomatically: "自动设置 NSFW 标签" @@ -1434,9 +1466,10 @@ _ad: back: "返回" reduceFrequencyOfThisAd: "减少此广告的频率" hide: "不显示" + timezoneinfo: "星期几是由服务器的时区所指定的。" _forgotPassword: - enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。" - ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。" + enterEmail: "请输入您设置的电子邮箱地址,密码重置链接将发送至该邮箱上。" + ifNoEmail: "如果您没有设置电子邮件地址,请联系管理员。" contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。" _gallery: my: "我的图库" @@ -1460,8 +1493,8 @@ _preferencesBackups: save: "覆盖存档" inputName: "请输入备份的名称" cannotSave: "无法保存" - nameAlreadyExists: "备份名称\"{name}\"已经存在,请指定其他名称。" - applyConfirm: "您是否要将备份\"{name}\"应用到当前设备上?当前设备现有配置将被丢弃。" + nameAlreadyExists: "备份名称 \"{name}\" 已经存在,请指定其他名称。" + applyConfirm: "您是否要将备份 \"{name}\" 应用到当前设备上?当前设备现有配置将被丢弃。" saveConfirm: "您确定要覆盖保存 {name} 吗?" deleteConfirm: "您确定要删除 {name} 吗?" renameConfirm: "您确定要把“{old}”改为“{new}”吗?" @@ -1477,18 +1510,18 @@ _registry: domain: "域" createKey: "创建键" _aboutMisskey: - about: "Misskey是由syuilo于2014年开发的开源软件。" + about: "Misskey 是由 syuilo 于 2014 年开发的开源软件。" contributors: "主要贡献者" allContributors: "全体贡献者" source: "源代码" - translation: "翻译Misskey" - donate: "赞助Misskey" - morePatrons: "还有很多其他的人也在支持我们,非常感谢🥰" + translation: "翻译 Misskey" + donate: "赞助 Misskey" + morePatrons: "还有很多其它的人也在支持我们,非常感谢🥰" patrons: "支持者" -_nsfw: - respect: "隐藏敏感内容" - ignore: "不隐藏敏感内容" - force: "总是隐藏内容" +_displayOfSensitiveMedia: + respect: "隐藏敏感媒体" + ignore: "显示敏感媒体" + force: "隐藏所有内容" _instanceTicker: none: "不显示" remote: "仅远程用户" @@ -1505,8 +1538,8 @@ _channel: featured: "热点" owned: "管理中" following: "正在关注" - usersCount: "有{n}人参与" - notesCount: "有{n}个帖子" + usersCount: "有 {n} 人参与" + notesCount: "有 {n} 个帖子" nameAndDescription: "名称与描述" nameOnly: "仅名称" _menuDisplay: @@ -1516,16 +1549,16 @@ _menuDisplay: hide: "隐藏" _wordMute: muteWords: "禁用词" - muteWordsDescription: "使用空格分隔表示AND逻辑,使用换行符分隔表示OR逻辑。" - muteWordsDescription2: "将关键字用斜线括起来表示正则表达式。" + muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" + muteWordsDescription2: "正则表达式用斜线包裹" softDescription: "隐藏时间线中指定条件的帖子。" hardDescription: "防止将具有指定条件的帖子添加到时间线。 即使您更改条件,未添加的帖文也会被排除在外。" soft: "软屏蔽" hard: "硬屏蔽" mutedNotes: "被屏蔽的帖子" _instanceMute: - instanceMuteDescription: "屏蔽配置服务器中的所有帖子和转帖,包括这些服务器上的用户回复。" - instanceMuteDescription2: "设置时用换行符来分隔" + instanceMuteDescription: "屏蔽服务器中的所有帖子和转帖,包括这些服务器上的用户回复。" + instanceMuteDescription2: "一行一个" title: "隐藏服务器已设置的帖子。" heading: "屏蔽服务器" _theme: @@ -1557,7 +1590,7 @@ _theme: lighten: "浅色" inputConstantName: "请输入常量名称" importInfo: "您可以在此处粘贴主题代码,将其导入到编辑器中" - deleteConstantConfirm: "确定要删除常量{const}吗?" + deleteConstantConfirm: "确定要删除常量 {const} 吗?" keys: accent: "强调色" bg: "背景" @@ -1589,8 +1622,8 @@ _theme: cwBg: "隐藏内容按钮背景" cwFg: "隐藏内容按钮文本" cwHoverBg: "隐藏内容按钮背景(悬停)" - toastBg: "Toast通知背景" - toastFg: "Toast通知文本" + toastBg: "Toast 通知背景" + toastFg: "Toast 通知文本" buttonBg: "按钮背景" buttonHoverBg: "按钮背景(悬停)" inputBorder: "输入框边框" @@ -1613,13 +1646,13 @@ _sfx: _ago: future: "未来" justNow: "最近" - secondsAgo: "{n}秒前" - minutesAgo: "{n}分前" - hoursAgo: "{n}小时前" - daysAgo: "{n}日前" - weeksAgo: "{n}周前" - monthsAgo: "{n}月前" - yearsAgo: "{n}年前" + secondsAgo: "{n} 秒前" + minutesAgo: "{n} 分前" + hoursAgo: "{n} 小时前" + daysAgo: "{n} 日前" + weeksAgo: "{n} 周前" + monthsAgo: "{n} 月前" + yearsAgo: "{n} 年前" invalid: "没有" _time: second: "秒" @@ -1627,7 +1660,7 @@ _time: hour: "小时" day: "日" _timelineTutorial: - title: "Misskey的使用方法" + title: "Misskey 的使用方法" step1_1: "这个画面是「时间线」。{name}的投稿会按照帖子的发布时间顺序来显示。" step1_2: "时间线有许多种类,比如在「首页时间线」中展现的是你关注的人的贴文;而在「本地时间线」中展现的是{name}里全部用户的贴文。" step2_1: "那么接下来,试着写一些什么东西来发布吧!你可以通过点击屏幕上的铅笔图标来打开投稿页面。" @@ -1640,20 +1673,20 @@ _2fa: alreadyRegistered: "此设备已被注册" registerTOTP: "开始设置认证应用" passwordToTOTP: "请输入您的密码" - step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。" + step1: "首先,在您的设备上安装验证应用,例如 {a} 或 {b}。" step2: "然后,扫描屏幕上显示的二维码。" - step2Click: "通过点击QR码,您可以使用设备上安装的身份验证器应用程序或密钥环进行注册" - step2Url: "在桌面应用程序中输入以下URL:" + step2Click: "通过点击二维码,您可以使用设备上安装的身份验证器应用程序或密钥环进行注册" + step2Url: "在桌面应用程序中输入以下 URL:" step3Title: "输入验证码" step3: "输入您的应用提供的动态口令以完成设置。" step4: "从现在开始,任何登录操作都将要求您提供动态口令。" securityKeyNotSupported: "您的浏览器不支持安全密钥。" - registerTOTPBeforeKey: "要注册安全密钥或Passkey,请先设置验证器应用程序。" + registerTOTPBeforeKey: "要注册安全密钥或 Passkey,请先设置验证器应用程序。" securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 码以及 Passkey 等。" - chromePasskeyNotSupported: "目前不支持 Chrome 的Passkey。" - registerSecurityKey: "注册安全密钥或Passkey" + chromePasskeyNotSupported: "目前不支持 Chrome 的 Passkey。" + registerSecurityKey: "注册安全密钥或 Passkey" securityKeyName: "输入密钥名称" - tapSecurityKey: "请按照浏览器说明操作来注册安全密钥或Passkey。" + tapSecurityKey: "请按照浏览器说明操作来注册安全密钥或 Passkey。" removeKey: "删除安全密钥" removeKeyConfirm: "您确定要删除 {name} 吗?" whyTOTPOnlyRenew: "如果注册了安全密钥,则无法取消验证器应用程序上的设置。" @@ -1696,9 +1729,9 @@ _permissions: "write:gallery-likes": "操作喜欢的图片" _auth: shareAccessTitle: "应用程序授权许可" - shareAccess: "您要授权允许“{name}”访问您的帐户吗?" + shareAccess: "您要授权允许 “{name}” 访问您的帐户吗?" shareAccessAsk: "您确定要授权此应用程序访问您的帐户吗?" - permission: "{name}需要以下权限" + permission: "{name} 需要以下权限" permissionAsk: "这个应用程序需要以下权限" pleaseGoBack: "请返回到应用程序" callback: "回到应用程序" @@ -1726,12 +1759,12 @@ _widgets: calendar: "日历" trends: "趋势" clock: "时钟" - rss: "RSS阅读器" + rss: "RSS 阅读器" rssTicker: "RSS Ticker" activity: "活动" photos: "照片" digitalClock: "数字时钟" - unixClock: "UNIX时钟" + unixClock: "UNIX 时钟" federation: "联合" instanceCloud: "服务器云" postForm: "投稿窗口" @@ -1740,7 +1773,7 @@ _widgets: onlineUsers: "在线用户" jobQueue: "作业队列" serverMetric: "服务器指标" - aiscript: "AiScript控制台" + aiscript: "AiScript 控制台" aiscriptApp: "AiScript App" aichan: "小蓝" userList: "用户列表" @@ -1750,11 +1783,11 @@ _widgets: _cw: hide: "隐藏" show: "查看更多" - chars: "{count}个字符" + chars: "{count} 个字符" files: "{count} 个文件" _poll: noOnlyOneChoice: "需要至少两个选项" - choiceN: "选择{n}" + choiceN: "选择 {n}" noMore: "无法再添加更多了" canMultipleVote: "允许多个投票" expiration: "截止时间" @@ -1764,16 +1797,16 @@ _poll: deadlineDate: "截止日期" deadlineTime: "小时" duration: "时长" - votesCount: "{n}票" - totalVotes: "总票数{n}" + votesCount: "{n} 票" + totalVotes: "总票数 {n}" vote: "投票" showResult: "显示结果" voted: "已投票" closed: "已截止" - remainingDays: "{d}天{h}小时后截止" - remainingHours: "{h}小时{m}分后截止" - remainingMinutes: "{m}分{s}秒后截止" - remainingSeconds: "{s}秒后截止" + remainingDays: "{d} 天 {h} 小时后截止" + remainingHours: "{h} 小时 {m} 分后截止" + remainingMinutes: "{m} 分 {s} 秒后截止" + remainingSeconds: "{s} 秒后截止" _visibility: public: "公开" publicDescription: "您的帖子将出现在全局时间线上" @@ -1849,16 +1882,16 @@ _timelines: social: "社交" global: "全局" _play: - new: "创建Play" - edit: "编辑Play" - created: "创建了一个Play" - updated: "更新了Play" - deleted: "删除了Play" - pageSetting: "Play设置" - editThisPage: "编辑此Play" + new: "创建 Play" + edit: "编辑 Play" + created: "创建了一个 Play" + updated: "更新了 Play" + deleted: "删除了 Play" + pageSetting: "Play 设置" + editThisPage: "编辑此 Play" viewSource: "查看源代码" - my: "我的Play" - liked: "点赞的Play" + my: "我的 Play" + liked: "点赞的 Play" featured: "热门" title: "标题" script: "脚本" @@ -1871,8 +1904,8 @@ _pages: updated: "页面已更新" deleted: "该页面已被删除" pageSetting: "页面设置" - nameAlreadyExists: "该页面URL已存在" - invalidNameTitle: "无效的页面URL" + nameAlreadyExists: "该页面 URL 已存在" + invalidNameTitle: "无效的页面 URL" invalidNameText: "请确认该项不为空" editThisPage: "编辑此页面" viewSource: "查看源代码" @@ -1887,7 +1920,7 @@ _pages: content: "页面内容" variables: "变量" title: "标题" - url: "页面URL" + url: "页面 URL" summary: "页面摘要" alignCenter: "居中" hideTitleWhenPinned: "置顶时隐藏标题" @@ -1909,7 +1942,7 @@ _pages: button: "按钮" note: "嵌入的帖子" _note: - id: "帖子ID" + id: "帖子 ID" idDescription: "您也可以通过粘贴帖子的URL来进行设置。" detailed: "显示详细信息" _relayStatus: @@ -1963,6 +1996,7 @@ _deck: introduction: "将各列进行组合以创建您自己的界面!" introduction2: "您可以随时通过屏幕右侧的 + 来添加列" widgetsIntroduction: "从列菜单中,选择“小工具编辑”来添加小工具" + useSimpleUiForNonRootPages: "用简易UI表示非根页面" _columns: main: "主列" widgets: "小工具" @@ -1987,7 +2021,7 @@ _webhookSettings: createWebhook: "创建 Webhook" name: "名称" secret: "密钥" - events: "何时运行Webhook" + events: "何时运行 Webhook" active: "已启用" _events: follow: "关注时" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 801701850..df4122ef3 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -1,7 +1,7 @@ --- _lang_: "繁體中文" -headlineMisskey: "貼文連繫網路" -introMisskey: "歡迎! Misskey是一個開放原始碼且去中心化的社群網路。\n透過「貼文」分享周邊新鮮事,並告訴其他人您的想法!📡\n透過「反應」功能,對大家的貼文表達情感!👍\n一起來探索這個新的世界吧!🚀" +headlineMisskey: "貼文連繫網絡" +introMisskey: "歡迎!Misskey 是一個開源且去中心化的社群網路服務。\n發佈「貼文」向身邊的人分享您的想法!📡\n利用「反應」表達您對貼文的感覺!👍\n讓我們一起探索新的世界吧!🚀" poweredByMisskeyDescription: "{name}是使用開放原始碼平台Misskey的服務之一(稱為 Misskey 伺服器)。\n" monthAndDay: "{month}月 {day}日" search: "搜尋" @@ -49,11 +49,15 @@ delete: "刪除" deleteAndEdit: "刪除並編輯" deleteAndEditConfirm: "要刪除並再次編輯嗎?此貼文的所有反應、轉發和回覆也將會消失。" addToList: "加入至清單" +addToAntenna: "新增至天線" sendMessage: "發送訊息" copyRSS: "複製RSS" copyUsername: "複製使用者名稱" -copyUserId: "複製使用者ID" -copyNoteId: "複製貼文ID" +copyUserId: "複製使用者 ID" +copyNoteId: "複製貼文 ID" +copyFileId: "複製檔案ID" +copyFolderId: "複製資料夾ID" +copyProfileUrl: "複製個人資料網址" searchUser: "搜尋使用者" reply: "回覆" loadMore: "載入更多" @@ -72,8 +76,8 @@ files: "檔案" download: "下載" driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此附件的貼文也會跟著消失。\n" unfollowConfirm: "確定要取消追隨{name}嗎?" -exportRequested: "已請求匯出。這可能會花一點時間。結束後檔案將會被放到雲端裡。" -importRequested: "已請求匯入。這可能會花一點時間" +exportRequested: "已請求匯出。這可能會花一點時間。匯出的檔案將會被放到雲端裡。" +importRequested: "已請求匯入。這可能會花一點時間。" lists: "清單" noLists: "你沒有任何清單" note: "貼文" @@ -87,9 +91,9 @@ error: "錯誤" somethingHappened: "發生錯誤" retry: "重試" pageLoadError: "載入頁面失敗" -pageLoadErrorDescription: "這通常是因為網路錯誤或是瀏覽器快取殘留的原因。請先清除瀏覽器快取,稍後再重試" +pageLoadErrorDescription: "這通常是網路錯誤或瀏覽器快取殘留而引起的。請先清除瀏覽器快取,稍後再重試。" serverIsDead: "伺服器沒有回應。請稍等片刻再試。" -youShouldUpgradeClient: "請重新載入以使用新版本的客戶端顯示此頁面" +youShouldUpgradeClient: "請重新載入以使用新版客戶端顯示此頁面。" enterListName: "輸入清單名稱" privacy: "隱私" makeFollowManuallyApprove: "手動審核追隨請求" @@ -111,13 +115,13 @@ inChannelQuote: "在頻道內引用" pinnedNote: "已置頂的貼文" pinned: "置頂" you: "您" -clickToShow: "按一下以顯示" +clickToShow: "點擊查看" sensitive: "敏感內容" add: "新增" reaction: "反應" reactions: "反應" reactionSetting: "在選擇器中顯示反應" -reactionSettingDescription2: "拖動以重新列序,點擊以刪除,按下 + 添加。" +reactionSettingDescription2: "拖動以交換,點擊以刪除,按下「+」以新增。" rememberNoteVisibility: "記住貼文可見性" attachCancel: "移除附件" markAsSensitive: "標記為敏感內容" @@ -136,8 +140,10 @@ unblockConfirm: "確定解除封鎖此用戶?" suspendConfirm: "確定凍結此帳戶?" unsuspendConfirm: "確定解凍此帳戶?" selectList: "選擇清單" +editList: "編輯清單" selectChannel: "選擇頻道" selectAntenna: "選擇天線" +editAntenna: "編輯天線" selectWidget: "選擇小工具" editWidgets: "編輯小工具" editWidgetsExit: "完成" @@ -146,18 +152,20 @@ emoji: "表情符號" emojis: "表情符號" emojiName: "表情符號名稱" emojiUrl: "表情符號URL" -addEmoji: "加入表情符號" +addEmoji: "新增表情符號" settingGuide: "推薦設定" cacheRemoteFiles: "快取遠端檔案" cacheRemoteFilesDescription: "禁用此設定會停止遠端檔案的緩存,從而節省儲存空間,但資料會因直接連線從而產生額外連接數據。" +cacheRemoteSensitiveFiles: "快取遠端的敏感檔案" +cacheRemoteSensitiveFilesDescription: "若停用這個設定,則不會快取遠端的敏感檔案,而是直接連結。" flagAsBot: "此使用者是機器人" -flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整Misskey內部系統將本帳戶識別為機器人" -flagAsCat: "喵~~~~~~~~~~~~~~!!!!!!!!!!!!" +flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整Misskey內部系統將本帳戶識別為機器人。" +flagAsCat: "此帳戶是一隻貓,喵~~~!!!" flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示" flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" flagShowTimelineRepliesDescription: "啟用時,時間線除了顯示用戶的貼文以外,還會顯示用戶對其他貼文的回覆。" autoAcceptFollowed: "自動允許來自追隨中使用者的追隨請求" -addAccount: "添加帳戶" +addAccount: "新增帳戶" reloadAccountsList: "更新帳戶清單的資訊" loginFailed: "登入失敗" showOnRemote: "轉到所在實例顯示" @@ -252,7 +260,7 @@ saved: "已儲存" messaging: "聊天" upload: "上傳" keepOriginalUploading: "保留原圖" -keepOriginalUploadingDescription: "上傳圖片時保留原始圖片。關閉時,瀏覽器會在上傳時生成一張用於web發布的圖片。" +keepOriginalUploadingDescription: "上傳圖片時保留原始圖片。關閉時,瀏覽器會在上傳時生成適用於網路傳送的版本。" fromDrive: "從雲端空間" fromUrl: "從URL" uploadFromUrl: "從網址上傳" @@ -271,7 +279,7 @@ basicNotesBeforeCreateAccount: "基本注意事項" termsOfService: "服務條款" start: "開始" home: "首頁" -remoteUserCaution: "由於該使用者來自遠端實例,因此資訊可能非即時的。" +remoteUserCaution: "由於該使用者來自其他實例,因此其資訊可能不完整。" activity: "動態" images: "圖片" image: "圖片" @@ -281,12 +289,12 @@ registeredDate: "註冊日期" location: "位置" theme: "外觀主題" themeForLightMode: "在淺色模式下使用的主題" -themeForDarkMode: "在黑暗模式下使用的主題" +themeForDarkMode: "在深色模式下使用的主題" light: "淺色" -dark: "黑暗" +dark: "深色" lightThemes: "明亮主題" darkThemes: "黑暗主題" -syncDeviceDarkMode: "將黑暗模式與設備設置同步" +syncDeviceDarkMode: "同步至此裝置的深色模式設定" drive: "雲端硬碟" fileName: "檔案名稱" selectFile: "選擇檔案" @@ -311,7 +319,7 @@ copyUrl: "複製URL" rename: "重新命名" avatar: "大頭貼" banner: "橫幅" -nsfw: "敏感內容" +displayOfSensitiveMedia: "顯示敏感媒體" whenServerDisconnected: "與伺服器的連接中斷時" disconnectedFromServer: "與伺服器中斷連線" reload: "重新整理" @@ -335,18 +343,18 @@ monthX: "{month}月" yearX: "{year}年" pages: "頁面" integration: "整合" -connectService: "己連結" -disconnectService: "己斷開 " -enableLocalTimeline: "開啟本地時間軸" +connectService: "已連結" +disconnectService: "已斷開 " +enableLocalTimeline: "啟用本地時間軸" enableGlobalTimeline: "啟用全域時間軸" disablingTimelinesInfo: "為了方便,即使您關閉了時間線功能,管理員和審查員仍可以繼續使用。" registration: "註冊" -enableRegistration: "開啟新使用者註冊" +enableRegistration: "開放新使用者註冊" invite: "邀請" driveCapacityPerLocalAccount: "每個本地用戶的雲端空間大小" driveCapacityPerRemoteAccount: "每個非本地用戶的雲端空間大小" inMb: "以Mbps為單位" -iconUrl: "圖標URL" +iconUrl: "圖標 URL(例如 favicon)" bannerUrl: "橫幅圖片URL" backgroundImageUrl: "背景圖片的來源網址 " basicInfo: "基本資訊" @@ -368,26 +376,26 @@ turnstile: "Turnstile" enableTurnstile: "啟用 Turnstile" turnstileSiteKey: "網站金鑰" turnstileSecretKey: "金鑰" -avoidMultiCaptchaConfirm: "使用多種驗證方式可能會造成干擾,您要關閉其他驗證方式嗎?您可以按“取消”保留多種驗證方式。" +avoidMultiCaptchaConfirm: "使用多種驗證方式可能會造成干擾,您要關閉其他驗證方式嗎?您可以按「取消」保留多種驗證方式。" antennas: "天線" manageAntennas: "管理天線" name: "名稱" antennaSource: "接收來源" antennaKeywords: "包含關鍵字" antennaExcludeKeywords: "排除關鍵字" -antennaKeywordsDescription: "用空格分隔指定AND、用換行符分隔指定OR" +antennaKeywordsDescription: "空格代表「以及」(AND),換行代表「或者」(OR)" notifyAntenna: "通知有新貼文" withFileAntenna: "僅帶有附件的貼文" -enableServiceworker: "開啟 ServiceWorker" -antennaUsersDescription: "指定用換行符分隔的用戶名" +enableServiceworker: "啟用 ServiceWorker" +antennaUsersDescription: "填寫使用者名稱,以換行分隔" caseSensitive: "區分大小寫" withReplies: "包含回覆" connectedTo: "您的帳戶已連接到以下社交帳戶" notesAndReplies: "貼文與回覆" withFiles: "附件" silence: "禁言" -silenceConfirm: "確定要靜音此使用者嗎?" -unsilence: "解除靜音" +silenceConfirm: "確定要禁言此帳戶嗎?" +unsilence: "解除禁言" unsilenceConfirm: "確定要解除禁言嗎?" popularUsers: "熱門使用者" recentlyUpdatedUsers: "最近發文的使用者" @@ -401,25 +409,25 @@ about: "關於" aboutMisskey: "關於 Misskey" administrator: "管理員" token: "權杖" -2fa: "雙因素驗證" +2fa: "雙重驗證" totp: "驗證應用程式" totpDescription: "以驗證應用程式輸入一次性密碼" moderator: "審查員" moderation: "審查" -nUsersMentioned: "提到了{n}" -securityKeyAndPasskey: "安全金鑰・Passkey" +nUsersMentioned: "被提及到 {n} 次" +securityKeyAndPasskey: "安全金鑰、Passkey" securityKey: "安全金鑰" lastUsed: "上次使用" -lastUsedAt: "最後使用:{t}" +lastUsedAt: "上次使用:{t}" unregister: "註銷帳戶" passwordLessLogin: "設置無密碼登入" passwordLessLoginDescription: "不使用密碼,以安全金鑰或 Passkey 登入" -resetPassword: "重置密碼" +resetPassword: "重設密碼" newPasswordIs: "新密碼為「{password}」" reduceUiAnimation: "減少介面的動態視覺" share: "分享" notFound: "找不到" -notFoundDescription: "找不到與指定URL回應的頁面" +notFoundDescription: "找不到該 URL 的頁面" uploadFolder: "預設上傳資料夾" cacheClear: "清除快取" markAsReadAllNotifications: "標記所有通知為已讀" @@ -468,17 +476,17 @@ disableDrawer: "不顯示下拉式選單" showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項" noHistory: "沒有歷史紀錄" signinHistory: "登入歷史" -enableAdvancedMfm: "啟用高級MFM" -enableAnimatedMfm: "啟用MFM動畫" +enableAdvancedMfm: "啟用高級 MFM" +enableAnimatedMfm: "啟用 MFM 動畫" doing: "正在進行" category: "類別" tags: "標籤" docSource: "文件來源" createAccount: "建立帳戶" existingAccount: "現有帳戶" -regenerate: "再生" +regenerate: "再次生成" fontSize: "字體大小" -mediaListWithOneImageAppearance: "僅1枚圖片的媒體列表高度" +mediaListWithOneImageAppearance: "只有一張圖片時的媒體列表高度" limitTo: "上限為{x}" noFollowRequests: "沒有追隨您的請求" openImageInNewTab: "於新分頁中開啟圖片" @@ -487,7 +495,7 @@ local: "本地" remote: "遠端" total: "合計" weekOverWeekChanges: "與上週相比" -dayOverDayChanges: "與前一日相比" +dayOverDayChanges: "與昨日相比" appearance: "外觀" clientSettings: "客戶端設定" accountSettings: "帳戶設定" @@ -499,26 +507,26 @@ showFeaturedNotesInTimeline: "在時間軸上顯示熱門推薦" objectStorage: "Object Storage (物件儲存)" useObjectStorage: "使用Object Storage" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "引用時的URL。如果您使用的是CDN或反向代理,请指定其URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" +objectStorageBaseUrlDesc: "引用時的 URL。如果您使用的是 CDN 或反向代理,請指定其 URL,例如 S3(https://.s3.amazonaws.com)、GCS(https://storage.googleapis.com/)。" objectStorageBucket: "儲存空間(Bucket)" -objectStorageBucketDesc: "請指定您正在使用的服務的存儲桶名稱。 " +objectStorageBucketDesc: "請填寫所用服務的儲存空間(Bucket)名稱。 " objectStoragePrefix: "前綴" -objectStoragePrefixDesc: "它存儲在此前綴目錄下。" +objectStoragePrefixDesc: "它儲存在此前綴目錄下。" objectStorageEndpoint: "端點(Endpoint)" -objectStorageEndpointDesc: "如要使用AWS S3,請留空。否則請依照你使用的服務商的說明書進行設定,以''或 ':'的形式設定端點(Endpoint)。" +objectStorageEndpointDesc: "如使用 AWS S3,請留空。如使用其他服務,請按照其說明文件以「」或「:」的形式設定端點(Endpoint)。" objectStorageRegion: "地域(Region)" -objectStorageRegionDesc: "指定一個分區,例如“xx-east-1”。 如果您使用的服務沒有分區的概念,請留空或填寫“us-east-1”。" -objectStorageUseSSL: "使用SSL" -objectStorageUseSSLDesc: "如果不使用https進行API連接,請關閉" +objectStorageRegionDesc: "請填寫一個分區,例如「xx-east-1」。 如果您使用的服務不設分區,請留空或填寫「us-east-1」。" +objectStorageUseSSL: "使用 SSL" +objectStorageUseSSLDesc: "請在不使用 https 連接 API 時關閉" objectStorageUseProxy: "使用網路代理" -objectStorageUseProxyDesc: "如果不使用代理進行API連接,請關閉" -objectStorageSetPublicRead: "上傳時設定為\"public-read\"" -s3ForcePathStyleDesc: "啟用 s3ForcePathStyle 會強制將儲存槽名稱指定為 URL 中路徑的一部分,而不是主機名。 使用自託管 Minio 之類的可能需要啟用。" +objectStorageUseProxyDesc: "請在不使用網路代理連接 API 時關閉" +objectStorageSetPublicRead: "上傳時設定為「public-read」" +s3ForcePathStyleDesc: "啟用 s3ForcePathStyle 將強制填寫儲存空間(Bucket)名稱至 URL 路徑內,而非寫入主機名。 使用如 Minio 等自行託管服務時可能需要啟用。" serverLogs: "伺服器日誌" deleteAll: "刪除所有記錄" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" showFixedPostFormInChannel: "於時間軸頁頂顯示「發送貼文」方框(頻道)" -newNoteRecived: "發現新的貼文" +newNoteRecived: "發現新貼文" sounds: "音效" sound: "音效" listen: "聆聽" @@ -542,19 +550,19 @@ sort: "排序" ascendingOrder: "昇冪" descendingOrder: "降冪" scratchpad: "暫存記憶體" -scratchpadDescription: "AiScript控制台為AiScript提供了實驗環境。您可以在此編寫、執行和確認代碼與Misskey互動的结果。" +scratchpadDescription: "AiScript 控制台為 AiScript 的實驗環境。您可以在此編寫、執行和確認程式碼與 Misskey 互動的结果。" output: "輸出" script: "腳本" -disablePagesScript: "停用頁面的AiScript腳本" +disablePagesScript: "停用頁面的 AiScript 腳本" updateRemoteUser: "更新遠端使用者資訊" deleteAllFiles: "刪除所有檔案" -deleteAllFilesConfirm: "要删除所有檔案嗎?" +deleteAllFilesConfirm: "要刪除所有檔案嗎?" removeAllFollowing: "解除所有追隨" removeAllFollowingDescription: "解除{host}所有的追隨。在伺服器不再存在時執行。" userSuspended: "該使用者已被停用" userSilenced: "該用戶已被禁言。" yourAccountSuspendedTitle: "帳戶已被凍結" -yourAccountSuspendedDescription: "由於違反了伺服器的服務條款或其他原因,該帳戶已被凍結。 您可以與管理員連繫以了解更多訊息。 請不要創建一個新的帳戶。" +yourAccountSuspendedDescription: "該帳戶已因違反伺服器服務條款或其他原因而被凍結。您可以向管理員查詢更多資訊。請不要建立新帳戶。" tokenRevoked: "權杖無效" tokenRevokedDescription: "登入權杖失效,請重新登入。" accountDeleted: "帳戶已被刪除" @@ -567,28 +575,28 @@ relays: "中繼" addRelay: "新增中繼" inboxUrl: "收件夾URL" addedRelays: "已加入的中繼" -serviceworkerInfo: "您需要啟用推送通知" -deletedNote: "已删除的貼文" -invisibleNote: "私密的貼文" +serviceworkerInfo: "您需要啟用推送通知。" +deletedNote: "已刪除的貼文" +invisibleNote: "隱藏的貼文" enableInfiniteScroll: "啟用自動滾動頁面模式" visibility: "可見性" poll: "投票" useCw: "隱藏內容" -enablePlayer: "打開播放器" +enablePlayer: "開啟播放器" disablePlayer: "關閉播放器" expandTweet: "展開推文" themeEditor: "主題編輯器" description: "描述" -describeFile: "添加標題 " -enterFileDescription: "輸入標題 " +describeFile: "新增標題" +enterFileDescription: "輸入標題" author: "作者" -leaveConfirm: "有未保存的更改。要放棄嗎?" +leaveConfirm: "尚未儲存修改。要放棄嗎?" manage: "管理" plugins: "外掛" preferencesBackups: "備份設定檔" deck: "多欄模式" undeck: "取消多欄模式" -useBlurEffectForModal: "在模態框使用模糊效果" +useBlurEffectForModal: "在對話框使用模糊效果" useFullReactionPicker: "使用全尺寸的反應選擇器" width: "寬度" height: "高度" @@ -608,14 +616,14 @@ enableEmail: "啟用發送電郵功能" emailConfigInfo: "用於確認電郵地址及密碼重置" email: "電子郵件" emailAddress: "電郵地址" -smtpConfig: "SMTP伺服器設定" +smtpConfig: "SMTP 伺服器設定" smtpHost: "主機" smtpPort: "埠" smtpUser: "使用者名稱" smtpPass: "密碼" emptyToDisableSmtpAuth: "留空使用者名稱和密碼以關閉SMTP驗證。" smtpSecure: "在 SMTP 連接中使用隱式 SSL/TLS" -smtpSecureInfo: "使用STARTTLS時關閉。" +smtpSecureInfo: "使用 STARTTLS 時關閉。" testEmail: "測試郵件發送" wordMute: "被靜音的文字" regexpError: "正規表達式錯誤" @@ -638,16 +646,16 @@ useGlobalSetting: "使用全域設定" useGlobalSettingDesc: "啟用時,將使用帳戶通知設定。停用時,則可以單獨設定。" other: "其他" regenerateLoginToken: "重新產生登入權杖" -regenerateLoginTokenDescription: "重新產生用於登入的內部權杖。一般情況下是不需要這樣做的。一旦重產,所有裝置將會被登出。" +regenerateLoginTokenDescription: "重新產生用於登入的內部權杖。一般情況下是不需要這樣做的。重新產生後,所有裝置將會被登出。" setMultipleBySeparatingWithSpace: "您可以使用空格分隔多個項目。" -fileIdOrUrl: "檔案ID或URL" +fileIdOrUrl: "檔案 ID 或 URL" behavior: "行為" sample: "範例" abuseReports: "檢舉" reportAbuse: "檢舉" reportAbuseOf: "檢舉{name}" -fillAbuseReportDescription: "請填寫檢舉的詳細理由。可以的話,請附上針對的URL網址。" -abuseReported: "回報已送出。感謝您的報告。" +fillAbuseReportDescription: "請填寫檢舉的詳細理由。如有需要,請附上相關 URL。" +abuseReported: "檢舉完成。感謝您的報告。" reporter: "檢舉者" reporteeOrigin: "檢舉來源" reporterOrigin: "檢舉者來源" @@ -657,13 +665,13 @@ send: "發送" abuseMarkAsResolved: "處理完畢" openInNewTab: "在新分頁中開啟" openInSideView: "在側欄中開啟" -defaultNavigationBehaviour: "默認導航" +defaultNavigationBehaviour: "預設導航" editTheseSettingsMayBreakAccount: "修改這些設定可能會毀損您的帳戶" instanceTicker: "貼文的實例來源" waitingFor: "等待{x}" random: "隨機" system: "系統" -switchUi: "切換界面" +switchUi: "切換介面" desktop: "桌面" clip: "摘錄" createNew: "新建" @@ -672,7 +680,7 @@ createNewClip: "建立新摘錄" unclip: "解除摘錄" confirmToUnclipAlreadyClippedNote: "此貼文已包含在摘錄「{name}」中。 你想將貼文從這個摘錄中排除嗎?" public: "公開" -i18nInfo: "Misskey已經被志願者們翻譯成各種語言版本,如果想要幫忙的話,可以進入{link}幫助翻譯。" +i18nInfo: "Misskey 已被志願者們翻譯成各種語言版本。您可以瀏覽{link}幫助翻譯。" manageAccessTokens: "管理存取權杖" accountInfo: "帳戶資訊" notesCount: "貼文數量" @@ -693,7 +701,7 @@ driveUsage: "雲端硬碟使用量" noCrawle: "拒絕搜尋引擎索引" noCrawleDescription: "要求網路搜尋引擎不要索引你的個人資料頁、貼文及頁面等。" lockedAccountInfo: "即使你通過了追隨者請求,除非你將貼文的可見性設定為 「追隨者」,否則任何人都能看見你的貼文。" -alwaysMarkSensitive: "默認將圖像/影像標記為敏感內容" +alwaysMarkSensitive: "預設將多媒體標記為敏感內容" loadRawImages: "以原始圖檔顯示附件圖檔的縮圖" disableShowingAnimatedImages: "不播放動態圖檔" verificationEmailSent: "已發送驗證電子郵件。請點擊進入電子郵件中的鏈接完成驗證。" @@ -730,7 +738,7 @@ myTheme: "我的佈景主題" backgroundColor: "背景" accentColor: "重點色彩" textColor: "文字" -saveAs: "另存為..." +saveAs: "另存新檔" advanced: "進階" advancedSettings: "進階設定" value: "數值" @@ -752,10 +760,10 @@ editCode: "編輯代碼" apply: "套用" receiveAnnouncementFromInstance: "接收由本實例發出的電郵通知" emailNotification: "郵件通知" -publish: "發佈" +publish: "發布" inChannelSearch: "頻道内搜尋" useReactionPickerForContextMenu: "點擊右鍵開啟反應工具欄" -typingUsers: "{users}輸入中..." +typingUsers: "{users}輸入中" jumpToSpecifiedDate: "跳轉到特定日期" showingPastTimeline: "顯示過往的時間線" clear: "清除" @@ -763,21 +771,21 @@ markAllAsRead: "全部標示為已讀" goBack: "返回" unlikeConfirm: "要取消按讚嗎?" fullView: "全熒幕顯示" -quitFullView: "退出全熒幕顯示" -addDescription: "添加描述" +quitFullView: "退出全螢幕顯示" +addDescription: "新增描述" userPagePinTip: "在貼文的選單中選擇\"置頂\",即可置頂該貼文至您的個人檔案頁面。" notSpecifiedMentionWarning: "此貼文有未指定的提及" info: "資訊" userInfo: "用戶資料" unknown: "未知" -onlineStatus: "在線狀態" -hideOnlineStatus: "隱藏在線狀態" -hideOnlineStatusDescription: "隱藏在線狀態後,可能會降低檢索等功能的便利性。" +onlineStatus: "上線狀態" +hideOnlineStatus: "隱藏上線狀態" +hideOnlineStatusDescription: "隱藏上線狀態後,可能會降低搜尋等功能的便利性。" online: "線上" active: "最近活躍" offline: "離線" notRecommended: "不推薦" -botProtection: "Bot防護" +botProtection: "Bot 防護" instanceBlocking: "已封鎖的實例" selectAccount: "選擇帳戶" switchAccount: "切換帳戶" @@ -788,11 +796,11 @@ user: "使用者" administration: "管理" accounts: "帳戶" switch: "切換" -noMaintainerInformationWarning: "尚未設定管理員信息。" -noBotProtectionWarning: "尚未設定Bot防護。" +noMaintainerInformationWarning: "尚未設定管理員訊息。" +noBotProtectionWarning: "尚未設定 Bot 防護。" configure: "設定" postToGallery: "發佈到相簿" -postToHashtag: "以此主題標籤發布" +postToHashtag: "以此主題標籤發佈" gallery: "相簿" recentPosts: "最新貼文" popularPosts: "熱門的貼文" @@ -809,8 +817,8 @@ emailNotConfiguredWarning: "沒有設定電子郵件地址" ratio: "%" previewNoteText: "預覽文本" customCss: "自定義 CSS" -customCssWarn: "這個設定必須由具備相關知識的人員操作,不當的設定可能导致客戶端無法正常使用。" -global: "公開" +customCssWarn: "這個設定必須由具備相關知識的人員操作,不當的設定可能導致客戶端無法正常使用。" +global: "全域" squareAvatars: "頭像以方形顯示" sent: "發送" received: "收取" @@ -827,7 +835,7 @@ accountDeletionInProgress: "正在刪除帳戶" usernameInfo: "在伺服器上您的帳戶是唯一的識別名稱。您可以使用字母 (a ~ z, A ~ Z)、數字 (0 ~ 9) 和下底線 (_)。之後帳戶名是不能更改的。" aiChanMode: "小藍模式" devMode: "開發者模式" -keepCw: "保持CW" +keepCw: "保持 CW" pubSub: "Pub/Sub 帳戶" lastCommunication: "最近的通信" resolved: "已解決" @@ -848,8 +856,8 @@ makeReactionsPublicDescription: "將您做過的反應設為公開可見。" classic: "經典" muteThread: "將貼文串設為靜音" unmuteThread: "將貼文串的靜音解除" -ffVisibility: "連接的公開範圍" -ffVisibilityDescription: "您可以設定您的關注/關注者資訊的公開範圍" +ffVisibility: "連繫的可見性" +ffVisibilityDescription: "您可以設定追隨或追隨者資訊的公開範圍" continueThread: "查看更多貼文" deleteAccountConfirm: "將要刪除帳戶。是否確定?" incorrectPassword: "密碼錯誤。" @@ -868,15 +876,15 @@ numberOfColumn: "列數" searchByGoogle: "搜尋" instanceDefaultLightTheme: "實例預設的淺色主題" instanceDefaultDarkTheme: "實例預設的深色主題" -instanceDefaultThemeDescription: "輸入物件形式的主题代碼" +instanceDefaultThemeDescription: "輸入物件形式的主題代碼" mutePeriod: "靜音的期限" period: "期限" indefinitely: "無期限" -tenMinutes: "10分鐘" -oneHour: "1小時" -oneDay: "1天" -oneWeek: "1週" -oneMonth: "1個月" +tenMinutes: "十分鐘" +oneHour: "一小時" +oneDay: "一天" +oneWeek: "一週" +oneMonth: "一個月" reflectMayTakeTime: "可能需要一些時間才會出現效果。" failedToFetchAccountInformation: "取得帳戶資訊失敗" rateLimitExceeded: "已超過速率限制" @@ -892,7 +900,7 @@ thereIsUnresolvedAbuseReportWarning: "有尚未處理的檢舉。" recommended: "推薦" check: "檢查" driveCapOverrideLabel: "更改這個使用者的雲端硬碟容量上限" -driveCapOverrideCaption: "如果指定0以下的值,就會被取消。" +driveCapOverrideCaption: "如果指定 0 以下的值,就會被取消。" requireAdminForView: "必須以管理員帳戶登入才可以檢視。" isSystemAccount: "由系統自動建立與管理的帳戶。" typeToConfirm: "要執行這項操作,請輸入 {x} " @@ -919,21 +927,21 @@ failedToUpload: "上傳失敗" cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上傳。" cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上傳。" cannotUploadBecauseExceedsFileSizeLimit: "由於超過了檔案大小的限制,無法上傳。" -beta: "Beta" -enableAutoSensitive: "自動NSFW判定" -enableAutoSensitiveDescription: "如果可用,請利用機器學習在媒體上自動設置 NSFW 旗標。 即使關閉此功能,依實例而定也可能會自動設置。" -activeEmailValidationDescription: "積極地驗證用戶的電子郵件地址,判斷它是否為免洗地址,或者它是否可以通信。 若關閉,則只會檢查字元是否正確。" +beta: "測試版" +enableAutoSensitive: "自動 NSFW 判定" +enableAutoSensitiveDescription: "如果可用,它將使用機器學習技術判斷多媒體內容是否需要標記 NSFW。即使關閉此功能,也可能會依實例規則而自動啟用。" +activeEmailValidationDescription: "積極驗證帳戶的電郵地址,以判斷它是否可以通訊。關閉此選項代表只會檢查地址是否符合格式。" navbar: "導覽列" shuffle: "隨機" account: "帳戶" move: "移動 " pushNotification: "推播通知" subscribePushNotification: "啟用推播通知" -unsubscribePushNotification: "停止推播通知" +unsubscribePushNotification: "停用推播通知" pushNotificationAlreadySubscribed: "推播通知啟用中" pushNotificationNotSupported: "瀏覽器或實例不支援推播通知" -sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除" -sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。" +sendPushNotificationReadMessage: "如果已閱讀通知與訊息,就刪除推播通知" +sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會更消耗裝置電池。" windowMaximize: "最大化" windowMinimize: "最小化" windowRestore: "復原" @@ -948,8 +956,8 @@ numberOfLikes: "讚數" show: "檢視" neverShow: "不再顯示" remindMeLater: "以後再說" -didYouLikeMisskey: "您是否喜愛Misskey呢?" -pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發能夠持續!" +didYouLikeMisskey: "您喜歡 Misskey 嗎?" +pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!" roles: "角色" role: "角色" noRole: "沒有角色" @@ -961,23 +969,23 @@ color: "顏色" manageCustomEmojis: "管理自訂表情符號" youCannotCreateAnymore: "您無法再建立更多了。" cannotPerformTemporary: "暫時無法進行" -cannotPerformTemporaryDescription: "由於超過操作次數限制,暫時無法進行。請過一段時間之後再嘗試。" +cannotPerformTemporaryDescription: "由於超過操作次數限制,因此暫時無法進行。請稍後再嘗試。" invalidParamError: "參數錯誤" -invalidParamErrorDescription: "請求參數有問題。通常是bug造成的,但也有輸入的字元數過多之類的可能性。" +invalidParamErrorDescription: "請求參數有問題。這可能是漏洞或輸入過多字元所致。" permissionDeniedError: "操作被拒絕" -permissionDeniedErrorDescription: "本帳號沒有執行這個操作的權限。" +permissionDeniedErrorDescription: "此帳戶沒有執行這個操作的權限。" preset: "預設值" selectFromPresets: "從預設值中選擇" achievements: "成就" gotInvalidResponseError: "伺服器的回應無效" gotInvalidResponseErrorDescription: "伺服器可能已關閉或者在維護中,請稍後再試。" thisPostMayBeAnnoying: "這篇貼文可能會造成別人的困擾。" -thisPostMayBeAnnoyingHome: "發布到首頁" +thisPostMayBeAnnoyingHome: "發佈到首頁" thisPostMayBeAnnoyingCancel: "退出" -thisPostMayBeAnnoyingIgnore: "直接發布貼文" +thisPostMayBeAnnoyingIgnore: "直接發佈貼文" collapseRenotes: "省略顯示已看過的轉發貼文" internalServerError: "內部伺服器錯誤" -internalServerErrorDescription: "內部伺服器發生了非預期的錯誤。" +internalServerErrorDescription: "內部伺服器出現意外錯誤。" copyErrorInfo: "複製錯誤資訊" joinThisServer: "在此伺服器上註冊" exploreOtherServers: "探索其他伺服器" @@ -987,7 +995,7 @@ disableFederationConfirmWarn: "即使停止了聯邦功能,貼文也不會變 disableFederationOk: "停止聯邦功能" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" emailNotSupported: "這個伺服器不支援寄送郵件" -postToTheChannel: "發布到頻道" +postToTheChannel: "發佈到頻道" cannotBeChangedLater: "之後不能變更。" reactionAcceptance: "接受表情反應" likeOnly: "僅限讚" @@ -998,7 +1006,7 @@ rolesAssignedToMe: "指派給自己的角色" resetPasswordConfirm: "重設密碼?" sensitiveWords: "敏感詞" sensitiveWordsDescription: "將含有設定詞彙的貼文可見性設為發送至首頁。可以用換行來進行複數的設定。" -sensitiveWordsDescription2: "用空格分隔關鍵詞構成AND格式,用斜線包圍關鍵字構成正規表達式。" +sensitiveWordsDescription2: "空格代表「以及」(AND),斜線包圍關鍵字代表使用正規表達式。" notesSearchNotAvailable: "無法使用搜尋貼文功能。" license: "授權" unfavoriteConfirm: "要取消收錄我的最愛嗎?" @@ -1009,8 +1017,8 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?" retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" enableChartsForRemoteUser: "生成遠端用戶的圖表" enableChartsForFederatedInstances: "生成遠端伺服器的圖表" -showClipButtonInNoteFooter: "將摘錄添加至貼文" -largeNoteReactions: "將貼文的反應放大顯示" +showClipButtonInNoteFooter: "新增摘錄至貼文" +largeNoteReactions: "放大顯示貼文反應" noteIdOrUrl: "貼文ID或URL" video: "影片" videos: "影片" @@ -1030,39 +1038,62 @@ rightTop: "右上" leftBottom: "左下" rightBottom: "右下" stackAxis: "堆疊方向" -vertical: "縱向" -horizontal: "側向" +vertical: "直向" +horizontal: "橫向" position: "位置" serverRules: "伺服器規則" pleaseConfirmBelowBeforeSignup: "在本伺服器註冊之前,請確認下列事項。" pleaseAgreeAllToContinue: "必須全部勾選「同意」才能繼續。" continue: "繼續" preservedUsernames: "保留的使用者名稱" -preservedUsernamesDescription: "換行列舉要保留的使用者名稱。此處指定的使用者名稱,在建立帳戶時無法使用,但由管理者所建立的帳戶不受此限。此外,既有的帳戶也不受影響。" +preservedUsernamesDescription: "換行列舉要保留的使用者名稱。此處出現的名稱將在註冊時禁用,但由管理者建立帳戶則不受此限。此外,既有的帳戶也不受影響。" createNoteFromTheFile: "由此檔案建立貼文" archive: "封存" channelArchiveConfirmTitle: "要封存{name}嗎?" -channelArchiveConfirmDescription: "封存以後,在頻道列表與搜索結果中不會顯示,也無法發布新的貼文。" +channelArchiveConfirmDescription: "封存後,將不會在頻道列表與搜尋結果中顯示,也無法發佈新貼文。" thisChannelArchived: "這個頻道已被封存。" displayOfNote: "顯示貼文" initialAccountSetting: "初始設定" youFollowing: "追隨中" preventAiLearning: "拒絕接受生成式AI的訓練" -preventAiLearningDescription: "要求外部的文章生成式AI或圖像生成式AI不以發布的貼文和圖像等內容為學習對象。這是透過在HTML響應中包含noai旗標來實現的,但不能完全防止AI的學習,因為這要看該AI是否遵守這個要求。" +preventAiLearningDescription: "要求站外生成式 AI 不使用您發佈的內容訓練模型。此功能會使伺服器於 HTML 回應新增「noai」標籤,而因為要視乎 AI 會否遵守該標籤,所以此功能無法完全阻止所有 AI 使用您的內容。" options: "選項" specifyUser: "指定使用者" failedToPreviewUrl: "無法預覽" update: "更新" -rolesThatCanBeUsedThisEmojiAsReaction: "可以當成反應使用的角色" -rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "如果是未指定角色的情況,則任何人都可以被當成反應來使用。" -rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "角色必須是公開的角色。" -cancelReactionConfirm: "要取消做出的反應嗎?" -changeReactionConfirm: "要變更做出的反應嗎?" +rolesThatCanBeUsedThisEmojiAsReaction: "可以使用此表情符號為反應的角色" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "如沒有指定角色,任何人都可使用此表情回應。" +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "必須為公開角色。" +cancelReactionConfirm: "要取消此反應嗎?" +changeReactionConfirm: "要更改反應嗎?" later: "稍後再說" -goToMisskey: "往Misskey" +goToMisskey: "往 Misskey" additionalEmojiDictionary: "表情符號的附加辭典" installed: "已安裝" branding: "品牌宣傳" +enableServerMachineStats: "公佈伺服器的機器資訊" +enableIdenticonGeneration: "啟用生成使用者的 Identicon " +turnOffToImprovePerformance: "關閉時會提高性能。" +createInviteCode: "建立邀請碼" +createWithOptions: "使用選項建立" +createCount: "建立數" +inviteCodeCreated: "已建立邀請碼" +inviteLimitExceeded: "可建立的邀請碼已達上限。" +createLimitRemaining: "可建立的邀請碼:剩餘 {limit} 個" +inviteLimitResetCycle: "可以在 {time} 內建立最多 {limit} 個邀請碼。" +expirationDate: "有效日期" +noExpirationDate: "不設有效日期" +inviteCodeUsedAt: "使用邀請碼的日期和時間" +registeredUserUsingInviteCode: "用了邀請碼的使用者" +waitingForMailAuth: "等待電子郵件認證" +inviteCodeCreator: "建立了邀請碼的使用者" +usedAt: "使用的日期和時間" +unused: "未使用" +used: "已使用" +expired: "過期" +doYouAgree: "你同意嗎?" +beSureToReadThisAsItIsImportant: "重要,請務必閱讀。" +iHaveReadXCarefullyAndAgree: "我已仔細閱讀並同意「{x}」的内容。" _initialAccountSetting: accountCreated: "帳戶已建立完成!" letsStartAccountSetup: "來進行帳戶的初始設定吧。" @@ -1079,7 +1110,7 @@ _initialAccountSetting: skipAreYouSure: "要略過初始設定嗎?" laterAreYouSure: "稍後再重新進行初始設定嗎?" _serverRules: - description: "設定伺服器的簡要規則,在新的註冊之前顯示。建議的內容是使用條款的摘要。" + description: "設定在註冊頁面顯示的伺服器簡要規則。建議是服務條款的摘要。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" @@ -1093,111 +1124,111 @@ _accountMigration: startMigration: "遷移" migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。" - postMigrationNote: "在遷移操作後的24小時之後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。" + postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。" movedTo: "要遷移到的帳戶:" _achievements: earnedAt: "獲得日期" _types: _notes1: - title: "just setting up my msky" + title: "歡迎!" description: "發出了第一則貼文" - flavor: "祝您的Misskey生活愉快!" + flavor: "祝您的 Misskey 生活愉快!" _notes10: title: "若干貼文" - description: "發表了10則貼文" + description: "發佈了十篇貼文" _notes100: title: "許多貼文" - description: "發表了100則貼文" + description: "發佈了一百篇貼文" _notes500: title: "滿滿的貼文" - description: "發表了500則貼文" + description: "發佈了五百篇貼文" _notes1000: title: "堆積如山的貼文" - description: "發表了1000則貼文" + description: "發佈了一千篇貼文" _notes5000: title: "滔滔不絕的貼文" - description: "發表了5000則貼文" + description: "發佈了五千篇貼文" _notes10000: title: "超級貼文" - description: "發表了10000則貼文" + description: "發佈了一萬篇貼文" _notes20000: - title: "需要更多的貼文" - description: "發表了20000則貼文" + title: "需要更多貼文" + description: "發佈了兩萬篇貼文" _notes30000: title: "貼文貼文貼文" - description: "發表了30000則貼文" + description: "發佈了三萬篇貼文" _notes40000: title: "貼文工廠" - description: "發表了40000則貼文" + description: "發佈了四萬篇貼文" _notes50000: title: "貼文星球" - description: "發表了50000則貼文" + description: "發佈了五萬篇貼文" _notes60000: title: "貼文類星體" - description: "發表了60000則貼文" + description: "發佈了六萬篇貼文" _notes70000: title: "貼文黑洞" - description: "發表了70000則貼文" + description: "發佈了七萬篇貼文" _notes80000: title: "貼文銀河" - description: "發表了80000則貼文" + description: "發佈了八萬篇貼文" _notes90000: title: "貼文宇宙" - description: "發表了90000則貼文" + description: "發佈了九萬篇貼文" _notes100000: title: "ALL YOUR NOTE ARE BELONG TO US" - description: "發表了100,000則貼文" + description: "發佈了十萬篇貼文" flavor: "有這麼多東西要寫嗎?" _login3: title: "初學者Ⅰ" - description: "總登入天數為3天" - flavor: "從今天開始,我就是Misskist" + description: "總登入天數為三天" + flavor: "從今天開始,我就是 Misskist" _login7: title: "初學者ⅠⅠ" - description: "總登入天數為7天" + description: "總登入天數為七天" flavor: "您開始習慣了嗎?" _login15: title: "初學者ⅠⅠⅠ" - description: "總登入天數為15天" + description: "總登入天數為十五天" _login30: title: "Misskist Ⅰ" - description: "總登入天數為30天" + description: "總登入天數為三十天" _login60: title: "Misskist ⅠⅠ" - description: "總登入天數為60天" + description: "總登入天數為六十天" _login100: title: "Misskist ⅠⅠⅠ" - description: "總登入天數為100天" - flavor: "辣個 Misskist 用戶" + description: "總登入天數為一百天" + flavor: "凶暴的 Misskist" _login200: title: "普通Ⅰ" - description: "總登入天數為200天" + description: "總登入天數為兩百天" _login300: - title: "普通IⅠ" - description: "總登入天數為300天" + title: "普通ⅠⅠ" + description: "總登入天數為三百天" _login400: - title: "普通IIⅠ" - description: "總登入天數為400天" + title: "普通ⅠⅠⅠ" + description: "總登入天數為四百天" _login500: title: "老兵Ⅰ" - description: "總登入天數為500天" + description: "總登入天數為五百天" flavor: "諸君,我喜歡貼文" _login600: title: "老兵ⅠⅠ" - description: "總登入天數為600天" + description: "總登入天數為六百天" _login700: title: "老兵ⅠⅠⅠ" - description: "總登入天數為700天" + description: "總登入天數為七百天" _login800: title: "貼文大師Ⅰ" - description: "總登入天數為800天" + description: "總登入天數為八百天" _login900: title: "貼文大師ⅠⅠ" - description: "總登入天數為900天" + description: "總登入天數為九百天" _login1000: title: "貼文大師ⅠⅠⅠ" - description: "總登入天數為1,000天" - flavor: "感謝您使用Misskey!" + description: "總登入天數為一千天" + flavor: "感謝您使用 Misskey!" _noteClipped1: title: "忍不住要收進摘錄裡" description: "第一次將貼文收進摘錄" @@ -1224,7 +1255,7 @@ _achievements: title: "朋友很多" description: "追隨超過50人了" _following100: - title: "100位朋友" + title: "一百位朋友" description: "追隨超過100人了" _following300: title: "朋友過多" @@ -1246,20 +1277,20 @@ _achievements: description: "追隨者超過300人了" _followers500: title: "基地台" - description: "超過500名追隨者了" + description: "超過五百名追隨者了" _followers1000: title: "影響者" - description: "超過1000名追隨者了" + description: "超過一千名追隨者了" _collectAchievements30: title: "成就收藏家" - description: "獲得30個以上的成就" + description: "獲得三十個以上的成就" _viewAchievements3min: title: "喜愛成就" - description: "看成就列表要花3分鐘以上" + description: "看成就列表要花三分鐘以上" _iLoveMisskey: title: "I Love Misskey" - description: "發布「I ❤ #Misskey」" - flavor: "感謝您使用Misskey! by 開發團隊" + description: "發佈「I ❤ #Misskey」" + flavor: "感謝您使用 Misskey!by 開發團隊" _foundTreasure: title: "尋寶" description: "發現了隱藏的寶藏" @@ -1357,7 +1388,7 @@ _role: iconUrl: "圖示的URL" asBadge: "顯示為徽章" descriptionOfAsBadge: "開啟的話,角色圖示會顯示在用戶名旁邊。" - isExplorable: "公開角色時間軸" + isExplorable: "讓使用者更容易找到您" descriptionOfIsExplorable: "若開啟則公開角色時間軸。若角色不是公開的,則無法公開時間軸。" displayOrder: "顯示順序" descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" @@ -1373,13 +1404,16 @@ _role: ltlAvailable: "瀏覽本地時間軸" canPublicNote: "允許公開貼文" canInvite: "發行實例邀請碼" + inviteLimit: "可建立邀請碼的數量" + inviteLimitCycle: "邀請碼的發放間隔" + inviteExpirationTime: "邀請碼的有效日期" canManageCustomEmojis: "管理自訂表情符號" driveCapacity: "雲端硬碟容量" alwaysMarkNsfw: "總是將檔案標記為NSFW" pinMax: "置頂貼文的最大數量" antennaMax: "可建立的天線數量" wordMuteMax: "靜音文字的最大字數" - webhookMax: "可建立的Webhook數量" + webhookMax: "可建立的 Webhook 數量" clipMax: "可建立的摘錄數量" noteEachClipsMax: "摘錄內貼文的最大數量" userListMax: "可建立的使用者清單數量" @@ -1397,37 +1431,37 @@ _role: followersMoreThanOrEq: "追隨者人數在~以上" followingLessThanOrEq: "追隨人數在~以下" followingMoreThanOrEq: "追隨人數在~以上" - notesLessThanOrEq: "發布數在~以下" - notesMoreThanOrEq: "發布數在~以上" - and: "~和~" + notesLessThanOrEq: "貼文數在~以下" + notesMoreThanOrEq: "貼文數在~以上" + and: "~及~" or: "~或~" not: "~否" _sensitiveMediaDetection: description: "您可以使用機器學習自動檢測敏感媒體並將其用於審查。 伺服器的負荷會稍微增加。" sensitivity: "檢測敏感度" sensitivityDescription: "敏感度低時,誤檢測(偽陽性)會減少。敏感度高時,漏檢(偽陰性)會減少。" - setSensitiveFlagAutomatically: "設定 NSFW 旗標" + setSensitiveFlagAutomatically: "設定 NSFW 標籤" setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。" analyzeVideos: "啟用影片分析" analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。" _emailUnavailable: - used: "已經在使用中" + used: "已被使用" format: "格式無效" disposable: "不是永久可用的地址" mx: "郵件伺服器不正確" smtp: "郵件伺服器沒有應答" _ffVisibility: - public: "發佈" + public: "公開" followers: "只有關注你的用戶能看到" private: "私密" _signup: almostThere: "即將完成" emailAddressInfo: "請輸入您所使用的電子郵件地址。電子郵件地址不會被公開。" - emailSent: "已將確認郵件發送至您輸入的電子郵件地址 ({email})。請開啟電子郵件中的連結以完成帳戶創建。" + emailSent: "已發送確認郵件至您輸入的電子郵件地址({email})。請開啟電子郵件中的連結完成註冊。" _accountDelete: accountDelete: "刪除帳戶" - mayTakeTime: "刪除帳戶的處理負荷較大,如果帳戶產生的內容數量上傳的檔案數量較多的話,就需要花费一段時間才能完成。" - sendEmail: "帳戶删除完成後,將向註冊地電子郵件地址發送通知。" + mayTakeTime: "刪除帳戶的處理負荷較大,如果帳戶發佈的內容以及上傳的檔案數量較多,則需要一段時間才能完成。" + sendEmail: "帳戶刪除完成後,將向其電子郵件地址發送通知。" requestAccountDelete: "刪除帳戶請求" started: "已開始刪除作業。" inProgress: "正在刪除" @@ -1435,6 +1469,7 @@ _ad: back: "返回" reduceFrequencyOfThisAd: "降低此廣告的頻率 " hide: "隱藏" + timezoneinfo: "星期幾是由伺服器的時區指定的。" _forgotPassword: enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。" ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。 " @@ -1442,8 +1477,8 @@ _forgotPassword: _gallery: my: "我的貼文" liked: "喜歡的貼文" - like: "讚" - unlike: "收回喜歡" + like: "讚好" + unlike: "收回讚好" _email: _follow: title: "您有新的追隨者" @@ -1451,7 +1486,7 @@ _email: title: "收到追隨請求" _plugin: install: "安裝外掛組件" - installWarn: "請不要安裝來源不明的外掛組件。" + installWarn: "請不要安裝來源不明的外掛。" manage: "管理外掛" _preferencesBackups: list: "已備份的設定檔" @@ -1461,7 +1496,7 @@ _preferencesBackups: save: "覆蓋存檔" inputName: "輸入備份檔名稱" cannotSave: "無法儲存" - nameAlreadyExists: "備份檔名稱「{name}」已經存在。請指定不同的名稱。" + nameAlreadyExists: "備份檔名稱「{name}」已經存在。請填寫其他名稱。" applyConfirm: "將備份檔「{name}」套用在現在的裝置嗎?現在的裝置設定將會消失。" saveConfirm: "要覆蓋存檔{name}嗎?" deleteConfirm: "要刪除{name}嗎?" @@ -1482,14 +1517,14 @@ _aboutMisskey: contributors: "主要貢獻者" allContributors: "全體貢獻人員" source: "原始碼" - translation: "翻譯Misskey" - donate: "贊助Misskey" + translation: "翻譯 Misskey" + donate: "贊助 Misskey" morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰" patrons: "贊助者" -_nsfw: - respect: "隱藏敏感內容" - ignore: "不隱藏敏感內容" - force: "隱藏所有內容" +_displayOfSensitiveMedia: + respect: "隱藏被標記為敏感的多媒體內容" + ignore: "不隱藏被標記為敏感的多媒體內容" + force: "隱藏所有多媒體內容" _instanceTicker: none: "隱藏" remote: "向遠端使用者顯示" @@ -1511,23 +1546,23 @@ _channel: nameAndDescription: "名稱與說明" nameOnly: "僅名稱" _menuDisplay: - sideFull: "側向" - sideIcon: "側向(圖示)" + sideFull: "橫向" + sideIcon: "橫向(圖示)" top: "頂部" hide: "隱藏" _wordMute: muteWords: "加入靜音文字" - muteWordsDescription: "用空格分隔指定AND,用換行分隔指定OR。" - muteWordsDescription2: "將關鍵字用斜線括起來表示正規表達式。" - softDescription: "隱藏時間軸中指定條件的貼文。" - hardDescription: "具有指定條件的貼文將不添加到時間軸。 即使您更改條件,未被添加的貼文也會被排除在外。" + muteWordsDescription: "空格代表「以及」(AND),換行代表「或者」(OR)。" + muteWordsDescription2: "用斜線包圍關鍵字代表正規表達式。" + softDescription: "隱藏時間軸中符合特定條件的貼文。" + hardDescription: "符合特定條件的貼文將不會新增至時間軸。 即使您更改條件,未被新增的貼文也會被排除在外。" soft: "軟性靜音" hard: "硬性靜音" mutedNotes: "已靜音的貼文" _instanceMute: instanceMuteDescription: "包括對被靜音實例上的用戶的回覆,被設定的實例上所有貼文及轉發都會被靜音。" - instanceMuteDescription2: "設定時以換行進行分隔" - title: "被設定的實例,貼文將被隱藏。" + instanceMuteDescription2: "換行以分隔" + title: "將隱藏被設定的實例貼文。" heading: "將實例靜音" _theme: explore: "取得佈景主題" @@ -1552,13 +1587,13 @@ _theme: func: "函数" funcKind: "功能類型" argument: "參數" - basedProp: "要基於的屬性的名稱 " + basedProp: "基於的屬性名稱 " alpha: "透明度" darken: "暗度" lighten: "亮度" - inputConstantName: "請輸入常數的名稱" + inputConstantName: "請輸入常數名稱" importInfo: "您可以在此貼上主題代碼,將其匯入編輯器中" - deleteConstantConfirm: "確定要删除常數{const}嗎?" + deleteConstantConfirm: "確定要刪除常數{const}嗎?" keys: accent: "重點色彩" bg: "背景" @@ -1566,14 +1601,14 @@ _theme: focus: "聚焦" indicator: "指標" panel: "面板" - shadow: "陰影" + shadow: "影子" header: "標題" navBg: "側邊欄的背景 " navFg: "側邊欄的文字" - navHoverFg: "側邊欄文字(懸停) " - navActive: "側邊欄文本 (活動)" + navHoverFg: "側邊欄文字(懸浮) " + navActive: "側邊欄文字(活動)" navIndicator: "側邊欄指示符" - link: "鏈接" + link: "連結" hashtag: "標籤" mention: "提到" mentionMe: "提到了我" @@ -1581,15 +1616,15 @@ _theme: modalBg: "對話框背景" divider: "分割線" scrollbarHandle: "捲動條" - scrollbarHandleHover: "捲動條 (漂浮)" + scrollbarHandleHover: "捲動條(懸浮)" dateLabelFg: "日期標籤文字" infoBg: "資訊背景" infoFg: "資訊內容" infoWarnBg: "警告背景" - infoWarnFg: "警告字元" + infoWarnFg: "警告文字" cwBg: "CW 按鈕背景" - cwFg: "CW 按鈕文本" - cwHoverBg: "CW 按鈕背景 (漂浮)" + cwFg: "CW 按鈕文字" + cwHoverBg: "CW 按鈕背景(懸浮)" toastBg: "通知背景" toastFg: "通知文本" buttonBg: "按鈕背景" @@ -1598,11 +1633,11 @@ _theme: listItemHoverBg: "列表物品背景 (漂浮)" driveFolderBg: "雲端硬碟文件夾背景" wallpaperOverlay: "壁紙覆蓋層" - badge: "獎章" + badge: "徽章" messageBg: "私訊背景" - accentDarken: "強調色(偏暗)" - accentLighten: "強調色(明亮)" - fgHighlighted: "高亮顯示文本" + accentDarken: "強調色(黑暗)" + accentLighten: "強調色(明亮)" + fgHighlighted: "突顯文字" _sfx: note: "貼文" noteMy: "我的貼文" @@ -1618,7 +1653,7 @@ _ago: minutesAgo: "{n}分鐘前" hoursAgo: "{n}小時前" daysAgo: "{n}天前" - weeksAgo: "{n}周前" + weeksAgo: "{n}週前" monthsAgo: "{n}個月前" yearsAgo: "{n}年前" invalid: "未發現" @@ -1628,33 +1663,33 @@ _time: hour: "小時" day: "日" _timelineTutorial: - title: "Misskey的使用方法" - step1_1: "這個畫面是「時間軸」。發布到{name}的「貼文」按照時間順序顯示。" - step1_2: "時間軸有多種類型,例如在「首頁時間軸」中流動的是您追蹤的人的貼文;而在「本地時間軸」流動的是{name}全體的貼文。" - step2_1: "試試看,發布個貼文吧!按畫面上鉛筆圖示的按鈕開啟表格。" - step2_2: "初次貼文的內容,建議包括自我介紹以及「開始使用{name}」。" + title: "Misskey 的使用方法" + step1_1: "這個畫面是「時間軸」。發佈到{name}的「貼文」會按照時間順序顯示。" + step1_2: "時間軸有多種類型,例如「首頁時間軸」是您追蹤帳戶的貼文、「本地時間軸」是{name}內所有帳戶的貼文。" + step2_1: "不如現在就嘗試發文吧!按鉛筆圖示的按鈕開啟發文頁面。" + step2_2: "您可以在第一篇貼文裡寫自我介紹,或是「我來到 {name} 了」之類的話。" step3_1: "貼文發出去了嗎?" - step3_2: "如果你的貼文出現在時間軸上,就代表發文成功。" + step3_2: "如果您的貼文出現在時間軸上,就代表發文成功。" step4_1: "可以對貼文標記「反應」。" - step4_2: "點擊貼文的「+」圖示,即可選擇喜好的表情符號來標記反應。" + step4_2: "點擊貼文的「+」圖示,即可選擇表情符號來反應。" _2fa: - alreadyRegistered: "此設備已經被註冊過了" + alreadyRegistered: "此裝置已被註冊過了" registerTOTP: "開始設定驗證應用程式" passwordToTOTP: "請輸入密碼" - step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。" - step2: "然後,掃描螢幕上的QR code。" - step2Click: "點擊QR code,可以使用設備上安裝的驗證應用程式或金鑰環進行註冊。" - step2Url: "在桌面版應用中,請輸入以下的URL:" + step1: "首先,在您的裝置上安裝驗證程式,例如 {a} 或 {b}。" + step2: "然後,掃描螢幕上的 QR 碼。" + step2Click: "您可以點擊 QR 碼,以使用裝置上的驗證應用程式或金鑰環註冊。" + step2Url: "請在桌面版應用程式中輸入以下的 URL:" step3Title: "輸入驗證碼" - step3: "輸入您的App提供的權杖以完成設定。" + step3: "輸入應用程式所提供的權杖以完成設定。" step4: "從現在開始,任何登入操作都將要求您提供權杖。" securityKeyNotSupported: "您的瀏覽器不支援安全金鑰。" - registerTOTPBeforeKey: "要註冊安全金鑰・Passkey,請先設定驗證應用程式。" - securityKeyInfo: "您可以設定使用支援FIDO2的硬體安全鎖、終端設備的指纹認證或者PIN碼來登入。" - chromePasskeyNotSupported: "目前不支援Chrome的Passkey。" - registerSecurityKey: "註冊安全金鑰・Passkey" + registerTOTPBeforeKey: "如要註冊安全金鑰或 Passkey,請先設定驗證應用程式。" + securityKeyInfo: "您可以設定使用支援 FIDO2 的硬體安全鎖、終端設備的指紋認證,或者 PIN 碼來登入。" + chromePasskeyNotSupported: "目前不支援 Chrome 的 Passkey。" + registerSecurityKey: "註冊安全金鑰或 Passkey" securityKeyName: "輸入金鑰名稱" - tapSecurityKey: "按照瀏覽器的說明操作,註冊安全金鑰和Passkey。" + tapSecurityKey: "按照瀏覽器的說明註冊安全金鑰或 Passkey。" removeKey: "刪除安全金鑰" removeKeyConfirm: "要刪除{name}嗎?" whyTOTPOnlyRenew: "如果註冊了安全金鑰,則無法解除驗證應用程式的設定。" @@ -1672,9 +1707,9 @@ _permissions: "read:favorites": "瀏覽我的最愛" "write:favorites": "編輯我的最愛列表" "read:following": "查看追隨中的用戶資訊" - "write:following": "追隨/解除追隨" + "write:following": "追隨/解除追隨" "read:messaging": "顯示訊息" - "write:messaging": "撰寫或刪除私人訊息" + "write:messaging": "撰寫或刪除訊息" "read:mutes": "顯示已靜音列表" "write:mutes": "編輯已靜音列表" "write:notes": "撰寫或刪除貼文" @@ -1727,12 +1762,12 @@ _widgets: calendar: "行事曆" trends: "發燒貼文" clock: "時鐘" - rss: "RSS閱讀器" - rssTicker: "RSS跑馬燈" + rss: "RSS 閱讀器" + rssTicker: "RSS 跑馬燈" activity: "動態" photos: "照片" digitalClock: "電子時鐘" - unixClock: "UNIX時間" + unixClock: "UNIX 時間" federation: "站台聯邦" instanceCloud: "實例雲" postForm: "發佈窗口" @@ -1740,8 +1775,8 @@ _widgets: button: "按鈕" onlineUsers: "線上的用戶" jobQueue: "佇列" - serverMetric: "服務器指標 " - aiscript: "AiScript控制台" + serverMetric: "伺服器指標 " + aiscript: "AiScript 控制台" aiscriptApp: "AiScript App" aichan: "小藍" userList: "使用者列表" @@ -1751,39 +1786,39 @@ _widgets: _cw: hide: "隱藏" show: "瀏覽更多" - chars: "{count}字元" + chars: "{count} 個字元" files: "{count} 個檔案" _poll: noOnlyOneChoice: "至少需要兩個選項。" - choiceN: "選擇{n}" + choiceN: "選擇 {n}" noMore: "沒辦法再添加選項了" canMultipleVote: "可以多次投票" expiration: "期限" infinite: "無期限" at: "結束時間" - after: "進度指定 " + after: "指定時效" deadlineDate: "截止日期" deadlineTime: "小時" duration: "時長" - votesCount: "{n}票" - totalVotes: "一共{n}票" + votesCount: "{n} 票" + totalVotes: "合共 {n} 票" vote: "投票" showResult: "顯示結果" voted: "已投票" closed: "已結束" - remainingDays: "{d}天{h}小時後結束" - remainingHours: "{h}小時{m}分後結束" - remainingMinutes: "{m}分{s}秒後結束" - remainingSeconds: "{s}秒後截止" + remainingDays: "{d} 天 {h} 小時後結束" + remainingHours: "{h} 小時 {m} 分後結束" + remainingMinutes: "{m} 分 {s} 秒後結束" + remainingSeconds: "{s} 秒後截止" _visibility: public: "公開" - publicDescription: "發布給所有用戶 " + publicDescription: "發佈給所有帳戶" home: "首頁" - homeDescription: "僅發送至首頁的時間軸" + homeDescription: "僅發布至首頁的時間軸" followers: "追隨者" - followersDescription: "僅發送至關注者" + followersDescription: "僅發布至關注者" specified: "指定使用者" - specifiedDescription: "僅發送至指定使用者" + specifiedDescription: "僅發布至指定使用者" disableFederation: "停用聯邦" disableFederationDescription: "不要傳遞給其他實例" _postForm: @@ -1791,12 +1826,12 @@ _postForm: quotePlaceholder: "引用此貼文..." channelPlaceholder: "發佈到頻道" _placeholders: - a: "今天過得如何?" - b: "有什麼新鮮事嗎?" + a: "今天過得如何?" + b: "有什麼新鮮事嗎?" c: "有什麼新鮮想法嗎?" - d: "想要發布些什麼嗎?" - e: "寫些什麼吧..." - f: "期待你發佈的內容..." + d: "想要發佈些什麼嗎?" + e: "寫些什麼吧……" + f: "靜待發文……" _profile: name: "名稱" username: "使用者名稱" @@ -1821,45 +1856,45 @@ _exportOrImport: _charts: federation: "站台聯邦" apRequest: "請求" - usersIncDec: "使用者増減" + usersIncDec: "使用者增減" usersTotal: "使用者合共" activeUsers: "活躍使用者" notesIncDec: "貼文増減" localNotesIncDec: "本地貼文増減" remoteNotesIncDec: "遠端貼文數目增减" notesTotal: "貼文合共" - filesIncDec: "檔案増減" - filesTotal: "累計檔案" - storageUsageIncDec: "儲存空間的増減" - storageUsageTotal: "已使用的儲存空間合共" + filesIncDec: "檔案增減" + filesTotal: "檔案總數" + storageUsageIncDec: "儲存空間增減" + storageUsageTotal: "儲存空間用量" _instanceCharts: requests: "請求" - users: "使用者増減" - usersTotal: "總計使用者" - notes: "貼文増減" + users: "使用者增減" + usersTotal: "使用者總數" + notes: "貼文增減" notesTotal: "累計貼文" - ff: "追隨/追隨者的増減" - ffTotal: "追隨/追隨者累計" - cacheSize: "增加或減少快取用量" - cacheSizeTotal: "快取大小總計" - files: "檔案數量的増減" - filesTotal: "檔案數量總計" + ff: "追隨/追隨者增減" + ffTotal: "追隨/追隨者總數" + cacheSize: "快取用量增減" + cacheSizeTotal: "快取用量總數" + files: "檔案總數增減" + filesTotal: "檔案總數累計" _timelines: home: "首頁" local: "本地" social: "社交" global: "公開" _play: - new: "新增Play" - edit: "編輯Play" - created: "已新增Play" - updated: "已更新Play" - deleted: "已刪除Play" + new: "新增 Play" + edit: "編輯 Play" + created: "已新增Play " + updated: "已更新Play " + deleted: "已刪除 Play" pageSetting: "Play設定" - editThisPage: "編輯這個Play" + editThisPage: "編輯此 Play" viewSource: "檢視原始碼" - my: "自己的Play" - liked: "按了讚的Play" + my: "自己的 Play" + liked: "按讚的 Play" featured: "人氣" title: "標題" script: "腳本" @@ -1872,16 +1907,16 @@ _pages: updated: "頁面已更新" deleted: "頁面已被刪除" pageSetting: "頁面設定" - nameAlreadyExists: "指定的頁面URL已經存在" - invalidNameTitle: "指定的頁面URL無效" + nameAlreadyExists: "該頁面 URL 已存在" + invalidNameTitle: "無效的頁面 URL" invalidNameText: "請確定是否為非空白" editThisPage: "編輯此頁面" viewSource: "檢視原始碼" viewPage: "顯示頁面" - like: "喜歡" - unlike: "收回喜歡" + like: "讚好" + unlike: "收回讚好" my: "我的頁面" - liked: "已喜歡的頁面" + liked: "已讚好的頁面" featured: "人氣" inspector: "面板檢查" contents: "內容" @@ -1903,7 +1938,7 @@ _pages: inputBlocks: "輸入" specialBlocks: "特殊" blocks: - text: "字串" + text: "文字" textarea: "字串區域" section: "區段" image: "圖片" @@ -1944,7 +1979,7 @@ _notification: achievementEarned: "獲得成就" app: "應用程式通知" _actions: - followBack: "回關" + followBack: "追隨回去" reply: "回覆" renote: "轉發" _deck: @@ -1961,9 +1996,10 @@ _deck: profile: "個人檔案" newProfile: "新建個人檔案" deleteProfile: "刪除個人檔案" - introduction: "組合欄位來製作屬於自己的介面吧!" - introduction2: "您可以隨時透過按畫面右方的 + 來添加欄位。" - widgetsIntroduction: "請從欄位的選單中,選擇「編輯小工具」來添加小工具" + introduction: "組合多個欄位,製作屬於自己的介面吧!" + introduction2: "您可以隨時按畫面右方的「+」新增欄位。" + widgetsIntroduction: "請從欄位選單中選擇「編輯小工具」新增小工具。" + useSimpleUiForNonRootPages: "用簡易 UI 顯示非根頁面" _columns: main: "主列" widgets: "小工具" @@ -1976,24 +2012,24 @@ _deck: direct: "指定使用者" roleTimeline: "角色時間軸" _dialog: - charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" - charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" + charactersExceeded: "您的貼文太長了!現時字數 {current}/限制字數 {max}" + charactersBelow: "您的貼文太短了!現時字數 {current}/限制字數 {min}" _disabledTimeline: - title: "停用的時間軸" - description: "目前的角色無法使用這個時間軸。" + title: "時間軸已停用" + description: "目前角色無法使用這個時間軸。" _drivecleaner: - orderBySizeDesc: "檔案由大到小" - orderByCreatedAtAsc: "依照加入的日期順序" + orderBySizeDesc: "按大小降序排列" + orderByCreatedAtAsc: "按新增日期降序排列" _webhookSettings: createWebhook: "建立 Webhook" name: "名稱" - secret: "秘密" - events: "什麼時候運行Webhook" + secret: "密鑰" + events: "何時運行 Webhook" active: "已啟用" _events: follow: "當你追隨時" followed: "當被追隨時" - note: "當發布貼文時" + note: "當發佈貼文時" reply: "當收到回覆時" renote: "當被轉發時" reaction: "當獲得反應時" diff --git a/package.json b/package.json index dd0c1d57e..58d153a4f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "13.13.2", + "version": "13.14.0-beta.7", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@8.6.0", + "packageManager": "pnpm@8.6.9", "workspaces": [ "packages/frontend", "packages/backend", @@ -25,7 +25,7 @@ "migrateandstart": "pnpm migrate && pnpm start", "gulp": "pnpm exec gulp build", "watch": "pnpm dev", - "dev": "node ./scripts/dev.js", + "dev": "node ./scripts/dev.mjs", "lint": "pnpm -r lint", "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:run": "pnpm cypress run", @@ -44,23 +44,23 @@ "lodash": "4.17.21" }, "dependencies": { - "execa": "5.1.1", + "execa": "7.1.1", "gulp": "4.0.2", "gulp-cssnano": "2.1.3", "gulp-rename": "2.0.0", "gulp-replace": "1.1.4", "gulp-terser": "2.1.0", "js-yaml": "4.1.0", - "typescript": "5.1.3" + "typescript": "5.1.6" }, "devDependencies": { "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", - "@typescript-eslint/eslint-plugin": "5.59.8", - "@typescript-eslint/parser": "5.59.8", + "@typescript-eslint/eslint-plugin": "5.61.0", + "@typescript-eslint/parser": "5.61.0", "cross-env": "7.0.3", - "cypress": "12.13.0", - "eslint": "8.41.0", + "cypress": "12.17.1", + "eslint": "8.45.0", "start-server-and-test": "2.0.0" }, "optionalDependencies": { diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index 08d4222d0..0504a2d38 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -17,7 +17,7 @@ "paths": { "@/*": ["*"] }, - "target": "es2021" + "target": "es2022" }, "minify": false } diff --git a/packages/backend/assets/avatar.png b/packages/backend/assets/avatar.png new file mode 100644 index 000000000..1b95a0c56 Binary files /dev/null and b/packages/backend/assets/avatar.png differ diff --git a/packages/backend/check_connect.js b/packages/backend/check_connect.js index ef0a350fb..da78c8ec7 100644 --- a/packages/backend/check_connect.js +++ b/packages/backend/check_connect.js @@ -2,14 +2,7 @@ import Redis from 'ioredis'; import { loadConfig } from './built/config.js'; const config = loadConfig(); -const redis = new Redis({ - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - keyPrefix: `${config.redis.prefix}:`, - db: config.redis.db ?? 0, -}); +const redis = new Redis(config.redis); redis.on('connect', () => redis.disconnect()); redis.on('error', (e) => { diff --git a/packages/backend/migration/1677054292210-ad4.js b/packages/backend/migration/1677054292210-ad4.js new file mode 100644 index 000000000..48499319b --- /dev/null +++ b/packages/backend/migration/1677054292210-ad4.js @@ -0,0 +1,9 @@ +export class ad1677054292210 { + name = 'ad1677054292210'; + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "ad" ADD "dayOfWeek" integer NOT NULL Default 0`); + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "dayOfWeek"`); + } +} diff --git a/packages/backend/migration/1688280713783-add-meta-options.js b/packages/backend/migration/1688280713783-add-meta-options.js new file mode 100644 index 000000000..12406fe08 --- /dev/null +++ b/packages/backend/migration/1688280713783-add-meta-options.js @@ -0,0 +1,13 @@ +export class AddMetaOptions1688280713783 { + name = 'AddMetaOptions1688280713783' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableServerMachineStats" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "enableIdenticonGeneration" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableIdenticonGeneration"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableServerMachineStats"`); + } +} diff --git a/packages/backend/migration/1688720440658-refactor-invite-system.js b/packages/backend/migration/1688720440658-refactor-invite-system.js new file mode 100644 index 000000000..0dd49f702 --- /dev/null +++ b/packages/backend/migration/1688720440658-refactor-invite-system.js @@ -0,0 +1,25 @@ +export class RefactorInviteSystem1688720440658 { + name = 'RefactorInviteSystem1688720440658' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "pendingUserId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "createdById" character varying(32)`); + await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedById" character varying(32)`); + await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189" UNIQUE ("usedById")`); + await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_beba993576db0261a15364ea96e" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189" FOREIGN KEY ("usedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189"`); + await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_beba993576db0261a15364ea96e"`); + await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189"`); + await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedById"`); + await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "createdById"`); + await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "pendingUserId"`); + await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedAt"`); + await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "expiresAt"`); + } +} diff --git a/packages/backend/migration/1688880985544-add-index-to-relations.js b/packages/backend/migration/1688880985544-add-index-to-relations.js new file mode 100644 index 000000000..d6b5c57f5 --- /dev/null +++ b/packages/backend/migration/1688880985544-add-index-to-relations.js @@ -0,0 +1,13 @@ +export class AddIndexToRelations1688880985544 { + name = 'AddIndexToRelations1688880985544' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_beba993576db0261a15364ea96" ON "registration_ticket" ("createdById") `); + await queryRunner.query(`CREATE INDEX "IDX_b6f93f2f30bdbb9a5ebdc7c718" ON "registration_ticket" ("usedById") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_b6f93f2f30bdbb9a5ebdc7c718"`); + await queryRunner.query(`DROP INDEX "public"."IDX_beba993576db0261a15364ea96"`); + } +} diff --git a/packages/backend/migration/1689102832143-nsfw-cache.js b/packages/backend/migration/1689102832143-nsfw-cache.js new file mode 100644 index 000000000..cdce0dae0 --- /dev/null +++ b/packages/backend/migration/1689102832143-nsfw-cache.js @@ -0,0 +1,11 @@ +export class NsfwCache1689102832143 { + name = 'NsfwCache1689102832143' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "cacheRemoteSensitiveFiles" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "cacheRemoteSensitiveFiles"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 56ecbc2ea..7f64c2a9a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -3,6 +3,9 @@ "main": "./index.js", "private": true, "type": "module", + "engines": { + "node": ">=18.16.0" + }, "scripts": { "start": "node ./built/index.js", "start:test": "NODE_ENV=test node ./built/index.js", @@ -51,62 +54,62 @@ "utf-8-validate": "^6.0.3" }, "dependencies": { - "@aws-sdk/client-s3": "3.321.1", - "@aws-sdk/lib-storage": "3.321.1", - "@aws-sdk/node-http-handler": "3.321.1", - "@bull-board/api": "5.2.0", - "@bull-board/fastify": "5.2.0", - "@bull-board/ui": "5.2.0", + "@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.6.1", + "@bull-board/fastify": "5.6.1", + "@bull-board/ui": "5.6.1", "@discordapp/twemoji": "14.1.2", - "@fastify/accepts": "4.1.0", + "@fastify/accepts": "4.2.0", "@fastify/cookie": "8.3.0", "@fastify/cors": "8.3.0", - "@fastify/http-proxy": "9.1.0", - "@fastify/multipart": "7.6.0", + "@fastify/http-proxy": "9.2.1", + "@fastify/multipart": "7.7.1", "@fastify/static": "6.10.2", - "@fastify/view": "7.4.1", - "@nestjs/common": "9.4.2", - "@nestjs/core": "9.4.2", - "@nestjs/testing": "9.4.2", + "@fastify/view": "8.0.0", + "@nestjs/common": "10.1.0", + "@nestjs/core": "10.1.0", + "@nestjs/testing": "10.1.0", "@peertube/http-signature": "1.7.0", - "@sinonjs/fake-timers": "10.2.0", + "@sinonjs/fake-timers": "10.3.0", "@swc/cli": "0.1.62", - "@swc/core": "1.3.61", + "@swc/core": "1.3.70", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", - "autwh": "0.1.0", + "async-mutex": "^0.4.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", - "bullmq": "3.15.0", - "cacheable-lookup": "6.1.0", + "bullmq": "4.4.0", + "cacheable-lookup": "7.0.0", "cbor": "9.0.0", - "chalk": "5.2.0", - "chalk-template": "0.4.0", + "chalk": "5.3.0", + "chalk-template": "1.1.0", "chokidar": "3.5.3", "cli-highlight": "2.1.11", "color-convert": "2.0.1", "content-disposition": "0.5.4", "date-fns": "2.30.0", "deep-email-validator": "0.1.21", - "escape-regexp": "0.0.1", - "fastify": "4.17.0", + "fastify": "4.20.0", "feed": "4.2.2", - "file-type": "18.4.0", + "file-type": "18.5.0", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", - "got": "12.6.0", - "happy-dom": "9.20.3", + "got": "13.0.0", + "happy-dom": "10.0.3", "hpagent": "1.2.0", "ioredis": "5.3.2", "ip-cidr": "3.1.0", - "is-svg": "4.3.2", + "ipaddr.js": "2.1.0", + "is-svg": "5.0.0", "js-yaml": "4.1.0", "jsdom": "22.1.0", "json5": "2.2.3", "jsonld": "8.2.0", "jsrsasign": "10.8.6", - "meilisearch": "0.32.5", + "meilisearch": "0.33.0", "mfm-js": "0.23.3", "mime-types": "2.1.35", "misskey-js": "workspace:*", @@ -117,10 +120,9 @@ "nsfwjs": "2.4.2", "oauth": "0.10.0", "os-utils": "0.0.14", - "otpauth": "9.1.2", + "otpauth": "9.1.3", "parse5": "7.1.2", - "pg": "8.11.0", - "private-ip": "3.0.0", + "pg": "8.11.1", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.2", @@ -129,41 +131,36 @@ "qrcode": "1.5.3", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.19.0", + "re2": "1.19.1", "redis-lock": "0.1.4", "reflect-metadata": "0.1.13", "rename": "1.0.4", - "rndstr": "1.0.0", "rss-parser": "3.13.0", "rxjs": "7.8.1", - "s-age": "1.1.2", - "sanitize-html": "2.10.0", - "seedrandom": "3.0.5", - "semver": "7.5.1", - "sharp": "0.32.1", + "sanitize-html": "2.11.0", + "semver": "7.5.4", + "sharp": "0.32.3", "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", "slacc": "0.0.9", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.17.16", + "systeminformation": "5.18.7", "tinycolor2": "1.6.0", "tmp": "0.2.1", - "tsc-alias": "1.8.6", + "tsc-alias": "1.8.7", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typeorm": "0.3.16", - "typescript": "5.1.3", + "typeorm": "0.3.17", + "typescript": "5.1.6", "ulid": "2.3.0", - "unzipper": "0.10.14", - "uuid": "9.0.0", "vary": "1.1.2", - "web-push": "3.6.1", + "web-push": "3.6.3", "ws": "8.13.0", "xev": "3.0.2" }, "devDependencies": { - "@jest/globals": "29.5.0", + "@jest/globals": "29.6.1", "@swc/jest": "0.2.26", "@types/accepts": "1.3.5", "@types/archiver": "5.3.2", @@ -171,25 +168,24 @@ "@types/cbor": "6.0.0", "@types/color-convert": "2.0.0", "@types/content-disposition": "0.5.5", - "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.21", - "@types/jest": "29.5.2", + "@types/jest": "29.5.3", "@types/js-yaml": "4.0.5", "@types/jsdom": "21.1.1", - "@types/jsonld": "1.5.8", + "@types/jsonld": "1.5.9", "@types/jsrsasign": "10.5.8", "@types/mime-types": "2.1.1", - "@types/node": "20.2.5", + "@types/ms": "^0.7.31", + "@types/node": "20.4.2", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.8", "@types/oauth": "0.9.1", - "@types/pg": "8.10.1", + "@types/pg": "8.10.2", "@types/pug": "2.0.6", "@types/punycode": "2.1.0", - "@types/qrcode": "1.5.0", + "@types/qrcode": "1.5.1", "@types/random-seed": "0.3.3", "@types/ratelimiter": "3.4.4", - "@types/redis": "4.0.11", "@types/rename": "1.0.4", "@types/sanitize-html": "2.9.0", "@types/semver": "7.5.0", @@ -197,20 +193,17 @@ "@types/sinonjs__fake-timers": "8.1.2", "@types/tinycolor2": "1.4.3", "@types/tmp": "0.2.3", - "@types/unzipper": "0.10.6", - "@types/uuid": "9.0.1", "@types/vary": "1.1.0", "@types/web-push": "3.3.2", - "@types/websocket": "1.0.5", - "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.59.8", - "@typescript-eslint/parser": "5.59.8", - "aws-sdk-client-mock": "2.1.1", + "@types/ws": "8.5.5", + "@typescript-eslint/eslint-plugin": "5.61.0", + "@typescript-eslint/parser": "5.61.0", + "aws-sdk-client-mock": "3.0.0", "cross-env": "7.0.3", - "eslint": "8.41.0", + "eslint": "8.45.0", "eslint-plugin-import": "2.27.5", - "execa": "6.1.0", - "jest": "29.5.0", - "jest-mock": "29.5.0" + "execa": "7.1.1", + "jest": "29.6.1", + "jest-mock": "29.6.1" } } diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 406e3192b..4caf4c3e9 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -41,14 +41,7 @@ const $meilisearch: Provider = { const $redis: Provider = { provide: DI.redis, useFactory: (config: Config) => { - return new Redis.Redis({ - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - keyPrefix: `${config.redis.prefix}:`, - db: config.redis.db ?? 0, - }); + return new Redis.Redis(config.redis); }, inject: [DI.config], }; @@ -56,14 +49,7 @@ const $redis: Provider = { const $redisForPub: Provider = { provide: DI.redisForPub, useFactory: (config: Config) => { - const redis = new Redis.Redis({ - port: config.redisForPubsub.port, - host: config.redisForPubsub.host, - family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, - password: config.redisForPubsub.pass, - keyPrefix: `${config.redisForPubsub.prefix}:`, - db: config.redisForPubsub.db ?? 0, - }); + const redis = new Redis.Redis(config.redisForPubsub); return redis; }, inject: [DI.config], @@ -72,14 +58,7 @@ const $redisForPub: Provider = { const $redisForSub: Provider = { provide: DI.redisForSub, useFactory: (config: Config) => { - const redis = new Redis.Redis({ - port: config.redisForPubsub.port, - host: config.redisForPubsub.host, - family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, - password: config.redisForPubsub.pass, - keyPrefix: `${config.redisForPubsub.prefix}:`, - db: config.redisForPubsub.db ?? 0, - }); + const redis = new Redis.Redis(config.redisForPubsub); redis.subscribe(config.host); return redis; }, diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index f5d936fad..c253f697f 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -31,7 +31,7 @@ function greet() { console.log(themeColor(' | |_|___ ___| |_ ___ _ _ ')); console.log(themeColor(' | | | | |_ -|_ -| \'_| -_| | |')); console.log(themeColor(' |_|_|_|_|___|___|_,_|___|_ |')); - console.log(' ' + chalk.gray(v) + themeColor(' |___|\n'.substr(v.length))); + console.log(' ' + chalk.gray(v) + themeColor(' |___|\n'.substring(v.length))); //#endregion console.log(' Misskey is an open-source decentralized microblogging platform.'); @@ -78,7 +78,7 @@ export async function masterMain() { await spawnWorkers(config.clusterLimit); } - bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true); + bootLogger.succ(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on port ${config.port} on ${config.url}`, null, true); } function showEnvironment(): void { @@ -96,12 +96,6 @@ function showNodejsVersion(): void { const nodejsLogger = bootLogger.createSubLogger('nodejs'); nodejsLogger.info(`Version ${process.version} detected.`); - - const minVersion = fs.readFileSync(`${_dirname}/../../../../.node-version`, 'utf-8').trim(); - if (semver.lt(process.version, minVersion)) { - nodejsLogger.error(`At least Node.js ${minVersion} required!`); - process.exit(1); - } } function loadConfigBoot(): Config { diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 9d1945e4d..253975096 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -6,6 +6,16 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; +import type { RedisOptions } from 'ioredis'; + +type RedisOptionsSource = Partial & { + host: string; + port: number; + family?: number; + pass: string; + db?: number; + prefix?: string; +}; /** * ユーザーが設定する必要のある情報 @@ -14,7 +24,9 @@ export type Source = { repository_url?: string; feedback_url?: string; url: string; - port: number; + port?: number; + socket?: string; + chmodSocket?: string; disableHsts?: boolean; db: { host: string; @@ -33,36 +45,16 @@ export type Source = { user: string; pass: string; }[]; - redis: { - host: string; - port: number; - family?: number; - pass: string; - db?: number; - prefix?: string; - }; - redisForPubsub?: { - host: string; - port: number; - family?: number; - pass: string; - db?: number; - prefix?: string; - }; - redisForJobQueue?: { - host: string; - port: number; - family?: number; - pass: string; - db?: number; - prefix?: string; - }; + redis: RedisOptionsSource; + redisForPubsub?: RedisOptionsSource; + redisForJobQueue?: RedisOptionsSource; meilisearch?: { host: string; port: string; apiKey: string; ssl?: boolean; index: string; + scope?: 'local' | 'global' | string[]; }; proxy?: string; @@ -116,8 +108,9 @@ export type Mixin = { mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; - redisForPubsub: NonNullable; - redisForJobQueue: NonNullable; + redis: RedisOptions & RedisOptionsSource; + redisForPubsub: RedisOptions & RedisOptionsSource; + redisForJobQueue: RedisOptions & RedisOptionsSource; }; export type Config = Source & Mixin; @@ -179,9 +172,9 @@ export function loadConfig() { config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator : null; - if (!config.redis.prefix) config.redis.prefix = mixin.host; - if (config.redisForPubsub == null) config.redisForPubsub = config.redis; - if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis; + 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); } @@ -193,3 +186,14 @@ function tryCreateUrl(url: string) { throw new Error(`url="${url}" is not a valid URL.`); } } + +function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptions & RedisOptionsSource { + return { + ...options, + password: options.pass, + prefix: options.prefix ?? host, + family: options.family == null ? 0 : options.family, + keyPrefix: `${options.prefix ?? host}:`, + db: options.db ?? 0, + }; +} diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index ab11785e2..111fcfd73 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -4,10 +4,9 @@ import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; -import type { User } from '@/models/entities/User.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -295,7 +294,7 @@ export class AccountMoveService { * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる * * @param dst movedToUrlを指定するユーザー - * @param check + * @param check * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか * @returns Promise */ diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index 059e335ef..c0596446d 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -4,6 +4,7 @@ import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; import * as nsfw from 'nsfwjs'; import si from 'systeminformation'; +import { Mutex } from 'async-mutex'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -17,6 +18,7 @@ let isSupportedCpu: undefined | boolean = undefined; @Injectable() export class AiService { private model: nsfw.NSFWJS; + private modelLoadMutex: Mutex = new Mutex(); constructor( @Inject(DI.config) @@ -31,16 +33,22 @@ export class AiService { const cpuFlags = await this.getCpuFlags(); isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required)); } - + if (!isSupportedCpu) { console.error('This CPU cannot use TensorFlow.'); return null; } - + const tf = await import('@tensorflow/tfjs-node'); - - if (this.model == null) this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 }); - + + if (this.model == null) { + await this.modelLoadMutex.runExclusive(async () => { + if (this.model == null) { + this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 }); + } + }); + } + const buffer = await fs.promises.readFile(path); const image = await tf.node.decodeImage(buffer, 3) as any; try { diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index d8df37191..9310fd8b5 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -99,7 +99,7 @@ export class AntennaService implements OnApplicationShutdown { 'MAXLEN', '~', '200', '*', 'note', note.id); - + this.globalEventService.publishAntennaStream(antenna.id, 'note', note); } @@ -112,16 +112,16 @@ export class AntennaService implements OnApplicationShutdown { public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }): Promise { if (note.visibility === 'specified') return false; if (note.visibility === 'followers') return false; - + if (!antenna.withReplies && note.replyId != null) return false; - + if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { const listUsers = (await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId!, })).map(x => x.userId); - + if (!listUsers.includes(note.userId)) return false; } else if (antenna.src === 'users') { const accts = antenna.users.map(x => { @@ -130,32 +130,32 @@ export class AntennaService implements OnApplicationShutdown { }); if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; } - + const keywords = antenna.keywords // Clean up .map(xs => xs.filter(x => x !== '')) .filter(xs => xs.length > 0); - + if (keywords.length > 0) { if (note.text == null && note.cw == null) return false; const _text = (note.text ?? '') + '\n' + (note.cw ?? ''); - + const matched = keywords.some(and => and.every(keyword => antenna.caseSensitive ? _text.includes(keyword) : _text.toLowerCase().includes(keyword.toLowerCase()), )); - + if (!matched) return false; } - + const excludeKeywords = antenna.excludeKeywords // Clean up .map(xs => xs.filter(x => x !== '')) .filter(xs => xs.length > 0); - + if (excludeKeywords.length > 0) { if (note.text == null && note.cw == null) return false; @@ -167,16 +167,16 @@ export class AntennaService implements OnApplicationShutdown { ? _text.includes(keyword) : _text.toLowerCase().includes(keyword.toLowerCase()), )); - + if (matched) return false; } - + if (antenna.withFile) { if (note.fileIds && note.fileIds.length === 0) return false; } - + // TODO: eval expression - + return true; } @@ -188,7 +188,7 @@ export class AntennaService implements OnApplicationShutdown { }); this.antennasFetched = true; } - + return this.antennas; } diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts index 8dd805552..6ccaec26b 100644 --- a/packages/backend/src/core/AppLockService.ts +++ b/packages/backend/src/core/AppLockService.ts @@ -32,11 +32,6 @@ export class AppLockService { return this.lock(`ap-object:${uri}`, timeout); } - @bindThis - public getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000): Promise<() => void> { - return this.lock(`instance:${host}`, timeout); - } - @bindThis public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> { return this.lock(`chart-insert:${lockKey}`, timeout); diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 2b7f9a48d..cd6b68e72 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -11,10 +11,10 @@ import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() export class CacheService implements OnApplicationShutdown { - public userByIdCache: MemoryKVCache; - public localUserByNativeTokenCache: MemoryKVCache; + public userByIdCache: MemoryKVCache; + public localUserByNativeTokenCache: MemoryKVCache; public localUserByIdCache: MemoryKVCache; - public uriPersonCache: MemoryKVCache; + public uriPersonCache: MemoryKVCache; public userProfileCache: RedisKVCache; public userMutingsCache: RedisKVCache>; public userBlockingCache: RedisKVCache>; @@ -55,10 +55,41 @@ export class CacheService implements OnApplicationShutdown { ) { //this.onMessage = this.onMessage.bind(this); - this.userByIdCache = new MemoryKVCache(Infinity); - this.localUserByNativeTokenCache = new MemoryKVCache(Infinity); - this.localUserByIdCache = new MemoryKVCache(Infinity); - this.uriPersonCache = new MemoryKVCache(Infinity); + const localUserByIdCache = new MemoryKVCache(1000 * 60 * 60 * 6 /* 6h */); + this.localUserByIdCache = localUserByIdCache; + + // ローカルユーザーならlocalUserByIdCacheにデータを追加し、こちらにはid(文字列)だけを追加する + const userByIdCache = new MemoryKVCache(1000 * 60 * 60 * 6 /* 6h */, { + toMapConverter: user => { + if (user.host === null) { + localUserByIdCache.set(user.id, user as LocalUser); + return user.id; + } + + return user; + }, + fromMapConverter: userOrId => typeof userOrId === 'string' ? localUserByIdCache.get(userOrId) : userOrId, + }); + this.userByIdCache = userByIdCache; + + this.localUserByNativeTokenCache = new MemoryKVCache(Infinity, { + toMapConverter: user => { + if (user === null) return null; + + localUserByIdCache.set(user.id, user); + return user.id; + }, + fromMapConverter: id => id === null ? null : localUserByIdCache.get(id), + }); + this.uriPersonCache = new MemoryKVCache(Infinity, { + toMapConverter: user => { + if (user === null) return null; + + userByIdCache.set(user.id, user); + return user.id; + }, + fromMapConverter: id => id === null ? null : userByIdCache.get(id), + }); this.userProfileCache = new RedisKVCache(this.redisClient, 'userProfile', { lifetime: 1000 * 60 * 30, // 30m @@ -131,7 +162,7 @@ export class CacheService implements OnApplicationShutdown { const user = await this.usersRepository.findOneByOrFail({ id: body.id }); this.userByIdCache.set(user.id, user); for (const [k, v] of this.uriPersonCache.cache.entries()) { - if (v.value?.id === user.id) { + if (v.value === user.id) { this.uriPersonCache.set(k, user); } } diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index 1a52a229c..10cfdba25 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -20,7 +20,7 @@ export class CaptchaService { secret, response, }); - + const res = await this.httpRequestService.send(url, { method: 'POST', body: params.toString(), @@ -28,14 +28,14 @@ export class CaptchaService { 'Content-Type': 'application/x-www-form-urlencoded', }, }, { throwErrorWhenResponseNotOk: false }); - + if (!res.ok) { throw new Error(`${res.status}`); } - + return await res.json() as CaptchaResponse; - } - + } + @bindThis public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise { if (response == null) { @@ -73,7 +73,7 @@ export class CaptchaService { if (response == null) { throw new Error('turnstile-failed: no response provided'); } - + const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { throw new Error(`turnstile-request-failed: ${err}`); }); diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index d3a1b1b02..c7c98b3bd 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -81,6 +81,7 @@ import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js'; import { HashtagEntityService } from './entities/HashtagEntityService.js'; import { InstanceEntityService } from './entities/InstanceEntityService.js'; +import { InviteCodeEntityService } from './entities/InviteCodeEntityService.js'; import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; import { MutingEntityService } from './entities/MutingEntityService.js'; import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; @@ -205,6 +206,7 @@ const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService }; const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService }; const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; +const $InviteCodeEntityService: Provider = { provide: 'InviteCodeEntityService', useExisting: InviteCodeEntityService }; const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; @@ -329,6 +331,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting GalleryPostEntityService, HashtagEntityService, InstanceEntityService, + InviteCodeEntityService, ModerationLogEntityService, MutingEntityService, RenoteMutingEntityService, @@ -448,6 +451,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $GalleryPostEntityService, $HashtagEntityService, $InstanceEntityService, + $InviteCodeEntityService, $ModerationLogEntityService, $MutingEntityService, $RenoteMutingEntityService, @@ -567,6 +571,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting GalleryPostEntityService, HashtagEntityService, InstanceEntityService, + InviteCodeEntityService, ModerationLogEntityService, MutingEntityService, RenoteMutingEntityService, @@ -685,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $GalleryPostEntityService, $HashtagEntityService, $InstanceEntityService, + $InviteCodeEntityService, $ModerationLogEntityService, $MutingEntityService, $RenoteMutingEntityService, diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts index 8f887d90f..cef664bf0 100644 --- a/packages/backend/src/core/CreateSystemUserService.ts +++ b/packages/backend/src/core/CreateSystemUserService.ts @@ -1,6 +1,6 @@ +import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; -import { v4 as uuid } from 'uuid'; import { IsNull, DataSource } from 'typeorm'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { User } from '@/models/entities/User.js'; @@ -24,28 +24,28 @@ export class CreateSystemUserService { @bindThis public async createSystemUser(username: string): Promise { - const password = uuid(); - + const password = randomUUID(); + // Generate hash of password const salt = await bcrypt.genSalt(8); const hash = await bcrypt.hash(password, salt); - + // Generate secret const secret = generateNativeUserToken(); - - const keyPair = await genRsaKeyPair(4096); - + + const keyPair = await genRsaKeyPair(); + let account!: User; - + // Start transaction await this.db.transaction(async transactionalEntityManager => { const exist = await transactionalEntityManager.findOneBy(User, { usernameLower: username.toLowerCase(), host: IsNull(), }); - + if (exist) throw new Error('the user is already exists'); - + account = await transactionalEntityManager.insert(User, { id: this.idService.genId(), createdAt: new Date(), @@ -58,25 +58,25 @@ export class CreateSystemUserService { isExplorable: false, isBot: true, }).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0])); - + await transactionalEntityManager.insert(UserKeypair, { publicKey: keyPair.publicKey, privateKey: keyPair.privateKey, userId: account.id, }); - + await transactionalEntityManager.insert(UserProfile, { userId: account.id, autoAcceptFollowed: false, password: hash, }); - + await transactionalEntityManager.insert(UsedUsername, { createdAt: new Date(), username: username.toLowerCase(), }); }); - + return account; } } diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 5f2ced77e..661d956bd 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -140,7 +140,7 @@ export class CustomEmojiService implements OnApplicationShutdown { this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: updated, - }); + }); } } @@ -194,7 +194,7 @@ export class CustomEmojiService implements OnApplicationShutdown { } this.localEmojisCache.refresh(); - + this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ids), }); @@ -215,7 +215,7 @@ export class CustomEmojiService implements OnApplicationShutdown { emojis: await this.emojiEntityService.packDetailedMany(ids), }); } - + @bindThis public async setLicenseBulk(ids: Emoji['id'][], license: string | null) { await this.emojisRepository.update({ diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 327283106..3a0592441 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -28,11 +28,11 @@ export class DeleteAccountService { // 物理削除する前にDelete activityを送信する await this.userSuspendService.doPostSuspend(user).catch(e => {}); - + this.queueService.createDeleteAccountJob(user, { soft: false, }); - + await this.usersRepository.update(user.id, { isDeleted: true, }); diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index bd535c603..09039a8b5 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -2,8 +2,7 @@ import * as fs from 'node:fs'; import * as stream from 'node:stream'; import * as util from 'node:util'; import { Inject, Injectable } from '@nestjs/common'; -import IPCIDR from 'ip-cidr'; -import PrivateIp from 'private-ip'; +import ipaddr from 'ipaddr.js'; import chalk from 'chalk'; import got, * as Got from 'got'; import { parse } from 'content-disposition'; @@ -123,15 +122,15 @@ export class DownloadService { public async downloadTextFile(url: string): Promise { // Create temp file const [path, cleanup] = await createTemp(); - + this.logger.info(`text file: Temp file is ${path}`); - + try { // write content at URL to temp file await this.downloadUrl(url, path); - + const text = await util.promisify(fs.readFile)(path, 'utf8'); - + return text; } finally { cleanup(); @@ -140,13 +139,14 @@ export class DownloadService { @bindThis private isPrivateIp(ip: string): boolean { + const parsedIp = ipaddr.parse(ip); + for (const net of this.config.allowedPrivateNetworks ?? []) { - const cidr = new IPCIDR(net); - if (cidr.contains(ip)) { + if (parsedIp.match(ipaddr.parseCIDR(net))) { return false; } } - return PrivateIp(ip) ?? false; + return parsedIp.range() !== 'unicast'; } } diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 1483b5546..355e5e8c0 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -1,6 +1,6 @@ +import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; -import { v4 as uuid } from 'uuid'; import sharp from 'sharp'; import { sharpBmp } from 'sharp-read-bmp'; import { IsNull } from 'typeorm'; @@ -162,7 +162,7 @@ export class DriveService { ?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`; // for original - const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`; + const key = `${meta.objectStoragePrefix}/${randomUUID()}${ext}`; const url = `${ baseUrl }/${ key }`; // for alts @@ -179,7 +179,7 @@ export class DriveService { ]; if (alts.webpublic) { - webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`; + webpublicKey = `${meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`; webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); @@ -187,7 +187,7 @@ export class DriveService { } if (alts.thumbnail) { - thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`; + thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); @@ -212,9 +212,9 @@ export class DriveService { return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); } else { // use internal storage - const accessKey = uuid(); - const thumbnailAccessKey = 'thumbnail-' + uuid(); - const webpublicAccessKey = 'webpublic-' + uuid(); + const accessKey = randomUUID(); + const thumbnailAccessKey = 'thumbnail-' + randomUUID(); + const webpublicAccessKey = 'webpublic-' + randomUUID(); const url = this.internalStorageService.saveFromPath(accessKey, path); @@ -584,9 +584,9 @@ export class DriveService { if (isLink) { file.url = url; // ローカルプロキシ用 - file.accessKey = uuid(); - file.thumbnailAccessKey = 'thumbnail-' + uuid(); - file.webpublicAccessKey = 'webpublic-' + uuid(); + file.accessKey = randomUUID(); + file.thumbnailAccessKey = 'thumbnail-' + randomUUID(); + file.webpublicAccessKey = 'webpublic-' + randomUUID(); } } @@ -713,9 +713,9 @@ export class DriveService { webpublicUrl: null, storedInternal: false, // ローカルプロキシ用 - accessKey: uuid(), - thumbnailAccessKey: 'thumbnail-' + uuid(), - webpublicAccessKey: 'webpublic-' + uuid(), + accessKey: randomUUID(), + thumbnailAccessKey: 'thumbnail-' + randomUUID(), + webpublicAccessKey: 'webpublic-' + randomUUID(), }); } else { this.driveFilesRepository.delete(file.id); diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 59932a5b8..a04e9c122 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -29,12 +29,12 @@ export class EmailService { @bindThis public async sendEmail(to: string, subject: string, html: string, text: string) { const meta = await this.metaService.fetch(true); - + const iconUrl = `${this.config.url}/static-assets/mi-white.png`; const emailSettingUrl = `${this.config.url}/settings/email`; - + const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; - + const transporter = nodemailer.createTransport({ host: meta.smtpHost, port: meta.smtpPort, @@ -46,7 +46,7 @@ export class EmailService { pass: meta.smtpPass, } : undefined, } as any); - + try { // TODO: htmlサニタイズ const info = await transporter.sendMail({ @@ -135,7 +135,7 @@ export class EmailService { `, }); - + this.logger.info(`Message sent: ${info.messageId}`); } catch (err) { this.logger.error(err as Error); @@ -149,12 +149,12 @@ export class EmailService { reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp'; }> { const meta = await this.metaService.fetch(); - + const exist = await this.userProfilesRepository.countBy({ emailVerified: true, email: emailAddress, }); - + const validated = meta.enableActiveEmailValidation ? await validateEmail({ email: emailAddress, validateRegex: true, @@ -163,9 +163,9 @@ export class EmailService { validateDisposable: true, // 捨てアドかどうかチェック validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので }) : { valid: true, reason: null }; - + const available = exist === 0 && validated.valid; - + return { available, reason: available ? null : diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 3603d59dc..a76203894 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -43,19 +43,19 @@ export class FederatedInstanceService implements OnApplicationShutdown { @bindThis public async fetch(host: string): Promise { host = this.utilityService.toPuny(host); - + const cached = await this.federatedInstanceCache.get(host); if (cached) return cached; - + const index = await this.instancesRepository.findOneBy({ host }); - + if (index == null) { const i = await this.instancesRepository.insert({ id: this.idService.genId(), host, firstRetrievedAt: new Date(), }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); - + this.federatedInstanceCache.set(host, i); return i; } else { @@ -74,7 +74,7 @@ export class FederatedInstanceService implements OnApplicationShutdown { .then((response) => { return response.raw[0]; }); - + this.federatedInstanceCache.set(result.host, result); } diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 9de633350..88706f1a4 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -2,9 +2,8 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import { JSDOM } from 'jsdom'; import tinycolor from 'tinycolor2'; +import * as Redis from 'ioredis'; import type { Instance } from '@/models/entities/Instance.js'; -import type { InstancesRepository } from '@/models/index.js'; -import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; import { DI } from '@/di-symbols.js'; import { LoggerService } from '@/core/LoggerService.js'; @@ -37,39 +36,49 @@ export class FetchInstanceMetadataService { private logger: Logger; constructor( - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - private appLockService: AppLockService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, private federatedInstanceService: FederatedInstanceService, + @Inject(DI.redis) + private redisClient: Redis.Redis, ) { this.logger = this.loggerService.getLogger('metadata', 'cyan'); } + @bindThis + public async tryLock(host: string): Promise { + const mutex = await this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '1', 'GET'); + return mutex !== '1'; + } + + @bindThis + public unlock(host: string): Promise<'OK'> { + return this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '0'); + } + @bindThis public async fetchInstanceMetadata(instance: Instance, force = false): Promise { - const unlock = await this.appLockService.getFetchInstanceMetadataLock(instance.host); - - if (!force) { - const _instance = await this.instancesRepository.findOneBy({ host: instance.host }); - const now = Date.now(); - if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { - unlock(); - return; - } - } - - this.logger.info(`Fetching metadata of ${instance.host} ...`); - + const host = instance.host; + // Acquire mutex to ensure no parallel runs + if (!await this.tryLock(host)) return; try { + if (!force) { + const _instance = await this.federatedInstanceService.fetch(host); + const now = Date.now(); + if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { + // unlock at the finally caluse + return; + } + } + + this.logger.info(`Fetching metadata of ${instance.host} ...`); + const [info, dom, manifest] = await Promise.all([ this.fetchNodeinfo(instance).catch(() => null), this.fetchDom(instance).catch(() => null), this.fetchManifest(instance).catch(() => null), ]); - + const [favicon, icon, themeColor, name, description] = await Promise.all([ this.fetchFaviconUrl(instance, dom).catch(() => null), this.fetchIconUrl(instance, dom, manifest).catch(() => null), @@ -77,13 +86,13 @@ export class FetchInstanceMetadataService { this.getSiteName(info, dom, manifest).catch(() => null), this.getDescription(info, dom, manifest).catch(() => null), ]); - + this.logger.succ(`Successfuly fetched metadata of ${instance.host}`); - + const updates = { infoUpdatedAt: new Date(), } as Record; - + if (info) { updates.softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?'; updates.softwareVersion = info.software?.version; @@ -91,27 +100,27 @@ export class FetchInstanceMetadataService { updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null; } - + if (name) updates.name = name; if (description) updates.description = description; - if (icon || favicon) updates.iconUrl = icon ?? favicon; + if (icon || favicon) updates.iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon; if (favicon) updates.faviconUrl = favicon; if (themeColor) updates.themeColor = themeColor; - + await this.federatedInstanceService.update(instance.id, updates); - + this.logger.succ(`Successfuly updated metadata of ${instance.host}`); } catch (e) { this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); } finally { - unlock(); + await this.unlock(host); } } @bindThis private async fetchNodeinfo(instance: Instance): Promise { this.logger.info(`Fetching nodeinfo of ${instance.host} ...`); - + try { const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') .catch(err => { @@ -121,33 +130,33 @@ export class FetchInstanceMetadataService { throw err.statusCode ?? err.message; } }) as Record; - + if (wellknown.links == null || !Array.isArray(wellknown.links)) { throw new Error('No wellknown links'); } - + const links = wellknown.links as any[]; - + const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); const link = lnik2_1 ?? lnik2_0 ?? lnik1_0; - + if (link == null) { throw new Error('No nodeinfo link provided'); } - + const info = await this.httpRequestService.getJson(link.href) .catch(err => { throw err.statusCode ?? err.message; }); - + this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); - + return info as NodeInfo; } catch (err) { this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`); - + throw err; } } @@ -155,51 +164,51 @@ export class FetchInstanceMetadataService { @bindThis private async fetchDom(instance: Instance): Promise { this.logger.info(`Fetching HTML of ${instance.host} ...`); - + const url = 'https://' + instance.host; - + const html = await this.httpRequestService.getHtml(url); - + const { window } = new JSDOM(html); const doc = window.document; - + return doc; } @bindThis private async fetchManifest(instance: Instance): Promise | null> { const url = 'https://' + instance.host; - + const manifestUrl = url + '/manifest.json'; - + const manifest = await this.httpRequestService.getJson(manifestUrl) as Record; - + return manifest; } @bindThis private async fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise { const url = 'https://' + instance.host; - + if (doc) { // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; - + if (href) { return (new URL(href, url)).href; } } - + const faviconUrl = url + '/favicon.ico'; - + const favicon = await this.httpRequestService.send(faviconUrl, { method: 'HEAD', }, { throwErrorWhenResponseNotOk: false }); - + if (favicon.ok) { return faviconUrl; } - + return null; } @@ -209,38 +218,38 @@ export class FetchInstanceMetadataService { const url = 'https://' + instance.host; return (new URL(manifest.icons[0].src, url)).href; } - + if (doc) { const url = 'https://' + instance.host; - + // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 const links = Array.from(doc.getElementsByTagName('link')).reverse(); // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 - const href = + const href = [ links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, links.find(link => link.relList.contains('apple-touch-icon'))?.href, links.find(link => link.relList.contains('icon'))?.href, ] .find(href => href); - + if (href) { return (new URL(href, url)).href; } } - + return null; } @bindThis private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; - + if (themeColor) { const color = new tinycolor(themeColor); if (color.isValid()) return color.toHexString(); } - + return null; } @@ -253,19 +262,19 @@ export class FetchInstanceMetadataService { return info.metadata.name; } } - + if (doc) { const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); - + if (og) { return og; } } - + if (manifest) { return manifest.name ?? manifest.short_name; } - + return null; } @@ -278,23 +287,23 @@ export class FetchInstanceMetadataService { return info.metadata.description; } } - + if (doc) { const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); if (meta) { return meta; } - + const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); if (og) { return og; } } - + if (manifest) { return manifest.name ?? manifest.short_name; } - + return null; } } diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index b6cae5ea7..d43575b33 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -161,20 +161,20 @@ export class FileInfoService { private async detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> { let sensitive = false; let porn = false; - + function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] { let sensitive = false; let porn = false; - + if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true; if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true; if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true; - + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true; - + return [sensitive, porn]; } - + if ([ 'image/jpeg', 'image/png', @@ -253,10 +253,10 @@ export class FileInfoService { disposeOutDir(); } } - + return [sensitive, porn]; } - + private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator { const watcher = new FSWatcher({ cwd, @@ -295,7 +295,7 @@ export class FileInfoService { } } } - + @bindThis private exists(path: string): Promise { return fs.promises.access(path).then(() => true, () => false); @@ -304,11 +304,11 @@ export class FileInfoService { @bindThis public fixMime(mime: string | fileType.MimeType): string { // see https://github.com/misskey-dev/misskey/pull/10686 - if (mime === "audio/x-flac") { - return "audio/flac"; + if (mime === 'audio/x-flac') { + return 'audio/flac'; } - if (mime === "audio/vnd.wave") { - return "audio/wav"; + if (mime === 'audio/vnd.wave') { + return 'audio/wav'; } return mime; @@ -355,11 +355,12 @@ export class FileInfoService { * Check the file is SVG or not */ @bindThis - public async checkSvg(path: string) { + public async checkSvg(path: string): Promise { try { const size = await this.getFileSize(path); if (size > 1 * 1024 * 1024) return false; - return isSvg(fs.readFileSync(path)); + const buffer = await fs.promises.readFile(path); + return isSvg(buffer.toString()); } catch { return false; } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 0ed524114..19d937008 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -20,7 +20,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; -import { Role } from '@/models'; +import { Role } from '@/models/index.js'; @Injectable() export class GlobalEventService { diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 375aa846c..3bb999ff8 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -1,5 +1,6 @@ import * as http from 'node:http'; import * as https from 'node:https'; +import * as net from 'node:net'; import CacheableLookup from 'cacheable-lookup'; import fetch from 'node-fetch'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; @@ -42,21 +43,21 @@ export class HttpRequestService { errorTtl: 30, // 30secs lookup: false, // nativeのdns.lookupにfallbackしない }); - + this.http = new http.Agent({ keepAlive: true, keepAliveMsecs: 30 * 1000, - lookup: cache.lookup, - } as http.AgentOptions); - + lookup: cache.lookup as unknown as net.LookupFunction, + }); + this.https = new https.Agent({ keepAlive: true, keepAliveMsecs: 30 * 1000, - lookup: cache.lookup, - } as https.AgentOptions); - + lookup: cache.lookup as unknown as net.LookupFunction, + }); + const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); - + this.httpAgent = config.proxy ? new HttpProxyAgent({ keepAlive: true, @@ -144,7 +145,7 @@ export class HttpRequestService { method: args.method ?? 'GET', headers: { 'User-Agent': this.config.userAgent, - ...(args.headers ?? {}) + ...(args.headers ?? {}), }, body: args.body, size: args.size ?? 10 * 1024 * 1024, diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 8aa6ccfc4..4d129407c 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -5,7 +5,7 @@ import type { Config } from '@/config.js'; import { genAid, parseAid } from '@/misc/id/aid.js'; import { genMeid, parseMeid } from '@/misc/id/meid.js'; import { genMeidg, parseMeidg } from '@/misc/id/meidg.js'; -import { genObjectId } from '@/misc/id/object-id.js'; +import { genObjectId, parseObjectId } from '@/misc/id/object-id.js'; import { bindThis } from '@/decorators.js'; import { parseUlid } from '@/misc/id/ulid.js'; @@ -23,7 +23,7 @@ export class IdService { @bindThis public genId(date?: Date): string { if (!date || (date > new Date())) date = new Date(); - + switch (this.method) { case 'aid': return genAid(date); case 'meid': return genMeid(date); @@ -38,7 +38,7 @@ export class IdService { public parse(id: string): { date: Date; } { switch (this.method) { case 'aid': return parseAid(id); - case 'objectid': + case 'objectid': return parseObjectId(id); case 'meid': return parseMeid(id); case 'meidg': return parseMeidg(id); case 'ulid': return parseUlid(id); diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts index 4fb3fc5b4..2e047dc5c 100644 --- a/packages/backend/src/core/InstanceActorService.ts +++ b/packages/backend/src/core/InstanceActorService.ts @@ -26,12 +26,12 @@ export class InstanceActorService { public async getInstanceActor(): Promise { const cached = this.cache.get(); if (cached) return cached; - + const user = await this.usersRepository.findOneBy({ host: IsNull(), username: ACTOR_USERNAME, }) as LocalUser | undefined; - + if (user) { this.cache.set(user); return user; diff --git a/packages/backend/src/core/LoggerService.ts b/packages/backend/src/core/LoggerService.ts index 441c254f4..14df9aa40 100644 --- a/packages/backend/src/core/LoggerService.ts +++ b/packages/backend/src/core/LoggerService.ts @@ -3,7 +3,7 @@ import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import type { KEYWORD } from 'color-convert/conversions'; +import type { KEYWORD } from 'color-convert/conversions.js'; @Injectable() export class LoggerService { diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 5acc9ad9a..aae0a9134 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -56,7 +56,7 @@ export class MetaService implements OnApplicationShutdown { @bindThis public async fetch(noCache = false): Promise { if (!noCache && this.cache) return this.cache; - + return await this.db.transaction(async transactionalEntityManager => { // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する const metas = await transactionalEntityManager.find(Meta, { @@ -64,9 +64,9 @@ export class MetaService implements OnApplicationShutdown { id: 'DESC', }, }); - + const meta = metas[0]; - + if (meta) { this.cache = meta; return meta; @@ -81,7 +81,7 @@ export class MetaService implements OnApplicationShutdown { ['id'], ) .then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0])); - + this.cache = saved; return saved; } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index dffee16e0..38aaa8452 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -27,29 +27,29 @@ export class MfmService { public fromHtml(html: string, hashtagNames?: string[]): string { // some AP servers like Pixelfed use br tags as well as newlines html = html.replace(/\r?\n/gi, '\n'); - + const dom = parse5.parseFragment(html); - + let text = ''; - + for (const n of dom.childNodes) { analyze(n); } - + return text.trim(); - + function getText(node: TreeAdapter.Node): string { if (treeAdapter.isTextNode(node)) return node.value; if (!treeAdapter.isElementNode(node)) return ''; if (node.nodeName === 'br') return '\n'; - + if (node.childNodes) { return node.childNodes.map(n => getText(n)).join(''); } - + return ''; } - + function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { if (childNodes) { for (const n of childNodes) { @@ -57,35 +57,35 @@ export class MfmService { } } } - + function analyze(node: TreeAdapter.Node) { if (treeAdapter.isTextNode(node)) { text += node.value; return; } - + // Skip comment or document type node if (!treeAdapter.isElementNode(node)) return; - + switch (node.nodeName) { case 'br': { text += '\n'; break; } - + case 'a': { const txt = getText(node); const rel = node.attrs.find(x => x.name === 'rel'); const href = node.attrs.find(x => x.name === 'href'); - + // ハッシュタグ if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { text += txt; // メンション } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { const part = txt.split('@'); - + if (part.length === 2 && href) { //#region ホスト名部分が省略されているので復元する const acct = `${txt}@${(new URL(href.value)).hostname}`; @@ -116,12 +116,12 @@ export class MfmService { return `[${txt}](${href.value})`; } }; - + text += generateLink(); } break; } - + case 'h1': { text += '【'; @@ -129,7 +129,7 @@ export class MfmService { text += '】\n'; break; } - + case 'b': case 'strong': { @@ -138,7 +138,7 @@ export class MfmService { text += '**'; break; } - + case 'small': { text += ''; @@ -146,7 +146,7 @@ export class MfmService { text += ''; break; } - + case 's': case 'del': { @@ -155,7 +155,7 @@ export class MfmService { text += '~~'; break; } - + case 'i': case 'em': { @@ -164,7 +164,7 @@ export class MfmService { text += ''; break; } - + // block code (
)
 				case 'pre': {
 					if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
@@ -176,7 +176,7 @@ export class MfmService {
 					}
 					break;
 				}
-	
+
 				// inline code ()
 				case 'code': {
 					text += '`';
@@ -184,7 +184,7 @@ export class MfmService {
 					text += '`';
 					break;
 				}
-	
+
 				case 'blockquote': {
 					const t = getText(node);
 					if (t) {
@@ -193,7 +193,7 @@ export class MfmService {
 					}
 					break;
 				}
-	
+
 				case 'p':
 				case 'h2':
 				case 'h3':
@@ -205,7 +205,7 @@ export class MfmService {
 					appendChildren(node.childNodes);
 					break;
 				}
-	
+
 				// other block elements
 				case 'div':
 				case 'header':
@@ -219,7 +219,7 @@ export class MfmService {
 					appendChildren(node.childNodes);
 					break;
 				}
-	
+
 				default:	// includes inline elements
 				{
 					appendChildren(node.childNodes);
@@ -234,48 +234,48 @@ export class MfmService {
 		if (nodes == null) {
 			return null;
 		}
-	
+
 		const { window } = new Window();
-	
+
 		const doc = window.document;
-	
+
 		function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
 			if (children) {
 				for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
 			}
 		}
-	
+
 		const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
 			bold: (node) => {
 				const el = doc.createElement('b');
 				appendChildren(node.children, el);
 				return el;
 			},
-	
+
 			small: (node) => {
 				const el = doc.createElement('small');
 				appendChildren(node.children, el);
 				return el;
 			},
-	
+
 			strike: (node) => {
 				const el = doc.createElement('del');
 				appendChildren(node.children, el);
 				return el;
 			},
-	
+
 			italic: (node) => {
 				const el = doc.createElement('i');
 				appendChildren(node.children, el);
 				return el;
 			},
-	
+
 			fn: (node) => {
 				const el = doc.createElement('i');
 				appendChildren(node.children, el);
 				return el;
 			},
-	
+
 			blockCode: (node) => {
 				const pre = doc.createElement('pre');
 				const inner = doc.createElement('code');
@@ -283,21 +283,21 @@ export class MfmService {
 				pre.appendChild(inner);
 				return pre;
 			},
-	
+
 			center: (node) => {
 				const el = doc.createElement('div');
 				appendChildren(node.children, el);
 				return el;
 			},
-	
+
 			emojiCode: (node) => {
 				return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
 			},
-	
+
 			unicodeEmoji: (node) => {
 				return doc.createTextNode(node.props.emoji);
 			},
-	
+
 			hashtag: (node) => {
 				const a = doc.createElement('a');
 				a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
@@ -305,32 +305,32 @@ export class MfmService {
 				a.setAttribute('rel', 'tag');
 				return a;
 			},
-	
+
 			inlineCode: (node) => {
 				const el = doc.createElement('code');
 				el.textContent = node.props.code;
 				return el;
 			},
-	
+
 			mathInline: (node) => {
 				const el = doc.createElement('code');
 				el.textContent = node.props.formula;
 				return el;
 			},
-	
+
 			mathBlock: (node) => {
 				const el = doc.createElement('code');
 				el.textContent = node.props.formula;
 				return el;
 			},
-	
+
 			link: (node) => {
 				const a = doc.createElement('a');
 				a.setAttribute('href', node.props.url);
 				appendChildren(node.children, a);
 				return a;
 			},
-	
+
 			mention: (node) => {
 				const a = doc.createElement('a');
 				const { username, host, acct } = node.props;
@@ -340,47 +340,47 @@ export class MfmService {
 				a.textContent = acct;
 				return a;
 			},
-	
+
 			quote: (node) => {
 				const el = doc.createElement('blockquote');
 				appendChildren(node.children, el);
 				return el;
 			},
-	
+
 			text: (node) => {
 				const el = doc.createElement('span');
 				const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
-	
+
 				for (const x of intersperse('br', nodes)) {
 					el.appendChild(x === 'br' ? doc.createElement('br') : x);
 				}
-	
+
 				return el;
 			},
-	
+
 			url: (node) => {
 				const a = doc.createElement('a');
 				a.setAttribute('href', node.props.url);
 				a.textContent = node.props.url;
 				return a;
 			},
-	
+
 			search: (node) => {
 				const a = doc.createElement('a');
 				a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
 				a.textContent = node.props.content;
 				return a;
 			},
-	
+
 			plain: (node) => {
 				const el = doc.createElement('span');
 				appendChildren(node.children, el);
 				return el;
 			},
 		};
-	
+
 		appendChildren(nodes, doc.body);
-	
+
 		return `

${doc.body.innerHTML}

`; - } + } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 1c8491bf5..648ff7648 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -570,12 +570,14 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.reply) { // 通知 if (data.reply.userHost === null) { - const threadMuted = await this.noteThreadMutingsRepository.findOneBy({ - userId: data.reply.userId, - threadId: data.reply.threadId ?? data.reply.id, + const isThreadMuted = await this.noteThreadMutingsRepository.exist({ + where: { + userId: data.reply.userId, + threadId: data.reply.threadId ?? data.reply.id, + }, }); - if (!threadMuted) { + if (!isThreadMuted) { nm.push(data.reply.userId, 'reply'); this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); @@ -672,7 +674,7 @@ export class NoteCreateService implements OnApplicationShutdown { // Register to search database this.index(note); } - + @bindThis private isSensitive(note: Option, sensitiveWord: string[]): boolean { if (sensitiveWord.length > 0) { @@ -712,12 +714,14 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private async createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) { for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { - const threadMuted = await this.noteThreadMutingsRepository.findOneBy({ - userId: u.id, - threadId: note.threadId ?? note.id, + const isThreadMuted = await this.noteThreadMutingsRepository.exist({ + where: { + userId: u.id, + threadId: note.threadId ?? note.id, + }, }); - if (threadMuted) { + if (isThreadMuted) { continue; } @@ -758,7 +762,7 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private index(note: Note) { if (note.text == null && note.cw == null) return; - + this.searchService.indexNote(note); } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index dd878f7bb..f77ea8aab 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -17,6 +17,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; +import { SearchService } from '@/core/SearchService.js'; @Injectable() export class NoteDeleteService { @@ -41,11 +42,12 @@ export class NoteDeleteService { private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, private metaService: MetaService, + private searchService: SearchService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private instanceChart: InstanceChart, ) {} - + /** * 投稿を削除します。 * @param user 投稿者 @@ -53,6 +55,7 @@ export class NoteDeleteService { */ async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; isBot: User['isBot']; }, note: Note, quiet = false) { const deletedAt = new Date(); + const cascadingNotes = await this.findCascadingNotes(note); // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) { @@ -88,8 +91,8 @@ export class NoteDeleteService { } // also deliever delete activity to cascaded notes - const cascadingNotes = (await this.findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes - for (const cascadingNote of cascadingNotes) { + const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes + for (const cascadingNote of federatedLocalCascadingNotes) { if (!cascadingNote.user) continue; if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue; const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); @@ -114,6 +117,11 @@ export class NoteDeleteService { } } + for (const cascadingNote of cascadingNotes) { + this.searchService.unindexNote(cascadingNote); + } + this.searchService.unindexNote(note); + await this.notesRepository.delete({ id: note.id, userId: user.id, @@ -121,10 +129,8 @@ export class NoteDeleteService { } @bindThis - private async findCascadingNotes(note: Note) { - const cascadingNotes: Note[] = []; - - const recursive = async (noteId: string) => { + private async findCascadingNotes(note: Note): Promise { + const recursive = async (noteId: string): Promise => { const query = this.notesRepository.createQueryBuilder('note') .where('note.replyId = :noteId', { noteId }) .orWhere(new Brackets(q => { @@ -133,14 +139,16 @@ export class NoteDeleteService { })) .leftJoinAndSelect('note.user', 'user'); const replies = await query.getMany(); - for (const reply of replies) { - cascadingNotes.push(reply); - await recursive(reply.id); - } - }; - await recursive(note.id); - return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users + return [ + replies, + ...await Promise.all(replies.map(reply => recursive(reply.id))), + ].flat(); + }; + + const cascadingNotes: Note[] = await recursive(note.id); + + return cascadingNotes; } @bindThis diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index e57e57d31..52e9bd369 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -43,11 +43,13 @@ export class NoteReadService implements OnApplicationShutdown { //#endregion // スレッドミュート - const threadMute = await this.noteThreadMutingsRepository.findOneBy({ - userId: userId, - threadId: note.threadId ?? note.id, + const isThreadMuted = await this.noteThreadMutingsRepository.exist({ + where: { + userId: userId, + threadId: note.threadId ?? note.id, + }, }); - if (threadMute) return; + if (isThreadMuted) return; const unread = { id: this.idService.genId(), @@ -62,9 +64,9 @@ export class NoteReadService implements OnApplicationShutdown { // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { - const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id }); + const exist = await this.noteUnreadsRepository.exist({ where: { id: unread.id } }); - if (exist == null) return; + if (!exist) return; if (params.isMentioned) { this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); @@ -99,7 +101,7 @@ export class NoteReadService implements OnApplicationShutdown { }); // TODO: ↓まとめてクエリしたい - + this.noteUnreadsRepository.countBy({ userId: userId, isMentioned: true, @@ -109,7 +111,7 @@ export class NoteReadService implements OnApplicationShutdown { this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions'); } }); - + this.noteUnreadsRepository.countBy({ userId: userId, isSpecified: true, diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index ed47165f7..8e25f8228 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -46,7 +46,7 @@ export class NotificationService implements OnApplicationShutdown { force = false, ) { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - + const latestNotificationIdsRes = await this.redisClient.xrevrange( `notificationTimeline:${userId}`, '+', diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts index 368753d9a..be1940005 100644 --- a/packages/backend/src/core/PollService.ts +++ b/packages/backend/src/core/PollService.ts @@ -39,12 +39,12 @@ export class PollService { @bindThis public async vote(user: User, note: Note, choice: number) { const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - + if (poll == null) throw new Error('poll not found'); - + // Check whether is valid choice if (poll.choices[choice] == null) throw new Error('invalid choice param'); - + // Check blocking if (note.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); @@ -52,13 +52,13 @@ export class PollService { throw new Error('blocked'); } } - + // if already voted const exist = await this.pollVotesRepository.findBy({ noteId: note.id, userId: user.id, }); - + if (poll.multiple) { if (exist.some(x => x.choice === choice)) { throw new Error('already voted'); @@ -66,7 +66,7 @@ export class PollService { } else if (exist.length !== 0) { throw new Error('already voted'); } - + // Create vote await this.pollVotesRepository.insert({ id: this.idService.genId(), @@ -75,11 +75,11 @@ export class PollService { userId: user.id, choice: choice, }); - + // Increment votes count const index = choice + 1; // In SQL, array index is 1 based await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); - + this.globalEventService.publishNoteStream(note.id, 'pollVoted', { choice: choice, userId: user.id, @@ -90,10 +90,10 @@ export class PollService { public async deliverQuestionUpdate(noteId: Note['id']) { const note = await this.notesRepository.findOneBy({ id: noteId }); if (note == null) throw new Error('note not found'); - + const user = await this.usersRepository.findOneBy({ id: note.userId }); if (user == null) throw new Error('note not found'); - + if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user)); this.apDeliverManagerService.deliverToFollowers(user, content); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 15a1d7487..e1c3d3943 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -3,7 +3,7 @@ import push from 'web-push'; import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/json-schema'; +import type { Packed } from '@/misc/json-schema.js'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js'; import { MetaService } from '@/core/MetaService.js'; @@ -31,7 +31,7 @@ function truncateBody(type: T, body: Pus ...body.note, // textをgetNoteSummaryしたものに置き換える text: getNoteSummary(('type' in body && body.type === 'renote') ? body.note.renote as Packed<'Note'> : body.note), - + cw: undefined, reply: undefined, renote: undefined, @@ -69,16 +69,16 @@ export class PushNotificationService implements OnApplicationShutdown { @bindThis public async pushNotification(userId: string, type: T, body: PushNotificationsTypes[T]) { const meta = await this.metaService.fetch(); - + if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; - + // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 push.setVapidDetails(this.config.url, meta.swPublicKey, meta.swPrivateKey); - + const subscriptions = await this.subscriptionsCache.fetch(userId); - + for (const subscription of subscriptions) { if ([ 'readAllNotifications', @@ -103,7 +103,7 @@ export class PushNotificationService implements OnApplicationShutdown { //swLogger.info(err.statusCode); //swLogger.info(err.headers); //swLogger.info(err.body); - + if (err.statusCode === 410) { this.swSubscriptionsRepository.delete({ userId: userId, diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index bf50a1cde..435d5d238 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -60,8 +60,8 @@ export class QueryService { q.orderBy(`${q.alias}.id`, 'DESC'); } return q; - } - + } + // ここでいうBlockedは被Blockedの意 @bindThis public generateBlockedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }): void { @@ -109,18 +109,18 @@ export class QueryService { q.andWhere('note.channelId IS NULL'); } else { q.leftJoinAndSelect('note.channel', 'channel'); - + const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing') .select('channelFollowing.followeeId') .where('channelFollowing.followerId = :followerId', { followerId: me.id }); - + q.andWhere(new Brackets(qb => { qb // チャンネルのノートではない .where('note.channelId IS NULL') // または自分がフォローしているチャンネルのノート .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); })); - + q.setParameters(channelFollowingQuery.getParameters()); } } @@ -130,9 +130,9 @@ export class QueryService { const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted') .select('muted.noteId') .where('muted.userId = :userId', { userId: me.id }); - + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); - + q.setParameters(mutedQuery.getParameters()); } @@ -141,13 +141,13 @@ export class QueryService { const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') .select('threadMuted.threadId') .where('threadMuted.userId = :userId', { userId: me.id }); - + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); q.andWhere(new Brackets(qb => { qb .where('note.threadId IS NULL') .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); })); - + q.setParameters(mutedQuery.getParameters()); } @@ -156,15 +156,15 @@ export class QueryService { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); - + if (exclude) { mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); } - + const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') .select('user_profile.mutedInstances') .where('user_profile.userId = :muterId', { muterId: me.id }); - + // 投稿の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない @@ -191,7 +191,7 @@ export class QueryService { .where('note.renoteUserHost IS NULL') .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); })); - + q.setParameters(mutingQuery.getParameters()); q.setParameters(mutingInstanceQuery.getParameters()); } @@ -201,9 +201,9 @@ export class QueryService { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); - + q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); - + q.setParameters(mutingQuery.getParameters()); } @@ -245,7 +245,7 @@ export class QueryService { const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') .where('following.followerId = :meId'); - + q.andWhere(new Brackets(qb => { qb // 公開投稿である .where(new Brackets(qb => { qb @@ -268,7 +268,7 @@ export class QueryService { })); })); })); - + q.setParameters({ meId: me.id }); } } @@ -278,10 +278,10 @@ export class QueryService { const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') .select('renote_muting.muteeId') .where('renote_muting.muterId = :muterId', { muterId: me.id }); - + q.andWhere(new Brackets(qb => { qb - .where(new Brackets(qb => { + .where(new Brackets(qb => { qb.where('note.renoteId IS NOT NULL'); qb.andWhere('note.text IS NULL'); qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); @@ -289,7 +289,7 @@ export class QueryService { .orWhere('note.renoteId IS NULL') .orWhere('note.text IS NOT NULL'); })); - + q.setParameters(mutingQuery.getParameters()); } } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 2ae8a2b75..546b4cee1 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -1,5 +1,5 @@ +import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; -import { v4 as uuid } from 'uuid'; import type { IActivity } from '@/core/activitypub/type.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; @@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; -import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; +import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; @@ -69,7 +69,7 @@ export class QueueService { if (content == null) return null; if (to == null) return null; - const data = { + const data: DeliverJobData = { user: { id: user.id, }, @@ -88,6 +88,40 @@ export class QueueService { }); } + /** + * ApDeliverManager-DeliverManager.execute()からinboxesを突っ込んでaddBulkしたい + * @param user `{ id: string; }` この関数ではThinUserに変換しないので前もって変換してください + * @param content IActivity | null + * @param inboxes `Map` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox) + * @returns void + */ + @bindThis + public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map) { + if (content == null) return null; + + const opts = { + attempts: this.config.deliverJobMaxAttempts ?? 12, + backoff: { + type: 'custom', + }, + removeOnComplete: true, + removeOnFail: true, + }; + + await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({ + name: d[0], + data: { + user, + content, + to: d[0], + isSharedInbox: d[1], + } as DeliverJobData, + opts, + }))); + + return; + } + @bindThis public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { const data = { @@ -382,7 +416,7 @@ export class QueueService { to: webhook.url, secret: webhook.secret, createdAt: Date.now(), - eventId: uuid(), + eventId: randomUUID(), }; return this.webhookDeliverQueue.add(webhook.id, data, { @@ -400,11 +434,11 @@ export class QueueService { this.deliverQueue.once('cleaned', (jobs, status) => { //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); - this.deliverQueue.clean(0, Infinity, 'delayed'); + this.deliverQueue.clean(0, 0, 'delayed'); this.inboxQueue.once('cleaned', (jobs, status) => { //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); - this.inboxQueue.clean(0, Infinity, 'delayed'); + this.inboxQueue.clean(0, 0, 'delayed'); } } diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 9d34d82be..c0113a21d 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -39,9 +39,9 @@ export class RelayService { host: IsNull(), username: ACTOR_USERNAME, }); - + if (user) return user as LocalUser; - + const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME); return created as LocalUser; } @@ -53,12 +53,12 @@ export class RelayService { inbox, status: 'requesting', }).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0])); - + const relayActor = await this.getRelayActor(); const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); const activity = this.apRendererService.addContext(follow); this.queueService.deliver(relayActor, activity, relay.inbox, false); - + return relay; } @@ -67,17 +67,17 @@ export class RelayService { const relay = await this.relaysRepository.findOneBy({ inbox, }); - + if (relay == null) { throw new Error('relay not found'); } - + const relayActor = await this.getRelayActor(); const follow = this.apRendererService.renderFollowRelay(relay, relayActor); const undo = this.apRendererService.renderUndo(follow, relayActor); const activity = this.apRendererService.addContext(undo); this.queueService.deliver(relayActor, activity, relay.inbox, false); - + await this.relaysRepository.delete(relay.id); } @@ -86,13 +86,13 @@ export class RelayService { const relays = await this.relaysRepository.find(); return relays; } - + @bindThis public async relayAccepted(id: string): Promise { const result = await this.relaysRepository.update(id, { status: 'accepted', }); - + return JSON.stringify(result); } @@ -101,24 +101,24 @@ export class RelayService { const result = await this.relaysRepository.update(id, { status: 'rejected', }); - + return JSON.stringify(result); } @bindThis public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise { if (activity == null) return; - + const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ status: 'accepted', })); if (relays.length === 0) return; - + const copy = deepClone(activity); if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; - + const signed = await this.apRendererService.attachLdSignature(copy, user); - + for (const relay of relays) { this.queueService.deliver(user, signed, relay.inbox, false); } diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index ff68c2421..e19a245e1 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -8,8 +8,9 @@ import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { WebfingerService } from '@/core/WebfingerService.js'; +import { ILink, WebfingerService } from '@/core/WebfingerService.js'; import { RemoteLoggerService } from '@/core/RemoteLoggerService.js'; +import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { bindThis } from '@/decorators.js'; @@ -27,6 +28,7 @@ export class RemoteUserResolveService { private utilityService: UtilityService, private webfingerService: WebfingerService, private remoteLoggerService: RemoteLoggerService, + private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, ) { this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user'); @@ -35,7 +37,7 @@ export class RemoteUserResolveService { @bindThis public async resolveUser(username: string, host: string | null): Promise { const usernameLower = username.toLowerCase(); - + if (host == null) { this.logger.info(`return local user: ${usernameLower}`); return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { @@ -46,9 +48,9 @@ export class RemoteUserResolveService { } }) as LocalUser; } - + host = this.utilityService.toPuny(host); - + if (this.config.host === host) { this.logger.info(`return local user: ${usernameLower}`); return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { @@ -59,39 +61,55 @@ export class RemoteUserResolveService { } }) as LocalUser; } - + const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null; - + const acctLower = `${usernameLower}@${host}`; - + if (user == null) { const self = await this.resolveSelf(acctLower); - + + if (self.href.startsWith(this.config.url)) { + const local = this.apDbResolverService.parseUri(self.href); + if (local.local && local.type === 'users') { + // the LR points to local + return (await this.apDbResolverService + .getUserFromApId(self.href) + .then((u) => { + if (u == null) { + throw new Error('local user not found'); + } else { + return u; + } + })) as LocalUser; + } + } + this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); return await this.apPersonService.createPerson(self.href); } - - // ユーザー情報が古い場合は、WebFilgerからやりなおして返す + + // ユーザー情報が古い場合は、WebFingerからやりなおして返す if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する await this.usersRepository.update(user.id, { lastFetchedAt: new Date(), }); - + this.logger.info(`try resync: ${acctLower}`); const self = await this.resolveSelf(acctLower); - + if (user.uri !== self.href) { // if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping. this.logger.info(`uri missmatch: ${acctLower}`); this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`); - + // validate uri const uri = new URL(self.href); if (uri.hostname !== host) { throw new Error('Invalid uri'); } - + await this.usersRepository.update({ usernameLower, host: host, @@ -101,9 +119,9 @@ export class RemoteUserResolveService { } else { this.logger.info(`uri is fine: ${acctLower}`); } - + await this.apPersonService.updatePerson(self.href); - + this.logger.info(`return resynced remote user: ${acctLower}`); return await this.usersRepository.findOneBy({ uri: self.href }).then(u => { if (u == null) { @@ -113,13 +131,13 @@ export class RemoteUserResolveService { } }); } - + this.logger.info(`return existing remote user: ${acctLower}`); return user; } @bindThis - private async resolveSelf(acctLower: string) { + private async resolveSelf(acctLower: string): Promise { this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); const finger = await this.webfingerService.webfinger(acctLower).catch(err => { this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 79922d0a8..d065b460c 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -13,7 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { Packed } from '@/misc/json-schema'; +import type { Packed } from '@/misc/json-schema.js'; import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { @@ -21,6 +21,9 @@ export type RolePolicies = { ltlAvailable: boolean; canPublicNote: boolean; canInvite: boolean; + inviteLimit: number; + inviteLimitCycle: number; + inviteExpirationTime: number; canManageCustomEmojis: boolean; canSearchNotes: boolean; canHideAds: boolean; @@ -42,6 +45,9 @@ export const DEFAULT_POLICIES: RolePolicies = { ltlAvailable: true, canPublicNote: true, canInvite: false, + inviteLimit: 0, + inviteLimitCycle: 60 * 24 * 7, + inviteExpirationTime: 0, canManageCustomEmojis: false, canSearchNotes: false, canHideAds: false, @@ -214,14 +220,19 @@ export class RoleService implements OnApplicationShutdown { } @bindThis - public async getUserRoles(userId: User['id']) { + public async getUserAssigns(userId: User['id']) { const now = Date.now(); let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); - const assignedRoleIds = assigns.map(x => x.roleId); + return assigns; + } + + @bindThis + public async getUserRoles(userId: User['id']) { const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); + const assigns = await this.getUserAssigns(userId); + const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); return [...assignedRoles, ...matchedCondRoles]; @@ -277,6 +288,9 @@ export class RoleService implements OnApplicationShutdown { ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), + inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), + inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), + inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), @@ -392,7 +406,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async unassign(userId: User['id'], roleId: Role['id']): Promise { const now = new Date(); - + const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId }); if (existing == null) { throw new RoleService.NotAssignedError(); diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 9502afcc9..392799da7 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -52,6 +52,7 @@ function compileQuery(q: Q): string { @Injectable() export class SearchService { + private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local'; private meilisearchNoteIndex: Index | null = null; constructor( @@ -92,6 +93,10 @@ export class SearchService { }, }); } + + if (config.meilisearch?.scope) { + this.meilisearchIndexScope = config.meilisearch.scope; + } } @bindThis @@ -100,7 +105,22 @@ export class SearchService { if (!['home', 'public'].includes(note.visibility)) return; if (this.meilisearch) { - this.meilisearchNoteIndex!.addDocuments([{ + switch (this.meilisearchIndexScope) { + case 'global': + break; + + case 'local': + if (note.userHost == null) break; + return; + + default: { + if (note.userHost == null) break; + if (this.meilisearchIndexScope.includes(note.userHost)) break; + return; + } + } + + await this.meilisearchNoteIndex?.addDocuments([{ id: note.id, createdAt: note.createdAt.getTime(), userId: note.userId, @@ -115,6 +135,15 @@ export class SearchService { } } + @bindThis + public async unindexNote(note: Note): Promise { + if (!['home', 'public'].includes(note.visibility)) return; + + if (this.meilisearch) { + this.meilisearchNoteIndex!.deleteDocument(note.id); + } + } + @bindThis public async searchNote(q: string, me: User | null, opts: { userId?: Note['userId'] | null; @@ -174,7 +203,7 @@ export class SearchService { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); - return await query.take(pagination.limit).getMany(); + return await query.limit(pagination.limit).getMany(); } } } diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 29eb65fda..070a9a9e3 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -50,33 +50,33 @@ export class SignupService { }) { const { username, password, passwordHash, host } = opts; let hash = passwordHash; - + // Validate username if (!this.userEntityService.validateLocalUsername(username)) { throw new Error('INVALID_USERNAME'); } - + if (password != null && passwordHash == null) { // Validate password if (!this.userEntityService.validatePassword(password)) { throw new Error('INVALID_PASSWORD'); } - + // Generate hash of password const salt = await bcrypt.genSalt(8); hash = await bcrypt.hash(password, salt); } - + // Generate secret const secret = generateUserToken(); - + // Check username duplication - if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { throw new Error('DUPLICATED_USERNAME'); } - + // Check deleted username duplication - if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { + if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) { throw new Error('USED_USERNAME'); } @@ -92,7 +92,7 @@ export class SignupService { const keyPair = await new Promise((res, rej) => generateKeyPair('rsa', { - modulusLength: 4096, + modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem', @@ -106,18 +106,18 @@ export class SignupService { }, (err, publicKey, privateKey) => err ? rej(err) : res([publicKey, privateKey]), )); - + let account!: User; - + // Start transaction await this.db.transaction(async transactionalEntityManager => { const exist = await transactionalEntityManager.findOneBy(User, { usernameLower: username.toLowerCase(), host: IsNull(), }); - + if (exist) throw new Error(' the username is already used'); - + account = await transactionalEntityManager.save(new User({ id: this.idService.genId(), createdAt: new Date(), @@ -127,27 +127,27 @@ export class SignupService { token: secret, isRoot: isTheFirstUser, })); - + await transactionalEntityManager.save(new UserKeypair({ publicKey: keyPair[0], privateKey: keyPair[1], userId: account.id, })); - + await transactionalEntityManager.save(new UserProfile({ userId: account.id, autoAcceptFollowed: true, password: hash, })); - + await transactionalEntityManager.save(new UsedUsername({ createdAt: new Date(), username: username.toLowerCase(), })); }); - + this.usersChart.update(account, true); - + return { account, secret }; } } diff --git a/packages/backend/src/core/TwoFactorAuthenticationService.ts b/packages/backend/src/core/TwoFactorAuthenticationService.ts index dda78236e..d4cf186a2 100644 --- a/packages/backend/src/core/TwoFactorAuthenticationService.ts +++ b/packages/backend/src/core/TwoFactorAuthenticationService.ts @@ -69,7 +69,7 @@ function verifyCertificateChain(certificates: string[]) { 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(); @@ -143,19 +143,19 @@ export class TwoFactorAuthenticationService { 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) @@ -168,7 +168,7 @@ export class TwoFactorAuthenticationService { none: { verify({ publicKey }: { publicKey: Map }) { const negTwo = publicKey.get(-2); - + if (!negTwo || negTwo.length !== 32) { throw new Error('invalid or no -2 key given'); } @@ -176,12 +176,12 @@ export class TwoFactorAuthenticationService { 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, @@ -207,16 +207,16 @@ export class TwoFactorAuthenticationService { 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'); } @@ -224,23 +224,23 @@ export class TwoFactorAuthenticationService { 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, @@ -267,43 +267,43 @@ export class TwoFactorAuthenticationService { 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'); } @@ -311,7 +311,7 @@ export class TwoFactorAuthenticationService { 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, @@ -342,17 +342,17 @@ export class TwoFactorAuthenticationService { 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'); } @@ -360,12 +360,12 @@ export class TwoFactorAuthenticationService { 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, @@ -375,12 +375,12 @@ export class TwoFactorAuthenticationService { 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, @@ -401,13 +401,13 @@ export class TwoFactorAuthenticationService { 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'); } @@ -415,12 +415,12 @@ export class TwoFactorAuthenticationService { 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, @@ -428,12 +428,12 @@ export class TwoFactorAuthenticationService { credentialId, publicKeyU2F, ]); - + const validSignature = crypto .createVerify('SHA256') .update(verificationData) .verify(PEMString(attCert), attStmt.sig); - + return { valid: validSignature, publicKey: publicKeyU2F, diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 7d90bc2c0..44269d3b8 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -1,5 +1,6 @@ import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; +import { IsNull } from 'typeorm'; import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; @@ -21,9 +22,8 @@ import { UserBlockingService } from '@/core/UserBlockingService.js'; import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; -import Logger from '../logger.js'; -import { IsNull } from 'typeorm'; import { AccountMoveService } from '@/core/AccountMoveService.js'; +import Logger from '../logger.js'; const logger = new Logger('following/create'); @@ -122,22 +122,26 @@ export class UserFollowingService implements OnModuleInit { let autoAccept = false; // 鍵アカウントであっても、既にフォローされていた場合はスルー - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, + const isFollowing = await this.followingsRepository.exist({ + where: { + followerId: follower.id, + followeeId: followee.id, + }, }); - if (following) { + if (isFollowing) { autoAccept = true; } // フォローしているユーザーは自動承認オプション if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { - const followed = await this.followingsRepository.findOneBy({ - followerId: followee.id, - followeeId: follower.id, + const isFollowed = await this.followingsRepository.exist({ + where: { + followerId: followee.id, + followeeId: follower.id, + }, }); - if (followed) autoAccept = true; + if (isFollowed) autoAccept = true; } // Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account. @@ -206,12 +210,14 @@ export class UserFollowingService implements OnModuleInit { this.cacheService.userFollowingsCache.refresh(follower.id); - const req = await this.followRequestsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, + const requestExist = await this.followRequestsRepository.exist({ + where: { + followeeId: followee.id, + followerId: follower.id, + }, }); - if (req) { + if (requestExist) { await this.followRequestsRepository.delete({ followeeId: followee.id, followerId: follower.id, @@ -316,7 +322,7 @@ export class UserFollowingService implements OnModuleInit { where: { followerId: follower.id, followeeId: followee.id, - } + }, }); if (following === null || !following.follower || !following.followee) { @@ -406,8 +412,8 @@ export class UserFollowingService implements OnModuleInit { followerId: user.id, followee: { movedToUri: IsNull(), - } - } + }, + }, }); const nonMovedFollowers = await this.followingsRepository.count({ relations: { @@ -417,8 +423,8 @@ export class UserFollowingService implements OnModuleInit { followeeId: user.id, follower: { movedToUri: IsNull(), - } - } + }, + }, }); await this.usersRepository.update( { id: user.id }, @@ -505,12 +511,14 @@ export class UserFollowingService implements OnModuleInit { } } - const request = await this.followRequestsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, + const requestExist = await this.followRequestsRepository.exist({ + where: { + followeeId: followee.id, + followerId: follower.id, + }, }); - if (request == null) { + if (!requestExist) { throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); } @@ -638,7 +646,7 @@ export class UserFollowingService implements OnModuleInit { where: { followeeId: followee.id, followerId: follower.id, - } + }, }); if (!following || !following.followee || !following.follower) return; diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index b197d335d..28ae32681 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -32,13 +32,13 @@ export class UserSuspendService { @bindThis public async doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); - + if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにDelete配信 const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - + const queue: string[] = []; - + const followings = await this.followingsRepository.find({ where: [ { followerSharedInbox: Not(IsNull()) }, @@ -46,13 +46,13 @@ export class UserSuspendService { ], select: ['followerSharedInbox', 'followeeSharedInbox'], }); - + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); - + for (const inbox of inboxes) { if (inbox != null && !queue.includes(inbox)) queue.push(inbox); } - + for (const inbox of queue) { this.queueService.deliver(user, content, inbox, true); } @@ -62,13 +62,13 @@ export class UserSuspendService { @bindThis public async doPostUnsuspend(user: User): Promise { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); - + if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにUndo Delete配信 const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); - + const queue: string[] = []; - + const followings = await this.followingsRepository.find({ where: [ { followerSharedInbox: Not(IsNull()) }, @@ -76,13 +76,13 @@ export class UserSuspendService { ], select: ['followerSharedInbox', 'followeeSharedInbox'], }); - + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); - + for (const inbox of inboxes) { if (inbox != null && !queue.includes(inbox)) queue.push(inbox); } - + for (const inbox of queue) { this.queueService.deliver(user as any, content, inbox, true); } diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index 5869905db..06f832ab0 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -21,7 +21,7 @@ export class VideoProcessingService { @bindThis public async generateVideoThumbnail(source: string): Promise { const [dir, cleanup] = await createTempDir(); - + try { await new Promise((res, rej) => { FFmpeg({ @@ -52,7 +52,7 @@ export class VideoProcessingService { query({ thumbnail: '1', url, - }) + }), ); } } diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index f58a6a10f..6b2428cdf 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -6,12 +6,12 @@ import { query as urlQuery } from '@/misc/prelude/url.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; -type ILink = { +export type ILink = { href: string; rel?: string; }; -type IWebFinger = { +export type IWebFinger = { links: ILink[]; subject: string; }; diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 467755a07..b6f526390 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -31,7 +31,7 @@ export class WebhookService implements OnApplicationShutdown { }); this.webhooksFetched = true; } - + return this.webhooks; } diff --git a/packages/backend/src/core/activitypub/ApAudienceService.ts b/packages/backend/src/core/activitypub/ApAudienceService.ts index 8282a6324..a4ab5eae2 100644 --- a/packages/backend/src/core/activitypub/ApAudienceService.ts +++ b/packages/backend/src/core/activitypub/ApAudienceService.ts @@ -16,6 +16,8 @@ type AudienceInfo = { visibleUsers: User[], }; +type GroupedAudience = Record<'public' | 'followers' | 'other', string[]>; + @Injectable() export class ApAudienceService { constructor( @@ -27,14 +29,14 @@ export class ApAudienceService { public async parseAudience(actor: RemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { const toGroups = this.groupingAudience(getApIds(to), actor); const ccGroups = this.groupingAudience(getApIds(cc), actor); - + const others = unique(concat([toGroups.other, ccGroups.other])); - + const limit = promiseLimit(2); const mentionedUsers = (await Promise.all( others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))), )).filter((x): x is User => x != null); - + if (toGroups.public.length > 0) { return { visibility: 'public', @@ -42,7 +44,7 @@ export class ApAudienceService { visibleUsers: [], }; } - + if (ccGroups.public.length > 0) { return { visibility: 'home', @@ -50,7 +52,7 @@ export class ApAudienceService { visibleUsers: [], }; } - + if (toGroups.followers.length > 0) { return { visibility: 'followers', @@ -58,22 +60,22 @@ export class ApAudienceService { visibleUsers: [], }; } - + return { visibility: 'specified', mentionedUsers, visibleUsers: mentionedUsers, }; } - + @bindThis - private groupingAudience(ids: string[], actor: RemoteUser) { - const groups = { - public: [] as string[], - followers: [] as string[], - other: [] as string[], + private groupingAudience(ids: string[], actor: RemoteUser): GroupedAudience { + const groups: GroupedAudience = { + public: [], + followers: [], + other: [], }; - + for (const id of ids) { if (this.isPublic(id)) { groups.public.push(id); @@ -83,25 +85,23 @@ export class ApAudienceService { groups.other.push(id); } } - + groups.other = unique(groups.other); - + return groups; } - + @bindThis - private isPublic(id: string) { + private isPublic(id: string): boolean { return [ 'https://www.w3.org/ns/activitystreams#Public', 'as#Public', 'Public', ].includes(id); } - + @bindThis - private isFollowers(id: string, actor: RemoteUser) { - return ( - id === (actor.followersUri ?? `${actor.uri}/followers`) - ); + private isFollowers(id: string, actor: RemoteUser): boolean { + return id === (actor.followersUri ?? `${actor.uri}/followers`); } } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index 2d9e7a14e..d5a530c90 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -1,5 +1,4 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import escapeRegexp from 'escape-regexp'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -56,25 +55,18 @@ export class ApDbResolverService implements OnApplicationShutdown { @bindThis public parseUri(value: string | IObject): UriParseResult { - const uri = getApId(value); - - // the host part of a URL is case insensitive, so use the 'i' flag. - const localRegex = new RegExp('^' + escapeRegexp(this.config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i'); - const matchLocal = uri.match(localRegex); - - if (matchLocal) { - return { - local: true, - type: matchLocal[1], - id: matchLocal[2], - rest: matchLocal[3], - }; - } else { - return { - local: false, - uri, - }; - } + const separator = '/'; + + const uri = new URL(getApId(value)); + if (uri.origin !== this.config.url) return { local: false, uri: uri.href }; + + const [, type, id, ...rest] = uri.pathname.split(separator); + return { + local: true, + type, + id, + rest: rest.length === 0 ? undefined : rest.join(separator), + }; } /** @@ -107,13 +99,15 @@ export class ApDbResolverService implements OnApplicationShutdown { if (parsed.local) { if (parsed.type !== 'users') return null; - return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ - id: parsed.id, - }).then(x => x ?? undefined)) as LocalUser | undefined ?? null; + return await this.cacheService.userByIdCache.fetchMaybe( + parsed.id, + () => this.usersRepository.findOneBy({ id: parsed.id }).then(x => x ?? undefined), + ) as LocalUser | undefined ?? null; } else { - return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ - uri: parsed.uri, - })) as RemoteUser | null; + return await this.cacheService.uriPersonCache.fetch( + parsed.uri, + () => this.usersRepository.findOneBy({ uri: parsed.uri }), + ) as RemoteUser | null; } } @@ -129,7 +123,7 @@ export class ApDbResolverService implements OnApplicationShutdown { const key = await this.userPublickeysRepository.findOneBy({ keyId, }); - + if (key == null) return null; return key; @@ -153,9 +147,11 @@ export class ApDbResolverService implements OnApplicationShutdown { } | null> { const user = await this.apPersonService.resolvePerson(uri) as RemoteUser; - if (user == null) return null; - - const key = await this.publicKeyByUserIdCache.fetch(user.id, () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null); + const key = await this.publicKeyByUserIdCache.fetch( + user.id, + () => this.userPublickeysRepository.findOneBy({ userId: user.id }), + v => v != null, + ); return { user, diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 62a2a33a1..09461973d 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -7,6 +7,8 @@ import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { QueueService } from '@/core/QueueService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import type { IActivity } from '@/core/activitypub/type.js'; +import { ThinUser } from '@/queue/types.js'; interface IRecipe { type: string; @@ -21,12 +23,127 @@ interface IDirectRecipe extends IRecipe { to: RemoteUser; } -const isFollowers = (recipe: any): recipe is IFollowersRecipe => +const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe => recipe.type === 'Followers'; -const isDirect = (recipe: any): recipe is IDirectRecipe => +const isDirect = (recipe: IRecipe): recipe is IDirectRecipe => recipe.type === 'Direct'; +class DeliverManager { + private actor: ThinUser; + private activity: IActivity | null; + private recipes: IRecipe[] = []; + + /** + * Constructor + * @param userEntityService + * @param followingsRepository + * @param queueService + * @param actor Actor + * @param activity Activity to deliver + */ + constructor( + private userEntityService: UserEntityService, + private followingsRepository: FollowingsRepository, + private queueService: QueueService, + + actor: { id: User['id']; host: null; }, + activity: IActivity | null, + ) { + // 型で弾いてはいるが一応ローカルユーザーかチェック + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (actor.host != null) throw new Error('actor.host must be null'); + + // パフォーマンス向上のためキューに突っ込むのはidのみに絞る + this.actor = { + id: actor.id, + }; + this.activity = activity; + } + + /** + * Add recipe for followers deliver + */ + @bindThis + public addFollowersRecipe(): void { + const deliver: IFollowersRecipe = { + type: 'Followers', + }; + + this.addRecipe(deliver); + } + + /** + * Add recipe for direct deliver + * @param to To + */ + @bindThis + public addDirectRecipe(to: RemoteUser): void { + const recipe: IDirectRecipe = { + type: 'Direct', + to, + }; + + this.addRecipe(recipe); + } + + /** + * Add recipe + * @param recipe Recipe + */ + @bindThis + public addRecipe(recipe: IRecipe): void { + this.recipes.push(recipe); + } + + /** + * Execute delivers + */ + @bindThis + public async execute(): Promise { + // The value flags whether it is shared or not. + // key: inbox URL, value: whether it is sharedInbox + const inboxes = new Map(); + + // build inbox list + // Process follower recipes first to avoid duplication when processing direct recipes later. + if (this.recipes.some(r => isFollowers(r))) { + // followers deliver + // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう + // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? + const followers = await this.followingsRepository.find({ + where: { + followeeId: this.actor.id, + followerHost: Not(IsNull()), + }, + select: { + followerSharedInbox: true, + followerInbox: true, + }, + }); + + for (const following of followers) { + const inbox = following.followerSharedInbox ?? following.followerInbox; + if (inbox === null) throw new Error('inbox is null'); + inboxes.set(inbox, following.followerSharedInbox != null); + } + } + + for (const recipe of this.recipes.filter(isDirect)) { + // check that shared inbox has not been added yet + if (recipe.to.sharedInbox !== null && inboxes.has(recipe.to.sharedInbox)) continue; + + // check that they actually have an inbox + if (recipe.to.inbox === null) continue; + + inboxes.set(recipe.to.inbox, false); + } + + // deliver + this.queueService.deliverMany(this.actor, this.activity, inboxes); + } +} + @Injectable() export class ApDeliverManagerService { constructor( @@ -46,11 +163,11 @@ export class ApDeliverManagerService { /** * Deliver activity to followers + * @param actor * @param activity Activity - * @param from Followee */ @bindThis - public async deliverToFollowers(actor: { id: LocalUser['id']; host: null; }, activity: any) { + public async deliverToFollowers(actor: { id: LocalUser['id']; host: null; }, activity: IActivity): Promise { const manager = new DeliverManager( this.userEntityService, this.followingsRepository, @@ -64,11 +181,12 @@ export class ApDeliverManagerService { /** * Deliver activity to user + * @param actor * @param activity Activity * @param to Target user */ @bindThis - public async deliverToUser(actor: { id: LocalUser['id']; host: null; }, activity: any, to: RemoteUser) { + public async deliverToUser(actor: { id: LocalUser['id']; host: null; }, activity: IActivity, to: RemoteUser): Promise { const manager = new DeliverManager( this.userEntityService, this.followingsRepository, @@ -81,129 +199,14 @@ export class ApDeliverManagerService { } @bindThis - public createDeliverManager(actor: { id: User['id']; host: null; }, activity: any) { + public createDeliverManager(actor: { id: User['id']; host: null; }, activity: IActivity | null): DeliverManager { return new DeliverManager( this.userEntityService, this.followingsRepository, this.queueService, - actor, + actor, activity, ); } } - -class DeliverManager { - private actor: { id: User['id']; host: null; }; - private activity: any; - private recipes: IRecipe[] = []; - - /** - * Constructor - * @param actor Actor - * @param activity Activity to deliver - */ - constructor( - private userEntityService: UserEntityService, - private followingsRepository: FollowingsRepository, - private queueService: QueueService, - - actor: { id: User['id']; host: null; }, - activity: any, - ) { - this.actor = actor; - this.activity = activity; - } - - /** - * Add recipe for followers deliver - */ - @bindThis - public addFollowersRecipe() { - const deliver = { - type: 'Followers', - } as IFollowersRecipe; - - this.addRecipe(deliver); - } - - /** - * Add recipe for direct deliver - * @param to To - */ - @bindThis - public addDirectRecipe(to: RemoteUser) { - const recipe = { - type: 'Direct', - to, - } as IDirectRecipe; - - this.addRecipe(recipe); - } - - /** - * Add recipe - * @param recipe Recipe - */ - @bindThis - public addRecipe(recipe: IRecipe) { - this.recipes.push(recipe); - } - - /** - * Execute delivers - */ - @bindThis - public async execute() { - if (!this.userEntityService.isLocalUser(this.actor)) return; - - // The value flags whether it is shared or not. - const inboxes = new Map(); - - /* - build inbox list - - Process follower recipes first to avoid duplication when processing - direct recipes later. - */ - if (this.recipes.some(r => isFollowers(r))) { - // followers deliver - // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう - // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? - const followers = await this.followingsRepository.find({ - where: { - followeeId: this.actor.id, - followerHost: Not(IsNull()), - }, - select: { - followerSharedInbox: true, - followerInbox: true, - }, - }) as { - followerSharedInbox: string | null; - followerInbox: string; - }[]; - - for (const following of followers) { - const inbox = following.followerSharedInbox ?? following.followerInbox; - inboxes.set(inbox, following.followerSharedInbox != null); - } - } - - this.recipes.filter((recipe): recipe is IDirectRecipe => - // followers recipes have already been processed - isDirect(recipe) - // check that shared inbox has not been added yet - && !(recipe.to.sharedInbox && inboxes.has(recipe.to.sharedInbox)) - // check that they actually have an inbox - && recipe.to.inbox != null, - ) - .forEach(recipe => inboxes.set(recipe.to.inbox!, false)); - - // deliver - for (const inbox of inboxes) { - // inbox[0]: inbox, inbox[1]: whether it is sharedInbox - this.queueService.deliver(this.actor, this.activity, inbox[0], inbox[1]); - } - } -} diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index efef777fb..8d5f4883e 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -21,10 +21,10 @@ import { CacheService } from '@/core/CacheService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, } from '@/models/index.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; -import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -86,7 +86,7 @@ export class ApInboxService { } @bindThis - public async performActivity(actor: RemoteUser, activity: IObject) { + public async performActivity(actor: RemoteUser, activity: IObject): Promise { if (isCollectionOrOrderedCollection(activity)) { const resolver = this.apResolverService.createResolver(); for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { @@ -107,7 +107,7 @@ export class ApInboxService { if (actor.uri) { if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { setImmediate(() => { - this.apPersonService.updatePerson(actor.uri!); + this.apPersonService.updatePerson(actor.uri); }); } } @@ -229,7 +229,7 @@ export class ApInboxService { @bindThis private async add(actor: RemoteUser, activity: IAdd): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { + if (actor.uri !== activity.actor) { throw new Error('invalid actor'); } @@ -273,7 +273,7 @@ export class ApInboxService { const unlock = await this.appLockService.getApLock(uri); try { - // 既に同じURIを持つものが登録されていないかチェック + // 既に同じURIを持つものが登録されていないかチェック const exist = await this.apNoteService.fetchNote(uri); if (exist) { return; @@ -292,7 +292,7 @@ export class ApInboxService { return; } - this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode ?? err}`); + this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`); } throw err; } @@ -409,7 +409,7 @@ export class ApInboxService { @bindThis private async delete(actor: RemoteUser, activity: IDelete): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { + if (actor.uri !== activity.actor) { throw new Error('invalid actor'); } @@ -420,7 +420,7 @@ export class ApInboxService { // typeが不明だけど、どうせ消えてるのでremote resolveしない formerType = undefined; } else { - const object = activity.object as IObject; + const object = activity.object; if (isTombstone(object)) { formerType = toSingle(object.formerType); } else { @@ -503,7 +503,10 @@ export class ApInboxService { // 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する const uris = getApIds(activity.object); - const userIds = uris.filter(uri => uri.startsWith(this.config.url + '/users/')).map(uri => uri.split('/').pop()!); + const userIds = uris + .filter(uri => uri.startsWith(this.config.url + '/users/')) + .map(uri => uri.split('/').at(-1)) + .filter((userId): userId is string => userId !== undefined); const users = await this.usersRepository.findBy({ id: In(userIds), }); @@ -566,7 +569,7 @@ export class ApInboxService { @bindThis private async remove(actor: RemoteUser, activity: IRemove): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { + if (actor.uri !== activity.actor) { throw new Error('invalid actor'); } @@ -586,7 +589,7 @@ export class ApInboxService { @bindThis private async undo(actor: RemoteUser, activity: IUndo): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { + if (actor.uri !== activity.actor) { throw new Error('invalid actor'); } @@ -618,12 +621,14 @@ export class ApInboxService { return 'skip: follower not found'; } - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: actor.id, + const isFollowing = await this.followingsRepository.exist({ + where: { + followerId: follower.id, + followeeId: actor.id, + }, }); - if (following) { + if (isFollowing) { await this.userFollowingService.unfollow(follower, actor); return 'ok: unfollowed'; } @@ -673,22 +678,26 @@ export class ApInboxService { return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません'; } - const req = await this.followRequestsRepository.findOneBy({ - followerId: actor.id, - followeeId: followee.id, + const requestExist = await this.followRequestsRepository.exist({ + where: { + followerId: actor.id, + followeeId: followee.id, + }, }); - const following = await this.followingsRepository.findOneBy({ - followerId: actor.id, - followeeId: followee.id, + const isFollowing = await this.followingsRepository.exist({ + where: { + followerId: actor.id, + followeeId: followee.id, + }, }); - if (req) { + if (requestExist) { await this.userFollowingService.cancelFollowRequest(followee, actor); return 'ok: follow request canceled'; } - if (following) { + if (isFollowing) { await this.userFollowingService.unfollow(actor, followee); return 'ok: unfollowed'; } @@ -713,7 +722,7 @@ export class ApInboxService { @bindThis private async update(actor: RemoteUser, activity: IUpdate): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { + if (actor.uri !== activity.actor) { return 'skip: invalid actor'; } @@ -727,7 +736,7 @@ export class ApInboxService { }); if (isActor(object)) { - await this.apPersonService.updatePerson(actor.uri!, resolver, object); + await this.apPersonService.updatePerson(actor.uri, resolver, object); return 'ok: Person updated'; } else if (getApType(object) === 'Question') { await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index 6116822f7..d7269eca9 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -4,9 +4,9 @@ import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { MfmService } from '@/core/MfmService.js'; import type { Note } from '@/models/entities/Note.js'; +import { bindThis } from '@/decorators.js'; import { extractApHashtagObjects } from './models/tag.js'; import type { IObject } from './type.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ApMfmService { @@ -19,15 +19,14 @@ export class ApMfmService { } @bindThis - public htmlToMfm(html: string, tag?: IObject | IObject[]) { - const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null); - + public htmlToMfm(html: string, tag?: IObject | IObject[]): string { + const hashtagNames = extractApHashtagObjects(tag).map(x => x.name); return this.mfmService.fromHtml(html, hashtagNames); } @bindThis - public getNoteHtml(note: Note) { + public getNoteHtml(note: Note): string | null { if (!note.text) return ''; return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)); - } + } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index d8b95ca4d..797c6267b 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -1,7 +1,6 @@ -import { createPublicKey } from 'node:crypto'; +import { createPublicKey, randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; -import { v4 as uuid } from 'uuid'; +import { In } from 'typeorm'; import * as mfm from 'mfm-js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -26,7 +25,6 @@ import { isNotNull } from '@/misc/is-not-null.js'; import { LdSignatureService } from './LdSignatureService.js'; import { ApMfmService } from './ApMfmService.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; -import type { IIdentifier } from './models/identifier.js'; @Injectable() export class ApRendererService { @@ -63,7 +61,7 @@ export class ApRendererService { } @bindThis - public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept { + public renderAccept(object: string | IObject, user: { id: User['id']; host: null }): IAccept { return { type: 'Accept', actor: this.userEntityService.genLocalUserUri(user.id), @@ -72,7 +70,7 @@ export class ApRendererService { } @bindThis - public renderAdd(user: LocalUser, target: any, object: any): IAdd { + public renderAdd(user: LocalUser, target: string | IObject | undefined, object: string | IObject): IAdd { return { type: 'Add', actor: this.userEntityService.genLocalUserUri(user.id), @@ -82,7 +80,7 @@ export class ApRendererService { } @bindThis - public renderAnnounce(object: any, note: Note): IAnnounce { + public renderAnnounce(object: string | IObject, note: Note): IAnnounce { const attributedTo = this.userEntityService.genLocalUserUri(note.userId); let to: string[] = []; @@ -133,13 +131,13 @@ export class ApRendererService { @bindThis public renderCreate(object: IObject, note: Note): ICreate { - const activity = { + const activity: ICreate = { id: `${this.config.url}/notes/${note.id}/activity`, actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Create', published: note.createdAt.toISOString(), object, - } as ICreate; + }; if (object.to) activity.to = object.to; if (object.cc) activity.cc = object.cc; @@ -209,7 +207,7 @@ export class ApRendererService { * @param id Follower|Followee ID */ @bindThis - public async renderFollowUser(id: User['id']) { + public async renderFollowUser(id: User['id']): Promise { const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser; return this.userEntityService.getUserUri(user); } @@ -223,8 +221,8 @@ export class ApRendererService { return { id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, type: 'Follow', - actor: this.userEntityService.getUserUri(follower)!, - object: this.userEntityService.getUserUri(followee)!, + actor: this.userEntityService.getUserUri(follower), + object: this.userEntityService.getUserUri(followee), }; } @@ -264,14 +262,14 @@ export class ApRendererService { public async renderLike(noteReaction: NoteReaction, note: { uri: string | null }): Promise { const reaction = noteReaction.reaction; - const object = { + const object: ILike = { type: 'Like', id: `${this.config.url}/likes/${noteReaction.id}`, actor: `${this.config.url}/users/${noteReaction.userId}`, object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`, content: reaction, _misskey_reaction: reaction, - } as ILike; + }; if (reaction.startsWith(':')) { const name = reaction.replaceAll(':', ''); @@ -287,7 +285,7 @@ export class ApRendererService { public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention { return { type: 'Mention', - href: this.userEntityService.getUserUri(mention)!, + href: this.userEntityService.getUserUri(mention), name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`, }; } @@ -297,8 +295,8 @@ export class ApRendererService { src: PartialLocalUser | PartialRemoteUser, dst: PartialLocalUser | PartialRemoteUser, ): IMove { - const actor = this.userEntityService.getUserUri(src)!; - const target = this.userEntityService.getUserUri(dst)!; + const actor = this.userEntityService.getUserUri(src); + const target = this.userEntityService.getUserUri(dst); return { id: `${this.config.url}/moves/${src.id}/${dst.id}`, actor, @@ -310,10 +308,10 @@ export class ApRendererService { @bindThis public async renderNote(note: Note, dive = true): Promise { - const getPromisedFiles = async (ids: string[]) => { - if (!ids || ids.length === 0) return []; + const getPromisedFiles = async (ids: string[]): Promise => { + if (ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); - return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; + return ids.map(id => items.find(item => item.id === id)).filter((item): item is DriveFile => item != null); }; let inReplyTo; @@ -323,9 +321,9 @@ export class ApRendererService { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); if (inReplyToNote != null) { - const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); + const inReplyToUserExist = await this.usersRepository.exist({ where: { id: inReplyToNote.userId } }); - if (inReplyToUser != null) { + if (inReplyToUserExist) { if (inReplyToNote.uri) { inReplyTo = inReplyToNote.uri; } else { @@ -375,7 +373,7 @@ export class ApRendererService { id: In(note.mentions), }) : []; - const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); + const hashtagTags = note.tags.map(tag => this.renderHashtag(tag)); const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser)); const files = await getPromisedFiles(note.fileIds); @@ -451,37 +449,26 @@ export class ApRendererService { @bindThis public async renderPerson(user: LocalUser) { const id = this.userEntityService.genLocalUserUri(user.id); - const isSystem = !!user.username.match(/\./); + const isSystem = user.username.includes('.'); const [avatar, banner, profile] = await Promise.all([ - user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : Promise.resolve(undefined), - user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : Promise.resolve(undefined), + user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : undefined, + user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : undefined, this.userProfilesRepository.findOneByOrFail({ userId: user.id }), ]); - const attachment: { + const attachment = profile.fields.map(field => ({ type: 'PropertyValue', - name: string, - value: string, - identifier?: IIdentifier, - }[] = []; - - if (profile.fields) { - for (const field of profile.fields) { - attachment.push({ - type: 'PropertyValue', - name: field.name, - value: (field.value != null && field.value.match(/^https?:/)) - ? `${new URL(field.value).href}` - : field.value, - }); - } - } + name: field.name, + value: /^https?:/.test(field.value) + ? `${new URL(field.value).href}` + : field.value, + })); const emojis = await this.getEmojis(user.emojis); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); - const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag)); + const hashtagTags = user.tags.map(tag => this.renderHashtag(tag)); const tag = [ ...apemojis, @@ -490,7 +477,7 @@ export class ApRendererService { const keypair = await this.userKeypairService.getUserKeypair(user.id); - const person = { + const person: any = { type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', id, inbox: `${id}/inbox`, @@ -508,11 +495,11 @@ export class ApRendererService { image: banner ? this.renderImage(banner) : null, tag, manuallyApprovesFollowers: user.isLocked, - discoverable: !!user.isExplorable, + discoverable: user.isExplorable, publicKey: this.renderKey(user, keypair, '#main-key'), isCat: user.isCat, attachment: attachment.length ? attachment : undefined, - } as any; + }; if (user.movedToUri) { person.movedTo = user.movedToUri; @@ -552,7 +539,7 @@ export class ApRendererService { } @bindThis - public renderReject(object: any, user: { id: User['id'] }): IReject { + public renderReject(object: string | IObject, user: { id: User['id'] }): IReject { return { type: 'Reject', actor: this.userEntityService.genLocalUserUri(user.id), @@ -561,7 +548,7 @@ export class ApRendererService { } @bindThis - public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove { + public renderRemove(user: { id: User['id'] }, target: string | IObject | undefined, object: string | IObject): IRemove { return { type: 'Remove', actor: this.userEntityService.genLocalUserUri(user.id), @@ -579,8 +566,8 @@ export class ApRendererService { } @bindThis - public renderUndo(object: any, user: { id: User['id'] }): IUndo { - const id = typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined; + public renderUndo(object: string | IObject, user: { id: User['id'] }): IUndo { + const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined; return { type: 'Undo', @@ -592,7 +579,7 @@ export class ApRendererService { } @bindThis - public renderUpdate(object: any, user: { id: User['id'] }): IUpdate { + public renderUpdate(object: string | IObject, user: { id: User['id'] }): IUpdate { return { id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, actor: this.userEntityService.genLocalUserUri(user.id), @@ -625,7 +612,7 @@ export class ApRendererService { @bindThis public addContext(x: T): T & { '@context': any; id: string; } { if (typeof x === 'object' && x.id == null) { - x.id = `${this.config.url}/${uuid()}`; + x.id = `${this.config.url}/${randomUUID()}`; } return Object.assign({ @@ -658,7 +645,7 @@ export class ApRendererService { vcard: 'http://www.w3.org/2006/vcard/ns#', }, ], - }, x as T & { id: string; }); + }, x as T & { id: string }); } @bindThis @@ -683,13 +670,13 @@ export class ApRendererService { */ @bindThis public renderOrderedCollectionPage(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) { - const page = { + const page: any = { id, partOf, type: 'OrderedCollectionPage', totalItems, orderedItems, - } as any; + }; if (prev) page.prev = prev; if (next) page.next = next; @@ -706,7 +693,7 @@ export class ApRendererService { * @param orderedItems attached objects (optional) */ @bindThis - public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: IObject[]) { + public renderOrderedCollection(id: string, totalItems: number, first?: string, last?: string, orderedItems?: IObject[]) { const page: any = { id, type: 'OrderedCollection', @@ -722,7 +709,7 @@ export class ApRendererService { @bindThis private async getEmojis(names: string[]): Promise { - if (names == null || names.length === 0) return []; + if (names.length === 0) return []; const allEmojis = await this.customEmojiService.localEmojisCache.fetch(); const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull); diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 5005612ab..44676cac2 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -140,7 +140,7 @@ export class ApRequestService { } @bindThis - public async signedPost(user: { id: User['id'] }, url: string, object: any) { + public async signedPost(user: { id: User['id'] }, url: string, object: unknown): Promise { const body = JSON.stringify(object); const keypair = await this.userKeypairService.getUserKeypair(user.id); @@ -169,7 +169,7 @@ export class ApRequestService { * @param url URL to fetch */ @bindThis - public async signedGet(url: string, user: { id: User['id'] }) { + public async signedGet(url: string, user: { id: User['id'] }): Promise { const keypair = await this.userKeypairService.getUserKeypair(user.id); const req = ApRequestCreator.createSignedGet({ diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index d3e0345c9..aa4720c56 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -61,10 +61,6 @@ export class Resolver { @bindThis public async resolve(value: string | IObject): Promise { - if (value == null) { - throw new Error('resolvee is null (or undefined)'); - } - if (typeof value !== 'string') { return value; } @@ -104,11 +100,11 @@ export class Resolver { ? await this.apRequestService.signedGet(value, this.user) as IObject : await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject; - if (object == null || ( + if ( Array.isArray(object['@context']) ? !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : object['@context'] !== 'https://www.w3.org/ns/activitystreams' - )) { + ) { throw new Error('invalid response'); } diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/LdSignatureService.ts index 20fe2a0a7..af7e24322 100644 --- a/packages/backend/src/core/activitypub/LdSignatureService.ts +++ b/packages/backend/src/core/activitypub/LdSignatureService.ts @@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import { CONTEXTS } from './misc/contexts.js'; +import type { JsonLdDocument } from 'jsonld'; +import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js'; // RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 @@ -18,22 +20,21 @@ class LdSignature { @bindThis public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise { - const options = { - type: 'RsaSignature2017', - creator, - domain, - nonce: crypto.randomBytes(16).toString('hex'), - created: (created ?? new Date()).toISOString(), - } as { + const options: { type: string; creator: string; domain?: string; nonce: string; created: string; + } = { + type: 'RsaSignature2017', + creator, + nonce: crypto.randomBytes(16).toString('hex'), + created: (created ?? new Date()).toISOString(), }; - if (!domain) { - delete options.domain; + if (domain) { + options.domain = domain; } const toBeSigned = await this.createVerifyData(data, options); @@ -62,7 +63,7 @@ class LdSignature { } @bindThis - public async createVerifyData(data: any, options: any) { + public async createVerifyData(data: any, options: any): Promise { const transformedOptions = { ...options, '@context': 'https://w3id.org/identity/v1', @@ -82,7 +83,7 @@ class LdSignature { } @bindThis - public async normalize(data: any) { + public async normalize(data: JsonLdDocument): Promise { const customLoader = this.getLoader(); // XXX: Importing jsonld dynamically since Jest frequently fails to import it statically // https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595 @@ -93,14 +94,14 @@ class LdSignature { @bindThis private getLoader() { - return async (url: string): Promise => { - if (!url.match('^https?\:\/\/')) throw new Error(`Invalid URL ${url}`); + return async (url: string): Promise => { + if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`); if (this.preLoad) { if (url in CONTEXTS) { if (this.debug) console.debug(`HIT: ${url}`); return { - contextUrl: null, + contextUrl: undefined, document: CONTEXTS[url], documentUrl: url, }; @@ -110,7 +111,7 @@ class LdSignature { if (this.debug) console.debug(`MISS: ${url}`); const document = await this.fetchDocument(url); return { - contextUrl: null, + contextUrl: undefined, document: document, documentUrl: url, }; @@ -118,13 +119,17 @@ class LdSignature { } @bindThis - private async fetchDocument(url: string) { - const json = await this.httpRequestService.send(url, { - headers: { - Accept: 'application/ld+json, application/json', + private async fetchDocument(url: string): Promise { + const json = await this.httpRequestService.send( + url, + { + headers: { + Accept: 'application/ld+json, application/json', + }, + timeout: this.loderTimeout, }, - timeout: this.loderTimeout, - }, { throwErrorWhenResponseNotOk: false }).then(res => { + { throwErrorWhenResponseNotOk: false }, + ).then(res => { if (!res.ok) { throw new Error(`${res.status} ${res.statusText}`); } else { @@ -132,7 +137,7 @@ class LdSignature { } }); - return json; + return json as JsonLd; } @bindThis diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index aee0d3629..2bcd3811b 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -1,3 +1,5 @@ +import type { JsonLd } from 'jsonld/jsonld-spec.js'; + /* eslint:disable:quotemark indent */ const id_v1 = { '@context': { @@ -86,7 +88,7 @@ const id_v1 = { 'accessControl': { '@id': 'perm:accessControl', '@type': '@id' }, 'writePermission': { '@id': 'perm:writePermission', '@type': '@id' }, }, -}; +} satisfies JsonLd; const security_v1 = { '@context': { @@ -137,7 +139,7 @@ const security_v1 = { 'signatureAlgorithm': 'sec:signingAlgorithm', 'signatureValue': 'sec:signatureValue', }, -}; +} satisfies JsonLd; const activitystreams = { '@context': { @@ -517,9 +519,9 @@ const activitystreams = { '@type': '@id', }, }, -}; +} satisfies JsonLd; -export const CONTEXTS: Record = { +export const CONTEXTS: Record = { 'https://w3id.org/identity/v1': id_v1, 'https://w3id.org/security/v1': security_v1, 'https://www.w3.org/ns/activitystreams': activitystreams, diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 0043907c2..1f2984894 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; import type { RemoteUser } from '@/models/entities/User.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import { MetaService } from '@/core/MetaService.js'; @@ -10,18 +9,16 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { DriveService } from '@/core/DriveService.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { checkHttps } from '@/misc/check-https.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; -import { checkHttps } from '@/misc/check-https.js'; +import type { IObject } from '../type.js'; @Injectable() export class ApImageService { private logger: Logger; constructor( - @Inject(DI.config) - private config: Config, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -32,21 +29,25 @@ export class ApImageService { ) { this.logger = this.apLoggerService.logger; } - + /** * Imageを作成します。 */ @bindThis - public async createImage(actor: RemoteUser, value: any): Promise { + public async createImage(actor: RemoteUser, value: string | IObject): Promise { // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { throw new Error('actor has been suspended'); } - const image = await this.apResolverService.createResolver().resolve(value) as any; + const image = await this.apResolverService.createResolver().resolve(value); if (image.url == null) { - throw new Error('invalid image: url not privided'); + throw new Error('invalid image: url not provided'); + } + + if (typeof image.url !== 'string') { + throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2)); } if (!checkHttps(image.url)) { @@ -57,29 +58,24 @@ export class ApImageService { const instance = await this.metaService.fetch(); - let file = await this.driveService.uploadFromUrl({ + // Cache if remote file cache is on AND either + // 1. remote sensitive file is also on + // 2. or the image is not sensitive + const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive); + + const file = await this.driveService.uploadFromUrl({ url: image.url, user: actor, uri: image.url, sensitive: image.sensitive, - isLink: !instance.cacheRemoteFiles, - comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH), + isLink: !shouldBeCached, + comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH), }); + if (!file.isLink || file.url === image.url) return file; - if (file.isLink) { - // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 - // URLを更新する - if (file.url !== image.url) { - await this.driveFilesRepository.update({ id: file.id }, { - url: image.url, - uri: image.url, - }); - - file = await this.driveFilesRepository.findOneByOrFail({ id: file.id }); - } - } - - return file; + // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、URLを更新する + await this.driveFilesRepository.update({ id: file.id }, { url: image.url, uri: image.url }); + return await this.driveFilesRepository.findOneByOrFail({ id: file.id }); } /** @@ -89,7 +85,7 @@ export class ApImageService { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolveImage(actor: RemoteUser, value: any): Promise { + public async resolveImage(actor: RemoteUser, value: string | IObject): Promise { // TODO // リモートサーバーからフェッチしてきて登録 diff --git a/packages/backend/src/core/activitypub/models/ApMentionService.ts b/packages/backend/src/core/activitypub/models/ApMentionService.ts index c581840ca..62ae3cf93 100644 --- a/packages/backend/src/core/activitypub/models/ApMentionService.ts +++ b/packages/backend/src/core/activitypub/models/ApMentionService.ts @@ -22,17 +22,17 @@ export class ApMentionService { } @bindThis - public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver) { - const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string)); + public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise { + const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href)); const limit = promiseLimit(2); const mentionedUsers = (await Promise.all( hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))), )).filter((x): x is User => x != null); - + return mentionedUsers; } - + @bindThis public extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] { if (tags == null) return []; diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 76757f530..46ed976a6 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -20,7 +20,6 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; import { ApDbResolverService } from '../ApDbResolverService.js'; @@ -55,7 +54,7 @@ export class ApNoteService { // 循環参照のため / for circular dependency @Inject(forwardRef(() => ApPersonService)) private apPersonService: ApPersonService, - + private utilityService: UtilityService, private apAudienceService: ApAudienceService, private apMentionService: ApMentionService, @@ -72,17 +71,13 @@ export class ApNoteService { } @bindThis - public validateNote(object: IObject, uri: string) { + public validateNote(object: IObject, uri: string): Error | null { const expectHost = this.utilityService.extractDbHost(uri); - - if (object == null) { - return new Error('invalid Note: object is null'); - } - + if (!validPost.includes(getApType(object))) { return new Error(`invalid Note: invalid object type ${getApType(object)}`); } - + if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); } @@ -91,10 +86,10 @@ export class ApNoteService { if (object.attributedTo && actualHost !== expectHost) { return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } - + return null; } - + /** * Noteをフェッチします。 * @@ -104,31 +99,30 @@ export class ApNoteService { public async fetchNote(object: string | IObject): Promise { return await this.apDbResolverService.getNoteFromApId(object); } - + /** * Noteを作成します。 */ @bindThis public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(value); - + const entryUri = getApId(value); const err = this.validateNote(object, entryUri); if (err) { - this.logger.error(`${err.message}`, { - resolver: { - history: resolver.getHistory(), - }, - value: value, - object: object, + this.logger.error(err.message, { + resolver: { history: resolver.getHistory() }, + value, + object, }); throw new Error('invalid note'); } - + const note = object as IPost; - + this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); if (note.id && !checkHttps(note.id)) { @@ -140,21 +134,25 @@ export class ApNoteService { if (url && !checkHttps(url)) { throw new Error('unexpected shcema of note url: ' + url); } - + this.logger.info(`Creating the Note: ${note.id}`); - + // 投稿者をフェッチ - const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo!), resolver) as RemoteUser; - + if (note.attributedTo == null) { + throw new Error('invalid note.attributedTo: ' + note.attributedTo); + } + + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as RemoteUser; + // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { throw new Error('actor has been suspended'); } - + const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); let visibility = noteAudience.visibility; const visibleUsers = noteAudience.visibleUsers; - + // Audience (to, cc) が指定されてなかった場合 if (visibility === 'specified' && visibleUsers.length === 0) { if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している @@ -162,81 +160,71 @@ export class ApNoteService { visibility = 'public'; } } - + const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); - const apHashtags = await extractApHashtags(note.tag); - + const apHashtags = extractApHashtags(note.tag); + // 添付ファイル // TODO: attachmentは必ずしもImageではない // TODO: attachmentは必ずしも配列ではない - // Noteがsensitiveなら添付もsensitiveにする - const limit = promiseLimit(2); - - note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; - const files = note.attachment - .map(attach => attach.sensitive = note.sensitive) - ? (await Promise.all(note.attachment.map(x => limit(() => this.apImageService.resolveImage(actor, x)) as Promise))) - .filter(image => image != null) - : []; - + const limit = promiseLimit(2); + const files = (await Promise.all(toArray(note.attachment).map(attach => ( + limit(() => this.apImageService.resolveImage(actor, { + ...attach, + sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする + })) + )))); + // リプライ const reply: Note | null = note.inReplyTo - ? await this.resolveNote(note.inReplyTo, resolver).then(x => { - if (x == null) { - this.logger.warn('Specified inReplyTo, but not found'); - throw new Error('inReplyTo not found'); - } else { + ? await this.resolveNote(note.inReplyTo, { resolver }) + .then(x => { + if (x == null) { + this.logger.warn('Specified inReplyTo, but not found'); + throw new Error('inReplyTo not found'); + } + return x; - } - }).catch(async err => { - this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); - throw err; - }) + }) + .catch(async err => { + this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); + throw err; + }) : null; - + // 引用 - let quote: Note | undefined | null; - + let quote: Note | undefined | null = null; + if (note._misskey_quote || note.quoteUrl) { - const tryResolveNote = async (uri: string): Promise<{ - status: 'ok'; - res: Note | null; - } | { - status: 'permerror' | 'temperror'; - }> => { - if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; + const tryResolveNote = async (uri: string): Promise< + | { status: 'ok'; res: Note } + | { status: 'permerror' | 'temperror' } + > => { + if (!/^https?:/.test(uri)) return { status: 'permerror' }; try { const res = await this.resolveNote(uri); - if (res) { - return { - status: 'ok', - res, - }; - } else { - return { - status: 'permerror', - }; - } + if (res == null) return { status: 'permerror' }; + return { status: 'ok', res }; } catch (e) { return { status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror', }; } }; - + const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string')); - const results = await Promise.all(uris.map(uri => tryResolveNote(uri))); - - quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); + const results = await Promise.all(uris.map(tryResolveNote)); + + quote = results.filter((x): x is { status: 'ok', res: Note } => x.status === 'ok').map(x => x.res).at(0); if (!quote) { if (results.some(x => x.status === 'temperror')) { throw new Error('quote resolve failed'); } } } - + const cw = note.summary === '' ? null : note.summary; - + // テキストのパース let text: string | null = null; if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { @@ -246,38 +234,38 @@ export class ApNoteService { } else if (typeof note.content === 'string') { text = this.apMfmService.htmlToMfm(note.content, note.tag); } - + // vote if (reply && reply.hasPoll) { const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); - + const tryCreateVote = async (name: string, index: number): Promise => { if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); } else if (index >= 0) { this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); await this.pollService.vote(actor, reply, index); - + // リモートフォロワーにUpdate配信 this.pollService.deliverQuestionUpdate(reply.id); } return null; }; - + if (note.name) { return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); } } - + const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { this.logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; + return []; }); - + const apEmojis = emojis.map(emoji => emoji.name); - + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); - + return await this.noteCreateService.create(actor, { createdAt: note.published ? new Date(note.published) : null, files, @@ -297,7 +285,7 @@ export class ApNoteService { url: url, }, silent); } - + /** * Noteを解決します。 * @@ -305,94 +293,91 @@ export class ApNoteService { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolveNote(value: string | IObject, resolver?: Resolver): Promise { - const uri = typeof value === 'string' ? value : value.id; - if (uri == null) throw new Error('missing uri'); - - // ブロックしてたら中断 + public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise { + const uri = getApId(value); + + // ブロックしていたら中断 const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw new StatusError('blocked host', 451); - + if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) { + throw new StatusError('blocked host', 451); + } + const unlock = await this.appLockService.getApLock(uri); - + try { //#region このサーバーに既に登録されていたらそれを返す const exist = await this.fetchNote(uri); - - if (exist) { - return exist; - } + if (exist) return exist; //#endregion - + if (uri.startsWith(this.config.url)) { throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); } - + // リモートサーバーからフェッチしてきて登録 // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 - return await this.createNote(uri, resolver, true); + const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri; + return await this.createNote(createFrom, options.resolver, true); } finally { unlock(); } } - + @bindThis public async extractEmojis(tags: IObject | IObject[], host: string): Promise { + // eslint-disable-next-line no-param-reassign host = this.utilityService.toPuny(host); - - if (!tags) return []; - + const eomjiTags = toArray(tags).filter(isEmoji); const existingEmojis = await this.emojisRepository.findBy({ host, - name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))), + name: In(eomjiTags.map(tag => tag.name.replaceAll(':', ''))), }); - + return await Promise.all(eomjiTags.map(async tag => { - const name = tag.name!.replaceAll(':', ''); + const name = tag.name.replaceAll(':', ''); tag.icon = toSingle(tag.icon); - + const exists = existingEmojis.find(x => x.name === name); - + if (exists) { - if ((tag.updated != null && exists.updatedAt == null) + if ((exists.updatedAt == null) || (tag.id != null && exists.uri == null) - || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt) - || (tag.icon!.url !== exists.originalUrl) + || (new Date(tag.updated) > exists.updatedAt) + || (tag.icon.url !== exists.originalUrl) ) { await this.emojisRepository.update({ host, name, }, { uri: tag.id, - originalUrl: tag.icon!.url, - publicUrl: tag.icon!.url, + originalUrl: tag.icon.url, + publicUrl: tag.icon.url, updatedAt: new Date(), }); - - return await this.emojisRepository.findOneBy({ - host, - name, - }) as Emoji; + + const emoji = await this.emojisRepository.findOneBy({ host, name }); + if (emoji == null) throw new Error('emoji update failed'); + return emoji; } - + return exists; } - + this.logger.info(`register emoji host=${host}, name=${name}`); - + return await this.emojisRepository.insert({ id: this.idService.genId(), host, name, uri: tag.id, - originalUrl: tag.icon!.url, - publicUrl: tag.icon!.url, + originalUrl: tag.icon.url, + publicUrl: tag.icon.url, updatedAt: new Date(), aliases: [], - } as Partial).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); })); } } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index f52ebed10..8fc083719 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,7 +3,7 @@ import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; @@ -15,7 +15,6 @@ import type Logger from '@/logger.js'; import type { Note } from '@/models/entities/Note.js'; import type { IdService } from '@/core/IdService.js'; import type { MfmService } from '@/core/MfmService.js'; -import type { Emoji } from '@/models/entities/Emoji.js'; import { toArray } from '@/misc/prelude/array.js'; import type { GlobalEventService } from '@/core/GlobalEventService.js'; import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -48,6 +47,8 @@ import type { IActor, IObject } from '../type.js'; const nameLength = 128; const summaryLength = 2048; +type Field = Record<'name' | 'value', string>; + @Injectable() export class ApPersonService implements OnModuleInit { private utilityService: UtilityService; @@ -94,28 +95,10 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - - //private utilityService: UtilityService, - //private userEntityService: UserEntityService, - //private idService: IdService, - //private globalEventService: GlobalEventService, - //private metaService: MetaService, - //private federatedInstanceService: FederatedInstanceService, - //private fetchInstanceMetadataService: FetchInstanceMetadataService, - //private cacheService: CacheService, - //private apResolverService: ApResolverService, - //private apNoteService: ApNoteService, - //private apImageService: ApImageService, - //private apMfmService: ApMfmService, - //private mfmService: MfmService, - //private hashtagService: HashtagService, - //private usersChart: UsersChart, - //private instanceChart: InstanceChart, - //private apLoggerService: ApLoggerService, ) { } - onModuleInit() { + onModuleInit(): void { this.utilityService = this.moduleRef.get('UtilityService'); this.userEntityService = this.moduleRef.get('UserEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); @@ -153,10 +136,6 @@ export class ApPersonService implements OnModuleInit { private validateActor(x: IObject, uri: string): IActor { const expectHost = this.punyHost(uri); - if (x == null) { - throw new Error('invalid Actor: object is null'); - } - if (!isActor(x)) { throw new Error(`invalid Actor type '${x.type}'`); } @@ -218,21 +197,19 @@ export class ApPersonService implements OnModuleInit { */ @bindThis public async fetchPerson(uri: string): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null; + const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null | undefined; if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ if (uri.startsWith(`${this.config.url}/`)) { const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }) as LocalUser; + const u = await this.usersRepository.findOneBy({ id }) as LocalUser | null; if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser; + const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser | null; if (exist) { this.cacheService.uriPersonCache.set(uri, exist); @@ -243,6 +220,23 @@ export class ApPersonService implements OnModuleInit { return null; } + private async resolveAvatarAndBanner(user: RemoteUser, icon: any, image: any): Promise> { + const [avatar, banner] = await Promise.all([icon, image].map(img => { + if (img == null) return null; + if (user == null) throw new Error('failed to create user: user is null'); + return this.apImageService.resolveImage(user, img).catch(() => null); + })); + + return { + avatarId: avatar?.id ?? null, + bannerId: banner?.id ?? null, + avatarUrl: avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null, + bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + avatarBlurhash: avatar?.blurhash ?? null, + bannerBlurhash: banner?.blurhash ?? null, + }; + } + /** * Personを作成します。 */ @@ -254,9 +248,11 @@ export class ApPersonService implements OnModuleInit { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(uri) as any; + const object = await resolver.resolve(uri); + if (object.id == null) throw new Error('invalid object.id: ' + object.id); const person = this.validateActor(object, uri); @@ -264,9 +260,9 @@ export class ApPersonService implements OnModuleInit { const host = this.punyHost(object.id); - const { fields } = this.analyzeAttachments(person.attachment ?? []); + const fields = this.analyzeAttachments(person.attachment ?? []); - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); const isBot = getApType(object) === 'Service'; @@ -279,9 +275,19 @@ export class ApPersonService implements OnModuleInit { } // Create user - let user: RemoteUser; + let user: RemoteUser | null = null; + + //#region カスタム絵文字取得 + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host) + .then(_emojis => _emojis.map(emoji => emoji.name)) + .catch(err => { + this.logger.error(`error occured while fetching user emojis`, { stack: err }); + return []; + }); + //#endregion + try { - // Start transaction + // Start transaction await this.db.transaction(async transactionalEntityManager => { user = await transactionalEntityManager.save(new User({ id: this.idService.genId(), @@ -290,30 +296,31 @@ export class ApPersonService implements OnModuleInit { createdAt: new Date(), lastFetchedAt: new Date(), name: truncate(person.name, nameLength), - isLocked: !!person.manuallyApprovesFollowers, + isLocked: person.manuallyApprovesFollowers, movedToUri: person.movedTo, movedAt: person.movedTo ? new Date() : null, alsoKnownAs: person.alsoKnownAs, - isExplorable: !!person.discoverable, + isExplorable: person.discoverable, username: person.preferredUsername, - usernameLower: person.preferredUsername!.toLowerCase(), + usernameLower: person.preferredUsername?.toLowerCase(), host, inbox: person.inbox, - sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, followersUri: person.followers ? getApId(person.followers) : undefined, featured: person.featured ? getApId(person.featured) : undefined, uri: person.id, tags, isBot, isCat: (person as any).isCat === true, + emojis, })) as RemoteUser; await transactionalEntityManager.save(new UserProfile({ userId: user.id, description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - url: url, + url, fields, - birthday: bday ? bday[0] : null, + birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, userHost: host, })); @@ -327,24 +334,24 @@ export class ApPersonService implements OnModuleInit { } }); } catch (e) { - // duplicate key error + // duplicate key error if (isDuplicateKeyValueError(e)) { - // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 - const u = await this.usersRepository.findOneBy({ - uri: person.id, - }); + // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 + const u = await this.usersRepository.findOneBy({ uri: person.id }); + if (u == null) throw new Error('already registered'); - if (u) { - user = u as RemoteUser; - } else { - throw new Error('already registered'); - } + user = u as RemoteUser; } else { this.logger.error(e instanceof Error ? e : new Error(e as string)); throw e; } } + if (user == null) throw new Error('failed to create user: user is null'); + + // Register to the cache + this.cacheService.uriPersonCache.set(user.uri, user); + // Register host this.federatedInstanceService.fetch(host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); @@ -354,68 +361,34 @@ export class ApPersonService implements OnModuleInit { } }); - this.usersChart.update(user!, true); + this.usersChart.update(user, true); // ハッシュタグ更新 - this.hashtagService.updateUsertags(user!, tags); + this.hashtagService.updateUsertags(user, tags); //#region アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image, - ].map(img => - img == null - ? Promise.resolve(null) - : this.apImageService.resolveImage(user!, img).catch(() => null), - )); + try { + const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image); + await this.usersRepository.update(user.id, updates); + user = { ...user, ...updates }; - const avatarId = avatar ? avatar.id : null; - const bannerId = banner ? banner.id : null; - const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null; - const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null; - const avatarBlurhash = avatar ? avatar.blurhash : null; - const bannerBlurhash = banner ? banner.blurhash : null; - - await this.usersRepository.update(user!.id, { - avatarId, - bannerId, - avatarUrl, - bannerUrl, - avatarBlurhash, - bannerBlurhash, - }); - - user!.avatarId = avatarId; - user!.bannerId = bannerId; - user!.avatarUrl = avatarUrl; - user!.bannerUrl = bannerUrl; - user!.avatarBlurhash = avatarBlurhash; - user!.bannerBlurhash = bannerBlurhash; + // Register to the cache + this.cacheService.uriPersonCache.set(user.uri, user); + } catch (err) { + this.logger.error('error occured while fetching user avatar/banner', { stack: err }); + } //#endregion - //#region カスタム絵文字取得 - const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { - this.logger.info(`extractEmojis: ${err}`); - return [] as Emoji[]; - }); + await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)); - const emojiNames = emojis.map(emoji => emoji.name); - - await this.usersRepository.update(user!.id, { - emojis: emojiNames, - }); - //#endregion - - await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); - - return user!; + return user; } /** * Personの情報を更新します。 * Misskeyに対象のPersonが登録されていなければ無視します。 * もしアカウントの移行が確認された場合、アカウント移行処理を行います。 - * + * * @param uri URI of Person * @param resolver Resolver * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) @@ -426,18 +399,14 @@ export class ApPersonService implements OnModuleInit { if (typeof uri !== 'string') throw new Error('uri is not string'); // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(`${this.config.url}/`)) { - return; - } + if (uri.startsWith(`${this.config.url}/`)) return; //#region このサーバーに既に登録されているか - const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null; - - if (exist === null) { - return; - } + const exist = await this.fetchPerson(uri) as RemoteUser | null; + if (exist === null) return; //#endregion + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const object = hint ?? await resolver.resolve(uri); @@ -446,27 +415,17 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Updating the Person: ${person.id}`); - // アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image, - ].map(img => - img == null - ? Promise.resolve(null) - : this.apImageService.resolveImage(exist, img).catch(() => null), - )); - // カスタム絵文字取得 const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => { this.logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; + return []; }); const emojiNames = emojis.map(emoji => emoji.name); - const { fields } = this.analyzeAttachments(person.attachment ?? []); + const fields = this.analyzeAttachments(person.attachment ?? []); - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); @@ -479,7 +438,7 @@ export class ApPersonService implements OnModuleInit { const updates = { lastFetchedAt: new Date(), inbox: person.inbox, - sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, followersUri: person.followers ? getApId(person.followers) : undefined, featured: person.featured, emojis: emojiNames, @@ -487,33 +446,33 @@ export class ApPersonService implements OnModuleInit { tags, isBot: getApType(object) === 'Service', isCat: (person as any).isCat === true, - isLocked: !!person.manuallyApprovesFollowers, + isLocked: person.manuallyApprovesFollowers, movedToUri: person.movedTo ?? null, alsoKnownAs: person.alsoKnownAs ?? null, - isExplorable: !!person.discoverable, + isExplorable: person.discoverable, + ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))), } as Partial & Pick; - const moving = + const moving = ((): boolean => { // 移行先がない→ある - (!exist.movedToUri && updates.movedToUri) || + if ( + exist.movedToUri === null && + updates.movedToUri + ) return true; + // 移行先がある→別のもの - (exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri); + if ( + exist.movedToUri !== null && + updates.movedToUri !== null && + exist.movedToUri !== updates.movedToUri + ) return true; + // 移行先がある→ない、ない→ないは無視 + return false; + })(); if (moving) updates.movedAt = new Date(); - if (avatar) { - updates.avatarId = avatar.id; - updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); - updates.avatarBlurhash = avatar.blurhash; - } - - if (banner) { - updates.bannerId = banner.id; - updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); - updates.bannerBlurhash = banner.blurhash; - } - // Update user await this.usersRepository.update(exist.id, updates); @@ -525,10 +484,10 @@ export class ApPersonService implements OnModuleInit { } await this.userProfilesRepository.update({ userId: exist.id }, { - url: url, + url, fields, description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - birthday: bday ? bday[0] : null, + birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, }); @@ -538,11 +497,10 @@ export class ApPersonService implements OnModuleInit { this.hashtagService.updateUsertags(exist, tags); // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await this.followingsRepository.update({ - followerId: exist.id, - }, { - followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), - }); + await this.followingsRepository.update( + { followerId: exist.id }, + { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox }, + ); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); @@ -580,27 +538,22 @@ export class ApPersonService implements OnModuleInit { */ @bindThis public async resolvePerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - //#region このサーバーに既に登録されていたらそれを返す const exist = await this.fetchPerson(uri); - - if (exist) { - return exist; - } + if (exist) return exist; //#endregion // リモートサーバーからフェッチしてきて登録 + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); return await this.createPerson(uri, resolver); } @bindThis - public analyzeAttachments(attachments: IObject | IObject[] | undefined) { - const fields: { - name: string, - value: string - }[] = []; + // TODO: `attachments`が`IObject`だった場合、返り値が`[]`になるようだが構わないのか? + public analyzeAttachments(attachments: IObject | IObject[] | undefined): Field[] { + const fields: Field[] = []; + if (Array.isArray(attachments)) { for (const attachment of attachments.filter(isPropertyValue)) { fields.push({ @@ -610,11 +563,11 @@ export class ApPersonService implements OnModuleInit { } } - return { fields }; + return fields; } @bindThis - public async updateFeatured(userId: User['id'], resolver?: Resolver) { + public async updateFeatured(userId: User['id'], resolver?: Resolver): Promise { const user = await this.usersRepository.findOneByOrFail({ id: userId }); if (!this.userEntityService.isRemoteUser(user)) return; if (!user.featured) return; @@ -636,20 +589,23 @@ export class ApPersonService implements OnModuleInit { const featuredNotes = await Promise.all(items .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも .slice(0, 5) - .map(item => limit(() => this.apNoteService.resolveNote(item, _resolver)))); + .map(item => limit(() => this.apNoteService.resolveNote(item, { + resolver: _resolver, + sentFrom: new URL(user.uri), + })))); await this.db.transaction(async transactionalEntityManager => { await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); // とりあえずidを別の時間で生成して順番を維持 let td = 0; - for (const note of featuredNotes.filter(note => note != null)) { + for (const note of featuredNotes.filter((note): note is Note => note != null)) { td -= 1000; transactionalEntityManager.insert(UserNotePining, { id: this.idService.genId(new Date(Date.now() + td)), createdAt: new Date(), userId: user.id, - noteId: note!.id, + noteId: note.id, }); } }); @@ -688,7 +644,7 @@ export class ApPersonService implements OnModuleInit { // (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする) dst = await this.resolvePerson(src.movedToUri); } - + if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ??? if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ??? if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri'; diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index 13a2f0fa5..229a44f90 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -4,12 +4,12 @@ import type { NotesRepository, PollsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type { IPoll } from '@/models/entities/Poll.js'; import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; import { isQuestion } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApResolverService } from '../ApResolverService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject, IQuestion } from '../type.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ApQuestionService { @@ -33,33 +33,25 @@ export class ApQuestionService { @bindThis public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const question = await resolver.resolve(source); + if (!isQuestion(question)) throw new Error('invalid type'); - if (!isQuestion(question)) { - throw new Error('invalid type'); - } + const multiple = question.oneOf === undefined; + if (multiple && question.anyOf === undefined) throw new Error('invalid question'); - const multiple = !question.oneOf; const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; - if (multiple && !question.anyOf) { - throw new Error('invalid question'); - } + const choices = question[multiple ? 'anyOf' : 'oneOf'] + ?.map((x) => x.name) + .filter((x): x is string => typeof x === 'string') + ?? []; - const choices = question[multiple ? 'anyOf' : 'oneOf']! - .map((x, i) => x.name!); + const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0); - const votes = question[multiple ? 'anyOf' : 'oneOf']! - .map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0); - - return { - choices, - votes, - multiple, - expiresAt, - }; + return { choices, votes, multiple, expiresAt }; } /** @@ -68,8 +60,9 @@ export class ApQuestionService { * @returns true if updated */ @bindThis - public async updateQuestion(value: any, resolver?: Resolver) { + public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise { const uri = typeof value === 'string' ? value : value.id; + if (uri == null) throw new Error('uri is null'); // URIがこのサーバーを指しているならスキップ if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); @@ -83,6 +76,7 @@ export class ApQuestionService { //#endregion // resolve new Question object + // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const question = await resolver.resolve(value) as IQuestion; this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); @@ -90,12 +84,14 @@ export class ApQuestionService { if (question.type !== 'Question') throw new Error('object is not a Question'); const apChoices = question.oneOf ?? question.anyOf; + if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices); let changed = false; for (const choice of poll.choices) { const oldCount = poll.votes[poll.choices.indexOf(choice)]; - const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems; + const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems; + if (newCount == null) throw new Error('invalid newCount: ' + newCount); if (oldCount !== newCount) { changed = true; @@ -103,9 +99,7 @@ export class ApQuestionService { } } - await this.pollsRepository.update({ noteId: note.id }, { - votes: poll.votes, - }); + await this.pollsRepository.update({ noteId: note.id }, { votes: poll.votes }); return changed; } diff --git a/packages/backend/src/core/activitypub/models/tag.ts b/packages/backend/src/core/activitypub/models/tag.ts index 803846a0b..9aeb84356 100644 --- a/packages/backend/src/core/activitypub/models/tag.ts +++ b/packages/backend/src/core/activitypub/models/tag.ts @@ -2,7 +2,7 @@ import { toArray } from '@/misc/prelude/array.js'; import { isHashtag } from '../type.js'; import type { IObject, IApHashtag } from '../type.js'; -export function extractApHashtags(tags: IObject | IObject[] | null | undefined) { +export function extractApHashtags(tags: IObject | IObject[] | null | undefined): string[] { if (tags == null) return []; const hashtags = extractApHashtagObjects(tags); diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 625135da6..4bb0fa61e 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -194,7 +194,6 @@ export interface IApPropertyValue extends IObject { } export const isPropertyValue = (object: IObject): object is IApPropertyValue => - object && getApType(object) === 'PropertyValue' && typeof object.name === 'string' && 'value' in object && diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts index d352adcc1..7a89233ed 100644 --- a/packages/backend/src/core/chart/core.ts +++ b/packages/backend/src/core/chart/core.ts @@ -254,7 +254,7 @@ export default abstract class Chart { private convertRawRecord(x: RawRecord): KVs { const kvs = {} as Record; for (const k of Object.keys(x).filter((k) => k.startsWith(COLUMN_PREFIX)) as (keyof Columns)[]) { - kvs[(k as string).substr(COLUMN_PREFIX.length).split(COLUMN_DELIMITER).join('.')] = x[k] as unknown as number; + kvs[(k as string).substring(COLUMN_PREFIX.length).split(COLUMN_DELIMITER).join('.')] = x[k] as unknown as number; } return kvs as KVs; } @@ -627,7 +627,7 @@ export default abstract class Chart { } // 要求された範囲の最も古い箇所に位置するログが存在しなかったら - } else if (!isTimeSame(new Date(logs[logs.length - 1].date * 1000), gt)) { + } else if (!isTimeSame(new Date(logs.at(-1)!.date * 1000), gt)) { // 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する // (隙間埋めできないため) const outdatedLog = await repository.findOne({ diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index 7f8240b8b..94ae1856b 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -3,8 +3,8 @@ import { DI } from '@/di-symbols.js'; import type { AbuseUserReportsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class AbuseUserReportEntityService { diff --git a/packages/backend/src/core/entities/AuthSessionEntityService.ts b/packages/backend/src/core/entities/AuthSessionEntityService.ts index b7edc8494..a1874c63a 100644 --- a/packages/backend/src/core/entities/AuthSessionEntityService.ts +++ b/packages/backend/src/core/entities/AuthSessionEntityService.ts @@ -4,8 +4,8 @@ import type { AuthSessionsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { AuthSession } from '@/models/entities/AuthSession.js'; import type { User } from '@/models/entities/User.js'; -import { AppEntityService } from './AppEntityService.js'; import { bindThis } from '@/decorators.js'; +import { AppEntityService } from './AppEntityService.js'; @Injectable() export class AuthSessionEntityService { diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 15ffd4486..de99ce72c 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -47,17 +47,26 @@ export class ChannelEntityService { const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; - const hasUnreadNote = meId ? (await this.noteUnreadsRepository.findOneBy({ noteChannelId: channel.id, userId: meId })) != null : undefined; + const hasUnreadNote = meId ? await this.noteUnreadsRepository.exist({ + where: { + noteChannelId: channel.id, + userId: meId, + }, + }) : undefined; - const following = meId ? await this.channelFollowingsRepository.findOneBy({ - followerId: meId, - followeeId: channel.id, - }) : null; + const isFollowing = meId ? await this.channelFollowingsRepository.exist({ + where: { + followerId: meId, + followeeId: channel.id, + }, + }) : false; - const favorite = meId ? await this.channelFavoritesRepository.findOneBy({ - userId: meId, - channelId: channel.id, - }) : null; + const isFavorited = meId ? await this.channelFavoritesRepository.exist({ + where: { + userId: meId, + channelId: channel.id, + }, + }) : false; const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ where: { @@ -80,8 +89,8 @@ export class ChannelEntityService { notesCount: channel.notesCount, ...(me ? { - isFollowing: following != null, - isFavorited: favorite != null, + isFollowing, + isFavorited, hasUnreadNote, } : {}), diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index 33d3c5380..f558cbc33 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -39,7 +39,7 @@ export class ClipEntityService { description: clip.description, isPublic: clip.isPublic, favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), - isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined, + isFavorited: meId ? await this.clipFavoritesRepository.exist({ where: { clipId: clip.id, userId: meId } }) : undefined, }); } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index d82f36d97..80442af09 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -47,7 +47,7 @@ export class DriveFileEntityService { private videoProcessingService: VideoProcessingService, ) { } - + @bindThis public validateFileName(name: string): boolean { return ( diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index e52a59188..92345457c 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -40,7 +40,7 @@ export class FlashEntityService { summary: flash.summary, script: flash.script, likedCount: flash.likedCount, - isLiked: meId ? await this.flashLikesRepository.findOneBy({ flashId: flash.id, userId: meId }).then(x => x != null) : undefined, + isLiked: meId ? await this.flashLikesRepository.exist({ where: { flashId: flash.id, userId: meId } }) : undefined, }); } diff --git a/packages/backend/src/core/entities/FollowRequestEntityService.ts b/packages/backend/src/core/entities/FollowRequestEntityService.ts index c2edc6a13..6f6f4be41 100644 --- a/packages/backend/src/core/entities/FollowRequestEntityService.ts +++ b/packages/backend/src/core/entities/FollowRequestEntityService.ts @@ -4,8 +4,8 @@ import type { FollowRequestsRepository } from '@/models/index.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { FollowRequest } from '@/models/entities/FollowRequest.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class FollowRequestEntityService { diff --git a/packages/backend/src/core/entities/GalleryLikeEntityService.ts b/packages/backend/src/core/entities/GalleryLikeEntityService.ts index db46045db..73c264da9 100644 --- a/packages/backend/src/core/entities/GalleryLikeEntityService.ts +++ b/packages/backend/src/core/entities/GalleryLikeEntityService.ts @@ -3,8 +3,8 @@ import { DI } from '@/di-symbols.js'; import type { GalleryLikesRepository } from '@/models/index.js'; import type { } from '@/models/entities/Blocking.js'; import type { GalleryLike } from '@/models/entities/GalleryLike.js'; -import { GalleryPostEntityService } from './GalleryPostEntityService.js'; import { bindThis } from '@/decorators.js'; +import { GalleryPostEntityService } from './GalleryPostEntityService.js'; @Injectable() export class GalleryLikeEntityService { diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index 632c75304..c44a5df11 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -46,7 +46,7 @@ export class GalleryPostEntityService { tags: post.tags.length > 0 ? post.tags : undefined, isSensitive: post.isSensitive, likedCount: post.likedCount, - isLiked: meId ? await this.galleryLikesRepository.findOneBy({ postId: post.id, userId: meId }).then(x => x != null) : undefined, + isLiked: meId ? await this.galleryLikesRepository.exist({ where: { postId: post.id, userId: meId } }) : undefined, }); } diff --git a/packages/backend/src/core/entities/InviteCodeEntityService.ts b/packages/backend/src/core/entities/InviteCodeEntityService.ts new file mode 100644 index 000000000..2d8e7a468 --- /dev/null +++ b/packages/backend/src/core/entities/InviteCodeEntityService.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { RegistrationTicketsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { User } from '@/models/entities/User.js'; +import type { RegistrationTicket } from '@/models/entities/RegistrationTicket.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class InviteCodeEntityService { + constructor( + @Inject(DI.registrationTicketsRepository) + private registrationTicketsRepository: RegistrationTicketsRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: RegistrationTicket['id'] | RegistrationTicket, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({ + where: { + id: src, + }, + relations: ['createdBy', 'usedBy'], + }); + + return await awaitAll({ + id: target.id, + code: target.code, + expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null, + createdAt: target.createdAt.toISOString(), + createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null, + usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null, + usedAt: target.usedAt ? target.usedAt.toISOString() : null, + used: !!target.usedAt, + }); + } + + @bindThis + public packMany( + targets: any[], + me: { id: User['id'] }, + ) { + return Promise.all(targets.map(x => this.pack(x, me))); + } +} diff --git a/packages/backend/src/core/entities/ModerationLogEntityService.ts b/packages/backend/src/core/entities/ModerationLogEntityService.ts index 7058e38af..59815d263 100644 --- a/packages/backend/src/core/entities/ModerationLogEntityService.ts +++ b/packages/backend/src/core/entities/ModerationLogEntityService.ts @@ -4,8 +4,8 @@ import type { ModerationLogsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { } from '@/models/entities/Blocking.js'; import type { ModerationLog } from '@/models/entities/ModerationLog.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class ModerationLogEntityService { diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 32269a410..546e5f56d 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -24,7 +24,7 @@ export class NoteEntityService implements OnModuleInit { private driveFileEntityService: DriveFileEntityService; private customEmojiService: CustomEmojiService; private reactionService: ReactionService; - + constructor( private moduleRef: ModuleRef, @@ -68,7 +68,7 @@ export class NoteEntityService implements OnModuleInit { this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.reactionService = this.moduleRef.get('ReactionService'); } - + @bindThis private async hideNote(packedNote: Packed<'Note'>, meId: User['id'] | null) { // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) @@ -106,16 +106,14 @@ export class NoteEntityService implements OnModuleInit { hide = false; } else { // フォロワーかどうか - const following = await this.followingsRepository.findOneBy({ - followeeId: packedNote.userId, - followerId: meId, + const isFollowing = await this.followingsRepository.exist({ + where: { + followeeId: packedNote.userId, + followerId: meId, + }, }); - if (following == null) { - hide = true; - } else { - hide = false; - } + hide = !isFollowing; } } @@ -457,12 +455,12 @@ export class NoteEntityService implements OnModuleInit { const query = this.notesRepository.createQueryBuilder('note') .where('note.userId = :userId', { userId }) .andWhere('note.renoteId = :renoteId', { renoteId }); - + // 指定した投稿を除く if (excludeNoteId) { query.andWhere('note.id != :excludeNoteId', { excludeNoteId }); } - + return await query.getCount(); - } + } } diff --git a/packages/backend/src/core/entities/NoteFavoriteEntityService.ts b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts index 8a7727b4c..badb8fb81 100644 --- a/packages/backend/src/core/entities/NoteFavoriteEntityService.ts +++ b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts @@ -4,8 +4,8 @@ import type { NoteFavoritesRepository } from '@/models/index.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { NoteFavorite } from '@/models/entities/NoteFavorite.js'; -import { NoteEntityService } from './NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { NoteEntityService } from './NoteEntityService.js'; @Injectable() export class NoteFavoriteEntityService { diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 8f943ba24..d454ddb70 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -17,7 +17,7 @@ export class NoteReactionEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; private reactionService: ReactionService; - + constructor( private moduleRef: ModuleRef, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index d76b86395..02c698284 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit { meId: User['id'], // eslint-disable-next-line @typescript-eslint/ban-types options: { - + }, hint?: { packedNotes: Map>; diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index d6da85663..94b26a501 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -97,7 +97,7 @@ export class PageEntityService { eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is DriveFile => x != null)), likedCount: page.likedCount, - isLiked: meId ? await this.pageLikesRepository.findOneBy({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, + isLiked: meId ? await this.pageLikesRepository.exist({ where: { pageId: page.id, userId: meId } }) : undefined, }); } diff --git a/packages/backend/src/core/entities/PageLikeEntityService.ts b/packages/backend/src/core/entities/PageLikeEntityService.ts index 3460c1e42..99465d0ea 100644 --- a/packages/backend/src/core/entities/PageLikeEntityService.ts +++ b/packages/backend/src/core/entities/PageLikeEntityService.ts @@ -4,8 +4,8 @@ import type { PageLikesRepository } from '@/models/index.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { PageLike } from '@/models/entities/PageLike.js'; -import { PageEntityService } from './PageEntityService.js'; import { bindThis } from '@/decorators.js'; +import { PageEntityService } from './PageEntityService.js'; @Injectable() export class PageLikeEntityService { diff --git a/packages/backend/src/core/entities/SigninEntityService.ts b/packages/backend/src/core/entities/SigninEntityService.ts index 51fa7543d..a6c4407ce 100644 --- a/packages/backend/src/core/entities/SigninEntityService.ts +++ b/packages/backend/src/core/entities/SigninEntityService.ts @@ -3,8 +3,8 @@ import { DI } from '@/di-symbols.js'; import type { SigninsRepository } from '@/models/index.js'; import type { } from '@/models/entities/Blocking.js'; import type { Signin } from '@/models/entities/Signin.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class SigninEntityService { diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index bfd506ea8..7d248f852 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, Not } from 'typeorm'; import * as Redis from 'ioredis'; -import Ajv from 'ajv'; +import _Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -31,6 +31,7 @@ type IsMeAndIsUserDetailed : Packed<'UserLite'>; +const Ajv = _Ajv.default; const ajv = new Ajv(); function isLocalUser(user: User): user is LocalUser; @@ -112,7 +113,7 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, - + @Inject(DI.userMemosRepository) private userMemosRepository: UserMemoRepository, @@ -229,12 +230,14 @@ export class UserEntityService implements OnModuleInit { /* const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); - const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ - antennaId: In(myAntennas.map(x => x.id)), - read: false, - }) : null; + const isUnread = (myAntennas.length > 0 ? await this.antennaNotesRepository.exist({ + where: { + antennaId: In(myAntennas.map(x => x.id)), + read: false, + }, + }) : false); - return unread != null; + return isUnread; */ return false; // TODO } diff --git a/packages/backend/src/daemons/QueueStatsService.ts b/packages/backend/src/daemons/QueueStatsService.ts index 53a0d14cd..4d0cb96a2 100644 --- a/packages/backend/src/daemons/QueueStatsService.ts +++ b/packages/backend/src/daemons/QueueStatsService.ts @@ -81,7 +81,7 @@ export class QueueStatsService implements OnApplicationShutdown { this.intervalId = setInterval(tick, interval); } - + @bindThis public dispose(): void { clearInterval(this.intervalId); diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index 6cd71c0e2..375fd5e51 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -3,6 +3,7 @@ import si from 'systeminformation'; import Xev from 'xev'; import * as osUtils from 'os-utils'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; const ev = new Xev(); @@ -14,9 +15,10 @@ const round = (num: number) => Math.round(num * 10) / 10; @Injectable() export class ServerStatsService implements OnApplicationShutdown { - private intervalId: NodeJS.Timer; + private intervalId: NodeJS.Timer | null = null; constructor( + private metaService: MetaService, ) { } @@ -24,7 +26,9 @@ export class ServerStatsService implements OnApplicationShutdown { * Report server stats regularly */ @bindThis - public start(): void { + public async start(): Promise { + if (!(await this.metaService.fetch(true)).enableServerMachineStats) return; + const log = [] as any[]; ev.on('requestServerStatsLog', x => { @@ -64,7 +68,9 @@ export class ServerStatsService implements OnApplicationShutdown { @bindThis public dispose(): void { - clearInterval(this.intervalId); + if (this.intervalId) { + clearInterval(this.intervalId); + } } @bindThis diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index 91039098f..465b557ce 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -4,7 +4,7 @@ import { default as convertColor } from 'color-convert'; import { format as dateFormat } from 'date-fns'; import { bindThis } from '@/decorators.js'; import { envOption } from './env.js'; -import type { KEYWORD } from 'color-convert/conversions'; +import type { KEYWORD } from 'color-convert/conversions.js'; type Context = { name: string; diff --git a/packages/backend/src/misc/acct.ts b/packages/backend/src/misc/acct.ts index d1a6852a9..fb3b657cf 100644 --- a/packages/backend/src/misc/acct.ts +++ b/packages/backend/src/misc/acct.ts @@ -4,7 +4,7 @@ export type Acct = { }; export function parse(acct: string): Acct { - if (acct.startsWith('@')) acct = acct.substr(1); + if (acct.startsWith('@')) acct = acct.substring(1); const split = acct.split('@', 2); return { username: split[0], host: split[1] ?? null }; } diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f130a7db8..e825d5137 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -181,14 +181,28 @@ export class RedisSingleCache { // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? -export class MemoryKVCache { - public cache: Map; +function nothingToDo(value: T): V { + return value as unknown as V; +} + +export class MemoryKVCache { + public cache: Map; private lifetime: number; private gcIntervalHandle: NodeJS.Timer; + private toMapConverter: (value: T) => V; + private fromMapConverter: (cached: V) => T | undefined; - constructor(lifetime: MemoryKVCache['lifetime']) { + constructor(lifetime: MemoryKVCache['lifetime'], options: { + toMapConverter: (value: T) => V; + fromMapConverter: (cached: V) => T | undefined; + } = { + toMapConverter: nothingToDo, + fromMapConverter: nothingToDo, + }) { this.cache = new Map(); this.lifetime = lifetime; + this.toMapConverter = options.toMapConverter; + this.fromMapConverter = options.fromMapConverter; this.gcIntervalHandle = setInterval(() => { this.gc(); @@ -199,7 +213,7 @@ export class MemoryKVCache { public set(key: string, value: T): void { this.cache.set(key, { date: Date.now(), - value, + value: this.toMapConverter(value), }); } @@ -211,7 +225,7 @@ export class MemoryKVCache { this.cache.delete(key); return undefined; } - return cached.value; + return this.fromMapConverter(cached.value); } @bindThis @@ -222,9 +236,10 @@ export class MemoryKVCache { /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + * fetcherの引数はcacheに保存されている値があれば渡されます */ @bindThis - public async fetch(key: string, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetch(key: string, fetcher: (value: V | undefined) => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -239,7 +254,7 @@ export class MemoryKVCache { } // Cache MISS - const value = await fetcher(); + const value = await fetcher(this.cache.get(key)?.value); this.set(key, value); return value; } @@ -247,9 +262,10 @@ export class MemoryKVCache { /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + * fetcherの引数はcacheに保存されている値があれば渡されます */ @bindThis - public async fetchMaybe(key: string, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetchMaybe(key: string, fetcher: (value: V | undefined) => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -264,7 +280,7 @@ export class MemoryKVCache { } // Cache MISS - const value = await fetcher(); + const value = await fetcher(this.cache.get(key)?.value); if (value !== undefined) { this.set(key, value); } diff --git a/packages/backend/src/misc/check-https.ts b/packages/backend/src/misc/check-https.ts index b33f01997..612032fe9 100644 --- a/packages/backend/src/misc/check-https.ts +++ b/packages/backend/src/misc/check-https.ts @@ -1,4 +1,4 @@ -export function checkHttps(url: string) { - return url.startsWith('https://') || - (url.startsWith('http://') && process.env.NODE_ENV !== 'production'); +export function checkHttps(url: string): boolean { + return url.startsWith('https://') || + (url.startsWith('http://') && process.env.NODE_ENV !== 'production'); } diff --git a/packages/backend/src/misc/dev-null.ts b/packages/backend/src/misc/dev-null.ts index 38b9d8266..6706af5e5 100644 --- a/packages/backend/src/misc/dev-null.ts +++ b/packages/backend/src/misc/dev-null.ts @@ -1,11 +1,11 @@ -import { Writable, WritableOptions } from "node:stream"; +import { Writable, WritableOptions } from 'node:stream'; export class DevNull extends Writable implements NodeJS.WritableStream { - constructor(opts?: WritableOptions) { - super(opts); - } + constructor(opts?: WritableOptions) { + super(opts); + } - _write (chunk: any, encoding: BufferEncoding, cb: (err?: Error | null) => void) { - setImmediate(cb); - } + _write (chunk: any, encoding: BufferEncoding, cb: (err?: Error | null) => void) { + setImmediate(cb); + } } diff --git a/packages/backend/src/misc/generate-invite-code.ts b/packages/backend/src/misc/generate-invite-code.ts new file mode 100644 index 000000000..617b27361 --- /dev/null +++ b/packages/backend/src/misc/generate-invite-code.ts @@ -0,0 +1,20 @@ +import { secureRndstr } from './secure-rndstr.js'; + +const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // [0-9A-Z] w/o [01IO] (32 patterns) + +export function generateInviteCode(): string { + const code = secureRndstr(8, { + chars: CHARS, + }); + + const uniqueId = []; + let n = Math.floor(Date.now() / 1000 / 60); + while (true) { + uniqueId.push(CHARS[n % CHARS.length]); + const t = Math.floor(n / CHARS.length); + if (!t) break; + n = t; + } + + return code + uniqueId.reverse().join(''); +} diff --git a/packages/backend/src/misc/generate-native-user-token.ts b/packages/backend/src/misc/generate-native-user-token.ts index 5d8a4c537..7292d765a 100644 --- a/packages/backend/src/misc/generate-native-user-token.ts +++ b/packages/backend/src/misc/generate-native-user-token.ts @@ -1,3 +1,3 @@ import { secureRndstr } from '@/misc/secure-rndstr.js'; -export default () => secureRndstr(16, true); +export default () => secureRndstr(16); diff --git a/packages/backend/src/misc/get-ip-hash.ts b/packages/backend/src/misc/get-ip-hash.ts index 70e61aef8..1a86fb881 100644 --- a/packages/backend/src/misc/get-ip-hash.ts +++ b/packages/backend/src/misc/get-ip-hash.ts @@ -1,6 +1,6 @@ import IPCIDR from 'ip-cidr'; -export function getIpHash(ip: string) { +export function getIpHash(ip: string): string { try { // because a single person may control many IPv6 addresses, // only a /64 subnet prefix of any IP will be taken into account. diff --git a/packages/backend/src/misc/id/ulid.ts b/packages/backend/src/misc/id/ulid.ts index e8aa75289..bfbc363cf 100644 --- a/packages/backend/src/misc/id/ulid.ts +++ b/packages/backend/src/misc/id/ulid.ts @@ -5,10 +5,10 @@ const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/; export function parseUlid(id: string): { date: Date; } { - const timestamp = id.slice(0, 10); - let time = 0; - for (let i = 0; i < 10; i++) { - time = time * 32 + CHARS.indexOf(timestamp[i]); - } - return { date: new Date(time) }; + const timestamp = id.slice(0, 10); + let time = 0; + for (let i = 0; i < 10; i++) { + time = time * 32 + CHARS.indexOf(timestamp[i]); + } + return { date: new Date(time) }; } diff --git a/packages/backend/src/misc/is-duplicate-key-value-error.ts b/packages/backend/src/misc/is-duplicate-key-value-error.ts index 04ff191e4..f5343d187 100644 --- a/packages/backend/src/misc/is-duplicate-key-value-error.ts +++ b/packages/backend/src/misc/is-duplicate-key-value-error.ts @@ -1,3 +1,5 @@ +import { QueryFailedError } from 'typeorm'; + export function isDuplicateKeyValueError(e: unknown | Error): boolean { - return (e as any).message && (e as Error).message.startsWith('duplicate key value'); + return e instanceof QueryFailedError && e.driverError.code === '23505'; } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index e748f93a2..ec6bc4a5f 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -19,6 +19,7 @@ import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js' import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; import { packedHashtagSchema } from '@/models/json-schema/hashtag.js'; +import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js'; import { packedPageSchema } from '@/models/json-schema/page.js'; import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; import { packedChannelSchema } from '@/models/json-schema/channel.js'; @@ -52,6 +53,7 @@ export const refs = { RenoteMuting: packedRenoteMutingSchema, Blocking: packedBlockingSchema, Hashtag: packedHashtagSchema, + InviteCode: packedInviteCodeSchema, Page: packedPageSchema, Channel: packedChannelSchema, QueueCount: packedQueueCountSchema, @@ -131,7 +133,7 @@ type NullOrUndefined

= | T; // https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection -// Get intersection from union +// Get intersection from union type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; type PartialIntersection = Partial>; diff --git a/packages/backend/src/misc/prelude/array.ts b/packages/backend/src/misc/prelude/array.ts index 0b2830cb7..2524eacfb 100644 --- a/packages/backend/src/misc/prelude/array.ts +++ b/packages/backend/src/misc/prelude/array.ts @@ -67,8 +67,9 @@ export function maximum(xs: number[]): number { export function groupBy(f: EndoRelation, xs: T[]): T[][] { const groups = [] as T[][]; for (const x of xs) { - if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { - groups[groups.length - 1].push(x); + const lastGroup = groups.at(-1); + if (lastGroup !== undefined && f(lastGroup[0], x)) { + lastGroup.push(x); } else { groups.push([x]); } diff --git a/packages/backend/src/misc/prelude/await-all.ts b/packages/backend/src/misc/prelude/await-all.ts index b955c3a5d..fd9832d6f 100644 --- a/packages/backend/src/misc/prelude/await-all.ts +++ b/packages/backend/src/misc/prelude/await-all.ts @@ -10,7 +10,7 @@ export async function awaitAll(obj: Promiseable): Promise { const resolvedValues = await Promise.all(values.map(value => (!value || !value.constructor || value.constructor.name !== 'Object') ? value - : awaitAll(value) + : awaitAll(value), )); for (let i = 0; i < keys.length; i++) { diff --git a/packages/backend/src/misc/prelude/url.ts b/packages/backend/src/misc/prelude/url.ts index 9b1dabc78..523967828 100644 --- a/packages/backend/src/misc/prelude/url.ts +++ b/packages/backend/src/misc/prelude/url.ts @@ -2,7 +2,7 @@ * 1. 配列に何も入っていない時はクエリを付けない * 2. プロパティがundefinedの時はクエリを付けない * (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない) - */ + */ export function query(obj: Record): string { const params = Object.entries(obj) .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) diff --git a/packages/backend/src/misc/secure-rndstr.ts b/packages/backend/src/misc/secure-rndstr.ts index 8d4fcb1ba..cde64c814 100644 --- a/packages/backend/src/misc/secure-rndstr.ts +++ b/packages/backend/src/misc/secure-rndstr.ts @@ -1,10 +1,9 @@ import * as crypto from 'node:crypto'; -const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; +export const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; -export function secureRndstr(length = 32, useLU = true): string { - const chars = useLU ? LU_CHARS : L_CHARS; +export function secureRndstr(length = 32, { chars = LU_CHARS } = {}): string { const chars_len = chars.length; let str = ''; diff --git a/packages/backend/src/models/entities/Ad.ts b/packages/backend/src/models/entities/Ad.ts index 56baf863c..a496a6d27 100644 --- a/packages/backend/src/models/entities/Ad.ts +++ b/packages/backend/src/models/entities/Ad.ts @@ -55,7 +55,10 @@ export class Ad { length: 8192, nullable: false, }) public memo: string; - + @Column('integer', { + default: 0, nullable: false, + }) + public dayOfWeek: number; constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index f799551f3..7bb1b6771 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -1,7 +1,6 @@ import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; import { id } from '../id.js'; import { User } from './User.js'; -import type { Clip } from './Clip.js'; @Entity() export class Meta { @@ -126,6 +125,11 @@ export class Meta { }) public cacheRemoteFiles: boolean; + @Column('boolean', { + default: true, + }) + public cacheRemoteSensitiveFiles: boolean; + @Column({ ...id(), nullable: true, @@ -413,6 +417,16 @@ export class Meta { }) public enableChartsForFederatedInstances: boolean; + @Column('boolean', { + default: false, + }) + public enableServerMachineStats: boolean; + + @Column('boolean', { + default: true, + }) + public enableIdenticonGeneration: boolean; + @Column('jsonb', { default: { }, }) diff --git a/packages/backend/src/models/entities/RegistrationTicket.ts b/packages/backend/src/models/entities/RegistrationTicket.ts index 139e40f85..4c42b20be 100644 --- a/packages/backend/src/models/entities/RegistrationTicket.ts +++ b/packages/backend/src/models/entities/RegistrationTicket.ts @@ -1,17 +1,60 @@ -import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn, OneToOne } from 'typeorm'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class RegistrationTicket { @PrimaryColumn(id()) public id: string; - @Column('timestamp with time zone') - public createdAt: Date; - @Index({ unique: true }) @Column('varchar', { length: 64, }) public code: string; + + @Column('timestamp with time zone', { + nullable: true, + }) + public expiresAt: Date | null; + + @Column('timestamp with time zone') + public createdAt: Date; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public createdBy: User | null; + + @Index() + @Column({ + ...id(), + nullable: true, + }) + public createdById: User['id'] | null; + + @OneToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public usedBy: User | null; + + @Index() + @Column({ + ...id(), + nullable: true, + }) + public usedById: User['id'] | null; + + @Column('timestamp with time zone', { + nullable: true, + }) + public usedAt: Date | null; + + @Column('varchar', { + length: 32, + nullable: true, + }) + public pendingUserId: string | null; } diff --git a/packages/backend/src/models/entities/UserProfile.ts b/packages/backend/src/models/entities/UserProfile.ts index 236ee8f98..c4ed9db9b 100644 --- a/packages/backend/src/models/entities/UserProfile.ts +++ b/packages/backend/src/models/entities/UserProfile.ts @@ -207,7 +207,7 @@ export class UserProfile { public mutedInstances: string[]; @Column('enum', { - enum: [ + enum: [ ...notificationTypes, // マイグレーションで削除が困難なので古いenumは残しておく ...obsoleteNotificationTypes, diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 4b230ab74..627281df7 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -49,7 +49,6 @@ import { User } from '@/models/entities/User.js'; import { UserIp } from '@/models/entities/UserIp.js'; import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UserList } from '@/models/entities/UserList.js'; -import { UserListFavorite } from './entities/UserListFavorite.js'; import { UserListJoining } from '@/models/entities/UserListJoining.js'; import { UserNotePining } from '@/models/entities/UserNotePining.js'; import { UserPending } from '@/models/entities/UserPending.js'; @@ -64,6 +63,7 @@ import { Role } from '@/models/entities/Role.js'; import { RoleAssignment } from '@/models/entities/RoleAssignment.js'; import { Flash } from '@/models/entities/Flash.js'; import { FlashLike } from '@/models/entities/FlashLike.js'; +import { UserListFavorite } from './entities/UserListFavorite.js'; import type { Repository } from 'typeorm'; export { diff --git a/packages/backend/src/models/json-schema/invite-code.ts b/packages/backend/src/models/json-schema/invite-code.ts new file mode 100644 index 000000000..b70a779f2 --- /dev/null +++ b/packages/backend/src/models/json-schema/invite-code.ts @@ -0,0 +1,45 @@ +export const packedInviteCodeSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + code: { + type: 'string', + optional: false, nullable: false, + example: 'GR6S02ERUA5VR', + }, + expiresAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + createdBy: { + type: 'object', + optional: false, nullable: true, + ref: 'UserLite', + }, + usedBy: { + type: 'object', + optional: false, nullable: true, + ref: 'UserLite', + }, + usedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + used: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 42f9c1af7..f575b1718 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -283,7 +283,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }); const relationshipLogger = this.logger.createSubLogger('relationship'); - + this.relationshipQueueWorker .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index d240fe70e..d49951a1c 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -15,11 +15,8 @@ export const QUEUE = { export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { return { connection: { - port: config.redisForJobQueue.port, - host: config.redisForJobQueue.host, - family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family, - password: config.redisForJobQueue.pass, - db: config.redisForJobQueue.db ?? 0, + ...config.redisForJobQueue, + keyPrefix: undefined }, prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`, }; diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts index c54bf59ae..6f887089e 100644 --- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, MoreThan, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository } from '@/models/index.js'; +import type { DriveFile, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; @@ -31,7 +31,7 @@ export class CleanRemoteFilesProcessorService { this.logger.info('Deleting cached remote files...'); let deletedCount = 0; - let cursor: any = null; + let cursor: DriveFile['id'] | null = null; while (true) { const files = await this.driveFilesRepository.find({ @@ -51,7 +51,7 @@ export class CleanRemoteFilesProcessorService { break; } - cursor = files[files.length - 1].id; + cursor = files.at(-1)?.id ?? null; await Promise.all(files.map(file => this.driveService.deleteFileSync(file, true))); diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 39dd801af..3b7db5f05 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -9,6 +9,7 @@ import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Note } from '@/models/entities/Note.js'; import { EmailService } from '@/core/EmailService.js'; import { bindThis } from '@/decorators.js'; +import { SearchService } from '@/core/SearchService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; @@ -36,6 +37,7 @@ export class DeleteAccountProcessorService { private driveService: DriveService, private emailService: EmailService, private queueLoggerService: QueueLoggerService, + private searchService: SearchService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); } @@ -68,9 +70,13 @@ export class DeleteAccountProcessorService { break; } - cursor = notes[notes.length - 1].id; + cursor = notes.at(-1)?.id ?? null; await this.notesRepository.delete(notes.map(note => note.id)); + + for (const note of notes) { + await this.searchService.unindexNote(note); + } } this.logger.succ('All of notes deleted'); @@ -95,7 +101,7 @@ export class DeleteAccountProcessorService { break; } - cursor = files[files.length - 1].id; + cursor = files.at(-1)?.id ?? null; for (const file of files) { await this.driveService.deleteFileSync(file); diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts index 6772c5dc7..07e376233 100644 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; +import type { UsersRepository, DriveFilesRepository, DriveFile } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; @@ -40,7 +40,7 @@ export class DeleteDriveFilesProcessorService { } let deletedCount = 0; - let cursor: any = null; + let cursor: DriveFile['id'] | null = null; while (true) { const files = await this.driveFilesRepository.find({ @@ -59,7 +59,7 @@ export class DeleteDriveFilesProcessorService { break; } - cursor = files[files.length - 1].id; + cursor = files.at(-1)?.id ?? null; for (const file of files) { await this.driveService.deleteFileSync(file); diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index ac52325c8..21c0bfe80 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -30,7 +30,7 @@ export class ExportAntennasProcessorService { @Inject(DI.userListJoiningsRepository) private userListJoiningsRepository: UserListJoiningsRepository, - + private driveService: DriveService, private utilityService: UtilityService, private queueLoggerService: QueueLoggerService, diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index eb758e162..d100c6d09 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { MoreThan } from 'typeorm'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository } from '@/models/index.js'; +import type { UsersRepository, BlockingsRepository, Blocking } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; @@ -53,7 +53,7 @@ export class ExportBlockingProcessorService { const stream = fs.createWriteStream(path, { flags: 'a' }); let exportedCount = 0; - let cursor: any = null; + let cursor: Blocking['id'] | null = null; while (true) { const blockings = await this.blockingsRepository.find({ @@ -72,7 +72,7 @@ export class ExportBlockingProcessorService { break; } - cursor = blockings[blockings.length - 1].id; + cursor = blockings.at(-1)?.id ?? null; for (const block of blockings) { const u = await this.usersRepository.findOneBy({ id: block.blockeeId }); diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index 76c38a6b8..2be42b1a7 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -94,7 +94,7 @@ export class ExportFavoritesProcessorService { break; } - cursor = favorites[favorites.length - 1].id; + cursor = favorites.at(-1)?.id ?? null; for (const favorite of favorites) { let poll: Poll | undefined; diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 8726cb140..d54e5e0b3 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -79,7 +79,7 @@ export class ExportFollowingProcessorService { break; } - cursor = followings[followings.length - 1].id; + cursor = followings.at(-1)?.id ?? null; for (const following of followings) { const u = await this.usersRepository.findOneBy({ id: following.followeeId }); diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index 0f11a9e84..030e38931 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, MoreThan } from 'typeorm'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; -import type { MutingsRepository, UsersRepository, BlockingsRepository } from '@/models/index.js'; +import type { MutingsRepository, UsersRepository, BlockingsRepository, Muting } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; @@ -56,7 +56,7 @@ export class ExportMutingProcessorService { const stream = fs.createWriteStream(path, { flags: 'a' }); let exportedCount = 0; - let cursor: any = null; + let cursor: Muting['id'] | null = null; while (true) { const mutes = await this.mutingsRepository.find({ @@ -76,7 +76,7 @@ export class ExportMutingProcessorService { break; } - cursor = mutes[mutes.length - 1].id; + cursor = mutes.at(-1)?.id ?? null; for (const mute of mutes) { const u = await this.usersRepository.findOneBy({ id: mute.muteeId }); diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 24fb33188..94c81a3cf 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -11,6 +11,8 @@ import { createTemp } from '@/misc/create-temp.js'; import type { Poll } from '@/models/entities/Poll.js'; import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; @@ -34,6 +36,8 @@ export class ExportNotesProcessorService { private driveService: DriveService, private queueLoggerService: QueueLoggerService, + + private driveFileEntityService: DriveFileEntityService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-notes'); } @@ -90,14 +94,15 @@ export class ExportNotesProcessorService { break; } - cursor = notes[notes.length - 1].id; + cursor = notes.at(-1)?.id ?? null; for (const note of notes) { let poll: Poll | undefined; if (note.hasPoll) { poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); } - const content = JSON.stringify(serialize(note, poll)); + const files = await this.driveFileEntityService.packManyByIds(note.fileIds); + const content = JSON.stringify(serialize(note, poll, files)); const isFirst = exportedNotesCount === 0; await write(isFirst ? content : ',\n' + content); exportedNotesCount++; @@ -125,12 +130,13 @@ export class ExportNotesProcessorService { } } -function serialize(note: Note, poll: Poll | null = null): Record { +function serialize(note: Note, poll: Poll | null = null, files: Packed<'DriveFile'>[]): Record { return { id: note.id, text: note.text, createdAt: note.createdAt, fileIds: note.fileIds, + files: files, replyId: note.replyId, renoteId: note.renoteId, poll: poll, diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index 575cad69d..74ef20fdd 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -1,5 +1,5 @@ import { Injectable, Inject } from '@nestjs/common'; -import Ajv from 'ajv'; +import _Ajv from 'ajv'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import Logger from '@/logger.js'; @@ -10,16 +10,18 @@ import { QueueLoggerService } from '../QueueLoggerService.js'; import { DBAntennaImportJobData } from '../types.js'; import type * as Bull from 'bullmq'; +const Ajv = _Ajv.default; + const validate = new Ajv().compile({ type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, src: { type: 'string', enum: ['home', 'all', 'users', 'list'] }, - userListAccts: { - type: 'array', + userListAccts: { + type: 'array', items: { type: 'string', - }, + }, nullable: true, }, keywords: { type: 'array', items: { diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index d86256787..37b929cb0 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; +import { ZipReader } from 'slacc'; import { DataSource } from 'typeorm'; -import unzipper from 'unzipper'; import { DI } from '@/di-symbols.js'; import type { EmojisRepository, DriveFilesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -72,9 +72,9 @@ export class ImportCustomEmojisProcessorService { } const outputPath = path + '/emojis'; - const unzipStream = fs.createReadStream(destPath); - const extractor = unzipper.Extract({ path: outputPath }); - extractor.on('close', async () => { + try { + this.logger.succ(`Unzipping to ${outputPath}`); + ZipReader.withDestinationPath(outputPath).viaBuffer(await fs.promises.readFile(destPath)); const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8'); const meta = JSON.parse(metaRaw); @@ -113,10 +113,14 @@ export class ImportCustomEmojisProcessorService { } cleanup(); - + this.logger.succ('Imported'); - }); - unzipStream.pipe(extractor); - this.logger.succ(`Unzipping to ${outputPath}`); + } catch (e) { + if (e instanceof Error || typeof e === 'string') { + this.logger.error(e); + } + cleanup(); + throw e; + } } } diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts index 722260d94..816c5fc5e 100644 --- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -1,16 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; -import type * as Bull from 'bullmq'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import { RelationshipJobData } from '../types.js'; import type { UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { LocalUser, RemoteUser } from '@/models/entities/User.js'; +import { RelationshipJobData } from '../types.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; @Injectable() export class RelationshipProcessorService { diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts index 8b40c1674..25e91761e 100644 --- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts @@ -31,7 +31,7 @@ export class WebhookDeliverProcessorService { public async process(job: Bull.Job): Promise { try { this.logger.debug(`delivering ${job.data.webhookId}`); - + const res = await this.httpRequestService.send(job.data.to, { method: 'POST', headers: { @@ -50,25 +50,25 @@ export class WebhookDeliverProcessorService { body: job.data.content, }), }); - + this.webhooksRepository.update({ id: job.data.webhookId }, { latestSentAt: new Date(), latestStatus: res.status, }); - + return 'Success'; } catch (res) { this.webhooksRepository.update({ id: job.data.webhookId }, { latestSentAt: new Date(), latestStatus: res instanceof StatusError ? res.statusCode : 1, }); - + if (res instanceof StatusError) { // 4xx if (res.isClientError) { throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); } - + // 5xx etc. throw new Error(`${res.statusCode} ${res.statusMessage}`); } else { diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 455acd1e4..634f5f0a4 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -181,7 +181,7 @@ export class ActivityPubServerService { undefined, inStock ? `${partOf}?${url.query({ page: 'true', - cursor: followings[followings.length - 1].id, + cursor: followings.at(-1)!.id, })}` : undefined, ); @@ -189,7 +189,11 @@ export class ActivityPubServerService { return (this.apRendererService.addContext(rendered)); } else { // index page - const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); + const rendered = this.apRendererService.renderOrderedCollection( + partOf, + user.followersCount, + `${partOf}?page=true`, + ); reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); @@ -269,7 +273,7 @@ export class ActivityPubServerService { undefined, inStock ? `${partOf}?${url.query({ page: 'true', - cursor: followings[followings.length - 1].id, + cursor: followings.at(-1)!.id, })}` : undefined, ); @@ -277,7 +281,11 @@ export class ActivityPubServerService { return (this.apRendererService.addContext(rendered)); } else { // index page - const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); + const rendered = this.apRendererService.renderOrderedCollection( + partOf, + user.followingCount, + `${partOf}?page=true`, + ); reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); @@ -310,7 +318,10 @@ export class ActivityPubServerService { const rendered = this.apRendererService.renderOrderedCollection( `${this.config.url}/users/${userId}/collections/featured`, - renderedNotes.length, undefined, undefined, renderedNotes, + renderedNotes.length, + undefined, + undefined, + renderedNotes, ); reply.header('Cache-Control', 'public, max-age=180'); @@ -369,7 +380,7 @@ export class ActivityPubServerService { })) .andWhere('note.localOnly = FALSE'); - const notes = await query.take(limit).getMany(); + const notes = await query.limit(limit).getMany(); if (sinceId) notes.reverse(); @@ -387,7 +398,7 @@ export class ActivityPubServerService { })}` : undefined, notes.length ? `${partOf}?${url.query({ page: 'true', - until_id: notes[notes.length - 1].id, + until_id: notes.at(-1)!.id, })}` : undefined, ); @@ -395,7 +406,9 @@ export class ActivityPubServerService { return (this.apRendererService.addContext(rendered)); } else { // index page - const rendered = this.apRendererService.renderOrderedCollection(partOf, user.notesCount, + const rendered = this.apRendererService.renderOrderedCollection( + partOf, + user.notesCount, `${partOf}?page=true`, `${partOf}?page=true&since_id=000000000000000000000000`, ); diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 98329ddff..2547d7336 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -3,6 +3,8 @@ import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; import rename from 'rename'; +import sharp from 'sharp'; +import { sharpBmp } from 'sharp-read-bmp'; import type { Config } from '@/config.js'; import type { DriveFile, DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; @@ -18,11 +20,9 @@ import { contentDisposition } from '@/misc/content-disposition.js'; import { FileInfoService } from '@/core/FileInfoService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import { isMimeImage } from '@/misc/is-mime-image.js'; -import sharp from 'sharp'; -import { sharpBmp } from 'sharp-read-bmp'; import { correctFilename } from '@/misc/correct-filename.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -180,8 +180,8 @@ export class FileServerService { reply.header('Content-Disposition', contentDisposition( 'inline', - correctFilename(file.filename, image.ext) - ) + correctFilename(file.filename, image.ext), + ), ); return image.data; } @@ -278,11 +278,11 @@ export class FileServerService { }; } else { const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) - .resize({ - height: 'emoji' in request.query ? 128 : 320, - withoutEnlargement: true, - }) - .webp(webpDefault); + .resize({ + height: 'emoji' in request.query ? 128 : 320, + withoutEnlargement: true, + }) + .webp(webpDefault); image = { data, @@ -355,8 +355,8 @@ export class FileServerService { reply.header('Content-Disposition', contentDisposition( 'inline', - correctFilename(file.filename, image.ext) - ) + correctFilename(file.filename, image.ext), + ), ); return image.data; } catch (e) { diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index c3d45e4ad..051920958 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -16,6 +16,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -45,6 +46,7 @@ export class ServerService implements OnApplicationShutdown { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + private metaService: MetaService, private userEntityService: UserEntityService, private apiServerService: ApiServerService, private openApiServerService: OpenApiServerService, @@ -161,11 +163,16 @@ export class ServerService implements OnApplicationShutdown { }); fastify.get<{ Params: { x: string } }>('/identicon/:x', async (request, reply) => { - const [temp, cleanup] = await createTemp(); - await genIdenticon(request.params.x, fs.createWriteStream(temp)); reply.header('Content-Type', 'image/png'); reply.header('Cache-Control', 'public, max-age=86400'); - return fs.createReadStream(temp).on('close', () => cleanup()); + + if ((await this.metaService.fetch()).enableIdenticonGeneration) { + const [temp, cleanup] = await createTemp(); + await genIdenticon(request.params.x, fs.createWriteStream(temp)); + return fs.createReadStream(temp).on('close', () => cleanup()); + } else { + return reply.redirect('/static-assets/avatar.png'); + } }); fastify.get<{ Params: { code: string } }>('/verify-email/:code', async (request, reply) => { @@ -217,14 +224,25 @@ export class ServerService implements OnApplicationShutdown { } }); - fastify.listen({ port: this.config.port, host: '0.0.0.0' }); + if (this.config.socket) { + if (fs.existsSync(this.config.socket)) { + fs.unlinkSync(this.config.socket); + } + fastify.listen({ path: this.config.socket }, (err, address) => { + if (this.config.chmodSocket) { + fs.chmodSync(this.config.socket!, this.config.chmodSocket); + } + }); + } else { + fastify.listen({ port: this.config.port, host: '0.0.0.0' }); + } await fastify.ready(); } @bindThis public async dispose(): Promise { - await this.streamingApiServerService.detach(); + await this.streamingApiServerService.detach(); await this.#fastify.close(); } diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index 9bf8deb22..aabe631fb 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -1,18 +1,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import vary from 'vary'; +import fastifyAccepts from '@fastify/accepts'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; import type { User } from '@/models/entities/User.js'; import * as Acct from '@/misc/acct.js'; -import { NodeinfoServerService } from './NodeinfoServerService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { FindOptionsWhere } from 'typeorm'; import { bindThis } from '@/decorators.js'; +import { NodeinfoServerService } from './NodeinfoServerService.js'; +import type { FindOptionsWhere } from 'typeorm'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; -import fastifyAccepts from '@fastify/accepts'; @Injectable() export class WellKnownServerService { diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index dad1a4132..c4c02e7af 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -1,8 +1,8 @@ +import { randomUUID } from 'node:crypto'; import { pipeline } from 'node:stream'; import * as fs from 'node:fs'; import { promisify } from 'node:util'; import { Inject, Injectable } from '@nestjs/common'; -import { v4 as uuid } from 'uuid'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { LocalUser, User } from '@/models/entities/User.js'; @@ -53,44 +53,72 @@ export class ApiCallService implements OnApplicationShutdown { }, 1000 * 60 * 60); } + #sendApiError(reply: FastifyReply, err: ApiError): void { + let statusCode = err.httpStatusCode; + if (err.httpStatusCode === 401) { + reply.header('WWW-Authenticate', 'Bearer realm="Misskey"'); + } else if (err.kind === 'client') { + reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); + statusCode = statusCode ?? 400; + } else if (err.kind === 'permission') { + // (ROLE_PERMISSION_DENIEDは関係ない) + if (err.code === 'PERMISSION_DENIED') { + reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); + } + statusCode = statusCode ?? 403; + } else if (!statusCode) { + statusCode = 500; + } + this.send(reply, statusCode, err); + } + + #sendAuthenticationError(reply: FastifyReply, err: unknown): void { + if (err instanceof AuthenticationError) { + const message = 'Authentication failed. Please ensure your token is correct.'; + reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_token", error_description="${message}"`); + this.send(reply, 401, new ApiError({ + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + })); + } else { + this.send(reply, 500, new ApiError()); + } + } + @bindThis public handleRequest( endpoint: IEndpoint & { exec: any }, request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, reply: FastifyReply, - ) { + ): void { const body = request.method === 'GET' ? request.query : request.body; - const token = body?.['i']; + // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) + const token = request.headers.authorization?.startsWith('Bearer ') + ? request.headers.authorization.slice(7) + : body?.['i']; if (token != null && typeof token !== 'string') { reply.code(400); return; } this.authenticateService.authenticate(token).then(([user, app]) => { this.call(endpoint, user, app, body, null, request).then((res) => { - if (request.method === 'GET' && endpoint.meta.cacheSec && !body?.['i'] && !user) { + if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) { reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); } this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); + this.#sendApiError(reply, err); }); if (user) { this.logIp(request, user); } }).catch(err => { - if (err instanceof AuthenticationError) { - this.send(reply, 403, new ApiError({ - message: 'Authentication failed. Please ensure your token is correct.', - code: 'AUTHENTICATION_FAILED', - id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', - })); - } else { - this.send(reply, 500, new ApiError()); - } + this.#sendAuthenticationError(reply, err); }); } @@ -99,7 +127,7 @@ export class ApiCallService implements OnApplicationShutdown { endpoint: IEndpoint & { exec: any }, request: FastifyRequest<{ Body: Record, Querystring: Record }>, reply: FastifyReply, - ) { + ): Promise { const multipartData = await request.file().catch(() => { /* Fastify throws if the remote didn't send multipart data. Return 400 below. */ }); @@ -117,7 +145,10 @@ export class ApiCallService implements OnApplicationShutdown { fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; } - const token = fields['i']; + // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) + const token = request.headers.authorization?.startsWith('Bearer ') + ? request.headers.authorization.slice(7) + : fields['i']; if (token != null && typeof token !== 'string') { reply.code(400); return; @@ -129,22 +160,14 @@ export class ApiCallService implements OnApplicationShutdown { }, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); + this.#sendApiError(reply, err); }); if (user) { this.logIp(request, user); } }).catch(err => { - if (err instanceof AuthenticationError) { - this.send(reply, 403, new ApiError({ - message: 'Authentication failed. Please ensure your token is correct.', - code: 'AUTHENTICATION_FAILED', - id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', - })); - } else { - this.send(reply, 500, new ApiError()); - } + this.#sendAuthenticationError(reply, err); }); } @@ -213,7 +236,7 @@ export class ApiCallService implements OnApplicationShutdown { } if (ep.meta.limit) { - // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. + // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. let limitActor: string; if (user) { limitActor = user.id; @@ -255,8 +278,8 @@ export class ApiCallService implements OnApplicationShutdown { throw new ApiError({ message: 'Your account has been suspended.', code: 'YOUR_ACCOUNT_SUSPENDED', + kind: 'permission', id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', - httpStatusCode: 403, }); } } @@ -266,8 +289,8 @@ export class ApiCallService implements OnApplicationShutdown { throw new ApiError({ message: 'You have moved your account.', code: 'YOUR_ACCOUNT_MOVED', + kind: 'permission', id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', - httpStatusCode: 403, }); } } @@ -278,6 +301,7 @@ export class ApiCallService implements OnApplicationShutdown { throw new ApiError({ message: 'You are not assigned to a moderator role.', code: 'ROLE_PERMISSION_DENIED', + kind: 'permission', id: 'd33d5333-db36-423d-a8f9-1a2b9549da41', }); } @@ -285,6 +309,7 @@ export class ApiCallService implements OnApplicationShutdown { throw new ApiError({ message: 'You are not assigned to an administrator role.', code: 'ROLE_PERMISSION_DENIED', + kind: 'permission', id: 'c3d38592-54c0-429d-be96-5636b0431a61', }); } @@ -296,6 +321,7 @@ export class ApiCallService implements OnApplicationShutdown { throw new ApiError({ message: 'You are not assigned to a required role.', code: 'ROLE_PERMISSION_DENIED', + kind: 'permission', id: '7f86f06f-7e15-4057-8561-f4b6d4ac755a', }); } @@ -305,6 +331,7 @@ export class ApiCallService implements OnApplicationShutdown { throw new ApiError({ message: 'Your app does not have the necessary permissions to use this endpoint.', code: 'PERMISSION_DENIED', + kind: 'permission', id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', }); } @@ -317,7 +344,7 @@ export class ApiCallService implements OnApplicationShutdown { try { data[k] = JSON.parse(data[k]); } catch (e) { - throw new ApiError({ + throw new ApiError({ message: 'Invalid param.', code: 'INVALID_PARAM', id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', @@ -335,7 +362,7 @@ export class ApiCallService implements OnApplicationShutdown { if (err instanceof ApiError || err instanceof AuthenticationError) { throw err; } else { - const errId = uuid(); + const errId = randomUUID(); this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { ep: ep.name, ps: data, diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 4ad0197d8..8b0fff80d 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -40,15 +40,15 @@ export class AuthenticateService implements OnApplicationShutdown { if (token == null) { return [null, null]; } - + if (isNativeToken(token)) { const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); - + if (user == null) { throw new AuthenticationError('user not found'); } - + return [user, null]; } else { const accessToken = await this.accessTokensRepository.findOne({ @@ -58,24 +58,24 @@ export class AuthenticateService implements OnApplicationShutdown { token: token, // miauth }], }); - + if (accessToken == null) { throw new AuthenticationError('invalid signature'); } - + this.accessTokensRepository.update(accessToken.id, { lastUsedAt: new Date(), }); - + const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId, () => this.usersRepository.findOneBy({ id: accessToken.userId, }) as Promise); - + if (accessToken.appId) { const app = await this.appCache.fetch(accessToken.appId, () => this.appsRepository.findOneByOrFail({ id: accessToken.appId! })); - + return [user, { id: accessToken.id, permission: app.permission, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index d1ff3fe92..4e6bc46e6 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; -import * as ep___invite from './endpoints/invite.js'; +import * as ep___admin_invite_create from './endpoints/admin/invite/create.js'; +import * as ep___admin_invite_list from './endpoints/admin/invite/list.js'; import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; @@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; +import * as ep___invite_create from './endpoints/invite/create.js'; +import * as ep___invite_delete from './endpoints/invite/delete.js'; +import * as ep___invite_list from './endpoints/invite/list.js'; +import * as ep___invite_limit from './endpoints/invite/limit.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; import * as ep___emoji from './endpoints/emoji.js'; @@ -378,7 +383,8 @@ const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federati const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default }; const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default }; const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default }; -const $invite: Provider = { provide: 'ep:invite', useClass: ep___invite.default }; +const $admin_invite_create: Provider = { provide: 'ep:admin/invite/create', useClass: ep___admin_invite_create.default }; +const $admin_invite_list: Provider = { provide: 'ep:admin/invite/list', useClass: ep___admin_invite_list.default }; const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default }; const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; @@ -570,6 +576,10 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default }; const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; +const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default }; +const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default }; +const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default }; +const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default }; const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; @@ -722,7 +732,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_getIndexStats, $admin_getTableStats, $admin_getUserIps, - $invite, + $admin_invite_create, + $admin_invite_list, $admin_promo_create, $admin_queue_clear, $admin_queue_deliverDelayed, @@ -914,6 +925,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_show, $i_webhooks_update, $i_webhooks_delete, + $invite_create, + $invite_delete, + $invite_list, + $invite_limit, $meta, $emojis, $emoji, @@ -1060,7 +1075,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_getIndexStats, $admin_getTableStats, $admin_getUserIps, - $invite, + $admin_invite_create, + $admin_invite_list, $admin_promo_create, $admin_queue_clear, $admin_queue_deliverDelayed, @@ -1252,6 +1268,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_show, $i_webhooks_update, $i_webhooks_delete, + $invite_create, + $invite_delete, + $invite_list, + $invite_limit, $meta, $emojis, $emoji, diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index fe2db1d66..f6ffbfab5 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -38,14 +38,14 @@ export class RateLimiterService { max: 1, db: this.redisClient, }); - + minIntervalLimiter.get((err, info) => { if (err) { return reject('ERR'); } - + this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); - + if (info.remaining === 0) { reject('BRIEF_REQUEST_INTERVAL'); } else { @@ -57,7 +57,7 @@ export class RateLimiterService { } }); }; - + // Long term limit const max = (): void => { const limiter = new Limiter({ @@ -66,14 +66,14 @@ export class RateLimiterService { max: limitation.max! / factor, db: this.redisClient, }); - + limiter.get((err, info) => { if (err) { return reject('ERR'); } - + this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); - + if (info.remaining === 0) { reject('RATE_LIMIT_EXCEEDED'); } else { @@ -81,13 +81,13 @@ export class RateLimiterService { } }); }; - + const hasShortTermLimit = typeof limitation.minInterval === 'number'; - + const hasLongTermLimit = typeof limitation.duration === 'number' && typeof limitation.max === 'number'; - + if (hasShortTermLimit) { min(); } else if (hasLongTermLimit) { diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index aaf1d10b4..96666f1f4 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -36,7 +36,7 @@ export class SigninService { headers: request.headers as any, success: true, }).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0])); - + // Publish signin event this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); }); diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index b2bd7d82e..7b215cea7 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -1,9 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, RegistrationTicket } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; @@ -14,6 +13,7 @@ import { EmailService } from '@/core/EmailService.js'; import { LocalUser } from '@/models/entities/User.js'; import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { bindThis } from '@/decorators.js'; +import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { SigninService } from './SigninService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @@ -67,7 +67,7 @@ export class SignupApiService { const body = request.body; const instance = await this.metaService.fetch(true); - + // Verify *Captcha // ただしテスト時はこの機構は障害となるため無効にする if (process.env.NODE_ENV !== 'test') { @@ -76,7 +76,7 @@ export class SignupApiService { throw new FastifyReplyError(400, err); }); } - + if (instance.enableRecaptcha && instance.recaptchaSecretKey) { await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { throw new FastifyReplyError(400, err); @@ -89,51 +89,61 @@ export class SignupApiService { }); } } - + const username = body['username']; const password = body['password']; const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null; const invitationCode = body['invitationCode']; const emailAddress = body['emailAddress']; - + if (instance.emailRequiredForSignup) { if (emailAddress == null || typeof emailAddress !== 'string') { reply.code(400); return; } - + const res = await this.emailService.validateEmailForAccount(emailAddress); if (!res.available) { reply.code(400); return; } } - + + let ticket: RegistrationTicket | null = null; + if (instance.disableRegistration) { if (invitationCode == null || typeof invitationCode !== 'string') { reply.code(400); return; } - - const ticket = await this.registrationTicketsRepository.findOneBy({ + + ticket = await this.registrationTicketsRepository.findOneBy({ code: invitationCode, }); - + if (ticket == null) { reply.code(400); return; } - - this.registrationTicketsRepository.delete(ticket.id); + + if (ticket.expiresAt && ticket.expiresAt < new Date()) { + reply.code(400); + return; + } + + if (ticket.usedAt) { + reply.code(400); + return; + } } - + if (instance.emailRequiredForSignup) { - if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); } // Check deleted username duplication - if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { + if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) { throw new FastifyReplyError(400, 'USED_USERNAME'); } @@ -142,20 +152,20 @@ export class SignupApiService { throw new FastifyReplyError(400, 'DENIED_USERNAME'); } - const code = rndstr('a-z0-9', 16); + const code = secureRndstr(16, { chars: L_CHARS }); // Generate hash of password const salt = await bcrypt.genSalt(8); const hash = await bcrypt.hash(password, salt); - await this.userPendingsRepository.insert({ + const pendingUser = await this.userPendingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), code, email: emailAddress!, username: username, password: hash, - }); + }).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0])); const link = `${this.config.url}/signup-complete/${code}`; @@ -163,6 +173,13 @@ export class SignupApiService { `To complete signup, please click this link:
${link}`, `To complete signup, please click this link: ${link}`); + if (ticket) { + await this.registrationTicketsRepository.update(ticket.id, { + usedAt: new Date(), + pendingUserId: pendingUser.id, + }); + } + reply.code(204); return; } else { @@ -170,12 +187,20 @@ export class SignupApiService { const { account, secret } = await this.signupService.signup({ username, password, host, }); - + const res = await this.userEntityService.pack(account, account, { detail: true, includeSecrets: true, }); - + + if (ticket) { + await this.registrationTicketsRepository.update(ticket.id, { + usedAt: new Date(), + usedBy: account, + usedById: account.id, + }); + } + return { ...res, token: secret, @@ -212,6 +237,15 @@ export class SignupApiService { emailVerifyCode: null, }); + const ticket = await this.registrationTicketsRepository.findOneBy({ pendingUserId: pendingUser.id }); + if (ticket) { + await this.registrationTicketsRepository.update(ticket.id, { + usedBy: account, + usedById: account.id, + pendingUserId: null, + }); + } + return this.signinService.signin(request, reply, account as LocalUser); } catch (err) { throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index d1394d6d7..e4291becf 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -10,7 +10,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; -import { LocalUser } from '@/models/entities/User'; +import { LocalUser } from '@/models/entities/User.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/index.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -58,11 +58,21 @@ export class StreamingApiServerService { let user: LocalUser | null = null; let app: AccessToken | null = null; + // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 + // Note that the standard WHATWG WebSocket API does not support setting any headers, + // but non-browser apps may still be able to set it. + const token = request.headers.authorization?.startsWith('Bearer ') + ? request.headers.authorization.slice(7) + : q.get('i'); + try { - [user, app] = await this.authenticateService.authenticate(q.get('i')); + [user, app] = await this.authenticateService.authenticate(token); } catch (e) { if (e instanceof AuthenticationError) { - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.write([ + 'HTTP/1.1 401 Unauthorized', + 'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"', + ].join('\r\n') + '\r\n\r\n'); } else { socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); } @@ -93,6 +103,13 @@ export class StreamingApiServerService { }); }); + const globalEv = new EventEmitter(); + + this.redisForSub.on('message', (_: string, data: string) => { + const parsed = JSON.parse(data); + globalEv.emit('message', parsed); + }); + this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage, ctx: { stream: MainStreamConnection, user: LocalUser | null; @@ -102,12 +119,11 @@ export class StreamingApiServerService { const ev = new EventEmitter(); - async function onRedisMessage(_: string, data: string): Promise { - const parsed = JSON.parse(data); - ev.emit(parsed.channel, parsed.message); + function onRedisMessage(data: any): void { + ev.emit(data.channel, data.message); } - this.redisForSub.on('message', onRedisMessage); + globalEv.on('message', onRedisMessage); await stream.listen(ev, connection); @@ -127,7 +143,7 @@ export class StreamingApiServerService { connection.once('close', () => { ev.removeAllListeners(); stream.dispose(); - this.redisForSub.off('message', onRedisMessage); + globalEv.off('message', onRedisMessage); this.#connections.delete(connection); if (userUpdateIntervalId) clearInterval(userUpdateIntervalId); }); diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index 1555a3ca4..364fa7a19 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -1,11 +1,13 @@ import * as fs from 'node:fs'; -import Ajv from 'ajv'; +import _Ajv from 'ajv'; import type { Schema, SchemaType } from '@/misc/json-schema.js'; import type { LocalUser } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import { ApiError } from './error.js'; import type { IEndpointMeta } from './endpoints.js'; +const Ajv = _Ajv.default; + const ajv = new Ajv({ useDefaults: true, }); @@ -32,23 +34,23 @@ export abstract class Endpoint { this.exec = (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => { let cleanup: undefined | (() => void) = undefined; - + if (meta.requireFile) { cleanup = () => { if (file) fs.unlink(file.path, () => {}); }; - + if (file == null) return Promise.reject(new ApiError({ message: 'File required.', code: 'FILE_REQUIRED', id: '4267801e-70d1-416a-b011-4ee502885d8b', })); } - + const valid = validate(params); if (!valid) { if (file) cleanup!(); - + const errors = validate.errors!; const err = new ApiError({ message: 'Invalid param.', @@ -60,7 +62,7 @@ export abstract class Endpoint { }); return Promise.reject(err); } - + return cb(params as SchemaType, user, token, file, cleanup, ip, headers); }; } diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 94206ef87..41c3a29ee 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; -import * as ep___invite from './endpoints/invite.js'; +import * as ep___admin_invite_create from './endpoints/admin/invite/create.js'; +import * as ep___admin_invite_list from './endpoints/admin/invite/list.js'; import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; @@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; +import * as ep___invite_create from './endpoints/invite/create.js'; +import * as ep___invite_delete from './endpoints/invite/delete.js'; +import * as ep___invite_list from './endpoints/invite/list.js'; +import * as ep___invite_limit from './endpoints/invite/limit.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; import * as ep___emoji from './endpoints/emoji.js'; @@ -376,7 +381,8 @@ const eps = [ ['admin/get-index-stats', ep___admin_getIndexStats], ['admin/get-table-stats', ep___admin_getTableStats], ['admin/get-user-ips', ep___admin_getUserIps], - ['invite', ep___invite], + ['admin/invite/create', ep___admin_invite_create], + ['admin/invite/list', ep___admin_invite_list], ['admin/promo/create', ep___admin_promo_create], ['admin/queue/clear', ep___admin_queue_clear], ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], @@ -568,6 +574,10 @@ const eps = [ ['i/webhooks/show', ep___i_webhooks_show], ['i/webhooks/update', ep___i_webhooks_update], ['i/webhooks/delete', ep___i_webhooks_delete], + ['invite/create', ep___invite_create], + ['invite/delete', ep___invite_delete], + ['invite/list', ep___invite_list], + ['invite/limit', ep___invite_limit], ['meta', ep___meta], ['emojis', ep___emojis], ['emoji', ep___emoji], diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts index 9bba16166..b8ea74b7c 100644 --- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -115,7 +115,7 @@ export default class extends Endpoint { case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; } - const reports = await query.take(ps.limit).getMany(); + const reports = await query.limit(ps.limit).getMany(); return await this.abuseUserReportEntityService.packMany(reports); }); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts index 917242db3..757030839 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -22,8 +22,9 @@ export const paramDef = { expiresAt: { type: 'integer' }, startsAt: { type: 'integer' }, imageUrl: { type: 'string', minLength: 1 }, + dayOfWeek: { type: 'integer' }, }, - required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl'], + required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl', 'dayOfWeek'], } as const; // eslint-disable-next-line import/no-default-export @@ -41,6 +42,7 @@ export default class extends Endpoint { createdAt: new Date(), expiresAt: new Date(ps.expiresAt), startsAt: new Date(ps.startsAt), + dayOfWeek: ps.dayOfWeek, url: ps.url, imageUrl: ps.imageUrl, priority: ps.priority, diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index 0b6d00605..725ddb58b 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -32,7 +32,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId); - const ads = await query.take(ps.limit).getMany(); + const ads = await query.limit(ps.limit).getMany(); return ads; }); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts index dbab7e9d4..70082290b 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -31,8 +31,9 @@ export const paramDef = { ratio: { type: 'integer' }, expiresAt: { type: 'integer' }, startsAt: { type: 'integer' }, + dayOfWeek: { type: 'integer' }, }, - required: ['id', 'memo', 'url', 'imageUrl', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt'], + required: ['id', 'memo', 'url', 'imageUrl', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'dayOfWeek'], } as const; // eslint-disable-next-line import/no-default-export @@ -56,6 +57,7 @@ export default class extends Endpoint { imageUrl: ps.imageUrl, expiresAt: new Date(ps.expiresAt), startsAt: new Date(ps.startsAt), + dayOfWeek: ps.dayOfWeek, }); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 9b2049412..11231f6e0 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -80,7 +80,7 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - const announcements = await query.take(ps.limit).getMany(); + const announcements = await query.limit(ps.limit).getMany(); const reads = new Map(); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index 12db1f78f..8cf9341a7 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { title: ps.title, text: ps.text, /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ - imageUrl: ps.imageUrl || null, + imageUrl: ps.imageUrl || null, }); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts index 8a4498d5f..2901fdb77 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -76,7 +76,7 @@ export default class extends Endpoint { } } - const files = await query.take(ps.limit).getMany(); + const files = await query.limit(ps.limit).getMany(); return await this.driveFileEntityService.packMany(files, { detail: true, withUser: true, self: true }); }); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 509224e9c..200ede0b0 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -1,10 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; -import rndstr from 'rndstr'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -56,6 +56,7 @@ export default class extends Endpoint { private customEmojiService: CustomEmojiService, + private emojiEntityService: EmojiEntityService, private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { @@ -78,9 +79,7 @@ export default class extends Endpoint { emojiId: emoji.id, }); - return { - id: emoji.id, - }; + return this.emojiEntityService.packDetailed(emoji); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index df3c28def..8d50413e9 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -98,7 +98,7 @@ export default class extends Endpoint { const emojis = await q .orderBy('emoji.id', 'DESC') - .take(ps.limit) + .limit(ps.limit) .getMany(); return this.emojiEntityService.packDetailedMany(emojis); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 4aa4ad82b..29b20fab8 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -84,14 +84,14 @@ export default class extends Endpoint { if (ps.query) { //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); - //const emojis = await q.take(ps.limit).getMany(); + //const emojis = await q.limit(ps.limit).getMany(); emojis = await q.getMany(); const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g); if (queryarry) { - emojis = emojis.filter(emoji => - queryarry.includes(`:${emoji.name}:`) + emojis = emojis.filter(emoji => + queryarry.includes(`:${emoji.name}:`), ); } else { emojis = emojis.filter(emoji => @@ -101,7 +101,7 @@ export default class extends Endpoint { } emojis.splice(ps.limit + 1); } else { - emojis = await q.take(ps.limit).getMany(); + emojis = await q.limit(ps.limit).getMany(); } return this.emojiEntityService.packDetailedMany(emojis); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index fb22bdc47..edc1af5a5 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -70,7 +70,7 @@ export default class extends Endpoint { driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); } - + await this.customEmojiService.update(ps.id, { driveFile, name: ps.name, diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts new file mode 100644 index 000000000..664b4d819 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/invite/create.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RegistrationTicketsRepository } from '@/models/index.js'; +import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { generateInviteCode } from '@/misc/generate-invite-code.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + errors: { + invalidDateTime: { + message: 'Invalid date-time format', + code: 'INVALID_DATE_TIME', + id: 'f1380b15-3760-4c6c-a1db-5c3aaf1cbd49', + }, + }, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + code: { + type: 'string', + optional: false, nullable: false, + example: 'GR6S02ERUA5VR', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + count: { type: 'integer', minimum: 1, maximum: 100, default: 1 }, + expiresAt: { type: 'string', nullable: true }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registrationTicketsRepository) + private registrationTicketsRepository: RegistrationTicketsRepository, + + private inviteCodeEntityService: InviteCodeEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.expiresAt && isNaN(Date.parse(ps.expiresAt))) { + throw new ApiError(meta.errors.invalidDateTime); + } + + const ticketsPromises = []; + + for (let i = 0; i < ps.count; i++) { + ticketsPromises.push(this.registrationTicketsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, + code: generateInviteCode(), + }).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0]))); + } + + const tickets = await Promise.all(ticketsPromises); + return await this.inviteCodeEntityService.packMany(tickets, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/invite/list.ts b/packages/backend/src/server/api/endpoints/admin/invite/list.ts new file mode 100644 index 000000000..5d7a7f632 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/invite/list.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RegistrationTicketsRepository } from '@/models/index.js'; +import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + offset: { type: 'integer', default: 0 }, + type: { type: 'string', enum: ['unused', 'used', 'expired', 'all'], default: 'all' }, + sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+usedAt', '-usedAt'] }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registrationTicketsRepository) + private registrationTicketsRepository: RegistrationTicketsRepository, + + private inviteCodeEntityService: InviteCodeEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.registrationTicketsRepository.createQueryBuilder('ticket') + .leftJoinAndSelect('ticket.createdBy', 'createdBy') + .leftJoinAndSelect('ticket.usedBy', 'usedBy'); + + switch (ps.type) { + case 'unused': query.andWhere('ticket.usedBy IS NULL'); break; + case 'used': query.andWhere('ticket.usedBy IS NOT NULL'); break; + case 'expired': query.andWhere('ticket.expiresAt < :now', { now: new Date() }); break; + } + + switch (ps.sort) { + case '+createdAt': query.orderBy('ticket.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('ticket.createdAt', 'ASC'); break; + case '+usedAt': query.orderBy('ticket.usedAt', 'DESC', 'NULLS LAST'); break; + case '-usedAt': query.orderBy('ticket.usedAt', 'ASC', 'NULLS FIRST'); break; + default: query.orderBy('ticket.id', 'DESC'); break; + } + + query.limit(ps.limit); + query.skip(ps.offset); + + const tickets = await query.getMany(); + + return await this.inviteCodeEntityService.packMany(tickets, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 4cc1b6011..084bdb598 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { MetaService } from '@/core/MetaService.js'; import type { Config } from '@/config.js'; @@ -20,6 +19,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + cacheRemoteSensitiveFiles: { + type: 'boolean', + optional: false, nullable: false, + }, emailRequiredForSignup: { type: 'boolean', optional: false, nullable: false, @@ -262,6 +265,14 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + enableServerMachineStats: { + type: 'boolean', + optional: false, nullable: false, + }, + enableIdenticonGeneration: { + type: 'boolean', + optional: false, nullable: false, + }, policies: { type: 'object', optional: false, nullable: false, @@ -324,6 +335,7 @@ export default class extends Endpoint { enableServiceWorker: instance.enableServiceWorker, translatorAvailable: instance.deeplAuthKey != null, cacheRemoteFiles: instance.cacheRemoteFiles, + cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, pinnedUsers: instance.pinnedUsers, hiddenTags: instance.hiddenTags, blockedHosts: instance.blockedHosts, @@ -364,6 +376,8 @@ export default class extends Endpoint { enableActiveEmailValidation: instance.enableActiveEmailValidation, enableChartsForRemoteUser: instance.enableChartsForRemoteUser, enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, + enableServerMachineStats: instance.enableServerMachineStats, + enableIdenticonGeneration: instance.enableIdenticonGeneration, policies: { ...DEFAULT_POLICIES, ...instance.policies }, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts index bee1ffbae..8401cf51d 100644 --- a/packages/backend/src/server/api/endpoints/admin/promo/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -50,9 +50,9 @@ export default class extends Endpoint { throw e; }); - const exist = await this.promoNotesRepository.findOneBy({ noteId: note.id }); + const exist = await this.promoNotesRepository.exist({ where: { noteId: note.id } }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyPromoted); } diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts index 4e57e6613..8330d6c82 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts @@ -33,15 +33,35 @@ export default class extends Endpoint { delayedQueues = await this.queueService.deliverQueue.getDelayed(); for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { const queue = delayedQueues[queueIndex]; - await queue.promote(); + try { + await queue.promote(); + } catch (e) { + if (e instanceof Error) { + if (e.message.indexOf('not in a delayed state') !== -1) { + throw e; + } + } else { + throw e; + } + } } break; - + case 'inbox': delayedQueues = await this.queueService.inboxQueue.getDelayed(); for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { const queue = delayedQueues[queueIndex]; - await queue.promote(); + try { + await queue.promote(); + } catch (e) { + if (e instanceof Error) { + if (e.message.indexOf('not in a delayed state') !== -1) { + throw e; + } + } else { + throw e; + } + } } break; } diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index d263f99f6..e9c3b0e69 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -1,9 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; -import rndstr from 'rndstr'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; export const meta = { tags: ['admin'], @@ -54,7 +54,7 @@ export default class extends Endpoint { throw new Error('cannot reset password of root'); } - const passwd = rndstr('a-zA-Z0-9', 8); + const passwd = secureRndstr(8); // Generate hash of password const hash = bcrypt.hashSync(passwd); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 467f157a6..1fedab454 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -69,8 +69,8 @@ export default class extends Endpoint { private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps) => { - const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); - if (role == null) { + const roleExist = await this.rolesRepository.exist({ where: { id: ps.roleId } }); + if (!roleExist) { throw new ApiError(meta.errors.noSuchRole); } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts index 35edca546..63650bb2b 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -64,7 +64,7 @@ export default class extends Endpoint { .innerJoinAndSelect('assign.user', 'user'); const assigns = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await Promise.all(assigns.map(async assign => ({ diff --git a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts index 24335a21c..69c95ef19 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -74,7 +74,7 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.moderationLogsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); - const reports = await query.take(ps.limit).getMany(); + const reports = await query.limit(ps.limit).getMany(); return await this.moderationLogEntityService.packMany(reports); }); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index f49d2a096..6f805b6b4 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -61,6 +61,7 @@ export default class extends Endpoint { const signins = await this.signinsRepository.findBy({ userId: user.id }); + const roleAssigns = await this.roleService.getUserAssigns(user.id); const roles = await this.roleService.getUserRoles(user.id); return { @@ -85,6 +86,11 @@ export default class extends Endpoint { signins, policies: await this.roleService.getUserPolicies(user.id), roles: await this.roleEntityService.packMany(roles, me), + roleAssigns: roleAssigns.map(a => ({ + createdAt: a.createdAt.toISOString(), + expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null, + roleId: a.roleId, + })), }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 426973f28..0a150d1df 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -104,7 +104,7 @@ export default class extends Endpoint { default: query.orderBy('user.id', 'ASC'); break; } - query.take(ps.limit); + query.limit(ps.limit); query.skip(ps.offset); const users = await query.getMany(); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 1de5e9efd..144360a92 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -43,6 +43,7 @@ export const paramDef = { defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, cacheRemoteFiles: { type: 'boolean' }, + cacheRemoteSensitiveFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, enableHcaptcha: { type: 'boolean' }, hcaptchaSiteKey: { type: 'string', nullable: true }, @@ -96,6 +97,8 @@ export const paramDef = { enableActiveEmailValidation: { type: 'boolean' }, enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' }, + enableServerMachineStats: { type: 'boolean' }, + enableIdenticonGeneration: { type: 'boolean' }, serverRules: { type: 'array', items: { type: 'string' } }, preservedUsernames: { type: 'array', items: { type: 'string' } }, }, @@ -134,7 +137,7 @@ export default class extends Endpoint { if (Array.isArray(ps.sensitiveWords)) { set.sensitiveWords = ps.sensitiveWords.filter(Boolean); } - + if (ps.themeColor !== undefined) { set.themeColor = ps.themeColor; } @@ -191,6 +194,10 @@ export default class extends Endpoint { set.cacheRemoteFiles = ps.cacheRemoteFiles; } + if (ps.cacheRemoteSensitiveFiles !== undefined) { + set.cacheRemoteSensitiveFiles = ps.cacheRemoteSensitiveFiles; + } + if (ps.emailRequiredForSignup !== undefined) { set.emailRequiredForSignup = ps.emailRequiredForSignup; } @@ -399,6 +406,14 @@ export default class extends Endpoint { set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; } + if (ps.enableServerMachineStats !== undefined) { + set.enableServerMachineStats = ps.enableServerMachineStats; + } + + if (ps.enableIdenticonGeneration !== undefined) { + set.enableIdenticonGeneration = ps.enableIdenticonGeneration; + } + if (ps.serverRules !== undefined) { set.serverRules = ps.serverRules; } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 79788be4e..735af51ee 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -79,7 +79,7 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); - const announcements = await query.take(ps.limit).getMany(); + const announcements = await query.limit(ps.limit).getMany(); if (me) { const reads = (await this.announcementReadsRepository.findBy({ diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index e756a9b51..2c4247cb7 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -76,6 +76,11 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchAntenna); } + this.antennasRepository.update(antenna.id, { + isActive: true, + lastUsedAt: new Date(), + }); + const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const noteIdsRes = await this.redisClient.xrevrange( `antennaTimeline:${antenna.id}`, @@ -112,11 +117,6 @@ export default class extends Endpoint { this.noteReadService.read(me.id, notes); } - this.antennasRepository.update(antenna.id, { - isActive: true, - lastUsedAt: new Date(), - }); - return await this.noteEntityService.packMany(notes, me); }); } diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 5f980bdbe..55218b644 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -112,6 +112,8 @@ export default class extends Endpoint { withReplies: ps.withReplies, withFile: ps.withFile, notify: ps.notify, + isActive: true, + lastUsedAt: new Date(), }); this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id })); diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts index c1d0a9dd7..aaef02d03 100644 --- a/packages/backend/src/server/api/endpoints/app/create.ts +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -44,7 +44,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { // Generate secret - const secret = secureRndstr(32, true); + const secret = secureRndstr(32); // for backward compatibility const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1'))); diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts index 05842460c..aa199ab73 100644 --- a/packages/backend/src/server/api/endpoints/auth/accept.ts +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -55,15 +55,17 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchSession); } - const accessToken = secureRndstr(32, true); + const accessToken = secureRndstr(32); // Fetch exist access token - const exist = await this.accessTokensRepository.findOneBy({ - appId: session.appId, - userId: me.id, + const exist = await this.accessTokensRepository.exist({ + where: { + appId: session.appId, + userId: me.id, + }, }); - if (exist == null) { + if (!exist) { const app = await this.appsRepository.findOneByOrFail({ id: session.appId }); // Generate Hash diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts index 6108d8202..631fb4f02 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/generate.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -1,4 +1,4 @@ -import { v4 as uuid } from 'uuid'; +import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { AppsRepository, AuthSessionsRepository } from '@/models/index.js'; @@ -71,7 +71,7 @@ export default class extends Endpoint { } // Generate token - const token = uuid(); + const token = randomUUID(); // Create session token document const doc = await this.authSessionsRepository.insert({ diff --git a/packages/backend/src/server/api/endpoints/blocking/create.ts b/packages/backend/src/server/api/endpoints/blocking/create.ts index d9ba99f20..4ad40c8f1 100644 --- a/packages/backend/src/server/api/endpoints/blocking/create.ts +++ b/packages/backend/src/server/api/endpoints/blocking/create.ts @@ -84,12 +84,14 @@ export default class extends Endpoint { }); // Check if already blocking - const exist = await this.blockingsRepository.findOneBy({ - blockerId: blocker.id, - blockeeId: blockee.id, + const exist = await this.blockingsRepository.exist({ + where: { + blockerId: blocker.id, + blockeeId: blockee.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyBlocking); } diff --git a/packages/backend/src/server/api/endpoints/blocking/delete.ts b/packages/backend/src/server/api/endpoints/blocking/delete.ts index 46dd26a45..ad3d9f22b 100644 --- a/packages/backend/src/server/api/endpoints/blocking/delete.ts +++ b/packages/backend/src/server/api/endpoints/blocking/delete.ts @@ -5,8 +5,8 @@ import type { UsersRepository, BlockingsRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['account'], @@ -84,12 +84,14 @@ export default class extends Endpoint { }); // Check not blocking - const exist = await this.blockingsRepository.findOneBy({ - blockerId: blocker.id, - blockeeId: blockee.id, + const exist = await this.blockingsRepository.exist({ + where: { + blockerId: blocker.id, + blockeeId: blockee.id, + }, }); - if (exist == null) { + if (!exist) { throw new ApiError(meta.errors.notBlocking); } diff --git a/packages/backend/src/server/api/endpoints/blocking/list.ts b/packages/backend/src/server/api/endpoints/blocking/list.ts index 969aae06f..d61bb0d21 100644 --- a/packages/backend/src/server/api/endpoints/blocking/list.ts +++ b/packages/backend/src/server/api/endpoints/blocking/list.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere('blocking.blockerId = :meId', { meId: me.id }); const blockings = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.blockingEntityService.packMany(blockings, me); diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts index 1a8d1164c..953f027aa 100644 --- a/packages/backend/src/server/api/endpoints/channels/featured.ts +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -41,7 +41,7 @@ export default class extends Endpoint { .andWhere('channel.isArchived = FALSE') .orderBy('channel.lastNotedAt', 'DESC'); - const channels = await query.take(10).getMany(); + const channels = await query.limit(10).getMany(); return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); }); diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts index f49f3105d..a1656903a 100644 --- a/packages/backend/src/server/api/endpoints/channels/followed.ts +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere({ followerId: me.id }); const followings = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await Promise.all(followings.map(x => this.channelEntityService.pack(x.followeeId, me))); diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts index 8fae972cb..4561bb2e9 100644 --- a/packages/backend/src/server/api/endpoints/channels/owned.ts +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -49,7 +49,7 @@ export default class extends Endpoint { .andWhere({ userId: me.id }); const channels = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts index a3b40b0bb..dfb693796 100644 --- a/packages/backend/src/server/api/endpoints/channels/search.ts +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { } const channels = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index c881074ba..e3119cc40 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -77,7 +77,7 @@ export default class extends Endpoint { const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 let noteIdsRes: [string, string[]][] = []; - + if (!ps.sinceId && !ps.sinceDate) { noteIdsRes = await this.redisClient.xrevrange( `channelTimeline:${channel.id}`, @@ -105,7 +105,7 @@ export default class extends Endpoint { } //#endregion - timeline = await query.take(ps.limit).getMany(); + timeline = await query.limit(ps.limit).getMany(); } else { const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index c3561e2a7..2837f2cf8 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -87,12 +87,14 @@ export default class extends Endpoint { throw e; }); - const exist = await this.clipNotesRepository.findOneBy({ - noteId: note.id, - clipId: clip.id, + const exist = await this.clipNotesRepository.exist({ + where: { + noteId: note.id, + clipId: clip.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyClipped); } diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts index f08caaf8d..ce0985553 100644 --- a/packages/backend/src/server/api/endpoints/clips/favorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -58,12 +58,14 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchClip); } - const exist = await this.clipFavoritesRepository.findOneBy({ - clipId: clip.id, - userId: me.id, + const exist = await this.clipFavoritesRepository.exist({ + where: { + clipId: clip.id, + userId: me.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyFavorited); } diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index dcb415b75..49607babe 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -88,7 +88,7 @@ export default class extends Endpoint { } const notes = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.noteEntityService.packMany(notes, me); diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts index 50c5d758b..d0ef79581 100644 --- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -2,8 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { ClipNotesRepository, ClipsRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['account', 'notes', 'clips'], diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index 460930777..f4343248b 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -73,7 +73,7 @@ export default class extends Endpoint { case '-size': query.orderBy('file.size', 'ASC'); break; } - const files = await query.take(ps.limit).getMany(); + const files = await query.limit(ps.limit).getMany(); return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); }); diff --git a/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts index 290cd4d2c..cdcdde7e8 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts @@ -34,12 +34,14 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, ) { super(meta, paramDef, async (ps, me) => { - const file = await this.driveFilesRepository.findOneBy({ - md5: ps.md5, - userId: me.id, + const exist = await this.driveFilesRepository.exist({ + where: { + md5: ps.md5, + userId: me.id, + }, }); - return file != null; + return exist; }); } } diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index 3ecbba22b..c43f812e2 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -40,7 +40,7 @@ export const meta = { code: 'NO_SUCH_FOLDER', id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73', }, - + restrictedByRole: { message: 'This feature is restricted by your role.', code: 'RESTRICTED_BY_ROLE', diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts index b41eaf446..eb674f3e1 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -54,7 +54,7 @@ export default class extends Endpoint { query.andWhere('folder.parentId IS NULL'); } - const folders = await query.take(ps.limit).getMany(); + const folders = await query.limit(ps.limit).getMany(); return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder))); }); diff --git a/packages/backend/src/server/api/endpoints/drive/stream.ts b/packages/backend/src/server/api/endpoints/drive/stream.ts index 61bcfea0c..a1c14a8e3 100644 --- a/packages/backend/src/server/api/endpoints/drive/stream.ts +++ b/packages/backend/src/server/api/endpoints/drive/stream.ts @@ -56,7 +56,7 @@ export default class extends Endpoint { } } - const files = await query.take(ps.limit).getMany(); + const files = await query.limit(ps.limit).getMany(); return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); }); diff --git a/packages/backend/src/server/api/endpoints/emoji.ts b/packages/backend/src/server/api/endpoints/emoji.ts index 681d3e649..51027f35c 100644 --- a/packages/backend/src/server/api/endpoints/emoji.ts +++ b/packages/backend/src/server/api/endpoints/emoji.ts @@ -36,7 +36,7 @@ export default class extends Endpoint { constructor( @Inject(DI.config) private config: Config, - + @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 13cc709d3..3c2d0ce4a 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -43,7 +43,7 @@ export default class extends Endpoint { constructor( @Inject(DI.config) private config: Config, - + @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts index be1d6c8e5..1b2f9446f 100644 --- a/packages/backend/src/server/api/endpoints/federation/followers.ts +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { .andWhere('following.followeeHost = :host', { host: ps.host }); const followings = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts index 74656ce86..c5aa1ec60 100644 --- a/packages/backend/src/server/api/endpoints/federation/following.ts +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { .andWhere('following.followerHost = :host', { host: ps.host }); const followings = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 061c6eb5b..ddf1a178b 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -126,7 +126,7 @@ export default class extends Endpoint { query.andWhere('instance.host like :host', { host: '%' + sqlLikeEscape(ps.host.toLowerCase()) + '%' }); } - const instances = await query.take(ps.limit).skip(ps.offset).getMany(); + const instances = await query.limit(ps.limit).skip(ps.offset).getMany(); return await this.instanceEntityService.packMany(instances); }); diff --git a/packages/backend/src/server/api/endpoints/federation/users.ts b/packages/backend/src/server/api/endpoints/federation/users.ts index a028930f2..06f252005 100644 --- a/packages/backend/src/server/api/endpoints/federation/users.ts +++ b/packages/backend/src/server/api/endpoints/federation/users.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { .andWhere('user.host = :host', { host: ps.host }); const users = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.userEntityService.packMany(users, me, { detail: true }); diff --git a/packages/backend/src/server/api/endpoints/flash/featured.ts b/packages/backend/src/server/api/endpoints/flash/featured.ts index 570aef96d..99c8763b1 100644 --- a/packages/backend/src/server/api/endpoints/flash/featured.ts +++ b/packages/backend/src/server/api/endpoints/flash/featured.ts @@ -40,7 +40,7 @@ export default class extends Endpoint { .andWhere('flash.likedCount > 0') .orderBy('flash.likedCount', 'DESC'); - const flashs = await query.take(10).getMany(); + const flashs = await query.limit(10).getMany(); return await this.flashEntityService.packMany(flashs, me); }); diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts index 23de2f397..57245f9f4 100644 --- a/packages/backend/src/server/api/endpoints/flash/like.ts +++ b/packages/backend/src/server/api/endpoints/flash/like.ts @@ -66,12 +66,14 @@ export default class extends Endpoint { } // if already liked - const exist = await this.flashLikesRepository.findOneBy({ - flashId: flash.id, - userId: me.id, + const exist = await this.flashLikesRepository.exist({ + where: { + flashId: flash.id, + userId: me.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyLiked); } diff --git a/packages/backend/src/server/api/endpoints/flash/my-likes.ts b/packages/backend/src/server/api/endpoints/flash/my-likes.ts index f7716ea74..7d1149ada 100644 --- a/packages/backend/src/server/api/endpoints/flash/my-likes.ts +++ b/packages/backend/src/server/api/endpoints/flash/my-likes.ts @@ -59,7 +59,7 @@ export default class extends Endpoint { .leftJoinAndSelect('like.flash', 'flash'); const likes = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return this.flashLikeEntityService.packMany(likes, me); diff --git a/packages/backend/src/server/api/endpoints/flash/my.ts b/packages/backend/src/server/api/endpoints/flash/my.ts index baed7f000..45a3b50e0 100644 --- a/packages/backend/src/server/api/endpoints/flash/my.ts +++ b/packages/backend/src/server/api/endpoints/flash/my.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere('flash.userId = :meId', { meId: me.id }); const flashs = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.flashEntityService.packMany(flashs); diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts index 4ad16de91..009fc96f6 100644 --- a/packages/backend/src/server/api/endpoints/following/create.ts +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -99,12 +99,14 @@ export default class extends Endpoint { }); // Check if already following - const exist = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, + const exist = await this.followingsRepository.exist({ + where: { + followerId: follower.id, + followeeId: followee.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyFollowing); } diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts index 4f12db127..77ef26316 100644 --- a/packages/backend/src/server/api/endpoints/following/delete.ts +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -5,8 +5,8 @@ import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['following', 'users'], @@ -84,12 +84,14 @@ export default class extends Endpoint { }); // Check not following - const exist = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, + const exist = await this.followingsRepository.exist({ + where: { + followerId: follower.id, + followeeId: followee.id, + }, }); - if (exist == null) { + if (!exist) { throw new ApiError(meta.errors.notFollowing); } diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts index 22304cacd..0e57f6328 100644 --- a/packages/backend/src/server/api/endpoints/following/invalidate.ts +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -5,8 +5,8 @@ import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['following', 'users'], diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts index d68248fab..29588e873 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/list.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -64,7 +64,7 @@ export default class extends Endpoint { .andWhere('request.followeeId = :meId', { meId: me.id }); const requests = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await Promise.all(requests.map(req => this.followRequestEntityService.pack(req))); diff --git a/packages/backend/src/server/api/endpoints/gallery/featured.ts b/packages/backend/src/server/api/endpoints/gallery/featured.ts index 9994ce90d..46347247f 100644 --- a/packages/backend/src/server/api/endpoints/gallery/featured.ts +++ b/packages/backend/src/server/api/endpoints/gallery/featured.ts @@ -41,7 +41,7 @@ export default class extends Endpoint { .andWhere('post.likedCount > 0') .orderBy('post.likedCount', 'DESC'); - const posts = await query.take(10).getMany(); + const posts = await query.limit(10).getMany(); return await this.galleryPostEntityService.packMany(posts, me); }); diff --git a/packages/backend/src/server/api/endpoints/gallery/popular.ts b/packages/backend/src/server/api/endpoints/gallery/popular.ts index 55d3dabfb..4ee3d68a9 100644 --- a/packages/backend/src/server/api/endpoints/gallery/popular.ts +++ b/packages/backend/src/server/api/endpoints/gallery/popular.ts @@ -40,7 +40,7 @@ export default class extends Endpoint { .andWhere('post.likedCount > 0') .orderBy('post.likedCount', 'DESC'); - const posts = await query.take(10).getMany(); + const posts = await query.limit(10).getMany(); return await this.galleryPostEntityService.packMany(posts, me); }); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts.ts b/packages/backend/src/server/api/endpoints/gallery/posts.ts index e94003eb7..b9aac3fb3 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts.ts @@ -43,7 +43,7 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) .innerJoinAndSelect('post.user', 'user'); - const posts = await query.take(ps.limit).getMany(); + const posts = await query.limit(ps.limit).getMany(); return await this.galleryPostEntityService.packMany(posts, me); }); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index 6ac5fa860..c0bb55f64 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -66,12 +66,14 @@ export default class extends Endpoint { } // if already liked - const exist = await this.galleryLikesRepository.findOneBy({ - postId: post.id, - userId: me.id, + const exist = await this.galleryLikesRepository.exist({ + where: { + postId: post.id, + userId: me.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyLiked); } diff --git a/packages/backend/src/server/api/endpoints/get-online-users-count.ts b/packages/backend/src/server/api/endpoints/get-online-users-count.ts index dea0f4799..810bde03e 100644 --- a/packages/backend/src/server/api/endpoints/get-online-users-count.ts +++ b/packages/backend/src/server/api/endpoints/get-online-users-count.ts @@ -9,6 +9,8 @@ export const meta = { tags: ['meta'], requireCredential: false, + allowGet: true, + cacheSec: 60 * 1, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/hashtags/list.ts b/packages/backend/src/server/api/endpoints/hashtags/list.ts index 226a11de0..693d938bf 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/list.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/list.ts @@ -73,7 +73,7 @@ export default class extends Endpoint { 'tag.attachedRemoteUsersCount', ]); - const tags = await query.take(ps.limit).getMany(); + const tags = await query.limit(ps.limit).getMany(); return this.hashtagEntityService.packMany(tags); }); diff --git a/packages/backend/src/server/api/endpoints/hashtags/search.ts b/packages/backend/src/server/api/endpoints/hashtags/search.ts index 4f5f97976..e2e00def7 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/search.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/search.ts @@ -41,7 +41,7 @@ export default class extends Endpoint { .where('tag.name like :q', { q: sqlLikeEscape(ps.query.toLowerCase()) + '%' }) .orderBy('tag.count', 'DESC') .groupBy('tag.id') - .take(ps.limit) + .limit(ps.limit) .skip(ps.offset) .getMany(); diff --git a/packages/backend/src/server/api/endpoints/hashtags/trend.ts b/packages/backend/src/server/api/endpoints/hashtags/trend.ts index cf45cc6c2..ce1cd9f01 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/trend.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/trend.ts @@ -26,6 +26,8 @@ export const meta = { tags: ['hashtags'], requireCredential: false, + allowGet: true, + cacheSec: 60 * 1, res: { type: 'array', diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index dd3549020..b00b005ad 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -39,7 +39,7 @@ export default class extends Endpoint { constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, - + private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { @@ -68,7 +68,7 @@ export default class extends Endpoint { case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; } - const users = await query.take(ps.limit).getMany(); + const users = await query.limit(ps.limit).getMany(); return await this.userEntityService.packMany(users, me, { detail: true }); }); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index a3e3e02a1..4d593542d 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -23,7 +23,7 @@ export const meta = { id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a', kind: 'permission', }, - } + }, } as const; export const paramDef = { @@ -68,7 +68,7 @@ export default class extends Endpoint { }); userProfile.loggedInDates = [...userProfile.loggedInDates, today]; } - + return await this.userEntityService.pack(userProfile.user!, userProfile.user!, { detail: true, includeSecrets: isSecure, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index e8985a9cd..a7e39fc02 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -103,7 +103,7 @@ export default class extends Endpoint { const procedures = this.twoFactorAuthenticationService.getProcedures(); if (!(procedures as any)[attestation.fmt]) { - throw new Error('unsupported fmt'); + throw new Error(`unsupported fmt: ${attestation.fmt}. Supported ones: ${Object.keys(procedures)}`); } const verificationData = (procedures as any)[attestation.fmt].verify({ diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts index d98f60fa5..2ef5e5a27 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { if (key.userId !== me.id) { throw new ApiError(meta.errors.accessDenied); } - + await this.userSecurityKeysRepository.update(key.id, { name: ps.name, }); diff --git a/packages/backend/src/server/api/endpoints/i/favorites.ts b/packages/backend/src/server/api/endpoints/i/favorites.ts index ce8ab4962..bdfb63974 100644 --- a/packages/backend/src/server/api/endpoints/i/favorites.ts +++ b/packages/backend/src/server/api/endpoints/i/favorites.ts @@ -49,7 +49,7 @@ export default class extends Endpoint { .leftJoinAndSelect('favorite.note', 'note'); const favorites = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.noteFavoriteEntityService.packMany(favorites, me); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts index d1b04cb65..915639e5f 100644 --- a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts +++ b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts @@ -60,7 +60,7 @@ export default class extends Endpoint { .leftJoinAndSelect('like.post', 'post'); const likes = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.galleryLikeEntityService.packMany(likes, me); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts index 32d14293f..5ba9afd4a 100644 --- a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere('post.userId = :meId', { meId: me.id }); const posts = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.galleryPostEntityService.packMany(posts, me); diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index efb5ce422..8582e98f7 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -54,7 +54,7 @@ export default class extends Endpoint { constructor ( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - + @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, @@ -66,8 +66,8 @@ export default class extends Endpoint { private downloadService: DownloadService, ) { super(meta, paramDef, async (ps, me) => { - const users = await this.usersRepository.findOneBy({ id: me.id }); - if (users === null) throw new ApiError(meta.errors.noSuchUser); + const userExist = await this.usersRepository.exist({ where: { id: me.id } }); + if (!userExist) throw new ApiError(meta.errors.noSuchUser); const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file === null) throw new ApiError(meta.errors.noSuchFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); @@ -79,6 +79,6 @@ export default class extends Endpoint { this.queueService.createImportAntennasJob(me, antennas); }); } -} +} export type Antenna = (_Antenna & { userListAccts: string[] | null })[]; diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 811971591..32c16300f 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -72,7 +72,7 @@ export default class extends Endpoint { const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), - true + true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index 8af278c88..1926a1f50 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -71,7 +71,7 @@ export default class extends Endpoint { const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), - true + true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index eb0f9ba47..34f262756 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -72,7 +72,7 @@ export default class extends Endpoint { const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), - true + true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index 4568e9390..1b3cb5359 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -71,7 +71,7 @@ export default class extends Endpoint { const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), - true + true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/page-likes.ts b/packages/backend/src/server/api/endpoints/i/page-likes.ts index 70e6e0a6a..9f073ba59 100644 --- a/packages/backend/src/server/api/endpoints/i/page-likes.ts +++ b/packages/backend/src/server/api/endpoints/i/page-likes.ts @@ -59,7 +59,7 @@ export default class extends Endpoint { .leftJoinAndSelect('like.page', 'page'); const likes = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return this.pageLikeEntityService.packMany(likes, me); diff --git a/packages/backend/src/server/api/endpoints/i/pages.ts b/packages/backend/src/server/api/endpoints/i/pages.ts index 285aa34e9..772486bef 100644 --- a/packages/backend/src/server/api/endpoints/i/pages.ts +++ b/packages/backend/src/server/api/endpoints/i/pages.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere('page.userId = :meId', { meId: me.id }); const pages = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.pageEntityService.packMany(pages); diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts index b8922b91e..352fe54c5 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -47,19 +47,21 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { // Check if announcement exists - const announcement = await this.announcementsRepository.findOneBy({ id: ps.announcementId }); + const announcementExist = await this.announcementsRepository.exist({ where: { id: ps.announcementId } }); - if (announcement == null) { + if (!announcementExist) { throw new ApiError(meta.errors.noSuchAnnouncement); } // Check if already read - const read = await this.announcementReadsRepository.findOneBy({ - announcementId: ps.announcementId, - userId: me.id, + const alreadyRead = await this.announcementReadsRepository.exist({ + where: { + announcementId: ps.announcementId, + userId: me.id, + }, }); - if (read != null) { + if (alreadyRead) { return; } diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index 93daeb0cd..415a60147 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -28,9 +28,9 @@ export default class extends Endpoint { private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { - const token = await this.accessTokensRepository.findOneBy({ id: ps.tokenId }); + const tokenExist = await this.accessTokensRepository.exist({ where: { id: ps.tokenId } }); - if (token) { + if (tokenExist) { await this.accessTokensRepository.delete({ id: ps.tokenId, userId: me.id, diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts index 9b30a2433..aa8cb5cf4 100644 --- a/packages/backend/src/server/api/endpoints/i/signin-history.ts +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -35,7 +35,7 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.signinsRepository.createQueryBuilder('signin'), ps.sinceId, ps.untilId) .andWhere('signin.userId = :meId', { meId: me.id }); - const history = await query.take(ps.limit).getMany(); + const history = await query.limit(ps.limit).getMany(); return await Promise.all(history.map(record => this.signinEntityService.pack(record))); }); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index 4f543a647..58e056bd3 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import rndstr from 'rndstr'; import ms from 'ms'; import bcrypt from 'bcryptjs'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -9,6 +8,7 @@ import { EmailService } from '@/core/EmailService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -94,7 +94,7 @@ export default class extends Endpoint { this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); if (ps.email != null) { - const code = rndstr('a-z0-9', 16); + const code = secureRndstr(16, { chars: L_CHARS }); await this.userProfilesRepository.update(me.id, { emailVerifyCode: code, diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts new file mode 100644 index 000000000..a64184be1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/invite/create.ts @@ -0,0 +1,82 @@ +import { MoreThan } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RegistrationTicketsRepository } from '@/models/index.js'; +import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DI } from '@/di-symbols.js'; +import { generateInviteCode } from '@/misc/generate-invite-code.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: true, + requireRolePolicy: 'canInvite', + + errors: { + exceededCreateLimit: { + message: 'You have exceeded the limit for creating an invitation code.', + code: 'EXCEEDED_LIMIT_OF_CREATE_INVITE_CODE', + id: '8b165dd3-6f37-4557-8db1-73175d63c641', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + code: { + type: 'string', + optional: false, nullable: false, + example: 'GR6S02ERUA5VR', + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registrationTicketsRepository) + private registrationTicketsRepository: RegistrationTicketsRepository, + + private inviteCodeEntityService: InviteCodeEntityService, + private idService: IdService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me.id); + + if (policies.inviteLimit) { + const count = await this.registrationTicketsRepository.countBy({ + createdAt: MoreThan(new Date(Date.now() - (policies.inviteLimitCycle * 1000 * 60))), + createdById: me.id, + }); + + if (count >= policies.inviteLimit) { + throw new ApiError(meta.errors.exceededCreateLimit); + } + } + + const ticket = await this.registrationTicketsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + createdBy: me, + createdById: me.id, + expiresAt: policies.inviteExpirationTime ? new Date(Date.now() + (policies.inviteExpirationTime * 1000 * 60)) : null, + code: generateInviteCode(), + }).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0])); + + return await this.inviteCodeEntityService.pack(ticket, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/invite/delete.ts b/packages/backend/src/server/api/endpoints/invite/delete.ts new file mode 100644 index 000000000..afca44954 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/invite/delete.ts @@ -0,0 +1,71 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RegistrationTicketsRepository } from '@/models/index.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: true, + requireRolePolicy: 'canInvite', + + errors: { + noSuchCode: { + message: 'No such invite code.', + code: 'NO_SUCH_INVITE_CODE', + id: 'cd4f9ae4-7854-4e3e-8df9-c296f051e634', + }, + + cantDelete: { + message: 'You can\'t delete this invite code.', + code: 'CAN_NOT_DELETE_INVITE_CODE', + id: 'ff17af39-000c-4d4e-abdf-848fa30fc1ce', + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '5eb8d909-2540-4970-90b8-dd6f86088121', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + inviteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['inviteId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registrationTicketsRepository) + private registrationTicketsRepository: RegistrationTicketsRepository, + + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const ticket = await this.registrationTicketsRepository.findOneBy({ id: ps.inviteId }); + const isModerator = await this.roleService.isModerator(me); + + if (ticket == null) { + throw new ApiError(meta.errors.noSuchCode); + } + + if (ticket.createdById !== me.id && !isModerator) { + throw new ApiError(meta.errors.accessDenied); + } + + if (ticket.usedAt && !isModerator) { + throw new ApiError(meta.errors.cantDelete); + } + + await this.registrationTicketsRepository.delete(ticket.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/invite.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts similarity index 60% rename from packages/backend/src/server/api/endpoints/invite.ts rename to packages/backend/src/server/api/endpoints/invite/limit.ts index 5d2c479e7..9a213b7b2 100644 --- a/packages/backend/src/server/api/endpoints/invite.ts +++ b/packages/backend/src/server/api/endpoints/invite/limit.ts @@ -1,8 +1,8 @@ -import rndstr from 'rndstr'; import { Inject, Injectable } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { RegistrationTicketsRepository } from '@/models/index.js'; -import { IdService } from '@/core/IdService.js'; +import { RoleService } from '@/core/RoleService.js'; import { DI } from '@/di-symbols.js'; export const meta = { @@ -15,12 +15,9 @@ export const meta = { type: 'object', optional: false, nullable: false, properties: { - code: { - type: 'string', - optional: false, nullable: false, - example: '2ERUA5VR', - maxLength: 8, - minLength: 8, + remaining: { + type: 'integer', + optional: false, nullable: true, }, }, }, @@ -39,22 +36,18 @@ export default class extends Endpoint { @Inject(DI.registrationTicketsRepository) private registrationTicketsRepository: RegistrationTicketsRepository, - private idService: IdService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const code = rndstr({ - length: 8, - chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) - }); + const policies = await this.roleService.getUserPolicies(me.id); - await this.registrationTicketsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - code, - }); + const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({ + createdAt: MoreThan(new Date(Date.now() - (policies.inviteExpirationTime * 60 * 1000))), + createdById: me.id, + }) : null; return { - code, + remaining: count !== null ? Math.max(0, policies.inviteLimit - count) : null, }; }); } diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts new file mode 100644 index 000000000..e04779026 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/invite/list.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RegistrationTicketsRepository } from '@/models/index.js'; +import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: true, + requireRolePolicy: 'canInvite', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.registrationTicketsRepository) + private registrationTicketsRepository: RegistrationTicketsRepository, + + private inviteCodeEntityService: InviteCodeEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.registrationTicketsRepository.createQueryBuilder('ticket'), ps.sinceId, ps.untilId) + .andWhere('ticket.createdById = :meId', { meId: me.id }) + .leftJoinAndSelect('ticket.createdBy', 'createdBy') + .leftJoinAndSelect('ticket.usedBy', 'usedBy'); + + const tickets = await query + .limit(ps.limit) + .getMany(); + + return await this.inviteCodeEntityService.packMany(tickets, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 3b3c5caa0..adfa57955 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -1,4 +1,4 @@ -import { IsNull, LessThanOrEqual, MoreThan } from 'typeorm'; +import { IsNull, LessThanOrEqual, MoreThan, Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import JSON5 from 'json5'; import type { AdsRepository, UsersRepository } from '@/models/index.js'; @@ -83,6 +83,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + cacheRemoteSensitiveFiles: { + type: 'boolean', + optional: false, nullable: false, + }, emailRequiredForSignup: { type: 'boolean', optional: false, nullable: false, @@ -250,7 +254,7 @@ export default class extends Endpoint { constructor( @Inject(DI.config) private config: Config, - + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -263,12 +267,15 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const instance = await this.metaService.fetch(true); - const ads = await this.adsRepository.find({ - where: { - expiresAt: MoreThan(new Date()), - startsAt: LessThanOrEqual(new Date()), - }, - }); + const ads = await this.adsRepository.createQueryBuilder('ads') + .where('ads.expiresAt > :now', { now: new Date() }) + .andWhere('ads.startsAt <= :now', { now: new Date() }) + .andWhere(new Brackets(qb => { + // 曜日のビットフラグを確認する + qb.where('ads.dayOfWeek & :dayOfWeek > 0', { dayOfWeek: 1 << new Date().getDay() }) + .orWhere('ads.dayOfWeek = 0'); + })) + .getMany(); const response: any = { maintainerName: instance.maintainerName, @@ -311,6 +318,7 @@ export default class extends Endpoint { place: ad.place, ratio: ad.ratio, imageUrl: ad.imageUrl, + dayOfWeek: ad.dayOfWeek, })), enableEmail: instance.enableEmail, enableServiceWorker: instance.enableServiceWorker, @@ -325,6 +333,7 @@ export default class extends Endpoint { ...(ps.detail ? { cacheRemoteFiles: instance.cacheRemoteFiles, + cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, requireSetup: (await this.usersRepository.countBy({ host: IsNull(), })) === 0, diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts index 97def8626..0ea29f04d 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -49,7 +49,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { // Generate access token - const accessToken = secureRndstr(32, true); + const accessToken = secureRndstr(32); const now = new Date(); diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index ee358d5c6..ef53f9ef4 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -79,12 +79,14 @@ export default class extends Endpoint { }); // Check if already muting - const exist = await this.mutingsRepository.findOneBy({ - muterId: muter.id, - muteeId: mutee.id, + const exist = await this.mutingsRepository.exist({ + where: { + muterId: muter.id, + muteeId: mutee.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyMuting); } diff --git a/packages/backend/src/server/api/endpoints/mute/list.ts b/packages/backend/src/server/api/endpoints/mute/list.ts index 9ec6d1727..4711e86d6 100644 --- a/packages/backend/src/server/api/endpoints/mute/list.ts +++ b/packages/backend/src/server/api/endpoints/mute/list.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere('muting.muterId = :meId', { meId: me.id }); const mutings = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.mutingEntityService.packMany(mutings, me); diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index 5fbc7aba5..9013b300e 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -53,34 +53,34 @@ export default class extends Endpoint { .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - + if (ps.local) { query.andWhere('note.userHost IS NULL'); } - + if (ps.reply !== undefined) { query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL'); } - + if (ps.renote !== undefined) { query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); } - + if (ps.withFiles !== undefined) { query.andWhere(ps.withFiles ? 'note.fileIds != \'{}\'' : 'note.fileIds = \'{}\''); } - + if (ps.poll !== undefined) { query.andWhere(ps.poll ? 'note.hasPoll = TRUE' : 'note.hasPoll = FALSE'); } - + // TODO //if (bot != undefined) { // query.isBot = bot; //} - - const notes = await query.take(ps.limit).getMany(); - + + const notes = await query.limit(ps.limit).getMany(); + return await this.noteEntityService.packMany(notes); }); } diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index 26f2d6772..5f03fd4b7 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -68,7 +68,7 @@ export default class extends Endpoint { this.queryService.generateBlockedUserQuery(query, me); } - const notes = await query.take(ps.limit).getMany(); + const notes = await query.limit(ps.limit).getMany(); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts index 5ecf7cf45..10f43b04c 100644 --- a/packages/backend/src/server/api/endpoints/notes/conversation.ts +++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts @@ -4,8 +4,8 @@ import type { NotesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 96be5ed84..739316997 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -217,11 +217,13 @@ export default class extends Endpoint { // Check blocking if (renote.userId !== me.id) { - const block = await this.blockingsRepository.findOneBy({ - blockerId: renote.userId, - blockeeId: me.id, + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: renote.userId, + blockeeId: me.id, + }, }); - if (block) { + if (blockExist) { throw new ApiError(meta.errors.youHaveBeenBlocked); } } @@ -240,11 +242,13 @@ export default class extends Endpoint { // Check blocking if (reply.userId !== me.id) { - const block = await this.blockingsRepository.findOneBy({ - blockerId: reply.userId, - blockeeId: me.id, + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: reply.userId, + blockeeId: me.id, + }, }); - if (block) { + if (blockExist) { throw new ApiError(meta.errors.youHaveBeenBlocked); } } diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts index 611ea1956..9299d6603 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -63,12 +63,14 @@ export default class extends Endpoint { }); // if already favorited - const exist = await this.noteFavoritesRepository.findOneBy({ - noteId: note.id, - userId: me.id, + const exist = await this.noteFavoritesRepository.exist({ + where: { + noteId: note.id, + userId: me.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyFavorited); } diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index bdb06498b..3a3cb0739 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -65,7 +65,7 @@ export default class extends Endpoint { let notes = await query .orderBy('note.score', 'DESC') - .take(100) + .limit(100) .getMany(); notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 88c1ca7f5..4ce2fdaec 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -88,7 +88,7 @@ export default class extends Endpoint { } //#endregion - const timeline = await query.take(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); process.nextTick(() => { if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 7a3581e6e..af94cf608 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -137,7 +137,7 @@ export default class extends Endpoint { } //#endregion - const timeline = await query.take(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); process.nextTick(() => { this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 2ee549232..fe7407f48 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -110,7 +110,7 @@ export default class extends Endpoint { } //#endregion - const timeline = await query.take(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); process.nextTick(() => { if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 4e9f604d8..6ee9de1e2 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -79,7 +79,7 @@ export default class extends Endpoint { query.setParameters(followingQuery.getParameters()); } - const mentions = await query.take(ps.limit).getMany(); + const mentions = await query.limit(ps.limit).getMany(); this.noteReadService.read(me.id, mentions); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts index 6cdc9b902..0b4ccdcf2 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -82,7 +82,7 @@ export default class extends Endpoint { const polls = await query .orderBy('poll.noteId', 'DESC') - .take(ps.limit) + .limit(ps.limit) .skip(ps.offset) .getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index d40685566..4ee12b335 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -71,7 +71,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); - const renotes = await query.take(ps.limit).getMany(); + const renotes = await query.limit(ps.limit).getMany(); return await this.noteEntityService.packMany(renotes, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index f2af71d55..900c40d32 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -55,7 +55,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); - const timeline = await query.take(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); return await this.noteEntityService.packMany(timeline, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 742df0ca9..dc0a5dcee 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -130,7 +130,7 @@ export default class extends Endpoint { } // Search notes - const notes = await query.take(ps.limit).getMany(); + const notes = await query.limit(ps.limit).getMany(); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index f6385400c..cd0e351e4 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -58,7 +58,7 @@ export default class extends Endpoint { constructor( @Inject(DI.config) private config: Config, - + private noteEntityService: NoteEntityService, private searchService: SearchService, private roleService: RoleService, @@ -68,7 +68,7 @@ export default class extends Endpoint { if (!policies.canSearchNotes) { throw new ApiError(meta.errors.unavailable); } - + const notes = await this.searchService.searchNote(ps.query, me, { userId: ps.userId, channelId: ps.channelId, diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index 6b1b84a18..2aec7d64d 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -3,8 +3,8 @@ import type { NotesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index e1f286439..7e9bf85d8 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -123,7 +123,7 @@ export default class extends Endpoint { } //#endregion - const timeline = await query.take(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); process.nextTick(() => { this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 66655234a..b91bc7b5e 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -44,7 +44,7 @@ export default class extends Endpoint { constructor( @Inject(DI.config) private config: Config, - + @Inject(DI.notesRepository) private notesRepository: NotesRepository, diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts index 74e459b42..e9581beed 100644 --- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -4,8 +4,8 @@ import type { UsersRepository, NotesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index afc9bc421..4c19e1a55 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -127,7 +127,7 @@ export default class extends Endpoint { } //#endregion - const timeline = await query.take(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/pages/featured.ts b/packages/backend/src/server/api/endpoints/pages/featured.ts index 31844165e..b1c056124 100644 --- a/packages/backend/src/server/api/endpoints/pages/featured.ts +++ b/packages/backend/src/server/api/endpoints/pages/featured.ts @@ -41,7 +41,7 @@ export default class extends Endpoint { .andWhere('page.likedCount > 0') .orderBy('page.likedCount', 'DESC'); - const pages = await query.take(10).getMany(); + const pages = await query.limit(10).getMany(); return await this.pageEntityService.packMany(pages, me); }); diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts index 543c126d9..bc6648810 100644 --- a/packages/backend/src/server/api/endpoints/pages/like.ts +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -66,12 +66,14 @@ export default class extends Endpoint { } // if already liked - const exist = await this.pageLikesRepository.findOneBy({ - pageId: page.id, - userId: me.id, + const exist = await this.pageLikesRepository.exist({ + where: { + pageId: page.id, + userId: me.id, + }, }); - if (exist != null) { + if (exist) { throw new ApiError(meta.errors.alreadyLiked); } diff --git a/packages/backend/src/server/api/endpoints/promo/read.ts b/packages/backend/src/server/api/endpoints/promo/read.ts index 90febdbce..a76866fe1 100644 --- a/packages/backend/src/server/api/endpoints/promo/read.ts +++ b/packages/backend/src/server/api/endpoints/promo/read.ts @@ -3,8 +3,8 @@ import type { PromoReadsRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -44,12 +44,14 @@ export default class extends Endpoint { throw err; }); - const exist = await this.promoReadsRepository.findOneBy({ - noteId: note.id, - userId: me.id, + const exist = await this.promoReadsRepository.exist({ + where: { + noteId: note.id, + userId: me.id, + }, }); - if (exist != null) { + if (exist) { return; } diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts index b2d7addb6..cb4e1feba 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/list.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere('muting.muterId = :meId', { meId: me.id }); const mutings = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.renoteMutingEntityService.packMany(mutings, me); diff --git a/packages/backend/src/server/api/endpoints/request-reset-password.ts b/packages/backend/src/server/api/endpoints/request-reset-password.ts index 3b6ebfe28..284ed8410 100644 --- a/packages/backend/src/server/api/endpoints/request-reset-password.ts +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -1,4 +1,3 @@ -import rndstr from 'rndstr'; import ms from 'ms'; import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; @@ -8,6 +7,7 @@ import { IdService } from '@/core/IdService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { EmailService } from '@/core/EmailService.js'; +import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; export const meta = { tags: ['reset password'], @@ -41,7 +41,7 @@ export default class extends Endpoint { constructor( @Inject(DI.config) private config: Config, - + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -77,7 +77,7 @@ export default class extends Endpoint { return; } - const token = rndstr('a-z0-9', 64); + const token = secureRndstr(64, { chars: L_CHARS }); await this.passwordResetRequestsRepository.insert({ id: this.idService.genId(), diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 42e36cb04..a30c31b72 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -71,7 +71,7 @@ export default class extends Endpoint { if (role == null) { throw new ApiError(meta.errors.noSuchRole); } - if (!role.isExplorable) { + if (!role.isExplorable) { return []; } const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts index b2cb8b42a..cc2720188 100644 --- a/packages/backend/src/server/api/endpoints/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -65,7 +65,7 @@ export default class extends Endpoint { .innerJoinAndSelect('assign.user', 'user'); const assigns = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await Promise.all(assigns.map(async assign => ({ diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index 1620e8ae5..552441e43 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -2,9 +2,12 @@ import * as os from 'node:os'; import si from 'systeminformation'; import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MetaService } from '@/core/MetaService.js'; export const meta = { requireCredential: false, + allowGet: true, + cacheSec: 60 * 1, tags: ['meta'], } as const; @@ -19,8 +22,24 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + private metaService: MetaService, ) { super(meta, paramDef, async () => { + if (!(await this.metaService.fetch()).enableServerMachineStats) return { + machine: '?', + cpu: { + model: '?', + cores: 0, + }, + mem: { + total: 0, + }, + fs: { + total: 0, + used: 0, + }, + }; + const memStats = await si.mem(); const fsStats = await si.fsSize(); diff --git a/packages/backend/src/server/api/endpoints/sw/update-registration.ts b/packages/backend/src/server/api/endpoints/sw/update-registration.ts index 9f08c8148..b82c4bf49 100644 --- a/packages/backend/src/server/api/endpoints/sw/update-registration.ts +++ b/packages/backend/src/server/api/endpoints/sw/update-registration.ts @@ -35,7 +35,7 @@ export const meta = { code: 'NO_SUCH_REGISTRATION', id: ' b09d8066-8064-5613-efb6-0e963b21d012', }, - } + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index 28cd9f6ce..2582932e3 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -80,7 +80,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQueryForUsers(query, me); if (me) this.queryService.generateBlockQueryForUsers(query, me); - query.take(ps.limit); + query.limit(ps.limit); query.skip(ps.offset); const users = await query.getMany(); diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts index c5aa93baa..c2ad420cb 100644 --- a/packages/backend/src/server/api/endpoints/users/clips.ts +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere('clip.isPublic = true'); const clips = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.clipEntityService.packMany(clips, me); diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index 97f1310c3..18d66500a 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -97,11 +97,13 @@ export default class extends Endpoint { if (me == null) { throw new ApiError(meta.errors.forbidden); } else if (me.id !== user.id) { - const following = await this.followingsRepository.findOneBy({ - followeeId: user.id, - followerId: me.id, + const isFollowing = await this.followingsRepository.exist({ + where: { + followeeId: user.id, + followerId: me.id, + }, }); - if (following == null) { + if (!isFollowing) { throw new ApiError(meta.errors.forbidden); } } @@ -112,7 +114,7 @@ export default class extends Endpoint { .innerJoinAndSelect('following.follower', 'follower'); const followings = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.followingEntityService.packMany(followings, me, { populateFollower: true }); diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index d406594a2..6ea7b923d 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -97,11 +97,13 @@ export default class extends Endpoint { if (me == null) { throw new ApiError(meta.errors.forbidden); } else if (me.id !== user.id) { - const following = await this.followingsRepository.findOneBy({ - followeeId: user.id, - followerId: me.id, + const isFollowing = await this.followingsRepository.exist({ + where: { + followeeId: user.id, + followerId: me.id, + }, }); - if (following == null) { + if (!isFollowing) { throw new ApiError(meta.errors.forbidden); } } @@ -112,7 +114,7 @@ export default class extends Endpoint { .innerJoinAndSelect('following.followee', 'followee'); const followings = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.followingEntityService.packMany(followings, me, { populateFollowee: true }); diff --git a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts index 6e57eee5f..3ee01953d 100644 --- a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { .andWhere('post.userId = :userId', { userId: ps.userId }); const posts = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.galleryPostEntityService.packMany(posts, me); diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts index 09f6acde9..b4c1e2ec8 100644 --- a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -5,8 +5,8 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['users'], diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index 8591e4ab9..beb0ba85f 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -84,18 +84,20 @@ export default class extends Endpoint { private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const list = await this.userListsRepository.findOneBy({ - id: ps.listId, - isPublic: true, + const listExist = await this.userListsRepository.exist({ + where: { + id: ps.listId, + isPublic: true, + }, }); - if (list === null) throw new ApiError(meta.errors.noSuchList); + if (!listExist) throw new ApiError(meta.errors.noSuchList); const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } - + const userList = await this.userListsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), @@ -114,20 +116,24 @@ export default class extends Endpoint { }); if (currentUser.id !== me.id) { - const block = await this.blockingsRepository.findOneBy({ - blockerId: currentUser.id, - blockeeId: me.id, + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: currentUser.id, + blockeeId: me.id, + }, }); - if (block) { + if (blockExist) { throw new ApiError(meta.errors.youHaveBeenBlocked); } } - const exist = await this.userListJoiningsRepository.findOneBy({ - userListId: userList.id, - userId: currentUser.id, + const exist = await this.userListJoiningsRepository.exist({ + where: { + userListId: userList.id, + userId: currentUser.id, + }, }); - + if (exist) { throw new ApiError(meta.errors.alreadyAdded); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts index 263852fde..2c09a47fe 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts @@ -41,21 +41,25 @@ export default class extends Endpoint { private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - isPublic: true, + const userListExist = await this.userListsRepository.exist({ + where: { + id: ps.listId, + isPublic: true, + }, }); - if (userList === null) { + if (!userListExist) { throw new ApiError(meta.errors.noSuchList); } - const exist = await this.userListFavoritesRepository.findOneBy({ - userId: me.id, - userListId: ps.listId, + const exist = await this.userListFavoritesRepository.exist({ + where: { + userId: me.id, + userListId: ps.listId, + }, }); - if (exist !== null) { + if (exist) { throw new ApiError(meta.errors.alreadyFavorited); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 925037e48..6e1f6b2c6 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -100,18 +100,22 @@ export default class extends Endpoint { // Check blocking if (user.id !== me.id) { - const block = await this.blockingsRepository.findOneBy({ - blockerId: user.id, - blockeeId: me.id, + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: user.id, + blockeeId: me.id, + }, }); - if (block) { + if (blockExist) { throw new ApiError(meta.errors.youHaveBeenBlocked); } } - const exist = await this.userListJoiningsRepository.findOneBy({ - userListId: userList.id, - userId: user.id, + const exist = await this.userListJoiningsRepository.exist({ + where: { + userListId: userList.id, + userId: user.id, + }, }); if (exist) { diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts index 8077841c8..3fd418d04 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/show.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -69,10 +69,12 @@ export default class extends Endpoint { userListId: ps.listId, }); if (me !== null) { - additionalProperties.isLiked = (await this.userListFavoritesRepository.findOneBy({ - userId: me.id, - userListId: ps.listId, - }) !== null); + additionalProperties.isLiked = await this.userListFavoritesRepository.exist({ + where: { + userId: me.id, + userListId: ps.listId, + }, + }); } else { additionalProperties.isLiked = false; } diff --git a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts index be8e31781..a7c3b5894 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts @@ -39,12 +39,14 @@ export default class extends Endpoint { private userListFavoritesRepository: UserListFavoritesRepository, ) { super(meta, paramDef, async (ps, me) => { - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - isPublic: true, + const userListExist = await this.userListsRepository.exist({ + where: { + id: ps.listId, + isPublic: true, + }, }); - if (userList === null) { + if (!userListExist) { throw new ApiError(meta.errors.noSuchList); } diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index aaf94734a..f42f84e6a 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -120,7 +120,7 @@ export default class extends Endpoint { //#endregion - const timeline = await query.take(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); return await this.noteEntityService.packMany(timeline, me); }); diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts index a105103f1..e9d13ba00 100644 --- a/packages/backend/src/server/api/endpoints/users/pages.ts +++ b/packages/backend/src/server/api/endpoints/users/pages.ts @@ -48,7 +48,7 @@ export default class extends Endpoint { .andWhere('page.visibility = \'public\''); const pages = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await this.pageEntityService.packMany(pages); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index ac401a60e..37fc854c3 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -73,7 +73,7 @@ export default class extends Endpoint { this.queryService.generateVisibilityQuery(query, me); const reactions = await query - .take(ps.limit) + .limit(ps.limit) .getMany(); return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me, { withNote: true }))); diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 5498b8c85..d39657059 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -44,7 +44,7 @@ export default class extends Endpoint { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - + private userEntityService: UserEntityService, private queryService: QueryService, ) { @@ -70,7 +70,7 @@ export default class extends Endpoint { query.setParameters(followingQuery.getParameters()); - const users = await query.take(ps.limit).skip(ps.offset).getMany(); + const users = await query.limit(ps.limit).skip(ps.offset).getMany(); return await this.userEntityService.packMany(users, me, { detail: true }); }); diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index d19d4007d..be361e02c 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -1,4 +1,4 @@ -import * as sanitizeHtml from 'sanitize-html'; +import sanitizeHtml from 'sanitize-html'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, AbuseUserReportsRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index b001159ee..1d0c7d0c1 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -97,7 +97,7 @@ export default class extends Endpoint { users = await query .orderBy('user.usernameLower', 'ASC') - .take(ps.limit) + .limit(ps.limit) .getMany(); if (users.length < ps.limit) { @@ -110,7 +110,7 @@ export default class extends Endpoint { const otherUsers = await otherQuery .orderBy('user.updatedAt', 'DESC') - .take(ps.limit - users.length) + .limit(ps.limit - users.length) .getMany(); users = users.concat(otherUsers); @@ -122,7 +122,7 @@ export default class extends Endpoint { users = await query .orderBy('user.updatedAt', 'DESC') - .take(ps.limit - users.length) + .limit(ps.limit - users.length) .getMany(); } diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index d7a60f043..1180de361 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -52,6 +52,7 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + ps.query = ps.query.trim(); const isUsername = ps.query.startsWith('@'); let users: User[] = []; @@ -73,12 +74,12 @@ export default class extends Endpoint { users = await usernameQuery .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .take(ps.limit) + .limit(ps.limit) .skip(ps.offset) .getMany(); } else { const nameQuery = this.usersRepository.createQueryBuilder('user') - .where(new Brackets(qb => { + .where(new Brackets(qb => { qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); // Also search username if it qualifies as username @@ -100,7 +101,7 @@ export default class extends Endpoint { users = await nameQuery .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .take(ps.limit) + .limit(ps.limit) .skip(ps.offset) .getMany(); @@ -126,7 +127,7 @@ export default class extends Endpoint { users = users.concat(await query .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .take(ps.limit) + .limit(ps.limit) .skip(ps.offset) .getMany(), ); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index ba432c273..8e25af64f 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -91,6 +91,7 @@ export default class extends Endpoint { let user; const isModerator = await this.roleService.isModerator(me); + ps.username = ps.username?.trim(); if (ps.userIds) { if (ps.userIds.length === 0) { diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index c77ba6602..4a544fadf 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -52,7 +52,7 @@ export class ChannelsService { case 'serverStats': return this.serverStatsChannelService; case 'queueStats': return this.queueStatsChannelService; case 'admin': return this.adminChannelService; - + default: throw new Error(`no such channel: ${name}`); } diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index e67aec9ec..94b92e02e 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -1,5 +1,5 @@ import { bindThis } from '@/decorators.js'; -import type Connection from '.'; +import type Connection from './index.js'; /** * Stream channel diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 0268fdedd..94ebf8641 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -49,7 +49,7 @@ class HashtagChannel extends Channel { if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 1755aa94c..fe0cc37b6 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -26,7 +26,7 @@ class HomeTimelineChannel extends Channel { @bindThis public async init(params: any) { this.withReplies = params.withReplies as boolean; - + this.subscriber.on('notesStream', this.onNote); } diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index ab9c1aa0b..6218fada9 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -3,16 +3,16 @@ import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import Channel from '../channel.js'; import { StreamMessages } from '../types.js'; -import { RoleService } from '@/core/RoleService.js'; class RoleTimelineChannel extends Channel { public readonly chName = 'roleTimeline'; public static shouldShare = false; public static requireCredential = false; private roleId: string; - + constructor( private noteEntityService: NoteEntityService, private roleservice: RoleService, diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 8802fc5ab..ea4cff0bc 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -20,7 +20,7 @@ class UserListChannel extends Channel { private userListsRepository: UserListsRepository, private userListJoiningsRepository: UserListJoiningsRepository, private noteEntityService: NoteEntityService, - + id: string, connection: Channel['connection'], ) { @@ -34,11 +34,13 @@ class UserListChannel extends Channel { this.listId = params.listId as string; // Check existence and owner - const list = await this.userListsRepository.findOneBy({ - id: this.listId, - userId: this.user!.id, + const listExist = await this.userListsRepository.exist({ + where: { + id: this.listId, + userId: this.user!.id, + }, }); - if (!list) return; + if (!listExist) return; // Subscribe stream this.subscriber.on(`userListStream:${this.listId}`, this.send); diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index d9dba682c..f239b0663 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -12,7 +12,7 @@ import type { Page } from '@/models/entities/Page.js'; import type { Packed } from '@/misc/json-schema.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import type { Meta } from '@/models/entities/Meta.js'; -import { Role, RoleAssignment } from '@/models'; +import { Role, RoleAssignment } from '@/models/index.js'; import type Emitter from 'strict-event-emitter-types'; import type { EventEmitter } from 'events'; @@ -233,7 +233,7 @@ export type StreamMessages = { // API event definitions // ストリームごとのEmitterの辞書を用意 -type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter void }> }; +type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter.default void }> }; // 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; // Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 07ba2731c..b5eea0777 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -1,7 +1,7 @@ +import { randomUUID } from 'node:crypto'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { v4 as uuid } from 'uuid'; import { createBullBoard } from '@bull-board/api'; import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { FastifyAdapter } from '@bull-board/fastify'; @@ -676,7 +676,7 @@ export class ClientServerService { }); fastify.setErrorHandler(async (error, request, reply) => { - const errId = uuid(); + const errId = randomUUID(); this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, { path: request.routerPath, params: request.params, diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 0c0e92cc0..0bd0d3c69 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -38,9 +38,9 @@ export class FeedService { link: `${this.config.url}/@${user.username}`, name: user.name ?? user.username, }; - + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - + const notes = await this.notesRepository.find({ where: { userId: user.id, @@ -50,7 +50,7 @@ export class FeedService { order: { createdAt: -1 }, take: 20, }); - + const feed = new Feed({ id: author.link, title: `${author.name} (@${user.username}@${this.config.host})`, @@ -66,13 +66,13 @@ export class FeedService { author, copyright: user.name ?? user.username, }); - + for (const note of notes) { const files = note.fileIds.length > 0 ? await this.driveFilesRepository.findBy({ id: In(note.fileIds), }) : []; const file = files.find(file => file.type.startsWith('image/')); - + feed.addItem({ title: `New note by ${author.name}`, link: `${this.config.url}/notes/${note.id}`, @@ -82,7 +82,7 @@ export class FeedService { image: file ? this.driveFileEntityService.getPublicUrl(file) ?? undefined : undefined, }); } - + return feed; } } diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/src/server/web/bios.js index c2ce5c381..51899dd3a 100644 --- a/packages/backend/src/server/web/bios.js +++ b/packages/backend/src/server/web/bios.js @@ -8,7 +8,7 @@ window.onload = async () => { const promise = new Promise((resolve, reject) => { // Append a credential if (i) data.i = i; - + // Send request window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { method: 'POST', @@ -17,7 +17,7 @@ window.onload = async () => { cache: 'no-cache' }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); - + if (res.status === 200) { resolve(body); } else if (res.status === 204) { @@ -27,7 +27,7 @@ window.onload = async () => { } }).catch(reject); }); - + return promise; }; diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js index 3467f7ac2..5bb576a27 100644 --- a/packages/backend/src/server/web/cli.js +++ b/packages/backend/src/server/web/cli.js @@ -8,7 +8,7 @@ window.onload = async () => { const promise = new Promise((resolve, reject) => { // Append a credential if (i) data.i = i; - + // Send request fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { headers: { @@ -20,7 +20,7 @@ window.onload = async () => { cache: 'no-cache' }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); - + if (res.status === 200) { resolve(body); } else if (res.status === 204) { @@ -30,7 +30,7 @@ window.onload = async () => { } }).catch(reject); }); - + return promise; }; diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 1216fc73f..2b61c6bc2 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -35,7 +35,7 @@ html link(rel='prefetch' href=infoImageUrl) link(rel='prefetch' href=notFoundImageUrl) //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.21.0') + link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.25.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists @@ -55,8 +55,8 @@ html block meta block og - meta(property='og:title' content= title || 'Misskey') - meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') + meta(property='og:title' content= title || 'Misskey') + meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') meta(property='og:image' content= img) meta(property='twitter:card' content='summary') diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug index b177ae411..44ebf53cf 100644 --- a/packages/backend/src/server/web/views/error.pug +++ b/packages/backend/src/server/web/views/error.pug @@ -32,12 +32,12 @@ body path(stroke="none", d="M0 0h24v24H0z", fill="none") path(d="M12 9v2m0 4v.01") path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75") - + h1 An error has occurred! button.button-big(onclick="location.reload();") span.button-label-big Refresh - + p.dont-worry Don't worry, it's (probably) not your fault. p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID. diff --git a/packages/backend/src/server/web/views/info-card.pug b/packages/backend/src/server/web/views/info-card.pug index 1d62778ce..2a4954ec8 100644 --- a/packages/backend/src/server/web/views/info-card.pug +++ b/packages/backend/src/server/web/views/info-card.pug @@ -47,4 +47,4 @@ html header#banner(style=`background-image: url(${meta.bannerUrl})`) div#title= meta.name || host div#content - div#description= meta.description + div#description!= meta.description diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug index ea0917a80..9bc652b6a 100644 --- a/packages/backend/src/server/web/views/note.pug +++ b/packages/backend/src/server/web/views/note.pug @@ -5,8 +5,8 @@ block vars - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; - const url = `${config.url}/notes/${note.id}`; - const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null; - - const image = (note.files || []).find(file => file.type.startsWith('image/') && !file.isSensitive) - - const video = (note.files || []).find(file => file.type.startsWith('video/') && !file.isSensitive) + - const images = (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive) + - const videos = (note.files || []).filter(file => file.type.startsWith('video/') && !file.isSensitive) block title = `${title} | ${instanceName}` @@ -19,15 +19,17 @@ block og meta(property='og:title' content= title) meta(property='og:description' content= summary) meta(property='og:url' content= url) - if video - meta(property='og:video:url' content= video.url) - meta(property='og:video:secure_url' content= video.url) - meta(property='og:video:type' content= video.type) - // FIXME: add width and height - // FIXME: add embed player for Twitter - if image + if videos.length + each video in videos + meta(property='og:video:url' content= video.url) + meta(property='og:video:secure_url' content= video.url) + meta(property='og:video:type' content= video.type) + // FIXME: add width and height + // FIXME: add embed player for Twitter + if images.length meta(property='twitter:card' content='summary_large_image') - meta(property='og:image' content= image.url) + each image in images + meta(property='og:image' content= image.url) else meta(property='twitter:card' content='summary') meta(property='og:image' content= avatarUrl) @@ -43,7 +45,7 @@ block meta meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) meta(name='misskey:note-id' content=note.id) - + // todo if user.twitter meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 5da997f28..04be97ad9 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -7,10 +7,11 @@ import * as OTPAuth from 'otpauth'; import { loadConfig } from '../../src/config.js'; import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('2要素認証', () => { let app: INestApplicationContext; - let alice: unknown; + let alice: misskey.entities.MeSignup; const config = loadConfig(); const password = 'test'; @@ -68,7 +69,7 @@ describe('2要素認証', () => { ])); // AuthenticatorAssertionResponse.authenticatorData - // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData const credentialIdLength = Buffer.allocUnsafe(2); credentialIdLength.writeUInt16BE(param.credentialId.length); const authData = Buffer.concat([ @@ -80,7 +81,7 @@ describe('2要素認証', () => { param.credentialId, credentialPublicKey, ]); - + return { attestationObject: cbor.encode({ fmt: 'none', @@ -98,7 +99,7 @@ describe('2要素認証', () => { name: param.keyName, }; }; - + const signinParam = (): { username: string, password: string, @@ -130,7 +131,7 @@ describe('2要素認証', () => { 'hcaptcha-response'?: string | null, } => { // AuthenticatorAssertionResponse.authenticatorData - // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData const authenticatorData = Buffer.concat([ rpIdHash(), Buffer.from([0x05]), // flags(1) @@ -146,7 +147,7 @@ describe('2要素認証', () => { .update(clientDataJSONBuffer) .digest(); const privateKey = crypto.createPrivateKey(pemToSign); - const signature = crypto.createSign('SHA256') + const signature = crypto.createSign('SHA256') .update(Buffer.concat([authenticatorData, hashedclientDataJSON])) .sign(privateKey); return { @@ -186,14 +187,14 @@ describe('2要素認証', () => { token: otpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 204); - + const usersShowResponse = await api('/users/show', { username, }, alice); assert.strictEqual(usersShowResponse.status, 200); assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); - - const signinResponse = await api('/signin', { + + const signinResponse = await api('/signin', { ...signinParam(), token: otpToken(registerResponse.body.secret), }); @@ -211,7 +212,7 @@ describe('2要素認証', () => { token: otpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 204); - + const registerKeyResponse = await api('/i/2fa/register-key', { password, }, alice); @@ -230,7 +231,7 @@ describe('2要素認証', () => { assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex')); assert.strictEqual(keyDoneResponse.body.name, keyName); - + const usersShowResponse = await api('/users/show', { username, }); @@ -267,7 +268,7 @@ describe('2要素認証', () => { token: otpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 204); - + const registerKeyResponse = await api('/i/2fa/register-key', { password, }, alice); @@ -282,7 +283,7 @@ describe('2要素認証', () => { credentialId, }), alice); assert.strictEqual(keyDoneResponse.status, 200); - + const passwordLessResponse = await api('/i/2fa/password-less', { value: true, }, alice); @@ -301,7 +302,7 @@ describe('2要素認証', () => { assert.strictEqual(signinResponse.status, 200); assert.strictEqual(signinResponse.body.i, undefined); - const signinResponse2 = await api('/signin', { + const signinResponse2 = await api('/signin', { ...signinWithSecurityKeyParam({ keyName, challengeId: signinResponse.body.challengeId, @@ -324,7 +325,7 @@ describe('2要素認証', () => { token: otpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 204); - + const registerKeyResponse = await api('/i/2fa/register-key', { password, }, alice); @@ -339,14 +340,14 @@ describe('2要素認証', () => { credentialId, }), alice); assert.strictEqual(keyDoneResponse.status, 200); - + const renamedKey = 'other-key'; const updateKeyResponse = await api('/i/2fa/update-key', { name: renamedKey, credentialId: credentialId.toString('hex'), }, alice); assert.strictEqual(updateKeyResponse.status, 200); - + const iResponse = await api('/i', { }, alice); assert.strictEqual(iResponse.status, 200); @@ -366,7 +367,7 @@ describe('2要素認証', () => { token: otpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 204); - + const registerKeyResponse = await api('/i/2fa/register-key', { password, }, alice); @@ -381,7 +382,7 @@ describe('2要素認証', () => { credentialId, }), alice); assert.strictEqual(keyDoneResponse.status, 200); - + // テストの実行順によっては複数残ってるので全部消す const iResponse = await api('/i', { }, alice); @@ -400,14 +401,14 @@ describe('2要素認証', () => { assert.strictEqual(usersShowResponse.status, 200); assert.strictEqual(usersShowResponse.body.securityKeys, false); - const signinResponse = await api('/signin', { + const signinResponse = await api('/signin', { ...signinParam(), token: otpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); assert.notEqual(signinResponse.body.i, undefined); }); - + test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => { const registerResponse = await api('/i/2fa/register', { password, @@ -418,7 +419,7 @@ describe('2要素認証', () => { token: otpToken(registerResponse.body.secret), }, alice); assert.strictEqual(doneResponse.status, 204); - + const usersShowResponse = await api('/users/show', { username, }); diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index dd3b09f85..cb526669f 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -32,7 +32,7 @@ describe('アンテナ', () => { // - srcのenumにgroupが残っている // - userGroupIdが残っている, isActiveがない type Antenna = misskey.entities.Antenna | Packed<'Antenna'>; - type User = misskey.entities.MeDetailed & { token: string }; + type User = misskey.entities.MeSignup; type Note = misskey.entities.Note; // アンテナを作成できる最小のパラメタ diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts index 3af0d3518..f781559d5 100644 --- a/packages/backend/test/e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -3,6 +3,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { signup, api, post, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('API visibility', () => { let app: INestApplicationContext; @@ -18,15 +19,15 @@ describe('API visibility', () => { describe('Note visibility', () => { //#region vars /** ヒロイン */ - let alice: any; + let alice: misskey.entities.MeSignup; /** フォロワー */ - let follower: any; + let follower: misskey.entities.MeSignup; /** 非フォロワー */ - let other: any; + let other: misskey.entities.MeSignup; /** 非フォロワーでもリプライやメンションをされた人 */ - let target: any; + let target: misskey.entities.MeSignup; /** specified mentionでmentionを飛ばされる人 */ - let target2: any; + let target2: misskey.entities.MeSignup; /** public-post */ let pub: any; diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts index a46f336a7..c6beec4f8 100644 --- a/packages/backend/test/e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -1,14 +1,16 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, startServer } from '../utils.js'; +import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; +import { IncomingMessage } from 'http'; describe('API', () => { let app: INestApplicationContext; - let alice: any; - let bob: any; - let carol: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; + let carol: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); @@ -80,4 +82,142 @@ describe('API', () => { assert.strictEqual(res.body.nullableDefault, 'hello'); }); }); + + test('管理者専用のAPIのアクセス制限', async () => { + // aliceは管理者、APIを使える + await successfulApiCall({ + endpoint: '/admin/get-index-stats', + parameters: {}, + user: alice, + }); + + // bobは一般ユーザーだからダメ + await failedApiCall({ + endpoint: '/admin/get-index-stats', + parameters: {}, + user: bob, + }, { + status: 403, + code: 'ROLE_PERMISSION_DENIED', + id: 'c3d38592-54c0-429d-be96-5636b0431a61', + }); + + // publicアクセスももちろんダメ + await failedApiCall({ + endpoint: '/admin/get-index-stats', + parameters: {}, + user: undefined, + }, { + status: 401, + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + }); + + // ごまがしもダメ + await failedApiCall({ + endpoint: '/admin/get-index-stats', + parameters: {}, + user: { token: 'tsukawasete' }, + }, { + status: 401, + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + }); + }); + + describe('Authentication header', () => { + test('一般リクエスト', async () => { + await successfulApiCall({ + endpoint: '/admin/get-index-stats', + parameters: {}, + user: { + token: alice.token, + bearer: true, + }, + }); + }); + + test('multipartリクエスト', async () => { + const result = await uploadFile({ + token: alice.token, + bearer: true, + }); + assert.strictEqual(result.status, 200); + }); + + test('streaming', async () => { + const fired = await waitFire( + { + token: alice.token, + bearer: true, + }, + 'homeTimeline', + () => api('notes/create', { text: 'foo' }, alice), + msg => msg.type === 'note' && msg.body.text === 'foo', + ); + assert.strictEqual(fired, true); + }); + }); + + describe('tokenエラー応答でWWW-Authenticate headerを送る', () => { + describe('invalid_token', () => { + test('一般リクエスト', async () => { + const result = await api('/admin/get-index-stats', {}, { + token: 'syuilo', + bearer: true, + }); + assert.strictEqual(result.status, 401); + assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description')); + }); + + test('multipartリクエスト', async () => { + const result = await uploadFile({ + token: 'syuilo', + bearer: true, + }); + assert.strictEqual(result.status, 401); + assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description')); + }); + + test('streaming', async () => { + await assert.rejects(connectStream( + { + token: 'syuilo', + bearer: true, + }, + 'homeTimeline', + () => { }, + ), (err: IncomingMessage) => { + assert.strictEqual(err.statusCode, 401); + assert.ok(err.headers['www-authenticate']?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description')); + return true; + }); + }); + }); + + describe('tokenがないとrealmだけおくる', () => { + test('一般リクエスト', async () => { + const result = await api('/admin/get-index-stats', {}); + assert.strictEqual(result.status, 401); + assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"'); + }); + + test('multipartリクエスト', async () => { + const result = await uploadFile(); + assert.strictEqual(result.status, 401); + assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"'); + }); + }); + + test('invalid_request', async () => { + const result = await api('/notes/create', { text: true }, { + token: alice.token, + bearer: true, + }); + assert.strictEqual(result.status, 400); + assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description')); + }); + + // TODO: insufficient_scope test (authテストが全然なくて書けない) + }); }); diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index 57a46ab38..835788409 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -3,14 +3,15 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { signup, api, post, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('Block', () => { let app: INestApplicationContext; // alice blocks bob - let alice: any; - let bob: any; - let carol: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; + let carol: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index f35aae9dc..175f2cac9 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -13,12 +13,12 @@ import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unf import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js'; import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js'; import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js'; -import { - signup, - post, - startServer, +import { + signup, + post, + startServer, api, - successfulApiCall, + successfulApiCall, failedApiCall, ApiRequest, hiddenNote, @@ -82,14 +82,14 @@ describe('クリップ', () => { const update = async (parameters: Partial, request: Partial = {}): Promise => { const clip = await successfulApiCall({ endpoint: '/clips/update', - parameters: { + parameters: { name: 'updated', ...parameters, }, user: alice, ...request, }); - + // 入力が結果として入っていること。clipIdはidになるので消しておく delete (parameters as { clipId?: string }).clipId; assert.deepStrictEqual(clip, { @@ -98,7 +98,7 @@ describe('クリップ', () => { }); return clip; }; - + type DeleteParam = JTDDataType; const deleteClip = async (parameters: DeleteParam, request: Partial = {}): Promise => { return await successfulApiCall({ @@ -129,7 +129,7 @@ describe('クリップ', () => { ...request, }); }; - + const usersClips = async (request: Partial): Promise => { return await successfulApiCall({ endpoint: '/users/clips', @@ -145,14 +145,14 @@ describe('クリップ', () => { bob = await signup({ username: 'bob' }); // FIXME: misskey-jsのNoteはoutdatedなので直接変換できない - aliceNote = await post(alice, { text: 'test' }) as any; - aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any; - aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any; - aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any; - bobNote = await post(bob, { text: 'test' }) as any; - bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any; - bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any; - bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any; + aliceNote = await post(alice, { text: 'test' }) as any; + aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any; + aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any; + aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any; + bobNote = await post(bob, { text: 'test' }) as any; + bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any; + bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any; + bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any; }, 1000 * 60 * 2); afterAll(async () => { @@ -172,7 +172,7 @@ describe('クリップ', () => { test('の作成ができる', async () => { const res = await create(); // ISO 8601で日付が返ってくること - assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); assert.strictEqual(res.lastClippedAt, null); assert.strictEqual(res.name, 'test'); assert.strictEqual(res.description, null); @@ -217,7 +217,7 @@ describe('クリップ', () => { ]; test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({ endpoint: '/clips/create', - parameters: { + parameters: { ...defaultCreate(), ...parameters, }, @@ -229,7 +229,7 @@ describe('クリップ', () => { })); test('の更新ができる', async () => { - const res = await update({ + const res = await update({ clipId: (await create()).id, name: 'updated', description: 'new description', @@ -237,7 +237,7 @@ describe('クリップ', () => { }); // ISO 8601で日付が返ってくること - assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); assert.strictEqual(res.lastClippedAt, null); assert.strictEqual(res.name, 'updated'); assert.strictEqual(res.description, 'new description'); @@ -251,7 +251,7 @@ describe('クリップ', () => { name: 'updated', ...parameters, })); - + test.each([ { label: 'clipIdがnull', parameters: { clipId: null } }, { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { @@ -265,7 +265,7 @@ describe('クリップ', () => { ...createClipDenyPattern as any, ])('の更新は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ endpoint: '/clips/update', - parameters: { + parameters: { clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, name: 'updated', ...parameters, @@ -279,7 +279,7 @@ describe('クリップ', () => { })); test('の削除ができる', async () => { - await deleteClip({ + await deleteClip({ clipId: (await create()).id, }); assert.deepStrictEqual(await list({}), []); @@ -297,7 +297,7 @@ describe('クリップ', () => { } }, ])('の削除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ endpoint: '/clips/delete', - parameters: { + parameters: { clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, ...parameters, }, @@ -329,14 +329,14 @@ describe('クリップ', () => { }); test.each([ - { label: 'clipId未指定', parameters: { clipId: undefined } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { code: 'NO_SUCH_CLIP', id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', } }, ])('のID指定取得は$labelならできない', async ({ parameters, assetion }) => failedApiCall({ endpoint: '/clips/show', - parameters: { + parameters: { ...parameters, }, user: alice, @@ -361,14 +361,14 @@ describe('クリップ', () => { // 返ってくる配列には順序保障がないのでidでソートして厳密比較 assert.deepStrictEqual( - res.sort(compareBy(s => s.id)), + res.sort(compareBy(s => s.id)), clips.sort(compareBy(s => s.id)), ); }); test('の一覧が取得できる(空)', async () => { const res = await usersClips({ - parameters: { + parameters: { userId: alice.id, }, }); @@ -381,14 +381,14 @@ describe('クリップ', () => { ])('の一覧が$label取得できる', async () => { const clips = await createMany({ isPublic: true }); const res = await usersClips({ - parameters: { + parameters: { userId: alice.id, }, }); // 返ってくる配列には順序保障がないのでidでソートして厳密比較 assert.deepStrictEqual( - res.sort(compareBy(s => s.id)), + res.sort(compareBy(s => s.id)), clips.sort(compareBy(s => s.id))); // 認証状態で見たときだけisFavoritedが入っている @@ -421,7 +421,7 @@ describe('クリップ', () => { await create({ isPublic: false }); const aliceClip = await create({ isPublic: true }); const res = await usersClips({ - parameters: { + parameters: { userId: alice.id, limit: 2, }, @@ -433,7 +433,7 @@ describe('クリップ', () => { const clips = await createMany({ isPublic: true }, 7); clips.sort(compareBy(s => s.id)); const res = await usersClips({ - parameters: { + parameters: { userId: alice.id, sinceId: clips[1].id, untilId: clips[5].id, @@ -443,7 +443,7 @@ describe('クリップ', () => { // Promise.allで返ってくる配列には順序保障がないのでidでソートして厳密比較 assert.deepStrictEqual( - res.sort(compareBy(s => s.id)), + res.sort(compareBy(s => s.id)), [clips[2], clips[3], clips[4]], // sinceIdとuntilId自体は結果に含まれない clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id)); }); @@ -454,7 +454,7 @@ describe('クリップ', () => { { label: 'limit最大+1', parameters: { limit: 101 } }, ])('の一覧は$labelだと取得できない', async ({ parameters }) => failedApiCall({ endpoint: '/users/clips', - parameters: { + parameters: { userId: alice.id, ...parameters, }, @@ -520,7 +520,7 @@ describe('クリップ', () => { ...request, }); }; - + beforeEach(async () => { aliceClip = await create(); }); @@ -544,7 +544,7 @@ describe('クリップ', () => { assert.strictEqual(clip2.favoritedCount, 1); assert.strictEqual(clip2.isFavorited, false); }); - + test('は1つのクリップに対して複数人が設定できる。', async () => { const publicClip = await create({ isPublic: true }); await favorite({ clipId: publicClip.id }, { user: bob }); @@ -552,7 +552,7 @@ describe('クリップ', () => { const clip = await show({ clipId: publicClip.id }, { user: bob }); assert.strictEqual(clip.favoritedCount, 2); assert.strictEqual(clip.isFavorited, true); - + const clip2 = await show({ clipId: publicClip.id }); assert.strictEqual(clip2.favoritedCount, 2); assert.strictEqual(clip2.isFavorited, true); @@ -581,7 +581,7 @@ describe('クリップ', () => { await favorite({ clipId: aliceClip.id }); await failedApiCall({ endpoint: '/clips/favorite', - parameters: { + parameters: { clipId: aliceClip.id, }, user: alice, @@ -604,7 +604,7 @@ describe('クリップ', () => { } }, ])('の設定は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ endpoint: '/clips/favorite', - parameters: { + parameters: { clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, ...parameters, }, @@ -615,7 +615,7 @@ describe('クリップ', () => { id: '3d81ceae-475f-4600-b2a8-2bc116157532', ...assertion, })); - + test('を設定解除できる。', async () => { await favorite({ clipId: aliceClip.id }); await unfavorite({ clipId: aliceClip.id }); @@ -641,7 +641,7 @@ describe('クリップ', () => { } }, ])('の設定解除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ endpoint: '/clips/unfavorite', - parameters: { + parameters: { clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, ...parameters, }, @@ -652,7 +652,7 @@ describe('クリップ', () => { id: '3d81ceae-475f-4600-b2a8-2bc116157532', ...assertion, })); - + test('を取得できる。', async () => { await favorite({ clipId: aliceClip.id }); const favorited = await myFavorites(); @@ -717,7 +717,7 @@ describe('クリップ', () => { const res = await show({ clipId: aliceClip.id }); assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString()); assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]); - + // 他人の非公開ノートも突っ込める await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id }); await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id }); @@ -728,7 +728,7 @@ describe('クリップ', () => { await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); await failedApiCall({ endpoint: '/clips/add-note', - parameters: { + parameters: { clipId: aliceClip.id, noteId: aliceNote.id, }, @@ -747,10 +747,10 @@ describe('クリップ', () => { text: `test ${i}`, }) as unknown)) as Note[]; await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id }))); - + await failedApiCall({ endpoint: '/clips/add-note', - parameters: { + parameters: { clipId: aliceClip.id, noteId: aliceNote.id, }, @@ -764,7 +764,7 @@ describe('クリップ', () => { test('は他人のクリップへ追加できない。', async () => await failedApiCall({ endpoint: '/clips/add-note', - parameters: { + parameters: { clipId: aliceClip.id, noteId: aliceNote.id, }, @@ -776,9 +776,9 @@ describe('クリップ', () => { })); test.each([ - { label: 'clipId未指定', parameters: { clipId: undefined } }, - { label: 'noteId未指定', parameters: { noteId: undefined } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { code: 'NO_SUCH_CLIP', id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', } }, @@ -792,7 +792,7 @@ describe('クリップ', () => { } }, ])('の追加は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ endpoint: '/clips/add-note', - parameters: { + parameters: { clipId: aliceClip.id, noteId: aliceNote.id, ...parameters, @@ -810,11 +810,11 @@ describe('クリップ', () => { await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id }); assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []); }); - + test.each([ - { label: 'clipId未指定', parameters: { clipId: undefined } }, - { label: 'noteId未指定', parameters: { noteId: undefined } }, - { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { code: 'NO_SUCH_CLIP', id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる } }, @@ -828,7 +828,7 @@ describe('クリップ', () => { } }, ])('の削除は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ endpoint: '/clips/remove-note', - parameters: { + parameters: { clipId: aliceClip.id, noteId: aliceNote.id, ...parameters, @@ -848,12 +848,12 @@ describe('クリップ', () => { } const res = await notes({ clipId: aliceClip.id }); - + // 自分のノートは非公開でも入れられるし、見える // 他人の非公開ノートは入れられるけど、除外される const expects = [ aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, - bobNote, bobHomeNote, + bobNote, bobHomeNote, ]; assert.deepStrictEqual( res.sort(compareBy(s => s.id)), @@ -867,7 +867,7 @@ describe('クリップ', () => { await addNote({ clipId: aliceClip.id, noteId: note.id }); } - const res = await notes({ + const res = await notes({ clipId: aliceClip.id, sinceId: noteList[2].id, limit: 3, @@ -892,7 +892,7 @@ describe('クリップ', () => { sinceId: noteList[1].id, untilId: noteList[4].id, }); - + // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 const expects = [noteList[2], noteList[3]]; assert.deepStrictEqual( @@ -918,7 +918,7 @@ describe('クリップ', () => { const res = await notes({ clipId: publicClip.id }, { user: undefined }); const expects = [ - aliceNote, aliceHomeNote, + aliceNote, aliceHomeNote, // 認証なしだと非公開ノートは結果には含むけどhideされる。 hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote), ]; @@ -926,7 +926,7 @@ describe('クリップ', () => { res.sort(compareBy(s => s.id)), expects.sort(compareBy(s => s.id))); }); - + test.todo('ブロック、ミュートされたユーザーからの設定&取得etc.'); test.each([ @@ -947,7 +947,7 @@ describe('クリップ', () => { } }, ])('は$labelだと取得できない', async ({ parameters, user, assertion }) => failedApiCall({ endpoint: '/clips/notes', - parameters: { + parameters: { clipId: aliceClip.id, ...parameters, }, diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index f885209b7..a1e89d483 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -4,17 +4,18 @@ import * as assert from 'assert'; // node-fetch only supports it's own Blob yet // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; +import { User } from '@/models/index.js'; import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; -import { User } from '@/models/index.js'; +import type * as misskey from 'misskey-js'; describe('Endpoints', () => { let app: INestApplicationContext; - let alice: any; - let bob: any; - let carol: any; - let dave: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; + let carol: misskey.entities.MeSignup; + let dave: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 78ca8b43b..115945dd3 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -4,6 +4,7 @@ import * as assert from 'assert'; import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js'; import type { SimpleGetResponse } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; // Request Accept const ONLY_AP = 'application/activity+json'; @@ -19,7 +20,7 @@ const JSON_UTF8 = 'application/json; charset=utf-8'; describe('Webリソース', () => { let app: INestApplicationContext; - let alice: any; + let alice: misskey.entities.MeSignup; let aliceUploadedFile: any; let alicesPost: any; let alicePage: any; @@ -28,8 +29,8 @@ describe('Webリソース', () => { let aliceGalleryPost: any; let aliceChannel: any; - type Request = { - path: string, + type Request = { + path: string, accept?: string, cookie?: string, }; @@ -46,7 +47,7 @@ describe('Webリソース', () => { const notOk = async (param: Request & { status?: number, code?: string, - }): Promise => { + }): Promise => { const { path, accept, cookie, status, code } = param; const res = await simpleGet(path, accept, cookie); assert.notStrictEqual(res.status, 200); @@ -58,8 +59,8 @@ describe('Webリソース', () => { } return res; }; - - const notFound = async (param: Request): Promise => { + + const notFound = async (param: Request): Promise => { return await notOk({ ...param, status: 404, @@ -94,23 +95,23 @@ describe('Webリソース', () => { { path: '/', type: HTML }, { path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。" // fastify-static gives charset=UTF-8 instead of utf-8 and that's okay - { path: '/api-doc', type: 'text/html; charset=UTF-8' }, - { path: '/api.json', type: JSON_UTF8 }, - { path: '/api-console', type: HTML }, - { path: '/_info_card_', type: HTML }, - { path: '/bios', type: HTML }, - { path: '/cli', type: HTML }, - { path: '/flush', type: HTML }, + { path: '/api-doc', type: 'text/html; charset=UTF-8' }, + { path: '/api.json', type: JSON_UTF8 }, + { path: '/api-console', type: HTML }, + { path: '/_info_card_', type: HTML }, + { path: '/bios', type: HTML }, + { path: '/cli', type: HTML }, + { path: '/flush', type: HTML }, { path: '/robots.txt', type: 'text/plain; charset=UTF-8' }, - { path: '/favicon.ico', type: 'image/vnd.microsoft.icon' }, + { path: '/favicon.ico', type: 'image/vnd.microsoft.icon' }, { path: '/opensearch.xml', type: 'application/opensearchdescription+xml' }, - { path: '/apple-touch-icon.png', type: 'image/png' }, - { path: '/twemoji/2764.svg', type: 'image/svg+xml' }, - { path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' }, - { path: '/twemoji-badge/2764.png', type: 'image/png' }, + { path: '/apple-touch-icon.png', type: 'image/png' }, + { path: '/twemoji/2764.svg', type: 'image/svg+xml' }, + { path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' }, + { path: '/twemoji-badge/2764.png', type: 'image/png' }, { path: '/twemoji-badge/2764-fe0f-200d-1f525.png', type: 'image/png' }, - { path: '/fluent-emoji/2764.png', type: 'image/png' }, - { path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' }, + { path: '/fluent-emoji/2764.png', type: 'image/png' }, + { path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' }, ])('$path', (p) => { test('がGETできる。', async () => await ok({ ...p })); @@ -120,58 +121,58 @@ describe('Webリソース', () => { }); describe.each([ - { path: '/twemoji/2764.png' }, - { path: '/twemoji/2764-fe0f-200d-1f525.png' }, - { path: '/twemoji-badge/2764.svg' }, + { path: '/twemoji/2764.png' }, + { path: '/twemoji/2764-fe0f-200d-1f525.png' }, + { path: '/twemoji-badge/2764.svg' }, { path: '/twemoji-badge/2764-fe0f-200d-1f525.svg' }, - { path: '/fluent-emoji/2764.svg' }, - { path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' }, + { path: '/fluent-emoji/2764.svg' }, + { path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' }, ])('$path', ({ path }) => { test('はGETできない。', async () => await notFound({ path })); }); describe.each([ - { ext: 'rss', type: 'application/rss+xml; charset=utf-8' }, - { ext: 'atom', type: 'application/atom+xml; charset=utf-8' }, - { ext: 'json', type: 'application/json; charset=utf-8' }, + { ext: 'rss', type: 'application/rss+xml; charset=utf-8' }, + { ext: 'atom', type: 'application/atom+xml; charset=utf-8' }, + { ext: 'json', type: 'application/json; charset=utf-8' }, ])('/@:username.$ext', ({ ext, type }) => { const path = (username: string): string => `/@${username}.${ext}`; - test('がGETできる。', async () => await ok({ + test('がGETできる。', async () => await ok({ path: path(alice.username), type, })); - test('は存在しないユーザーはGETできない。', async () => await notOk({ + test('は存在しないユーザーはGETできない。', async () => await notOk({ path: path('nonexisting'), - status: 404, + status: 404, })); }); describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { - test('はGETできない。', async () => await notOk({ + test('はGETできない。', async () => await notOk({ path, - status: 404, + status: 404, code: 'UNKNOWN_API_ENDPOINT', })); }); describe.each([{ path: '/queue' }])('$path', ({ path }) => { - test('はadminでなければGETできない。', async () => await notOk({ + test('はadminでなければGETできない。', async () => await notOk({ path, status: 500, // FIXME? 403ではない。 })); - - test('はadminならGETできる。', async () => await ok({ + + test('はadminならGETできる。', async () => await ok({ path, cookie: cookie(alice), - })); + })); }); describe.each([{ path: '/streaming' }])('$path', ({ path }) => { - test('はGETできない。', async () => await notOk({ + test('はGETできない。', async () => await notOk({ path, - status: 503, + status: 503, })); }); @@ -183,21 +184,21 @@ describe('Webリソース', () => { { accept: UNSPECIFIED }, ])('(Acceptヘッダ: $accept)', ({ accept }) => { test('はHTMLとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.username), - accept, + const res = await ok({ + path: path(alice.username), + accept, type: HTML, }); assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - + // TODO ogタグの検証 // TODO profile.noCrawleの検証 // TODO twitter:creatorの検証 // TODO の検証 }); - test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), + test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), type: HTML, })); }); @@ -207,22 +208,22 @@ describe('Webリソース', () => { { accept: PREFER_AP }, ])('(Acceptヘッダ: $accept)', ({ accept }) => { test('はActivityPubとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.username), - accept, + const res = await ok({ + path: path(alice.username), + accept, type: AP, }); assert.strictEqual(res.body.type, 'Person'); }); - test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ - path: path('xxxxxxxxxx'), + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ + path: path('xxxxxxxxxx'), accept, })); }); }); - describe.each([ + describe.each([ // 実際のハンドルはフロントエンド(index.vue)で行われる { sub: 'home' }, { sub: 'notes' }, @@ -236,32 +237,32 @@ describe('Webリソース', () => { const path = (username: string): string => `/@${username}/${sub}`; test('はHTMLとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.username), + const res = await ok({ + path: path(alice.username), }); assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); }); }); - + describe('/@:user/pages/:page', () => { const path = (username: string, pagename: string): string => `/@${username}/pages/${pagename}`; test('はHTMLとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.username, alicePage.name), + const res = await ok({ + path: path(alice.username, alicePage.name), }); assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); assert.strictEqual(metaTag(res, 'misskey:page-id'), alicePage.id); - + // TODO ogタグの検証 // TODO profile.noCrawleの検証 // TODO twitter:creatorの検証 }); - - test('はGETできる。(存在しないIDでも。)', async () => await ok({ - path: path(alice.username, 'xxxxxxxxxx'), + + test('はGETできる。(存在しないIDでも。)', async () => await ok({ + path: path(alice.username, 'xxxxxxxxxx'), })); }); @@ -278,7 +279,7 @@ describe('Webリソース', () => { assert.strictEqual(res.location, `/@${alice.username}`); }); - test('は存在しないユーザーはGETできない。', async () => await notFound({ + test('は存在しないユーザーはGETできない。', async () => await notFound({ path: path('xxxxxxxx'), })); }); @@ -288,24 +289,24 @@ describe('Webリソース', () => { { accept: PREFER_AP }, ])('(Acceptヘッダ: $accept)', ({ accept }) => { test('はActivityPubとしてGETできる。', async () => { - const res = await ok({ - path: path(alice.id), - accept, + const res = await ok({ + path: path(alice.id), + accept, type: AP, }); assert.strictEqual(res.body.type, 'Person'); }); - test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({ + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({ path: path('xxxxxxxx'), accept, status: 404, })); }); }); - + describe('/users/inbox', () => { - test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ + test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ path: '/inbox', })); @@ -315,7 +316,7 @@ describe('Webリソース', () => { describe('/users/:id/inbox', () => { const path = (id: string): string => `/users/${id}/inbox`; - test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ + test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ path: path(alice.id), })); @@ -326,14 +327,14 @@ describe('Webリソース', () => { const path = (id: string): string => `/users/${id}/outbox`; test('がGETできる。', async () => { - const res = await ok({ - path: path(alice.id), + const res = await ok({ + path: path(alice.id), type: AP, }); assert.strictEqual(res.body.type, 'OrderedCollection'); }); }); - + describe('/notes/:id', () => { const path = (noteId: string): string => `/notes/${noteId}`; @@ -342,22 +343,22 @@ describe('Webリソース', () => { { accept: UNSPECIFIED }, ])('(Acceptヘッダ: $accept)', ({ accept }) => { test('はHTMLとしてGETできる。', async () => { - const res = await ok({ - path: path(alicesPost.id), - accept, + const res = await ok({ + path: path(alicesPost.id), + accept, type: HTML, }); assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); - assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id); - + assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id); + // TODO ogタグの検証 // TODO profile.noCrawleの検証 // TODO twitter:creatorの検証 }); - test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), + test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), })); }); @@ -366,48 +367,48 @@ describe('Webリソース', () => { { accept: PREFER_AP }, ])('(Acceptヘッダ: $accept)', ({ accept }) => { test('はActivityPubとしてGETできる。', async () => { - const res = await ok({ - path: path(alicesPost.id), + const res = await ok({ + path: path(alicesPost.id), accept, type: AP, }); assert.strictEqual(res.body.type, 'Note'); }); - test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ - path: path('xxxxxxxxxx'), + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ + path: path('xxxxxxxxxx'), accept, })); }); }); - + describe('/play/:id', () => { const path = (playid: string): string => `/play/${playid}`; test('がGETできる。', async () => { - const res = await ok({ - path: path(alicePlay.id), + const res = await ok({ + path: path(alicePlay.id), }); assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); assert.strictEqual(metaTag(res, 'misskey:flash-id'), alicePlay.id); - + // TODO ogタグの検証 // TODO profile.noCrawleの検証 // TODO twitter:creatorの検証 }); - test('がGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), })); }); - + describe('/clips/:clip', () => { const path = (clip: string): string => `/clips/${clip}`; test('がGETできる。', async () => { - const res = await ok({ - path: path(aliceClip.id), + const res = await ok({ + path: path(aliceClip.id), }); assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); @@ -416,9 +417,9 @@ describe('Webリソース', () => { // TODO ogタグの検証 // TODO profile.noCrawleの検証 }); - - test('がGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), })); }); @@ -426,8 +427,8 @@ describe('Webリソース', () => { const path = (post: string): string => `/gallery/${post}`; test('がGETできる。', async () => { - const res = await ok({ - path: path(aliceGalleryPost.id), + const res = await ok({ + path: path(aliceGalleryPost.id), }); assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); @@ -436,26 +437,26 @@ describe('Webリソース', () => { // TODO profile.noCrawleの検証 // TODO twitter:creatorの検証 }); - - test('がGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), })); }); - + describe('/channels/:channel', () => { const path = (channel: string): string => `/channels/${channel}`; test('はGETできる。', async () => { const res = await ok({ - path: path(aliceChannel.id), + path: path(aliceChannel.id), }); // FIXME: misskey関連のmetaタグの設定がない // TODO ogタグの検証 }); - - test('がGETできる。(存在しないIDでも。)', async () => await ok({ - path: path('xxxxxxxxxx'), + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), })); }); }); diff --git a/packages/backend/test/e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts index 7b75005a3..9082c77f0 100644 --- a/packages/backend/test/e2e/ff-visibility.ts +++ b/packages/backend/test/e2e/ff-visibility.ts @@ -3,12 +3,13 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { signup, api, startServer, simpleGet } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('FF visibility', () => { let app: INestApplicationContext; - let alice: any; - let bob: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts index 7d6c64609..2fefcd0f0 100644 --- a/packages/backend/test/e2e/move.ts +++ b/packages/backend/test/e2e/move.ts @@ -1,12 +1,13 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import rndstr from 'rndstr'; import { loadConfig } from '@/config.js'; import { User, UsersRepository } from '@/models/index.js'; import { jobQueue } from '@/boot/common.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('Account Move', () => { let app: INestApplicationContext; @@ -14,12 +15,12 @@ describe('Account Move', () => { let url: URL; let root: any; - let alice: any; - let bob: any; - let carol: any; - let dave: any; - let eve: any; - let frank: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; + let carol: misskey.entities.MeSignup; + let dave: misskey.entities.MeSignup; + let eve: misskey.entities.MeSignup; + let frank: misskey.entities.MeSignup; let Users: UsersRepository; @@ -162,7 +163,7 @@ describe('Account Move', () => { alsoKnownAs: [`@alice@${url.hostname}`], }, root); const listRoot = await api('/users/lists/create', { - name: rndstr('0-9a-z', 8), + name: secureRndstr(8), }, root); await api('/users/lists/push', { listId: listRoot.body.id, @@ -176,9 +177,9 @@ describe('Account Move', () => { userId: eve.id, }, alice); const antenna = await api('/antennas/create', { - name: rndstr('0-9a-z', 8), + name: secureRndstr(8), src: 'home', - keywords: [rndstr('0-9a-z', 8)], + keywords: [secureRndstr(8)], excludeKeywords: [], users: [], caseSensitive: false, @@ -210,7 +211,7 @@ describe('Account Move', () => { userId: dave.id, }, eve); const listEve = await api('/users/lists/create', { - name: rndstr('0-9a-z', 8), + name: secureRndstr(8), }, eve); await api('/users/lists/push', { listId: listEve.body.id, @@ -419,9 +420,9 @@ describe('Account Move', () => { test('Prohibit access after moving: /antennas/update', async () => { const res = await api('/antennas/update', { antennaId, - name: rndstr('0-9a-z', 8), + name: secureRndstr(8), src: 'users', - keywords: [rndstr('0-9a-z', 8)], + keywords: [secureRndstr(8)], excludeKeywords: [], users: [eve.id], caseSensitive: false, diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index 25bd532cf..79e2c90f6 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -3,14 +3,15 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('Mute', () => { let app: INestApplicationContext; // alice mutes carol - let alice: any; - let bob: any; - let carol: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; + let carol: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index d2eb8f01d..33da811a2 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -4,13 +4,14 @@ import * as assert from 'assert'; import { Note } from '@/models/entities/Note.js'; import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('Note', () => { let app: INestApplicationContext; let Notes: any; - let alice: any; - let bob: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); @@ -378,7 +379,7 @@ describe('Note', () => { }, }, }, alice); - + assert.strictEqual(res.status, 200); const assign = await api('admin/roles/assign', { diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index 0f73b8d09..72fc599aa 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -3,14 +3,15 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('Renote Mute', () => { let app: INestApplicationContext; // alice mutes carol - let alice: any; - let bob: any; - let carol: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; + let carol: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index d1394ef7a..2cddafed2 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -4,6 +4,7 @@ import * as assert from 'assert'; import { Following } from '@/models/entities/Following.js'; import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('Streaming', () => { let app: INestApplicationContext; @@ -26,13 +27,13 @@ describe('Streaming', () => { describe('Streaming', () => { // Local users - let ayano: any; - let kyoko: any; - let chitose: any; + let ayano: misskey.entities.MeSignup; + let kyoko: misskey.entities.MeSignup; + let chitose: misskey.entities.MeSignup; // Remote users - let akari: any; - let chinatsu: any; + let akari: misskey.entities.MeSignup; + let chinatsu: misskey.entities.MeSignup; let kyokoNote: any; let list: any; diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 2ae2eb67c..e01ea90fe 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -3,13 +3,14 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { signup, api, post, connectStream, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('Note thread mute', () => { let app: INestApplicationContext; - let alice: any; - let bob: any; - let carol: any; + let alice: misskey.entities.MeSignup; + let bob: misskey.entities.MeSignup; + let carol: misskey.entities.MeSignup; beforeAll(async () => { app = await startServer(); diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index c11099e7b..3681456c7 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -3,11 +3,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { signup, api, post, uploadUrl, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; describe('users/notes', () => { let app: INestApplicationContext; - let alice: any; + let alice: misskey.entities.MeSignup; let jpgNote: any; let pngNote: any; let jpgPngNote: any; diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 02684c93b..64efaa57c 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -4,14 +4,14 @@ import * as assert from 'assert'; import { inspect } from 'node:util'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import type { Packed } from '@/misc/json-schema.js'; -import { - signup, - post, +import { + signup, + post, page, role, - startServer, + startServer, api, - successfulApiCall, + successfulApiCall, failedApiCall, uploadFile, } from '../utils.js'; @@ -36,19 +36,19 @@ describe('ユーザー', () => { badgeRoles: any[], }; - type UserDetailedNotMe = UserLite & + type UserDetailedNotMe = UserLite & misskey.entities.UserDetailed & { roles: any[], }; - type MeDetailed = UserDetailedNotMe & + type MeDetailed = UserDetailedNotMe & misskey.entities.MeDetailed & { achievements: object[], loggedInDays: number, policies: object, }; - - type User = MeDetailed & { token: string }; + + type User = MeDetailed & { token: string }; const show = async (id: string, me = root): Promise => { return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any; @@ -159,7 +159,7 @@ describe('ユーザー', () => { mutedInstances: user.mutedInstances, mutingNotificationTypes: user.mutingNotificationTypes, emailNotificationTypes: user.emailNotificationTypes, - achievements: user.achievements, + achievements: user.achievements, loggedInDays: user.loggedInDays, policies: user.policies, ...(security ? { @@ -222,11 +222,11 @@ describe('ユーザー', () => { beforeAll(async () => { root = await signup({ username: 'root' }); alice = await signup({ username: 'alice' }); - aliceNote = await post(alice, { text: 'test' }) as any; + aliceNote = await post(alice, { text: 'test' }) as any; alicePage = await page(alice); aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body; bob = await signup({ username: 'bob' }); - bobNote = await post(bob, { text: 'test' }) as any; + bobNote = await post(bob, { text: 'test' }) as any; carol = await signup({ username: 'carol' }); dave = await signup({ username: 'dave' }); ellen = await signup({ username: 'ellen' }); @@ -236,10 +236,10 @@ describe('ユーザー', () => { usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => { const u = await signup({ username: `replying${i}` }); for (let j = 0; j < 10 - i; j++) { - const p = await post(u, { text: `test${j}` }); + const p = await post(u, { text: `test${j}` }); await post(alice, { text: `@${u.username} test${j}`, replyId: p.id }); } - + return (await acc).concat(u); }, Promise.resolve([] as User[])); @@ -376,7 +376,7 @@ describe('ユーザー', () => { assert.strictEqual(response.securityKeys, false); assert.deepStrictEqual(response.roles, []); assert.strictEqual(response.memo, null); - + // MeDetailedOnly assert.strictEqual(response.avatarId, null); assert.strictEqual(response.bannerId, null); @@ -406,7 +406,7 @@ describe('ユーザー', () => { assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.loggedInDays, 0); - assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); + assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); assert.notStrictEqual(response.email, undefined); assert.strictEqual(response.emailVerified, false); assert.deepStrictEqual(response.securityKeysList, []); @@ -499,8 +499,8 @@ describe('ユーザー', () => { const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/); - const expected = { - ...meDetailed(alice, true), + const expected = { + ...meDetailed(alice, true), avatarId: aliceFile.id, avatarBlurhash: response.avatarBlurhash, avatarUrl: response.avatarUrl, @@ -509,8 +509,8 @@ describe('ユーザー', () => { const parameters2 = { avatarId: null }; const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); - const expected2 = { - ...meDetailed(alice, true), + const expected2 = { + ...meDetailed(alice, true), avatarId: null, avatarBlurhash: null, avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる @@ -524,8 +524,8 @@ describe('ユーザー', () => { const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/); - const expected = { - ...meDetailed(alice, true), + const expected = { + ...meDetailed(alice, true), bannerId: aliceFile.id, bannerBlurhash: response.bannerBlurhash, bannerUrl: response.bannerUrl, @@ -534,8 +534,8 @@ describe('ユーザー', () => { const parameters2 = { bannerId: null }; const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); - const expected2 = { - ...meDetailed(alice, true), + const expected2 = { + ...meDetailed(alice, true), bannerId: null, bannerBlurhash: null, bannerUrl: null, @@ -551,7 +551,7 @@ describe('ユーザー', () => { const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice }); const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] }; assert.deepStrictEqual(response, expected); - + const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice }); const expected2 = meDetailed(alice, false); assert.deepStrictEqual(response2, expected2); @@ -612,7 +612,7 @@ describe('ユーザー', () => { }); test.todo('をリスト形式で取得することができる(リモート, hostname指定)'); test.todo('をリスト形式で取得することができる(pagenation)'); - + //#endregion //#region ユーザー情報(users/show) @@ -684,9 +684,9 @@ describe('ユーザー', () => { const parameters = { userIds: [bob.id, alice.id, carol.id] }; const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); const expected = [ - await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }), - await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }), - await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }), ]; assert.deepStrictEqual(response, expected); }); @@ -701,7 +701,7 @@ describe('ユーザー', () => { // BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる //{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true }, { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, - { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, ] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => { const parameters = { userIds: [user().id] }; const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice }); @@ -734,7 +734,7 @@ describe('ユーザー', () => { { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, - { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, ] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => { const parameters = { query: user().username, limit: 1 }; const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); @@ -747,7 +747,7 @@ describe('ユーザー', () => { //#endregion //#region ID指定検索(users/search-by-username-and-host) - test.each([ + test.each([ { label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] }, { label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] }, { label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] }, @@ -786,7 +786,7 @@ describe('ユーザー', () => { test('がよくリプライをするユーザーのリストを取得できる', async () => { const parameters = { userId: alice.id, limit: 5 }; const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); - const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({ + const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({ user: await show(s.id, alice), weight: (usersReplying.length - i) / usersReplying.length, }))); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index a7bcd859a..9dbe77a7c 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -18,7 +18,8 @@ type MockResponse = { }; export class MockResolver extends Resolver { - private _rs = new Map(); + #responseMap = new Map(); + #remoteGetTrials: string[] = []; constructor(loggerService: LoggerService) { super( @@ -38,18 +39,28 @@ export class MockResolver extends Resolver { ); } - public async _register(uri: string, content: string | Record, type = 'application/activity+json') { - this._rs.set(uri, { + public register(uri: string, content: string | Record, type = 'application/activity+json'): void { + this.#responseMap.set(uri, { type, content: typeof content === 'string' ? content : JSON.stringify(content), }); } + public clear(): void { + this.#responseMap.clear(); + this.#remoteGetTrials.length = 0; + } + + public remoteGetTrials(): string[] { + return this.#remoteGetTrials; + } + @bindThis public async resolve(value: string | IObject): Promise { if (typeof value !== 'string') return value; - const r = this._rs.get(value); + this.#remoteGetTrials.push(value); + const r = this.#responseMap.get(value); if (!r) { throw new Error('Not registed for mock'); diff --git a/packages/backend/test/prelude/get-api-validator.ts b/packages/backend/test/prelude/get-api-validator.ts index 1f4a2dbc9..f09577476 100644 --- a/packages/backend/test/prelude/get-api-validator.ts +++ b/packages/backend/test/prelude/get-api-validator.ts @@ -5,7 +5,7 @@ export const getValidator = (paramDef: Schema) => { const ajv = new Ajv({ useDefaults: true, }); - ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); + ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); return ajv.compile(paramDef); } diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index 8a024a678..21afe1aaf 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -9,9 +9,9 @@ "noFallthroughCasesInSwitch": true, "declaration": false, "sourceMap": true, - "target": "es2021", + "target": "ES2022", "module": "es2020", - "moduleResolution": "node", + "moduleResolution": "node16", "allowSyntheticDefaultImports": true, "removeComments": false, "noLib": false, @@ -39,6 +39,6 @@ "include": [ "./**/*.ts", "../src/**/*.test.ts", - "../src/@types/**/*.ts", + "../src/@types/**/*.ts" ] } diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts index 406566557..9ee6d4bcf 100644 --- a/packages/backend/test/unit/DriveService.ts +++ b/packages/backend/test/unit/DriveService.ts @@ -34,7 +34,7 @@ describe('DriveService', () => { test('delete a file', async () => { s3Mock.on(DeleteObjectCommand) .resolves({} as DeleteObjectCommandOutput); - + await driveService.deleteObjectStorageFile('peace of the world'); }); diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts new file mode 100644 index 000000000..1ee293982 --- /dev/null +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -0,0 +1,109 @@ +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { ModuleMocker } from 'jest-mock'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; +import { Redis } from 'ioredis' + +function mockRedis() { + const hash = {}; + const set = jest.fn((key, value) => { + const ret = hash[key]; + hash[key] = value; + return ret; + }); + return set; +} + +describe('FetchInstanceMetadataService', () => { + let app: TestingModule; + let fetchInstanceMetadataService: jest.Mocked; + let federatedInstanceService: jest.Mocked; + let httpRequestService: jest.Mocked; + let redisClient: jest.Mocked; + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + FetchInstanceMetadataService, + LoggerService, + UtilityService, + IdService, + ], + }) + .useMocker((token) => { + if (token === HttpRequestService) { + return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn(), }; + } else if (token === FederatedInstanceService) { + return { fetch: jest.fn() }; + } else if (token === DI.redis) { + return mockRedis; + }}) + .compile(); + + app.enableShutdownHooks(); + + fetchInstanceMetadataService = app.get(FetchInstanceMetadataService); + federatedInstanceService = app.get(FederatedInstanceService) as jest.Mocked; + redisClient = app.get(DI.redis) as jest.Mocked; + httpRequestService = app.get(HttpRequestService) as jest.Mocked; + }); + + afterAll(async () => { + await app.close(); + }); + + test('Lock and update', async () => { + redisClient.set = mockRedis(); + const now = Date.now(); + federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } }); + httpRequestService.getJson.mockImplementation(() => { throw Error(); }); + const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); + const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); + await fetchInstanceMetadataService.fetchInstanceMetadata({ host: "example.com" }); + expect(tryLockSpy).toHaveBeenCalledTimes(1); + expect(unlockSpy).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(httpRequestService.getJson).toHaveBeenCalled(); + }); + test("Lock and don't update", async () => { + redisClient.set = mockRedis(); + const now = Date.now(); + federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => now } }); + httpRequestService.getJson.mockImplementation(() => { throw Error(); }); + const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); + const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); + await fetchInstanceMetadataService.fetchInstanceMetadata({ host: "example.com" }); + expect(tryLockSpy).toHaveBeenCalledTimes(1); + expect(unlockSpy).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); + }); + test('Do nothing when lock not acquired', async () => { + redisClient.set = mockRedis(); + federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } }); + httpRequestService.getJson.mockImplementation(() => { throw Error(); }); + const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); + const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); + await fetchInstanceMetadataService.tryLock("example.com"); + await fetchInstanceMetadataService.fetchInstanceMetadata({ host: "example.com" }); + expect(tryLockSpy).toHaveBeenCalledTimes(2); + expect(unlockSpy).toHaveBeenCalledTimes(0); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); + expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index f378184c7..efb9bdacc 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -94,7 +94,7 @@ describe('FileInfoService', () => { orientation: undefined, }); }); - + test('Generic APNG', async () => { const path = `${resources}/anime.png`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -114,7 +114,7 @@ describe('FileInfoService', () => { orientation: undefined, }); }); - + test('Generic AGIF', async () => { const path = `${resources}/anime.gif`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -134,7 +134,7 @@ describe('FileInfoService', () => { orientation: undefined, }); }); - + test('PNG with alpha', async () => { const path = `${resources}/with-alpha.png`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -154,7 +154,7 @@ describe('FileInfoService', () => { orientation: undefined, }); }); - + test('Generic SVG', async () => { const path = `${resources}/image.svg`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -174,7 +174,7 @@ describe('FileInfoService', () => { orientation: undefined, }); }); - + test('SVG with XML definition', async () => { // https://github.com/misskey-dev/misskey/issues/4413 const path = `${resources}/with-xml-def.svg`; @@ -195,7 +195,7 @@ describe('FileInfoService', () => { orientation: undefined, }); }); - + test('Dimension limit', async () => { const path = `${resources}/25000x25000.png`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -215,7 +215,7 @@ describe('FileInfoService', () => { orientation: undefined, }); }); - + test('Rotate JPEG', async () => { const path = `${resources}/rotate.jpg`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -257,7 +257,7 @@ describe('FileInfoService', () => { }, }); }); - + test('WAV', async () => { const path = `${resources}/kick_gaba7.wav`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -277,7 +277,7 @@ describe('FileInfoService', () => { }, }); }); - + test('AAC', async () => { const path = `${resources}/kick_gaba7.aac`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -297,7 +297,7 @@ describe('FileInfoService', () => { }, }); }); - + test('FLAC', async () => { const path = `${resources}/kick_gaba7.flac`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; @@ -317,7 +317,7 @@ describe('FileInfoService', () => { }, }); }); - + /* * video/webmとして検出されてしまう test('WEBM AUDIO', async () => { diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index c2280142a..6bf08f509 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -61,7 +61,7 @@ describe('RelayService', () => { await app.close(); }); - test('addRelay', async () => { + test('addRelay', async () => { const result = await relayService.addRelay('https://example.com'); expect(result.inbox).toBe('https://example.com'); @@ -72,7 +72,7 @@ describe('RelayService', () => { //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); }); - test('listRelay', async () => { + test('listRelay', async () => { const result = await relayService.listRelay(); expect(result.length).toBe(1); @@ -80,7 +80,7 @@ describe('RelayService', () => { expect(result[0].status).toBe('requesting'); }); - test('removeRelay: succ', async () => { + test('removeRelay: succ', async () => { await relayService.removeRelay('https://example.com'); expect(queueService.deliver).toHaveBeenCalled(); @@ -93,7 +93,7 @@ describe('RelayService', () => { expect(list.length).toBe(0); }); - test('removeRelay: fail', async () => { + test('removeRelay: fail', async () => { await expect(relayService.removeRelay('https://x.example.com')) .rejects.toThrow('relay not found'); }); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 907f1f2ed..6979f23e0 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -4,7 +4,6 @@ import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; -import rndstr from 'rndstr'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js'; @@ -14,6 +13,7 @@ import { genAid } from '@/misc/id/aid.js'; import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; import { sleep } from '../utils.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -30,7 +30,7 @@ describe('RoleService', () => { let clock: lolex.InstalledClock; function createUser(data: Partial = {}) { - const un = rndstr('a-z0-9', 16); + const un = secureRndstr(16); return usersRepository.insert({ id: genAid(new Date()), createdAt: new Date(), @@ -106,19 +106,19 @@ describe('RoleService', () => { }); describe('getUserPolicies', () => { - test('instance default policies', async () => { + test('instance default policies', async () => { const user = await createUser(); metaService.fetch.mockResolvedValue({ policies: { canManageCustomEmojis: false, }, } as any); - + const result = await roleService.getUserPolicies(user.id); - + expect(result.canManageCustomEmojis).toBe(false); }); - + test('instance default policies 2', async () => { const user = await createUser(); metaService.fetch.mockResolvedValue({ @@ -126,12 +126,12 @@ describe('RoleService', () => { canManageCustomEmojis: true, }, } as any); - + const result = await roleService.getUserPolicies(user.id); - + expect(result.canManageCustomEmojis).toBe(true); }); - + test('with role', async () => { const user = await createUser(); const role = await createRole({ @@ -150,9 +150,9 @@ describe('RoleService', () => { canManageCustomEmojis: false, }, } as any); - + const result = await roleService.getUserPolicies(user.id); - + expect(result.canManageCustomEmojis).toBe(true); }); @@ -185,9 +185,9 @@ describe('RoleService', () => { driveCapacityMb: 50, }, } as any); - + const result = await roleService.getUserPolicies(user.id); - + expect(result.driveCapacityMb).toBe(100); }); @@ -226,7 +226,7 @@ describe('RoleService', () => { canManageCustomEmojis: false, }, } as any); - + const user1Policies = await roleService.getUserPolicies(user1.id); const user2Policies = await roleService.getUserPolicies(user2.id); expect(user1Policies.canManageCustomEmojis).toBe(false); @@ -251,7 +251,7 @@ describe('RoleService', () => { canManageCustomEmojis: false, }, } as any); - + const result = await roleService.getUserPolicies(user.id); expect(result.canManageCustomEmojis).toBe(true); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 146998937..78b916c11 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -1,10 +1,10 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import rndstr from 'rndstr'; import { Test } from '@nestjs/testing'; import { jest } from '@jest/globals'; +import { ApImageService } from '@/core/activitypub/models/ApImageService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -12,15 +12,22 @@ import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; -import type { IActor } from '@/core/activitypub/type.js'; +import type { IActor, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js'; +import { Meta, Note } from '@/models/index.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { MetaService } from '@/core/MetaService.js'; +import type { RemoteUser } from '@/models/entities/User.js'; import { MockResolver } from '../misc/mock-resolver.js'; -import { Note } from '@/models/index.js'; const host = 'https://host1.test'; -function createRandomActor(): IActor & { id: string } { - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; - const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; +type NonTransientIActor = IActor & { id: string }; +type NonTransientIPost = IPost & { id: string }; + +function createRandomActor({ actorHost = host } = {}): NonTransientIActor { + const preferredUsername = secureRndstr(8); + const actorId = `${actorHost}/users/${preferredUsername.toLowerCase()}`; return { '@context': 'https://www.w3.org/ns/activitystreams', @@ -32,16 +39,75 @@ function createRandomActor(): IActor & { id: string } { }; } +function createRandomNote(actor: NonTransientIActor): NonTransientIPost { + const id = secureRndstr(8); + const noteId = `${new URL(actor.id).origin}/notes/${id}`; + + return { + id: noteId, + type: 'Note', + attributedTo: actor.id, + content: 'test test foo', + }; +} + +function createRandomNotes(actor: NonTransientIActor, length: number): NonTransientIPost[] { + return new Array(length).fill(null).map(() => createRandomNote(actor)); +} + +function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): ICollection { + const items = createRandomNotes(actor, length); + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: actor.outbox as string, + totalItems: items.length, + items, + }; +} + +async function createRandomRemoteUser( + resolver: MockResolver, + personService: ApPersonService, +): Promise { + const actor = createRandomActor(); + resolver.register(actor.id, actor); + + return await personService.createPerson(actor.id, resolver); +} + describe('ActivityPub', () => { + let imageService: ApImageService; let noteService: ApNoteService; let personService: ApPersonService; let rendererService: ApRendererService; let resolver: MockResolver; - beforeEach(async () => { + const metaInitial = { + cacheRemoteFiles: true, + cacheRemoteSensitiveFiles: true, + blockedHosts: [] as string[], + sensitiveWords: [] as string[], + } as Meta; + let meta = metaInitial; + + beforeAll(async () => { const app = await Test.createTestingModule({ imports: [GlobalModule, CoreModule], - }).compile(); + }) + .overrideProvider(DownloadService).useValue({ + async downloadUrl(): Promise<{ filename: string }> { + return { + filename: 'dummy.tmp', + }; + }, + }) + .overrideProvider(MetaService).useValue({ + async fetch(): Promise { + return meta; + }, + }).compile(); await app.init(); app.enableShutdownHooks(); @@ -49,11 +115,16 @@ describe('ActivityPub', () => { noteService = app.get(ApNoteService); personService = app.get(ApPersonService); rendererService = app.get(ApRendererService); + imageService = app.get(ApImageService); resolver = new MockResolver(await app.resolve(LoggerService)); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error const federatedInstanceService = app.get(FederatedInstanceService); - jest.spyOn(federatedInstanceService, 'fetch').mockImplementation(() => new Promise(() => {})); + jest.spyOn(federatedInstanceService, 'fetch').mockImplementation(() => new Promise(() => { })); + }); + + beforeEach(() => { + resolver.clear(); }); describe('Parse minimum object', () => { @@ -61,7 +132,7 @@ describe('ActivityPub', () => { const post = { '@context': 'https://www.w3.org/ns/activitystreams', - id: `${host}/users/${rndstr('0-9a-z', 8)}`, + id: `${host}/users/${secureRndstr(8)}`, type: 'Note', attributedTo: actor.id, to: 'https://www.w3.org/ns/activitystreams#Public', @@ -69,7 +140,7 @@ describe('ActivityPub', () => { }; test('Minimum Actor', async () => { - resolver._register(actor.id, actor); + resolver.register(actor.id, actor); const user = await personService.createPerson(actor.id, resolver); @@ -79,8 +150,8 @@ describe('ActivityPub', () => { }); test('Minimum Note', async () => { - resolver._register(actor.id, actor); - resolver._register(post.id, post); + resolver.register(actor.id, actor); + resolver.register(post.id, post); const note = await noteService.createNote(post.id, resolver, true); @@ -94,10 +165,10 @@ describe('ActivityPub', () => { test('Truncate long name', async () => { const actor = { ...createRandomActor(), - name: rndstr('0-9a-z', 129), + name: secureRndstr(129), }; - resolver._register(actor.id, actor); + resolver.register(actor.id, actor); const user = await personService.createPerson(actor.id, resolver); @@ -110,7 +181,7 @@ describe('ActivityPub', () => { name: '', }; - resolver._register(actor.id, actor); + resolver.register(actor.id, actor); const user = await personService.createPerson(actor.id, resolver); @@ -126,4 +197,149 @@ describe('ActivityPub', () => { } as Note); }); }); + + describe('Featured', () => { + test('Fetch featured notes from IActor', async () => { + const actor = createRandomActor(); + actor.featured = `${actor.id}/collections/featured`; + + const featured = createRandomFeaturedCollection(actor, 5); + + resolver.register(actor.id, actor); + resolver.register(actor.featured, featured); + + await personService.createPerson(actor.id, resolver); + + // All notes in `featured` are same-origin, no need to fetch notes again + assert.deepStrictEqual(resolver.remoteGetTrials(), [actor.id, actor.featured]); + + // Created notes without resolving anything + for (const item of featured.items as IPost[]) { + const note = await noteService.fetchNote(item); + assert.ok(note); + assert.strictEqual(note.text, 'test test foo'); + assert.strictEqual(note.uri, item.id); + } + }); + + test('Fetch featured notes from IActor pointing to another remote server', async () => { + const actor1 = createRandomActor(); + actor1.featured = `${actor1.id}/collections/featured`; + const actor2 = createRandomActor({ actorHost: 'https://host2.test' }); + + const actor2Note = createRandomNote(actor2); + const featured = createRandomFeaturedCollection(actor1, 0); + (featured.items as IPost[]).push({ + ...actor2Note, + content: 'test test bar', // fraud! + }); + + resolver.register(actor1.id, actor1); + resolver.register(actor1.featured, featured); + resolver.register(actor2.id, actor2); + resolver.register(actor2Note.id, actor2Note); + + await personService.createPerson(actor1.id, resolver); + + // actor2Note is from a different server and needs to be fetched again + assert.deepStrictEqual( + resolver.remoteGetTrials(), + [actor1.id, actor1.featured, actor2Note.id, actor2.id], + ); + + const note = await noteService.fetchNote(actor2Note.id); + assert.ok(note); + + // Reflects the original content instead of the fraud + assert.strictEqual(note.text, 'test test foo'); + assert.strictEqual(note.uri, actor2Note.id); + }); + }); + + describe('Images', () => { + test('Create images', async () => { + const imageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/foo.png', + name: '', + }; + const driveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + imageObject, + ); + assert.ok(!driveFile.isLink); + + const sensitiveImageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/bar.png', + name: '', + sensitive: true, + }; + const sensitiveDriveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + sensitiveImageObject, + ); + assert.ok(!sensitiveDriveFile.isLink); + }); + + test('cacheRemoteFiles=false disables caching', async () => { + meta = { ...metaInitial, cacheRemoteFiles: false }; + + const imageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/foo.png', + name: '', + }; + const driveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + imageObject, + ); + assert.ok(driveFile.isLink); + + const sensitiveImageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/bar.png', + name: '', + sensitive: true, + }; + const sensitiveDriveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + sensitiveImageObject, + ); + assert.ok(sensitiveDriveFile.isLink); + }); + + test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => { + meta = { ...metaInitial, cacheRemoteSensitiveFiles: false }; + + const imageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/foo.png', + name: '', + }; + const driveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + imageObject, + ); + assert.ok(!driveFile.isLink); + + const sensitiveImageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/bar.png', + name: '', + sensitive: true, + }; + const sensitiveDriveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + sensitiveImageObject, + ); + assert.ok(sensitiveDriveFile.isLink); + }); + }); }); diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/unit/chart.ts index 5ac4cc18a..40554d3a4 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -475,16 +475,16 @@ describe('Chart', () => { await testIntersectionChart.addA('bob'); await testIntersectionChart.addB('carol'); await testIntersectionChart.save(); - + const chartHours = await testIntersectionChart.getChart('hour', 3, null); const chartDays = await testIntersectionChart.getChart('day', 3, null); - + assert.deepStrictEqual(chartHours, { a: [2, 0, 0], b: [1, 0, 0], aAndB: [0, 0, 0], }); - + assert.deepStrictEqual(chartDays, { a: [2, 0, 0], b: [1, 0, 0], @@ -498,16 +498,16 @@ describe('Chart', () => { await testIntersectionChart.addB('carol'); await testIntersectionChart.addB('alice'); await testIntersectionChart.save(); - + const chartHours = await testIntersectionChart.getChart('hour', 3, null); const chartDays = await testIntersectionChart.getChart('day', 3, null); - + assert.deepStrictEqual(chartHours, { a: [2, 0, 0], b: [2, 0, 0], aAndB: [1, 0, 0], }); - + assert.deepStrictEqual(chartDays, { a: [2, 0, 0], b: [2, 0, 0], diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 22f7d81e4..31ea3e5ab 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -2,7 +2,7 @@ import * as assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import { isAbsolute, basename } from 'node:path'; import { inspect } from 'node:util'; -import WebSocket from 'ws'; +import WebSocket, { ClientOptions } from 'ws'; import fetch, { Blob, File, RequestInit } from 'node-fetch'; import { DataSource } from 'typeorm'; import { JSDOM } from 'jsdom'; @@ -13,14 +13,19 @@ import type * as misskey from 'misskey-js'; export { server as startServer } from '@/boot/common.js'; +interface UserToken { + token: string; + bearer?: boolean; +} + const config = loadConfig(); export const port = config.port; -export const cookie = (me: any): string => { +export const cookie = (me: UserToken): string => { return `token=${me.token};`; }; -export const api = async (endpoint: string, params: any, me?: any) => { +export const api = async (endpoint: string, params: any, me?: UserToken) => { const normalized = endpoint.replace(/^\//, ''); return await request(`api/${normalized}`, params, me); }; @@ -28,7 +33,7 @@ export const api = async (endpoint: string, params: any, me?: any) => { export type ApiRequest = { endpoint: string, parameters: object, - user: object | undefined, + user: UserToken | undefined, }; export const successfulApiCall = async (request: ApiRequest, assertion: { @@ -55,27 +60,33 @@ export const failedApiCall = async (request: ApiRequest, assertion: { return res.body; }; -const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { - const auth = me ? { - i: me.token, - } : {}; +const request = async (path: string, params: any, me?: UserToken): Promise<{ status: number, headers: Headers, body: any }> => { + const bodyAuth: Record = {}; + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (me?.bearer) { + headers.Authorization = `Bearer ${me.token}`; + } else if (me) { + bodyAuth.i = me.token; + } const res = await relativeFetch(path, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(Object.assign(auth, params)), + headers, + body: JSON.stringify(Object.assign(bodyAuth, params)), redirect: 'manual', }); - const status = res.status; const body = res.headers.get('content-type') === 'application/json; charset=utf-8' ? await res.json() : null; return { - body, status, + status: res.status, + headers: res.headers, + body, }; }; @@ -83,7 +94,7 @@ const relativeFetch = async (path: string, init?: RequestInit | undefined) => { return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); }; -export const signup = async (params?: any): Promise => { +export const signup = async (params?: Partial): Promise> => { const q = Object.assign({ username: 'test', password: 'test', @@ -94,7 +105,7 @@ export const signup = async (params?: any): Promise => { return res.body; }; -export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => { +export const post = async (user: UserToken, params?: misskey.Endpoints['notes/create']['req']): Promise => { const q = params; const res = await api('notes/create', q, user); @@ -117,21 +128,21 @@ export const hiddenNote = (note: any): any => { return temp; }; -export const react = async (user: any, note: any, reaction: string): Promise => { +export const react = async (user: UserToken, note: any, reaction: string): Promise => { await api('notes/reactions/create', { noteId: note.id, reaction: reaction, }, user); }; -export const userList = async (user: any, userList: any = {}): Promise => { +export const userList = async (user: UserToken, userList: any = {}): Promise => { const res = await api('users/lists/create', { name: 'test', }, user); return res.body; }; -export const page = async (user: any, page: any = {}): Promise => { +export const page = async (user: UserToken, page: any = {}): Promise => { const res = await api('pages/create', { alignCenter: false, content: [ @@ -154,7 +165,7 @@ export const page = async (user: any, page: any = {}): Promise => { return res.body; }; -export const play = async (user: any, play: any = {}): Promise => { +export const play = async (user: UserToken, play: any = {}): Promise => { const res = await api('flash/create', { permissions: [], script: 'test', @@ -165,7 +176,7 @@ export const play = async (user: any, play: any = {}): Promise => { return res.body; }; -export const clip = async (user: any, clip: any = {}): Promise => { +export const clip = async (user: UserToken, clip: any = {}): Promise => { const res = await api('clips/create', { description: null, isPublic: true, @@ -175,7 +186,7 @@ export const clip = async (user: any, clip: any = {}): Promise => { return res.body; }; -export const galleryPost = async (user: any, channel: any = {}): Promise => { +export const galleryPost = async (user: UserToken, channel: any = {}): Promise => { const res = await api('gallery/posts/create', { description: null, fileIds: [], @@ -186,7 +197,7 @@ export const galleryPost = async (user: any, channel: any = {}): Promise => return res.body; }; -export const channel = async (user: any, channel: any = {}): Promise => { +export const channel = async (user: UserToken, channel: any = {}): Promise => { const res = await api('channels/create', { bannerId: null, description: null, @@ -196,7 +207,7 @@ export const channel = async (user: any, channel: any = {}): Promise => { return res.body; }; -export const role = async (user: any, role: any = {}, policies: any = {}): Promise => { +export const role = async (user: UserToken, role: any = {}, policies: any = {}): Promise => { const res = await api('admin/roles/create', { asBadge: false, canEditMembersByModerator: false, @@ -213,8 +224,8 @@ export const role = async (user: any, role: any = {}, policies: any = {}): Promi isPublic: false, name: 'New Role', target: 'manual', - policies: { - ...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, { + policies: { + ...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, { priority: 0, useDefault: true, value: v, @@ -239,7 +250,7 @@ interface UploadOptions { * Upload file * @param user User */ -export const uploadFile = async (user: any, { path, name, blob }: UploadOptions = {}): Promise => { +export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadOptions = {}): Promise<{ status: number, headers: Headers, body: misskey.Endpoints['drive/files/create']['res'] | null }> => { const absPath = path == null ? new URL('resources/Lenna.jpg', import.meta.url) : isAbsolute(path.toString()) @@ -247,7 +258,6 @@ export const uploadFile = async (user: any, { path, name, blob }: UploadOptions : new URL(path, new URL('resources/', import.meta.url)); const formData = new FormData(); - formData.append('i', user.token); formData.append('file', blob ?? new File([await readFile(absPath)], basename(absPath.toString()))); formData.append('force', 'true'); @@ -255,20 +265,29 @@ export const uploadFile = async (user: any, { path, name, blob }: UploadOptions formData.append('name', name); } + const headers: Record = {}; + if (user?.bearer) { + headers.Authorization = `Bearer ${user.token}`; + } else if (user) { + formData.append('i', user.token); + } + const res = await relativeFetch('api/drive/files/create', { method: 'POST', body: formData, + headers, }); - const body = res.status !== 204 ? await res.json() : null; + const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null; return { status: res.status, + headers: res.headers, body, }; }; -export const uploadUrl = async (user: any, url: string) => { +export const uploadUrl = async (user: UserToken, url: string) => { let file: any; const marker = Math.random().toString(); @@ -290,10 +309,18 @@ export const uploadUrl = async (user: any, url: string) => { return file; }; -export function connectStream(user: any, channel: string, listener: (message: Record) => any, params?: any): Promise { +export function connectStream(user: UserToken, channel: string, listener: (message: Record) => any, params?: any): Promise { return new Promise((res, rej) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`); + const url = new URL(`ws://127.0.0.1:${port}/streaming`); + const options: ClientOptions = {}; + if (user.bearer) { + options.headers = { Authorization: `Bearer ${user.token}` }; + } else { + url.searchParams.set('i', user.token); + } + const ws = new WebSocket(url, options); + ws.on('unexpected-response', (req, res) => rej(res)); ws.on('open', () => { ws.on('message', data => { const msg = JSON.parse(data.toString()); @@ -317,7 +344,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re }); } -export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record) => boolean, params?: any) => { +export const waitFire = async (user: UserToken, channel: string, trgr: () => any, cond: (msg: Record) => boolean, params?: any) => { return new Promise(async (res, rej) => { let timer: NodeJS.Timeout | null = null; @@ -351,11 +378,11 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond }); }; -export type SimpleGetResponse = { - status: number, - body: any | JSDOM | null, - type: string | null, - location: string | null +export type SimpleGetResponse = { + status: number, + body: any | JSDOM | null, + type: string | null, + location: string | null }; export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise => { const res = await relativeFetch(path, { @@ -374,9 +401,9 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde 'text/html; charset=utf-8', ]; - const body = - jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : - htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : + const body = + jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : null; return { @@ -420,12 +447,12 @@ export async function testPaginationConsistency id + ':' + createdAt), @@ -453,7 +480,7 @@ export async function testPaginationConsistency id + ':' + createdAt), diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index faadbcdfc..93944a68d 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -9,9 +9,9 @@ "noFallthroughCasesInSwitch": true, "declaration": false, "sourceMap": false, - "target": "es2021", - "module": "esnext", - "moduleResolution": "node", + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node16", "allowSyntheticDefaultImports": true, "removeComments": false, "noLib": false, diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts index fc0f0c286..a1275132b 100644 --- a/packages/frontend/.storybook/changes.ts +++ b/packages/frontend/.storybook/changes.ts @@ -1,7 +1,10 @@ import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import path from 'node:path'; import micromatch from 'micromatch'; -import main from './main'; +import main from './main.js'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); interface Stats { readonly modules: readonly { @@ -13,8 +16,8 @@ interface Stats { }[]; } -fs.readFile( - path.resolve(__dirname, '../storybook-static/preview-stats.json') +await fs.readFile( + new URL('../storybook-static/preview-stats.json', import.meta.url) ).then((buffer) => { const stats: Stats = JSON.parse(buffer.toString()); const keys = new Set(stats.modules.map((stat) => stat.id)); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 5fd21cdf0..a4289cff7 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -115,3 +115,27 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi url: null, }; } + +export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) { + const date = new Date(); + const createdAt = new Date(); + createdAt.setDate(date.getDate() - 1) + const expiresAt = new Date(); + + if (isExpired) { + expiresAt.setHours(date.getHours() - 1) + } else { + expiresAt.setHours(date.getHours() + 1) + } + + return { + id: "9gyqzizw77", + code: "SLF3JKF7UV2H9", + expiresAt: hasExpiration ? expiresAt.toISOString() : null, + createdAt: createdAt.toISOString(), + createdBy: isCreatedBySystem ? null : userDetailed('8i3rvznx32'), + usedBy: isUsed ? userDetailed('3i3r2znx1v') : null, + usedAt: isUsed ? date.toISOString() : null, + used: isUsed, + } +} diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index f44242210..d47d8672c 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -96,7 +96,7 @@ declare global { } } -function toStories(component: string): string { +function toStories(component: string): Promise { const msw = `${component.slice(0, -'.vue'.length)}.msw`; const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`; const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`; @@ -394,18 +394,21 @@ function toStories(component: string): string { } // glob('src/{components,pages,ui,widgets}/**/*.vue') -Promise.all([ - glob('src/components/global/*.vue'), - glob('src/components/Mk{A,B}*.vue'), - glob('src/components/MkDigitalClock.vue'), - glob('src/components/MkGalleryPostPreview.vue'), - glob('src/components/MkSignupServerRules.vue'), - glob('src/components/MkUserSetupDialog.vue'), - glob('src/components/MkUserSetupDialog.*.vue'), - glob('src/pages/user/home.vue'), -]) - .then((globs) => globs.flat()) - .then((components) => Promise.all(components.map((component) => { +(async () => { + const globs = await Promise.all([ + glob('src/components/global/*.vue'), + glob('src/components/Mk{A,B}*.vue'), + glob('src/components/MkDigitalClock.vue'), + glob('src/components/MkGalleryPostPreview.vue'), + glob('src/components/MkSignupServerRules.vue'), + glob('src/components/MkUserSetupDialog.vue'), + glob('src/components/MkUserSetupDialog.*.vue'), + glob('src/components/MkInviteCode.vue'), + glob('src/pages/user/home.vue'), + ]); + const components = globs.flat(); + await Promise.all(components.map(async (component) => { const stories = component.replace(/\.vue$/, '.stories.ts'); - return writeFile(stories, toStories(component)); - }))); + await writeFile(stories, await toStories(component)); + })) +})(); diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts index 1d0ce5ab6..b64979980 100644 --- a/packages/frontend/.storybook/main.ts +++ b/packages/frontend/.storybook/main.ts @@ -1,7 +1,11 @@ import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { StorybookConfig } from '@storybook/vue3-vite'; import { type Plugin, mergeConfig } from 'vite'; import turbosnap from 'vite-plugin-turbosnap'; + +const dirname = fileURLToPath(new URL('.', import.meta.url)); + const config = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], addons: [ @@ -9,7 +13,7 @@ const config = { '@storybook/addon-interactions', '@storybook/addon-links', '@storybook/addon-storysource', - resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'), + resolve(dirname, '../node_modules/storybook-addon-misskey-theme'), ], framework: { name: '@storybook/vue3-vite', @@ -28,7 +32,8 @@ const config = { } return mergeConfig(config, { plugins: [ - turbosnap({ + // XXX: https://github.com/IanVS/vite-plugin-turbosnap/issues/8 + (turbosnap as any as typeof turbosnap['default'])({ rootDir: config.root ?? process.cwd(), }), ], diff --git a/packages/frontend/.storybook/package.json b/packages/frontend/.storybook/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/packages/frontend/.storybook/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/frontend/.storybook/preload-locale.ts b/packages/frontend/.storybook/preload-locale.ts index a54164742..2b7362b88 100644 --- a/packages/frontend/.storybook/preload-locale.ts +++ b/packages/frontend/.storybook/preload-locale.ts @@ -1,9 +1,8 @@ import { writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import * as locales from '../../../locales'; +import locales from '../../../locales/index.js'; -writeFile( - resolve(__dirname, 'locale.ts'), +await writeFile( + new URL('locale.ts', import.meta.url), `export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`, 'utf8', ) diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts index 1ff8f71ec..42fbeff73 100644 --- a/packages/frontend/.storybook/preload-theme.ts +++ b/packages/frontend/.storybook/preload-theme.ts @@ -1,6 +1,5 @@ import { readFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import * as JSON5 from 'json5'; +import JSON5 from 'json5'; const keys = [ '_dark', @@ -26,9 +25,9 @@ const keys = [ 'd-u0', ] -Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => { +await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => { writeFile( - resolve(__dirname, './themes.ts'), + new URL('./themes.ts', import.meta.url), `export default ${JSON.stringify( Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])), undefined, diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index e887acaa2..67c81c666 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -3,10 +3,10 @@ import { FORCE_REMOUNT } from '@storybook/core-events'; import { type Preview, setup } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import { initialize, mswDecorator } from 'msw-storybook-addon'; -import { userDetailed } from './fakes'; -import locale from './locale'; -import { commonHandlers, onUnhandledRequest } from './mocks'; -import themes from './themes'; +import { userDetailed } from './fakes.js'; +import locale from './locale.js'; +import { commonHandlers, onUnhandledRequest } from './mocks.js'; +import themes from './themes.js'; import '../src/style.scss'; const appInitialized = Symbol(); diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json index 2db2f1eab..02465f5af 100644 --- a/packages/frontend/.storybook/tsconfig.json +++ b/packages/frontend/.storybook/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "target": "es2022", + "module": "Node16", "strict": true, "allowUnusedLabels": false, "allowUnreachableCode": false, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 506d18790..2819f858c 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,8 +4,9 @@ "scripts": { "watch": "vite", "build": "vite build", - "storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'", - "build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build", + "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", + "build-storybook-pre": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", + "build-storybook": "pnpm build-storybook-pre && storybook build", "chromatic": "chromatic", "test": "vitest --run", "test-and-coverage": "vitest --run --coverage", @@ -19,30 +20,30 @@ "@rollup/plugin-json": "6.0.0", "@rollup/plugin-replace": "5.0.2", "@rollup/pluginutils": "5.0.2", - "@syuilo/aiscript": "0.13.3", - "@tabler/icons-webfont": "2.21.0", + "@syuilo/aiscript": "0.15.0", + "@tabler/icons-webfont": "2.25.0", "@vitejs/plugin-vue": "4.2.3", - "@vue-macros/reactivity-transform": "0.3.9", + "@vue-macros/reactivity-transform": "0.3.15", "@vue/compiler-sfc": "3.3.4", "astring": "1.8.6", "autosize": "6.0.1", "broadcast-channel": "5.1.0", "browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3", - "buraha": "github:misskey-dev/buraha", + "buraha": "0.0.1", "canvas-confetti": "1.6.0", "chart.js": "4.3.0", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "6.18.0", + "chromatic": "6.19.9", "compare-versions": "5.0.3", - "cropperjs": "2.0.0-beta.2", + "cropperjs": "2.0.0-beta.3", "date-fns": "2.30.0", "escape-regexp": "0.0.1", "estree-walker": "^3.0.3", "eventemitter3": "5.0.1", - "gsap": "3.11.5", + "gsap": "3.12.2", "idb-keyval": "6.2.1", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", @@ -50,97 +51,94 @@ "matter-js": "0.19.0", "mfm-js": "0.23.3", "misskey-js": "workspace:*", - "photoswipe": "5.3.7", + "photoswipe": "5.3.8", "prismjs": "1.29.0", "punycode": "2.3.0", "querystring": "0.2.1", - "rndstr": "1.0.0", - "rollup": "3.23.0", + "rollup": "3.26.3", "s-age": "1.1.2", - "sanitize-html": "2.10.0", - "sass": "1.62.1", - "seedrandom": "3.0.5", + "sanitize-html": "2.11.0", + "sass": "1.63.6", "strict-event-emitter-types": "2.0.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.153.0", + "three": "0.154.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.6", + "tsc-alias": "1.8.7", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "5.1.3", + "typescript": "5.1.6", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vite": "4.3.9", + "vite": "4.4.4", "vue": "3.3.4", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { - "@storybook/addon-actions": "7.0.18", - "@storybook/addon-essentials": "7.0.18", - "@storybook/addon-interactions": "7.0.18", - "@storybook/addon-links": "7.0.18", - "@storybook/addon-storysource": "7.0.18", - "@storybook/addons": "7.0.18", - "@storybook/blocks": "7.0.18", - "@storybook/core-events": "7.0.18", + "@storybook/addon-actions": "7.0.27", + "@storybook/addon-essentials": "7.0.27", + "@storybook/addon-interactions": "7.0.27", + "@storybook/addon-links": "7.0.27", + "@storybook/addon-storysource": "7.0.27", + "@storybook/addons": "7.0.27", + "@storybook/blocks": "7.0.27", + "@storybook/core-events": "7.0.27", "@storybook/jest": "0.1.0", - "@storybook/manager-api": "7.0.18", - "@storybook/preview-api": "7.0.18", - "@storybook/react": "7.0.18", - "@storybook/react-vite": "7.0.18", - "@storybook/testing-library": "0.1.0", - "@storybook/theming": "7.0.18", - "@storybook/types": "7.0.18", - "@storybook/vue3": "7.0.18", - "@storybook/vue3-vite": "7.0.18", + "@storybook/manager-api": "7.0.27", + "@storybook/preview-api": "7.0.27", + "@storybook/react": "7.0.27", + "@storybook/react-vite": "7.0.27", + "@storybook/testing-library": "0.2.0", + "@storybook/theming": "7.0.27", + "@storybook/types": "7.0.27", + "@storybook/vue3": "7.0.27", + "@storybook/vue3-vite": "7.0.27", "@testing-library/jest-dom": "5.16.5", "@testing-library/vue": "7.0.0", "@types/escape-regexp": "0.0.1", "@types/estree": "1.0.1", - "@types/gulp": "4.0.10", + "@types/gulp": "4.0.13", "@types/gulp-rename": "2.0.2", "@types/matter-js": "0.18.5", "@types/micromatch": "4.0.2", - "@types/node": "20.2.5", + "@types/node": "20.4.2", "@types/punycode": "2.1.0", "@types/sanitize-html": "2.9.0", - "@types/seedrandom": "3.0.5", - "@types/testing-library__jest-dom": "^5.14.6", + "@types/testing-library__jest-dom": "5.14.8", "@types/throttle-debounce": "5.0.0", "@types/tinycolor2": "1.4.3", - "@types/uuid": "9.0.1", + "@types/uuid": "9.0.2", "@types/websocket": "1.0.5", - "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.59.8", - "@typescript-eslint/parser": "5.59.8", - "@vitest/coverage-c8": "0.31.4", + "@types/ws": "8.5.5", + "@typescript-eslint/eslint-plugin": "5.61.0", + "@typescript-eslint/parser": "5.61.0", + "@vitest/coverage-v8": "0.33.0", "@vue/runtime-core": "3.3.4", - "acorn": "^8.8.2", - "chokidar-cli": "3.0.0", + "acorn": "8.10.0", "cross-env": "7.0.3", - "cypress": "12.13.0", - "eslint": "8.41.0", + "cypress": "12.17.1", + "eslint": "8.45.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.14.1", - "fast-glob": "3.2.12", - "happy-dom": "9.20.3", - "micromatch": "3.1.10", - "msw": "1.2.1", + "eslint-plugin-vue": "9.15.1", + "fast-glob": "3.3.0", + "happy-dom": "10.0.3", + "micromatch": "4.0.5", + "msw": "1.2.2", "msw-storybook-addon": "1.8.0", - "prettier": "2.8.8", + "nodemon": "3.0.1", + "prettier": "3.0.0", "react": "18.2.0", "react-dom": "18.2.0", "start-server-and-test": "2.0.0", - "storybook": "7.0.18", + "storybook": "7.0.27", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "summaly": "github:misskey-dev/summaly", "vite-plugin-turbosnap": "1.0.2", - "vitest": "0.31.4", + "vitest": "0.33.0", "vitest-fetch-mock": "0.2.2", - "vue-eslint-parser": "9.3.0", - "vue-tsc": "1.6.5" + "vue-eslint-parser": "9.3.1", + "vue-tsc": "1.8.5" } } diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 76e8c5072..0a351cd6e 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -13,10 +13,11 @@ import { miLocalStorage } from '@/local-storage'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements'; import { mainRouter } from '@/router'; import { initializeSw } from '@/scripts/initialize-sw'; +import { deckStore } from '@/ui/deck/deck-store'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( - new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : + new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : @@ -87,7 +88,7 @@ export async function mainBoot() { const now = new Date(); const m = now.getMonth() + 1; const d = now.getDate(); - + if ($i.birthday) { const bm = parseInt($i.birthday.split('-')[1]); const bd = parseInt($i.birthday.split('-')[2]); diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index c95da64bb..cb315c8ff 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -4,3 +4,4 @@ import { Cache } from '@/scripts/cache'; export const clipsCache = new Cache(Infinity); export const rolesCache = new Cache(Infinity); export const userListsCache = new Cache(Infinity); +export const antennasCache = new Cache(Infinity); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 8bfcfa6aa..7aa8f94c3 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -39,7 +39,7 @@ -

+
@@ -102,10 +102,6 @@ function openPostForm() { gap: 12px; } -.containerCenter { - text-align: center; -} - .fontSerif { font-family: serif; } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index fd892d817..9211d92df 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -356,9 +356,7 @@ onMounted(() => { props.textarea.addEventListener('keydown', onKeydown); - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', onMousedown); - } + document.body.addEventListener('mousedown', onMousedown); nextTick(() => { exec(); @@ -374,9 +372,7 @@ onMounted(() => { onBeforeUnmount(() => { props.textarea.removeEventListener('keydown', onKeydown); - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', onMousedown); - } + document.body.removeEventListener('mousedown', onMousedown); }); diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 630620fc0..437dce0a1 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -1,24 +1,29 @@ diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index fb11834f4..f39c94419 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -61,15 +61,11 @@ onMounted(() => { rootEl.style.top = `${top}px`; rootEl.style.left = `${left}px`; - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', onMousedown); - } + document.body.addEventListener('mousedown', onMousedown); }); onBeforeUnmount(() => { - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', onMousedown); - } + document.body.removeEventListener('mousedown', onMousedown); }); function onMousedown(evt: Event) { diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 82363499b..b2d60d36c 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -47,6 +47,7 @@ const emit = defineEmits<{ const props = defineProps<{ file: misskey.entities.DriveFile; aspectRatio: number; + uploadFolder?: string | null; }>(); const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); @@ -58,11 +59,17 @@ let loading = $ref(true); const ok = async () => { const promise = new Promise(async (res) => { const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); - croppedCanvas.toBlob(blob => { + croppedCanvas?.toBlob(blob => { + if (!blob) return; const formData = new FormData(); formData.append('file', blob); - formData.append('i', $i.token); - if (defaultStore.state.uploadFolder) { + formData.append('name', `cropped_${props.file.name}`); + formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); + formData.append('comment', props.file.comment ?? 'null'); + formData.append('i', $i!.token); + if (props.uploadFolder || props.uploadFolder === null) { + formData.append('folderId', props.uploadFolder ?? 'null'); + } else if (defaultStore.state.uploadFolder) { formData.append('folderId', defaultStore.state.uploadFolder); } @@ -82,12 +89,12 @@ const ok = async () => { const f = await promise; emit('ok', f); - dialogEl.close(); + dialogEl!.close(); }; const cancel = () => { emit('cancel'); - dialogEl.close(); + dialogEl!.close(); }; const onImageLoad = () => { @@ -100,7 +107,7 @@ const onImageLoad = () => { }; onMounted(() => { - cropper = new Cropper(imgEl, { + cropper = new Cropper(imgEl!, { }); const computedStyle = getComputedStyle(document.documentElement); @@ -112,13 +119,13 @@ onMounted(() => { selection.outlined = true; window.setTimeout(() => { - cropper.getCropperImage()!.$center('contain'); + cropper!.getCropperImage()!.$center('contain'); selection.$center(); }, 100); // モーダルオープンアニメーションが終わったあとで再度調整 window.setTimeout(() => { - cropper.getCropperImage()!.$center('contain'); + cropper!.getCropperImage()!.$center('contain'); selection.$center(); }, 500); }); diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index f0641161b..8b3f91731 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -19,14 +19,14 @@
-

{{ i18n.ts.nsfw }}

+

{{ i18n.ts.sensitive }}

- {{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }} - {{ file.name.substr(file.name.lastIndexOf('.')) }} + {{ file.name.lastIndexOf('.') != -1 ? file.name.substring(0, file.name.lastIndexOf('.')) : file.name }} + {{ file.name.substring(file.name.lastIndexOf('.')) }}

@@ -44,6 +44,7 @@ import { getDriveFileMenu } from '@/scripts/get-drive-file-menu'; const props = withDefaults(defineProps<{ file: Misskey.entities.DriveFile; + folder: Misskey.entities.DriveFolder | null; isSelected?: boolean; selectMode?: boolean; }>(), { @@ -65,12 +66,12 @@ function onClick(ev: MouseEvent) { if (props.selectMode) { emit('chosen', props.file); } else { - os.popupMenu(getDriveFileMenu(props.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); + os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } } function onContextmenu(ev: MouseEvent) { - os.contextMenu(getDriveFileMenu(props.file), ev); + os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); } function onDragstart(ev: DragEvent) { diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 196934240..13f32ff7a 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -33,6 +33,7 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; import { claimAchievement } from '@/scripts/achievements'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -93,9 +94,9 @@ function onDragover(ev: DragEvent) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': - case 'copy': - case 'copyLink': - case 'copyMove': + case 'copy': + case 'copyLink': + case 'copyMove': ev.dataTransfer.dropEffect = 'copy'; break; case 'linkMove': @@ -244,7 +245,8 @@ function setAsUploadFolder() { } function onContextmenu(ev: MouseEvent) { - os.contextMenu([{ + let menu; + menu = [{ text: i18n.ts.openInWindow, icon: 'ti ti-app-window', action: () => { @@ -262,7 +264,17 @@ function onContextmenu(ev: MouseEvent) { icon: 'ti ti-trash', danger: true, action: deleteFolder, - }], ev); + }]; + if (defaultStore.state.devMode) { + menu = menu.concat([null, { + icon: 'ti ti-id', + text: i18n.ts.copyFolderId, + action: () => { + copyToClipboard(props.folder.id); + }, + }]); + } + os.contextMenu(menu, ev); } diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index 3349603d3..df4c209c2 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -61,9 +61,9 @@ function onDragover(ev: DragEvent) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': - case 'copy': - case 'copyLink': - case 'copyMove': + case 'copy': + case 'copyLink': + case 'copyMove': ev.dataTransfer.dropEffect = 'copy'; break; case 'linkMove': diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 52aef450d..aff227da4 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -56,7 +56,7 @@ />
- {{ i18n.ts.loadMore }} + {{ i18n.ts.loadMore }}
{ + if (folders.length === max + 1) { + moreFolders.value = true; + folders.pop(); + } else { + moreFolders.value = false; + } + for (const x of folders) appendFolder(x); + fetching.value = false; + }); +} + function fetchMoreFiles() { fetching.value = true; @@ -568,7 +591,7 @@ function fetchMoreFiles() { os.api('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, - untilId: files.value[files.value.length - 1].id, + untilId: files.value.at(-1)?.id, limit: max + 1, }).then(files => { if (files.length === max + 1) { diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue index 71a35ae6e..77b38b4bb 100644 --- a/packages/frontend/src/components/MkFileListForAdmin.vue +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -89,7 +89,7 @@ const props = defineProps<{ > .file { position: relative; aspect-ratio: 1; - + > .thumbnail { width: 100%; height: 100%; diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index 7c9ae155a..b5505ac8f 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -87,7 +87,7 @@ const props = defineProps<{ @media (max-width: 500px) { font-size: 10px; - + > article { padding: 8px; diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 672a28f6d..4e36defb7 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -22,10 +22,13 @@ import TestWebGL2 from '@/workers/test-webgl2?worker'; import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; -const workerPromise = new Promise(resolve => { +const canvasPromise = new Promise(resolve => { // テスト環境で Web Worker インスタンスは作成できない if (import.meta.env.MODE === 'test') { - resolve(null); + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + resolve(canvas); return; } const testWorker = new TestWebGL2(); @@ -38,7 +41,10 @@ const workerPromise = new Promise(resolve => { resolve(workers); if (_DEV_) console.log('WebGL2 in worker is supported!'); } else { - resolve(null); + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + resolve(canvas); if (_DEV_) console.log('WebGL2 in worker is not supported...'); } testWorker.terminate(); @@ -70,6 +76,7 @@ const props = withDefaults(defineProps<{ width?: number; cover?: boolean; forceBlurhash?: boolean; + onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画 }>(), { transition: null, src: null, @@ -79,6 +86,7 @@ const props = withDefaults(defineProps<{ width: 64, cover: true, forceBlurhash: false, + onlyAvgColor: false, }); const viewId = uuid(); @@ -100,7 +108,7 @@ function waitForDecode() { .then(() => { loaded = true; }, error => { - console.error('Error occured during decoding image', img.value, error); + console.error('Error occurred during decoding image', img.value, error); throw Error(error); }); } else { @@ -139,8 +147,8 @@ function drawImage(bitmap: CanvasImageSource) { ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight); } -async function draw() { - if (!canvas.value || props.hash == null) return; +function drawAvg() { + if (!canvas.value || !props.hash) return; const ctx = canvas.value.getContext('2d'); if (!ctx) return; @@ -149,27 +157,30 @@ async function draw() { ctx.beginPath(); ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); +} - const workers = await workerPromise; - if (workers) { - workers.postMessage( +async function draw() { + if (props.hash == null) return; + + drawAvg(); + + if (props.onlyAvgColor) return; + + const work = await canvasPromise; + if (work instanceof WorkerMultiDispatch) { + work.postMessage( { id: viewId, hash: props.hash, - width: canvasWidth, - height: canvasHeight, }, undefined, ); } else { try { - const work = document.createElement('canvas'); - work.width = canvasWidth; - work.height = canvasHeight; render(props.hash, work); - ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight); + drawImage(work); } catch (error) { - console.error('Error occured during drawing blurhash', error); + console.error('Error occurred during drawing blurhash', error); } } } @@ -179,9 +190,9 @@ function workerOnMessage(event: MessageEvent) { drawImage(event.data.bitmap as ImageBitmap); } -workerPromise.then(worker => { - if (worker) { - worker.addListener(workerOnMessage); +canvasPromise.then(work => { + if (work instanceof WorkerMultiDispatch) { + work.addListener(workerOnMessage); } draw(); @@ -204,8 +215,10 @@ onMounted(() => { }); onUnmounted(() => { - workerPromise.then(worker => { - worker?.removeListener(workerOnMessage); + canvasPromise.then(work => { + if (work instanceof WorkerMultiDispatch) { + work.removeListener(workerOnMessage); + } }); }); diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts new file mode 100644 index 000000000..def0a96e6 --- /dev/null +++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed, inviteCode } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkInviteCode from './MkInviteCode.vue'; + +export const Default = { + render(args) { + return { + components: { + MkInviteCode, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + invite: inviteCode() as any, + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/show', (req, res, ctx) => { + return res(ctx.json(userDetailed(req.params.userId as string))); + }), + ], + }, + }, + decorators: [() => ({ + template: '
', + })], +} satisfies StoryObj; + +export const Used = { + ...Default, + args: { + invite: inviteCode(true) as any + }, +} satisfies StoryObj; + +export const Expired = { + ...Default, + args: { + invite: inviteCode(false, true, true) as any + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue new file mode 100644 index 000000000..97bf73235 --- /dev/null +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 2e4f93e84..8e61c7048 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -1,6 +1,6 @@ @@ -113,6 +114,21 @@ function showMenu(ev: MouseEvent) { align-items: center; } +.hide { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 12px; + opacity: .5; + padding: 5px 8px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; +} + .hiddenTextWrapper { display: table-cell; text-align: center; @@ -137,8 +153,8 @@ function showMenu(ev: MouseEvent) { backdrop-filter: var(--blur, blur(15px)); color: #fff; font-size: 0.8em; - width: 32px; - height: 32px; + width: 28px; + height: 28px; text-align: center; bottom: 10px; right: 10px; diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index a0a245005..be0aed652 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -113,8 +113,10 @@ onMounted(() => { right: 0, }, imageClickAction: 'close', - tapAction: 'toggle-controls', + tapAction: 'close', bgOpacity: 1, + showAnimationDuration: 100, + hideAnimationDuration: 100, pswpModule: PhotoSwipe, }); diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 40bae90b5..dc5807b2d 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -17,8 +17,8 @@ controls @contextmenu.stop > - diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 89050e10f..e88445570 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -59,8 +59,8 @@ function draw(): void { polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`; - headX = _polylinePoints[_polylinePoints.length - 1][0]; - headY = _polylinePoints[_polylinePoints.length - 1][1]; + headX = _polylinePoints.at(-1)![0]; + headY = _polylinePoints.at(-1)![1]; } watch(() => props.src, draw, { immediate: true }); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 7c9ddadbf..deeae6e94 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -165,6 +165,7 @@ import { getNoteSummary } from '@/scripts/get-note-summary'; import { MenuItem } from '@/types/menu'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog'; +import { shouldCollapsed } from '@/scripts/collapsed'; const props = defineProps<{ note: misskey.entities.Note; @@ -204,17 +205,7 @@ let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; -const isLong = (appearNote.cw == null && appearNote.text != null && ( - (appearNote.text.includes('$[x2')) || - (appearNote.text.includes('$[x3')) || - (appearNote.text.includes('$[x4')) || - (appearNote.text.includes('$[scale')) || - (appearNote.text.includes('$[position')) || - (appearNote.text.split('\n').length > 9) || - (appearNote.text.length > 500) || - (appearNote.files.length >= 5) || - (urls && urls.length >= 4) -)); +const isLong = shouldCollapsed(appearNote); const collapsed = ref(appearNote.cw == null && isLong); const isDeleted = ref(false); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); @@ -222,7 +213,7 @@ const translation = ref(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id); -let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId)) || (appearNote.myReaction != null))); +let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); const keymap = { 'r': () => reply(true), @@ -259,6 +250,17 @@ useTooltip(renoteButton, async (showing) => { }, {}, 'closed'); }); +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +// defaultStore.state.visibilityがstringなためstringも受け付けている +function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { + if (a === 'specified' || b === 'specified') return 'specified'; + if (a === 'followers' || b === 'followers') return 'followers'; + if (a === 'home' || b === 'home') return 'home'; + // if (a === 'public' || b === 'public') + return 'public'; +} + function renote(viaKeyboard = false) { pleaseLogin(); showMovedDialog(); @@ -309,7 +311,12 @@ function renote(viaKeyboard = false) { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } + const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; + const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; + os.api('notes/create', { + localOnly, + visibility: smallerVisibility(appearNote.visibility, configuredVisibility), renoteId: appearNote.id, }).then(() => { os.toast(i18n.ts.renoted); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index a65039277..1f8a36b8d 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -293,7 +293,7 @@ function renote(viaKeyboard = false) { const y = rect.top + (el.offsetHeight / 2); os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - + os.api('notes/create', { renoteId: appearNote.id, }).then(() => { diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 709b5a52d..6e35ad424 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -17,25 +17,27 @@ -
+
+ + diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index aabebb3ab..69d495d86 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -6,7 +6,7 @@ :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]" @click="toggleReaction()" > - + {{ count }} diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index 9f56189f3..276bd6f98 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -90,6 +90,7 @@ onMounted(async () => { ticks: { callback: (value, index, values) => value + '%', }, + min: 0, }, }, interaction: { diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index b6ffba6cc..de5195ab4 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -9,7 +9,10 @@ {{ i18n.ts.invitationRequiredToRegister }}
-
{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}
+
+
{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}
+
{{ i18n.ts.beSureToReadThisAsItIsImportant }}
+
@@ -19,7 +22,7 @@
  • - {{ i18n.ts.agree }} + {{ i18n.ts.agree }}
    @@ -28,7 +31,7 @@ {{ i18n.ts.termsOfService }} - {{ i18n.ts.agree }} + {{ i18n.ts.agree }} @@ -37,7 +40,7 @@ {{ i18n.ts.basicNotesBeforeCreateAccount }} - {{ i18n.ts.agree }} + {{ i18n.ts.agree }}
    {{ i18n.ts.pleaseAgreeAllToContinue }}
    @@ -52,13 +55,14 @@ diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 3a050889c..3a032a116 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -15,9 +15,12 @@ {{ i18n.ts.poll }} - +
    @@ -28,16 +31,15 @@ import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import { i18n } from '@/i18n'; import { $i } from '@/account'; +import { shouldCollapsed } from '@/scripts/collapsed'; const props = defineProps<{ note: misskey.entities.Note; }>(); -const collapsed = $ref( - props.note.cw == null && props.note.text != null && ( - (props.note.text.split('\n').length > 9) || - (props.note.text.length > 500) - )); +const isLong = shouldCollapsed(props.note); + +const collapsed = $ref(isLong); diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 72b70416d..0bc9b0316 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -46,7 +46,7 @@ defineProps<{ margin: 0 0 8px 0; font-size: 0.9em; } - + > .items { > .item { display: flex; diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index fcad5b806..f7b1b7dff 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -32,7 +32,7 @@
    - +
    @@ -52,19 +52,21 @@
    -
    - - {{ i18n.ts.expandTweet }} - -
    -
    - - {{ i18n.ts.enablePlayer }} - - - {{ i18n.ts.openInWindow }} - -
    +
    @@ -85,9 +87,11 @@ const props = withDefaults(defineProps<{ url: string; detail?: boolean; compact?: boolean; + showActions?: boolean; }>(), { detail: false, compact: false, + showActions: true, }); const MOBILE_THRESHOLD = 500; diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index 36a9e2f73..d360169c8 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -1,7 +1,7 @@ diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 172b51751..5e538cc52 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -15,13 +15,13 @@
    -

    {{ i18n.ts.notes }}

    {{ user.notesCount }} +

    {{ i18n.ts.notes }}

    {{ number(user.notesCount) }}
    -
    -

    {{ i18n.ts.following }}

    {{ user.followingCount }} +
    +

    {{ i18n.ts.following }}

    {{ number(user.followingCount) }}
    -
    -

    {{ i18n.ts.followers }}

    {{ user.followersCount }} +
    +

    {{ i18n.ts.followers }}

    {{ number(user.followersCount) }}
    @@ -31,9 +31,11 @@ diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts index 2708b759a..6706d08f2 100644 --- a/packages/frontend/src/components/global/i18n.ts +++ b/packages/frontend/src/components/global/i18n.ts @@ -11,13 +11,13 @@ export default function(props: { src: string; tag?: string; textTag?: string; }, parsed.push(str); break; } else { - if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); + if (nextBracketOpen > 0) parsed.push(str.substring(0, nextBracketOpen)); parsed.push({ arg: str.substring(nextBracketOpen + 1, nextBracketClose), }); } - str = str.substr(nextBracketClose + 1); + str = str.substring(nextBracketClose + 1); } return h(props.tag ?? 'span', parsed.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]())); diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index ad7fa372e..1d883c038 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -57,6 +57,9 @@ export const ROLE_POLICIES = [ 'ltlAvailable', 'canPublicNote', 'canInvite', + 'inviteLimit', + 'inviteLimitCycle', + 'inviteExpirationTime', 'canManageCustomEmojis', 'canSearchNotes', 'canHideAds', diff --git a/packages/frontend/src/directives/adaptive-bg.ts b/packages/frontend/src/directives/adaptive-bg.ts index 313aad799..83bcd7089 100644 --- a/packages/frontend/src/directives/adaptive-bg.ts +++ b/packages/frontend/src/directives/adaptive-bg.ts @@ -10,7 +10,7 @@ export default { return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; } }; - + const parentBg = getBgColor(src.parentElement); const myBg = window.getComputedStyle(src).backgroundColor; diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts index 619c9f0b6..5bd04024b 100644 --- a/packages/frontend/src/directives/adaptive-border.ts +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -10,7 +10,7 @@ export default { return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; } }; - + const parentBg = getBgColor(src.parentElement); const myBg = window.getComputedStyle(src).backgroundColor; diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts index d31dc41ed..8727183d3 100644 --- a/packages/frontend/src/directives/panel.ts +++ b/packages/frontend/src/directives/panel.ts @@ -10,7 +10,7 @@ export default { return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; } }; - + const parentBg = getBgColor(src.parentElement); const myBg = getComputedStyle(document.documentElement).getPropertyValue('--panel'); diff --git a/packages/frontend/src/filters/date.ts b/packages/frontend/src/filters/date.ts index 706b7d60c..9bc9bfe8a 100644 --- a/packages/frontend/src/filters/date.ts +++ b/packages/frontend/src/filters/date.ts @@ -1,4 +1,4 @@ import { dateTimeFormat } from '@/scripts/intl-const'; -export default (d: Date | number | undefined) => dateTimeFormat.format(d); +export default (d: Date | number | undefined) => dateTimeFormat.format(d); export const dateString = (d: string) => dateTimeFormat.format(new Date(d)); diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index ca4f21f79..f9d04f795 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -14,7 +14,7 @@ type Keys = 'wallpaper' | 'theme' | 'colorScheme' | - 'useSystemFont' | + 'useSystemFont' | 'fontSize' | 'ui' | 'ui_temp' | diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts index 68977ed79..3a03444de 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/nirax.ts @@ -1,8 +1,7 @@ // NIRAX --- A lightweight router import { EventEmitter } from 'eventemitter3'; -import { Component, shallowRef, ShallowRef } from 'vue'; -import { pleaseLogin } from '@/scripts/please-login'; +import { Component, onMounted, shallowRef, ShallowRef } from 'vue'; import { safeURIDecode } from '@/scripts/safe-uri-decode'; type RouteDef = { @@ -23,7 +22,7 @@ type ParsedPath = (string | { optional?: boolean; })[]; -export type Resolved = { route: RouteDef; props: Map; child?: Resolved; }; +export type Resolved = { route: RouteDef; props: Map; child?: Resolved; }; function parsePath(path: string): ParsedPath { const res = [] as ParsedPath; @@ -75,15 +74,19 @@ export class Router extends EventEmitter<{ public currentRef: ShallowRef = shallowRef(); public currentRoute: ShallowRef = shallowRef(); private currentPath: string; + private isLoggedIn: boolean; + private notFoundPageComponent: Component; private currentKey = Date.now().toString(); public navHook: ((path: string, flag?: any) => boolean) | null = null; - constructor(routes: Router['routes'], currentPath: Router['currentPath']) { + constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) { super(); this.routes = routes; this.currentPath = currentPath; + this.isLoggedIn = isLoggedIn; + this.notFoundPageComponent = notFoundPageComponent; this.navigate(currentPath, null, false); } @@ -159,11 +162,11 @@ export class Router extends EventEmitter<{ if (route.hash != null && hash != null) { props.set(route.hash, safeURIDecode(hash)); } - + if (route.query != null && queryString != null) { const queryObject = [...new URLSearchParams(queryString).entries()] .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); - + for (const q in route.query) { const as = route.query[q]; if (queryObject[q]) { @@ -171,7 +174,7 @@ export class Router extends EventEmitter<{ } } } - + return { route, props, @@ -212,8 +215,9 @@ export class Router extends EventEmitter<{ throw new Error('no route found for: ' + path); } - if (res.route.loginRequired) { - pleaseLogin('/'); + if (res.route.loginRequired && !this.isLoggedIn) { + res.route.component = this.notFoundPageComponent; + res.props.set('showLoginPopup', true); } const isSamePath = beforePath === path; @@ -263,13 +267,33 @@ export class Router extends EventEmitter<{ }); } - public replace(path: string, key?: string | null, emitEvent = true) { + public replace(path: string, key?: string | null) { this.navigate(path, key); - if (emitEvent) { - this.emit('replace', { - path, - key: this.currentKey, - }); - } } } + +export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: Router) { + const scrollPosStore = new Map(); + + onMounted(() => { + const scrollContainer = getScrollContainer(); + + scrollContainer.addEventListener('scroll', () => { + scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop); + }, { passive: true }); + + router.addListener('change', ctx => { + const scrollPos = scrollPosStore.get(ctx.key) ?? 0; + scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); + if (scrollPos !== 0) { + window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール + scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); + }, 100); + } + }); + + router.addListener('same', () => { + scrollContainer.scroll({ top: 0, behavior: 'smooth' }); + }); + }); +} diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index c44d34804..1a5ed9054 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -460,11 +460,13 @@ export async function pickEmoji(src: HTMLElement | null, opts) { export async function cropImage(image: Misskey.entities.DriveFile, options: { aspectRatio: number; + uploadFolder?: string | null; }): Promise { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { file: image, aspectRatio: options.aspectRatio, + uploadFolder: options.uploadFolder, }, { ok: x => { resolve(x); diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 0017145fa..6d2f7e155 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -88,10 +88,13 @@
    - Mask Network + Mask Network
    - DC Advirth + Skeb +
    +
    + DC Advirth
    @@ -155,6 +158,30 @@ const patronsWithIcon = [{ }, { name: 'spinlock', icon: 'https://misskey-hub.net/patrons/6a1cebc819d540a78bf20e9e3115baa8.jpg', +}, { + name: 'じゅくま', + icon: 'https://misskey-hub.net/patrons/3e56bdac69dd42f7a06e0f12cf2fc895.jpg', +}, { + name: '清遊あみ', + icon: 'https://misskey-hub.net/patrons/de25195b88e940a388388bea2e7637d8.jpg', +}, { + name: 'Nagi8410', + icon: 'https://misskey-hub.net/patrons/31b102ab4fc540ed806b0461575d38be.jpg', +}, { + name: '山岡士郎', + icon: 'https://misskey-hub.net/patrons/84b9056341684266bb1eda3e680d094d.jpg', +}, { + name: 'よもやまたろう', + icon: 'https://misskey-hub.net/patrons/4273c9cce50d445f8f7d0f16113d6d7f.jpg', +}, { + name: '花咲ももか', + icon: 'https://misskey-hub.net/patrons/8c9b2b9128cb4fee99f04bb4f86f2efa.jpg', +}, { + name: 'カガミ', + icon: 'https://misskey-hub.net/patrons/226ea3a4617749548580ec2d9a263e24.jpg', +}, { + name: 'フランギ・シュウ', + icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg', }]; const patrons = [ @@ -250,6 +277,9 @@ const patrons = [ 'binvinyl', '渡志郎', 'ぷーざ', + '越貝鯛丸', + 'Nick / pprmint.', + 'kino3277', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index 3744bed10..cc0bf2eed 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -20,7 +20,7 @@
    - +
    @@ -56,7 +56,7 @@ function search() { const queryarry = q.match(/\:([a-z0-9_]*)\:/g); if (queryarry) { - searchEmojis = customEmojis.value.filter(emoji => + searchEmojis = customEmojis.value.filter(emoji => queryarry.includes(`:${emoji.name}:`), ); } else { diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue index 24c863ba6..57e50b692 100644 --- a/packages/frontend/src/pages/admin-file.vue +++ b/packages/frontend/src/pages/admin-file.vue @@ -32,7 +32,7 @@
    - NSFW + {{ i18n.ts.sensitive }}
    diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 3bc5ee972..9cf96d3d0 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -75,7 +75,7 @@ const pagination = { }; function resolved(reportId) { - reports.removeItem(item => item.id === reportId); + reports.removeItem(reportId); } const headerActions = $computed(() => []); diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 2c9e18b0b..9a5bd88b2 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -36,6 +36,16 @@ + + + + {{ i18n.ts._ad.timezoneinfo }} +
    + + +
    +
    +
    @@ -59,6 +69,7 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkRadios from '@/components/MkRadios.vue'; +import MkFolder from '@/components/MkFolder.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -69,6 +80,7 @@ let ads: any[] = $ref([]); // ISO形式はTZがUTCになってしまうので、TZ分ずらして時間を初期化 const localTime = new Date(); const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000; +const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday]; os.api('admin/ad/list').then(adsResponse => { ads = adsResponse.map(r => { @@ -84,6 +96,11 @@ os.api('admin/ad/list').then(adsResponse => { }); }); +// 選択された曜日(index)のビットフラグを操作する +function toggleDayOfWeek(ad, index) { + ad.dayOfWeek ^= 1 << index; +} + function add() { ads.unshift({ id: null, @@ -95,6 +112,7 @@ function add() { imageUrl: null, expiresAt: null, startsAt: null, + dayOfWeek: 0, }); } @@ -105,6 +123,7 @@ function remove(ad) { }).then(({ canceled }) => { if (canceled) return; ads = ads.filter(x => x !== ad); + if (ad.id == null) return; os.apiWithDialog('admin/ad/delete', { id: ad.id, }); diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 8b083bc89..e91f65b5d 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -1,6 +1,6 @@ - +
    + +
    diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue index e656f00bb..baba9e4da 100644 --- a/packages/frontend/src/ui/minimum.vue +++ b/packages/frontend/src/ui/minimum.vue @@ -1,6 +1,8 @@