diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 94d7d1371..5b1f1de4f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,7 +37,6 @@ jobs: - frontend - sw lint: - - typecheck - eslint steps: - uses: actions/checkout@v3.3.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2a5c252..06772a02a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,27 @@ You should also include the user name that made the change. --> -## 13.x.x (unreleased) +## 13.7.0 (2023/02/22) + +### Changes +- チャット機能が削除されました ### Improvements - Server: URLプレビュー(summaly)はプロキシを通すように +- Client: 2FA設定のUIをまともにした +- セキュリティキーの名前を変更できるように +- enhance(client): add quiz preset for play +- 広告開始時期を設定できるように +- みつけるで公開ロール一覧とそのメンバーを閲覧できるように +- enhance(client): MFMのx3, x4が含まれていたらノートをたたむように +- enhance(client): make possible to reload page of window ### Bugfixes -- +- ユーザー検索ダイアログでローカルユーザーを絞って検索できない問題を修正 +- fix(client): MkHeader及びデッキのカラムでチャンネル一覧を選択したとき、最大5個までしか表示されない +- 管理画面の広告を10個以上見えるように +- Moderation note が保存できない +- ユーザーのハッシュタグ検索が機能していないのを修正 ## 13.6.1 (2023/02/12) diff --git a/ROADMAP.md b/ROADMAP.md index c95bb8d92..420f72875 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,9 @@ Also, the later tasks are more indefinite and are subject to change as developme ## (1) Improve maintainability \ This is the phase we are at now. We need to make a high-maintenance environment that can withstand future development. -- Make the number of type errors zero (backend) +- ~~Make the number of type errors zero (backend)~~ → Done ✔️ - Improve CI - - Fix tests + - ~~Fix tests~~ → Done ✔️ - Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986 - Add more tests - ~~May need to implement a mechanism that allows for DI~~ → Done ✔️ diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..7b28f690a --- /dev/null +++ b/codecov.yml @@ -0,0 +1,5 @@ +coverage: + status: + project: + default: + only_pulls: true diff --git a/locales/de-DE.yml b/locales/de-DE.yml index fb80b9285..5173ea01b 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -103,6 +103,8 @@ renoted: "Renote getätigt." cantRenote: "Renote dieses Beitrags nicht möglich." cantReRenote: "Renote einer Renote nicht möglich." quote: "Zitieren" +inChannelRenote: "Kanal-interner Renote" +inChannelQuote: "Kanal-internes Zitat" pinnedNote: "Angeheftete Notiz" pinned: "Angeheftet" you: "Du" @@ -467,6 +469,8 @@ youHaveNoGroups: "Keine Gruppen vorhanden" joinOrCreateGroup: "Lass dich zu einer Gruppe einladen oder erstelle deine eigene." noHistory: "Kein Verlauf gefunden" signinHistory: "Anmeldungsverlauf" +enableAdvancedMfm: "Erweitertes MFM aktivieren" +enableAnimatedMfm: "Animiertes MFM aktivieren" doing: "In Bearbeitung …" category: "Kategorie" tags: "Schlagwörter" @@ -945,6 +949,14 @@ selectFromPresets: "Aus Vorlagen wählen" achievements: "Errungenschaften" gotInvalidResponseError: "Ungültige Antwort des Servers" gotInvalidResponseErrorDescription: "Eventuell ist der Server momentan nicht erreichbar oder untergeht Wartungsarbeiten. Bitte versuche es später noch einmal." +thisPostMayBeAnnoying: "Dieser Beitrag stört eventuell andere Benutzer." +thisPostMayBeAnnoyingHome: "Zur Startseite schicken" +thisPostMayBeAnnoyingCancel: "Abbrechen" +thisPostMayBeAnnoyingIgnore: "Trotzdem schicken" +collapseRenotes: "Bereits gesehene Renotes verkürzt anzeigen" +internalServerError: "Serverinterner Fehler" +internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten." +copyErrorInfo: "Fehlerdetails kopieren" _achievements: earnedAt: "Freigeschaltet am" _types: diff --git a/locales/en-US.yml b/locales/en-US.yml index beb5242b1..e82772d87 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -103,6 +103,8 @@ renoted: "Renoted." cantRenote: "This post can't be renoted." cantReRenote: "A renote can't be renoted." quote: "Quote" +inChannelRenote: "Channel-only Renote" +inChannelQuote: "Channel-only Quote" pinnedNote: "Pinned note" pinned: "Pin to profile" you: "You" @@ -468,7 +470,7 @@ joinOrCreateGroup: "Get invited to a group or create your own." noHistory: "No history available" signinHistory: "Login history" enableAdvancedMfm: "Enable advanced MFM" -enableAnimatedMfm: "Enable MFM with animation" +enableAnimatedMfm: "Enable animated MFM" doing: "Processing..." category: "Category" tags: "Tags" @@ -951,6 +953,10 @@ thisPostMayBeAnnoying: "This note may annoy others." thisPostMayBeAnnoyingHome: "Post to home timeline" thisPostMayBeAnnoyingCancel: "Cancel" thisPostMayBeAnnoyingIgnore: "Post anyway" +collapseRenotes: "Collapse renotes you've already seen" +internalServerError: "Internal Server Error" +internalServerErrorDescription: "The server has run into an unexpected error." +copyErrorInfo: "Copy error details" _achievements: earnedAt: "Unlocked at" _types: diff --git a/locales/it-IT.yml b/locales/it-IT.yml index fbee5d971..1788756ca 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -84,12 +84,12 @@ error: "Errore" somethingHappened: "Si è verificato un problema" retry: "Riprova" pageLoadError: "Caricamento pagina non riuscito. " -pageLoadErrorDescription: "Questo viene normalmente causato dalla rete o dalla cache del browser. Si prega di pulire la cache, o di attendere e riprovare più tardi." +pageLoadErrorDescription: "Questo problema viene normalmente causato da errori di rete o dalla cache del browser. Si prega di pulire la cache, o di attendere e riprovare più tardi." serverIsDead: "Il server non risponde. Si prega di attendere e riprovare più tardi." youShouldUpgradeClient: "Per visualizzare la pagina è necessario aggiornare il client alla nuova versione e ricaricare." enterListName: "Nome della lista" privacy: "Privacy" -makeFollowManuallyApprove: "Richiedi di approvare i follower manualmente" +makeFollowManuallyApprove: "Approva i follower manualmente" defaultNoteVisibility: "Privacy predefinita delle note" follow: "Segui" followRequest: "Richiesta di follow" @@ -103,6 +103,8 @@ renoted: "Rinotato!" cantRenote: "È impossibile rinotare questa nota." cantReRenote: "È impossibile rinotare una Rinota." quote: "Cita" +inChannelRenote: "Rinota nel canale" +inChannelQuote: "Cita nel canale" pinnedNote: "Nota fissata" pinned: "Fissa sul profilo" you: "Tu" @@ -129,6 +131,7 @@ unblockConfirm: "Vuoi davvero sbloccare il profilo?" suspendConfirm: "Vuoi sospendere questo profilo?" unsuspendConfirm: "Vuoi revocare la sospensione si questo profilo?" selectList: "Seleziona una lista" +selectChannel: "Seleziona canale" selectAntenna: "Scegli un'antenna" selectWidget: "Seleziona il riquadro" editWidgets: "Modifica i riquadri" @@ -256,6 +259,8 @@ noMoreHistory: "Non c'è più cronologia da visualizzare" startMessaging: "Nuovo messaggio" nUsersRead: "Letto da {n} persone" agreeTo: "Sono d'accordo con {0}" +agreeBelow: "Accetto quanto riportato sotto" +basicNotesBeforeCreateAccount: "Note importanti" tos: "Termini di servizio" start: "Inizia!" home: "Home" @@ -464,6 +469,8 @@ youHaveNoGroups: "Nessun gruppo" joinOrCreateGroup: "Puoi creare il tuo gruppo o essere invitat@ a gruppi che già esistono." noHistory: "Nessuna cronologia" signinHistory: "Storico degli accessi al profilo" +enableAdvancedMfm: "Attiva MFM avanzati" +enableAnimatedMfm: "Attiva MFM animati" doing: "In corso..." category: "Categoria" tags: "Tag" @@ -860,6 +867,8 @@ failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul pro rateLimitExceeded: "Superato il limite di velocità." cropImage: "Ritaglio dell'immagine" cropImageAsk: "Si desidera ritagliare l'immagine?" +cropYes: "Ritaglia" +cropNo: "Non ritagliare" file: "Allegati" recentNHours: "Ultime {n} ore" recentNDays: "Ultimi {n} giorni" @@ -938,6 +947,16 @@ cannotPerformTemporaryDescription: "L'attività non può essere svolta, poiché preset: "Preimpostato" selectFromPresets: "Seleziona preimpostato" achievements: "Obiettivi raggiunti" +gotInvalidResponseError: "Risposta del server non valida" +gotInvalidResponseErrorDescription: "Il server potrebbe essere irraggiungibile o in manutenzione. Riprova più tardi." +thisPostMayBeAnnoying: "Questa nota potrebbe essere offensiva" +thisPostMayBeAnnoyingHome: "Pubblica sulla timeline principale" +thisPostMayBeAnnoyingCancel: "Annulla" +thisPostMayBeAnnoyingIgnore: "Pubblica lo stesso" +collapseRenotes: "Comprimi i Rinota già letti" +internalServerError: "Errore interno del server" +internalServerErrorDescription: "Si è verificato un errore imprevisto all'interno del server" +copyErrorInfo: "Copia le informazioni sull'errore" _achievements: earnedAt: "Data di conseguimento" _types: @@ -1526,12 +1545,15 @@ _permissions: "read:gallery-likes": "Visualizza i contenuti della galleria." "write:gallery-likes": "Manipolazione dei \"Mi piace\" della galleria." _auth: + shareAccessTitle: "Permessi dell'applicazione" shareAccess: "Vuoi autorizzare {name} ad accedere al tuo profilo?" shareAccessAsk: "Vuoi autorizzare questa App ad accedere al tuo profilo?" + permission: "{name} richiede i permessi seguenti" permissionAsk: "Questa app richiede le seguenti autorizzazioni:" pleaseGoBack: "Si prega di ritornare sulla app" callback: "Ritornando sulla app" denied: "Accesso negato" + pleaseLogin: "Per favore accedi al tuo account per cambiare i permessi dell'applicazione" _antennaSources: all: "Tutte le note" homeTimeline: "Note dagli utenti che segui" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b296bef29..6eaa84aa2 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -394,17 +394,20 @@ userList: "リスト" about: "情報" aboutMisskey: "Misskeyについて" administrator: "管理者" -token: "トークン" -twoStepAuthentication: "二段階認証" +token: "確認コード" +2fa: "二要素認証" +totp: "認証アプリ" +totpDescription: "認証アプリを使ってワンタイムパスワードを入力" moderator: "モデレーター" moderation: "モデレーション" nUsersMentioned: "{n}人が投稿" +securityKeyAndPasskey: "セキュリティキー・パスキー" securityKey: "セキュリティキー" -securityKeyName: "キーの名前" -registerSecurityKey: "セキュリティキーを登録する" lastUsed: "最後の使用" +lastUsedAt: "最後の使用: {t}" unregister: "登録を解除" -passwordLessLogin: "パスワード無しログイン" +passwordLessLogin: "パスワードレスログイン" +passwordLessLoginDescription: "パスワードを使用せず、セキュリティキーやパスキーなどのみでログインします" resetPassword: "パスワードをリセット" newPasswordIs: "新しいパスワードは「{password}」です" reduceUiAnimation: "UIのアニメーションを減らす" @@ -449,7 +452,6 @@ passwordMatched: "一致しました" passwordNotMatched: "一致していません" signinWith: "{x}でログイン" signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" -tapSecurityKey: "セキュリティキーにタッチ" or: "もしくは" language: "言語" uiLanguage: "UIの表示言語" @@ -946,6 +948,9 @@ collapseRenotes: "見たことのあるRenoteを省略して表示" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。" copyErrorInfo: "エラー情報をコピー" +joinThisServer: "このサーバーに登録する" +exploreOtherServers: "他のサーバーを探す" +letsLookAtTimeline: "タイムラインを見てみる" _achievements: earnedAt: "獲得日時" @@ -1529,14 +1534,29 @@ _tutorial: _2fa: alreadyRegistered: "既に設定は完了しています。" - registerDevice: "デバイスを登録" - registerKey: "キーを登録" + registerTOTP: "認証アプリの設定を開始" + passwordToTOTP: "パスワードを入力してください" step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" step2: "次に、表示されているQRコードをアプリでスキャンします。" - step2Url: "デスクトップアプリでは次のURLを入力します:" - step3: "アプリに表示されているトークンを入力して完了です。" - step4: "これからログインするときも、同じようにトークンを入力します。" - securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。" + step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。" + step2Url: "デスクトップアプリでは次のURIを入力します:" + step3Title: "確認コードを入力" + step3: "アプリに表示されている確認コード(トークン)を入力して完了です。" + step4: "これからログインするときも、同じように確認コードを入力します。" + securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。" + registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。" + securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。" + chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。" + registerSecurityKey: "セキュリティキー・パスキーを登録する" + securityKeyName: "キーの名前を入力" + tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください" + removeKey: "セキュリティキーを削除" + removeKeyConfirm: "{name}を削除しますか?" + whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。" + renewTOTP: "認証アプリを再設定" + renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります" + renewTOTPOk: "再設定する" + renewTOTPCancel: "やめておく" _permissions: "read:account": "アカウントの情報を見る" @@ -1871,3 +1891,7 @@ _deck: channel: "チャンネル" mentions: "あなた宛て" direct: "ダイレクト" + +_dialog: + charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" + charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index cbbd928b2..6b394a806 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -103,6 +103,8 @@ renoted: "Renoteしたで。" cantRenote: "この投稿はRenoteできへんらしい。" cantReRenote: "Renote自体はRenoteできへんで。" quote: "引用" +inChannelRenote: "チャンネル内Renote" +inChannelQuote: "チャンネル内引用" pinnedNote: "ピン留めされとるノート" pinned: "ピン留めしとく" you: "あんた" @@ -948,35 +950,168 @@ achievements: "実績" gotInvalidResponseError: "サーバー黙っとるわ、知らんけど" gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。" thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。" +thisPostMayBeAnnoyingHome: "ホームに投稿" +thisPostMayBeAnnoyingCancel: "やめとく" +thisPostMayBeAnnoyingIgnore: "このまま投稿" collapseRenotes: "見たことあるRenoteは省略やで" +internalServerError: "サーバー内部エラー" +internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ" +copyErrorInfo: "エラー情報をコピー" _achievements: earnedAt: "貰った日ぃ" _types: _notes1: title: "まいど!" description: "初めてノート投稿したった" + flavor: "Misskeyを楽しんでな~" _notes10: title: "ノートの天保山" + description: "ノートを10回投稿した" _notes100: title: "ノートの真田山" + description: "ノートを100回投稿した" _notes500: title: "ノートの生駒山" + description: "ノートを500回投稿した" + _notes1000: + title: "ノートの山" + description: "ノートを1,000回投稿した" _notes5000: title: "箕面の滝からノート" + description: "ノートを5,000回投稿した" + _notes10000: + title: "スーパーノート" + description: "ノートを10,000回投稿した" + _notes20000: + title: "ニードモアノート" + description: "ノートを20,000回投稿した" + _notes30000: + title: "ノートノートノート" + description: "ノートを30,000回投稿した" + _notes40000: + title: "ノート工場" + description: "ノートを40,000回投稿した" + _notes50000: + title: "ノートの惑星" + description: "ノートを50,000回投稿した" + _notes60000: + title: "ノートクエーサー" + description: "ノートを60,000回投稿した" + _notes70000: + title: "ブラックノートホール" + description: "ノートを70,000回投稿した" + _notes80000: + title: "ノートギャラクシー" + description: "ノートを80,000回投稿した" + _notes90000: + title: "ノートバース" + description: "ノートを90,000回投稿した" + _notes100000: + title: "ALL YOUR NOTE ARE BELONG TO US" + description: "ノートを100,000回投稿した" + flavor: "そんなに書くことあるんか?" _login3: + title: "ビギナーⅠ" + description: "通算ログイン日数が3日" flavor: "今日からワシはミスキストやで" + _login7: + title: "ビギナーⅡ" + description: "通算ログイン日数が7日" + flavor: "慣れてきたんちゃう?" + _login15: + title: "ビギナーⅢ" + description: "通算ログイン日数が15日" + _login30: + title: "ミスキストⅠ" + description: "通算ログイン日数が30日" + _login60: + title: "ミスキストⅡ" + description: "通算ログイン日数が60日" + _login100: + title: "ミスキストⅢ" + description: "通算ログイン日数が100日" + flavor: "そのユーザー、ミスキストにつき" + _login200: + title: "常連Ⅰ" + _followers500: + title: "基地局" + description: "フォロワーが500人を超した" + _followers1000: + title: "インフルエンサー" + description: "フォロワーが1,000人を超した" + _collectAchievements30: + title: "実績コレクター" + description: "実績を30個以上獲得した" + _viewAchievements3min: + title: "実績好き" + description: "実績一覧を3分以上眺め続けた" _iLoveMisskey: title: "Misskey好きやねん" + description: "\"I ❤ #Misskey\"を投稿した" + flavor: "Misskeyを使ってくれてありがとうな~ by 開発チーム" _foundTreasure: title: "なんでも鑑定団" + description: "隠されたお宝を発見した" _client30min: title: "ねんね" + description: "クライアントを起動してから30分以上経過した" _noteDeletedWithin1min: title: "*おおっと*" + description: "投稿してから1分以内にその投稿を消した" + _postedAtLateNight: + title: "夜行性" + description: "深夜にノートを投稿した" + flavor: "そろそろ寝よか" + _postedAt0min0sec: + title: "時報" + description: "0分0秒にノートを投稿した" + flavor: "ポッ ポッ ポッ ピーン" + _selfQuote: + title: "自己言及" + description: "自分のノートを引用した" + _htl20npm: + title: "流れるTL" + description: "ホームタイムラインの流速が20npmを超す" + _viewInstanceChart: + title: "アナリスト" + description: "インスタンスのチャートを表示した" + _outputHelloWorldOnScratchpad: + title: "Hello, world!" + description: "スクラッチパッドで hello worldを出力した" _open3windows: title: "マド開けすぎ" + description: "ウィンドウを3つ以上開いた状態にした" _driveFolderCircularReference: title: "環状線" + description: "ドライブのフォルダを再帰的な入れ子にしようとした" + _reactWithoutRead: + title: "ちゃんと読んだんか?" + description: "100文字以上のテキストを含むノートに投稿されてから3秒以内にリアクションした" + _clickedClickHere: + title: "ここをクリック" + description: "ここをクリックした" + _justPlainLucky: + title: "単なるラッキー" + description: "10秒ごとに0.005%の確率で獲得" + _setNameToSyuilo: + title: "神様コンプレックス" + description: "名前を syuilo に設定した" + _passedSinceAccountCreated1: + title: "一周年" + description: "アカウント作成から1年経過した" + _passedSinceAccountCreated2: + title: "二周年" + description: "アカウント作成から2年経過した" + _passedSinceAccountCreated3: + title: "三周年" + description: "アカウント作成から3年経過した" + _loggedInOnBirthday: + title: "ハッピーバースデー!" + description: "誕生日にログインした" + _loggedInOnNewYearsDay: + title: "あけましておめでとうございます!" + description: "元旦にログインした" + flavor: "今年も弊インスタンスをよろしくお願いします" _role: new: "ロールの作成" edit: "ロールの編集" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 27882e50f..6ada5dc20 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -1113,6 +1113,8 @@ _achievements: _loggedInOnNewYearsDay: title: "З Новим роком!" description: "Увійшли в перший день року" + _cookieClicked: + flavor: "Чекайте, це вірний сайт?" _brainDiver: title: "Brain Diver" description: "Відправити посилання на \"Brain Diver\"" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 71ca55d9e..8ee699ce3 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -103,6 +103,8 @@ renoted: "已转发。" cantRenote: "该帖无法转发。" cantReRenote: "转发无法被再次转发。" quote: "引用" +inChannelRenote: "在频道内转发" +inChannelQuote: "在频道内引用" pinnedNote: "已置顶的帖子" pinned: "置顶" you: "您" @@ -951,6 +953,10 @@ thisPostMayBeAnnoying: "这个帖子可能会让其他人感到困扰。" thisPostMayBeAnnoyingHome: "发到首页" thisPostMayBeAnnoyingCancel: "取消" thisPostMayBeAnnoyingIgnore: "就这样发布" +collapseRenotes: "省略显示已经看过的转发内容" +internalServerError: "内部服务器错误" +internalServerErrorDescription: "内部服务器发生了预期外的错误" +copyErrorInfo: "复制错误信息" _achievements: earnedAt: "达成时间" _types: diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index f99ca91e6..b0f9a631d 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -103,6 +103,8 @@ renoted: "轉傳成功" cantRenote: "無法轉發此貼文。" cantReRenote: "無法轉傳之前已經轉傳過的內容。" quote: "引用" +inChannelRenote: "在頻道內轉發" +inChannelQuote: "在頻道內引用" pinnedNote: "已置頂的貼文" pinned: "置頂" you: "您" @@ -952,6 +954,9 @@ thisPostMayBeAnnoyingHome: "發布到首頁" thisPostMayBeAnnoyingCancel: "退出" thisPostMayBeAnnoyingIgnore: "直接發布貼文" collapseRenotes: "省略顯示已看過的轉發貼文" +internalServerError: "內部伺服器錯誤" +internalServerErrorDescription: "內部伺服器發生了非預期的錯誤。" +copyErrorInfo: "複製錯誤資訊" _achievements: earnedAt: "獲得日期" _types: diff --git a/package.json b/package.json index b8ad9aca6..27d888c1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "13.6.1-simkey", + "version": "13.7.0-simkey", "codename": "nasubi", "repository": { "type": "git", @@ -54,12 +54,12 @@ "devDependencies": { "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", - "@typescript-eslint/eslint-plugin": "5.51.0", - "@typescript-eslint/parser": "5.51.0", + "@typescript-eslint/eslint-plugin": "5.52.0", + "@typescript-eslint/parser": "5.52.0", "cross-env": "7.0.3", - "cypress": "12.5.1", - "eslint": "8.33.0", - "start-server-and-test": "1.15.3" + "cypress": "12.6.0", + "eslint": "8.34.0", + "start-server-and-test": "1.15.4" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.2.0" diff --git a/packages/backend/package.json b/packages/backend/package.json index 52b0d74ad..7ea1ec3c7 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -25,30 +25,30 @@ "@tensorflow/tfjs-node": "4.2.0" }, "dependencies": { - "@bull-board/api": "4.11.1", - "@bull-board/fastify": "4.11.1", - "@bull-board/ui": "4.11.1", + "@bull-board/api": "4.12.1", + "@bull-board/fastify": "4.12.1", + "@bull-board/ui": "4.12.1", "@discordapp/twemoji": "14.0.2", "@fastify/accepts": "4.1.0", "@fastify/cookie": "8.3.0", "@fastify/cors": "8.2.0", "@fastify/http-proxy": "8.4.0", - "@fastify/multipart": "7.4.0", - "@fastify/static": "6.8.0", + "@fastify/multipart": "7.4.1", + "@fastify/static": "6.9.0", "@fastify/view": "7.4.1", - "@nestjs/common": "9.3.7", - "@nestjs/core": "9.3.7", - "@nestjs/testing": "9.3.7", + "@nestjs/common": "9.3.9", + "@nestjs/core": "9.3.9", + "@nestjs/testing": "9.3.9", "@peertube/http-signature": "1.7.0", "@sinonjs/fake-timers": "10.0.2", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", "autwh": "0.1.0", - "aws-sdk": "2.1295.0", + "aws-sdk": "2.1318.0", "bcryptjs": "2.4.3", - "blurhash": "2.0.4", - "bull": "4.10.3", + "blurhash": "2.0.5", + "bull": "4.10.4", "cacheable-lookup": "6.1.0", "cbor": "8.1.0", "chalk": "5.2.0", @@ -60,12 +60,13 @@ "date-fns": "2.29.3", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", - "fastify": "4.12.0", + "fastify": "4.13.0", "feed": "4.2.2", - "file-type": "18.2.0", + "file-type": "18.2.1", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", "got": "12.5.3", + "happy-dom": "^8.7.0", "hpagent": "1.2.0", "ioredis": "4.28.5", "ip-cidr": "3.1.0", @@ -85,6 +86,7 @@ "nsfwjs": "2.4.2", "oauth": "0.10.0", "os-utils": "0.0.14", + "otpauth": "^9.0.2", "parse5": "7.1.2", "pg": "8.9.0", "private-ip": "3.0.0", @@ -104,15 +106,14 @@ "rss-parser": "3.12.0", "rxjs": "7.8.0", "s-age": "1.1.2", - "sanitize-html": "2.9.0", + "sanitize-html": "2.10.0", "seedrandom": "3.0.5", "semver": "7.3.8", "sharp": "0.31.3", - "speakeasy": "2.0.0", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.17.8", + "systeminformation": "5.17.9", "tinycolor2": "1.6.0", "tmp": "0.2.1", "tsc-alias": "1.8.2", @@ -126,14 +127,14 @@ "vary": "1.1.2", "web-push": "3.5.0", "websocket": "1.0.34", - "ws": "8.12.0", + "ws": "8.12.1", "xev": "3.0.2" }, "devDependencies": { - "@jest/globals": "29.4.2", + "@jest/globals": "29.4.3", "@redocly/openapi-core": "1.0.0-beta.123", - "@swc/cli": "0.1.61", - "@swc/core": "1.3.34", + "@swc/cli": "0.1.62", + "@swc/core": "1.3.35", "@swc/jest": "0.2.24", "@types/accepts": "1.3.5", "@types/archiver": "5.3.1", @@ -151,7 +152,7 @@ "@types/jsonld": "1.5.8", "@types/jsrsasign": "10.5.5", "@types/mime-types": "2.1.1", - "@types/node": "18.13.0", + "@types/node": "18.14.0", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.7", "@types/oauth": "0.9.1", @@ -167,7 +168,6 @@ "@types/semver": "7.3.13", "@types/sharp": "0.31.1", "@types/sinonjs__fake-timers": "8.1.2", - "@types/speakeasy": "2.0.7", "@types/tinycolor2": "1.4.3", "@types/tmp": "0.2.3", "@types/unzipper": "0.10.5", @@ -176,13 +176,13 @@ "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.51.0", - "@typescript-eslint/parser": "5.51.0", + "@typescript-eslint/eslint-plugin": "5.52.0", + "@typescript-eslint/parser": "5.52.0", "cross-env": "7.0.3", - "eslint": "8.33.0", + "eslint": "8.34.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", - "jest": "29.4.2", - "jest-mock": "29.4.2" + "jest": "29.4.3", + "jest-mock": "29.4.3" } } diff --git a/packages/backend/src/@types/redis-lock.d.ts b/packages/backend/src/@types/redis-lock.d.ts new file mode 100644 index 000000000..9242656a9 --- /dev/null +++ b/packages/backend/src/@types/redis-lock.d.ts @@ -0,0 +1,8 @@ +declare module 'redis-lock' { + import type Redis from 'ioredis'; + + type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise) => void; + function redisLock(client: Redis.Redis, retryDelay: number): Lock; + + export = redisLock; +} diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts index 5f3072a41..ee179b7f0 100644 --- a/packages/backend/src/core/AppLockService.ts +++ b/packages/backend/src/core/AppLockService.ts @@ -12,7 +12,7 @@ const retryDelay = 100; @Injectable() export class AppLockService { - private lock: (key: string, timeout?: number) => Promise<() => void>; + private lock: (key: string, timeout?: number, _?: (() => Promise) | undefined) => Promise<() => void>; constructor( @Inject(DI.redis) diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 8d961452c..9b2d5dc0f 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -1,7 +1,7 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as parse5 from 'parse5'; -import { JSDOM } from 'jsdom'; +import { Window } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; @@ -235,7 +235,7 @@ export class MfmService { return null; } - const { window } = new JSDOM(''); + const { window } = new Window(); const doc = window.document; @@ -300,7 +300,7 @@ export class MfmService { hashtag: (node) => { const a = doc.createElement('a'); - a.href = `${this.config.url}/tags/${node.props.hashtag}`; + a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`); a.textContent = `#${node.props.hashtag}`; a.setAttribute('rel', 'tag'); return a; @@ -326,7 +326,7 @@ export class MfmService { link: (node) => { const a = doc.createElement('a'); - a.href = node.props.url; + a.setAttribute('href', node.props.url); appendChildren(node.children, a); return a; }, @@ -335,7 +335,7 @@ export class MfmService { const a = doc.createElement('a'); const { username, host, acct } = node.props; const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); - a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`; + a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`); a.className = 'u-url mention'; a.textContent = acct; return a; @@ -360,14 +360,14 @@ export class MfmService { url: (node) => { const a = doc.createElement('a'); - a.href = node.props.url; + a.setAttribute('href', node.props.url); a.textContent = node.props.url; return a; }, search: (node) => { const a = doc.createElement('a'); - a.href = `https://www.google.com/search?q=${node.props.query}`; + a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`); a.textContent = node.props.content; return a; }, diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 4b7ca5c96..2cad1bc07 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -9,7 +9,7 @@ import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; // Defined also packages/sw/types.ts#L13 -type pushNotificationsTypes = { +type PushNotificationsTypes = { 'notification': Packed<'Notification'>; 'unreadAntennaNote': { antenna: { id: string, name: string }; @@ -22,7 +22,7 @@ type pushNotificationsTypes = { }; // Reduce length because push message servers have character limits -function truncateBody(type: T, body: pushNotificationsTypes[T]): pushNotificationsTypes[T] { +function truncateBody(type: T, body: PushNotificationsTypes[T]): PushNotificationsTypes[T] { if (typeof body !== 'object') return body; return { @@ -56,7 +56,7 @@ export class PushNotificationService { } @bindThis - public async pushNotification(userId: string, type: T, body: pushNotificationsTypes[T]) { + 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; diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 9bd80b606..21d2d16ed 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -452,7 +452,7 @@ export class ApInboxService { const user = await this.usersRepository.findOneByOrFail({ id: actor.id }); if (user.isDeleted) { - this.logger.info('skip: already deleted'); + return 'skip: already deleted'; } const job = await this.queueService.createDeleteAccountJob(actor); diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 268bf9911..7f2ca9c05 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -1,8 +1,8 @@ -export type obj = { [x: string]: any }; +export type Obj = { [x: string]: any }; export type ApObject = IObject | string | (IObject | string)[]; export interface IObject { - '@context'?: string | string[] | obj | obj[]; + '@context'?: string | string[] | Obj | Obj[]; type: string | string[]; id?: string; name?: string | null; diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index f2ba64237..80ef5ac1f 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -25,14 +25,7 @@ export class RoleEntityService { public async pack( src: Role['id'] | Role, me?: { id: User['id'] } | null | undefined, - options?: { - detail?: boolean; - }, ) { - const opts = Object.assign({ - detail: true, - }, options); - const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); const assigns = await this.roleAssignmentsRepository.findBy({ @@ -65,9 +58,6 @@ export class RoleEntityService { canEditMembersByModerator: role.canEditMembersByModerator, policies: policies, usersCount: assigns.length, - ...(opts.detail ? { - users: this.userEntityService.packMany(assigns.map(x => x.userId), me), - } : {}), }); } @@ -75,11 +65,8 @@ export class RoleEntityService { public packMany( roles: any[], me: { id: User['id'] }, - options?: { - detail?: boolean; - }, ) { - return Promise.all(roles.map(x => this.pack(x, me, options))); + return Promise.all(roles.map(x => this.pack(x, me))); } } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 30101a2c6..d3e2219bd 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; +import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; @@ -170,6 +171,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; +import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_apps from './endpoints/i/apps.js'; @@ -276,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; import * as ep___ping from './endpoints/ping.js'; import * as ep___pinnedUsers from './endpoints/pinned-users.js'; import * as ep___promo_read from './endpoints/promo/read.js'; +import * as ep___roles_list from './endpoints/roles/list.js'; +import * as ep___roles_show from './endpoints/roles/show.js'; +import * as ep___roles_users from './endpoints/roles/users.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; @@ -382,6 +387,7 @@ const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useCla const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default }; const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default }; const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default }; +const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default }; const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; @@ -486,6 +492,7 @@ const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___ const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default }; const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default }; const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default }; +const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default }; const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default }; const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; @@ -592,6 +599,9 @@ const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___ const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default }; const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default }; const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default }; +const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; +const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; +const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; @@ -702,6 +712,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_roles_assign, $admin_roles_unassign, $admin_roles_updateDefaultPolicies, + $admin_roles_users, $announcements, $antennas_create, $antennas_delete, @@ -806,6 +817,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_2fa_passwordLess, $i_2fa_registerKey, $i_2fa_register, + $i_2fa_updateKey, $i_2fa_removeKey, $i_2fa_unregister, $i_apps, @@ -912,6 +924,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $ping, $pinnedUsers, $promo_read, + $roles_list, + $roles_show, + $roles_users, $requestResetPassword, $resetDb, $resetPassword, @@ -1016,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_roles_assign, $admin_roles_unassign, $admin_roles_updateDefaultPolicies, + $admin_roles_users, $announcements, $antennas_create, $antennas_delete, @@ -1120,6 +1136,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_2fa_passwordLess, $i_2fa_registerKey, $i_2fa_register, + $i_2fa_updateKey, $i_2fa_removeKey, $i_2fa_unregister, $i_apps, @@ -1226,6 +1243,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $ping, $pinnedUsers, $promo_read, + $roles_list, + $roles_show, + $roles_users, $requestResetPassword, $resetDb, $resetPassword, diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index f1164b995..bd3d8a28d 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from 'otpauth'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js'; @@ -155,19 +155,19 @@ export class SigninApiService { }); } - const verified = (speakeasy as any).totp.verify({ - secret: profile.twoFactorSecret, - encoding: 'base32', - token: token, - window: 2, + const delta = OTPAuth.TOTP.validate({ + secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!), + digits: 6, + token, + window: 1, }); - if (verified) { - return this.signinService.signin(request, reply, user); - } else { + if (delta === null) { return await fail(403, { id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', }); + } else { + return this.signinService.signin(request, reply, user); } } else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) { if (!same && !profile.usePasswordLessLogin) { diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index 115526d99..ed283eb83 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -20,14 +20,14 @@ type File = { }; // TODO: paramsの型をT['params']のスキーマ定義から推論する -type executor = +type Executor = (params: SchemaType, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record | null) => Promise>>; export abstract class Endpoint { public exec: (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => Promise; - constructor(meta: T, paramDef: Ps, cb: executor) { + constructor(meta: T, paramDef: Ps, cb: Executor) { const validate = ajv.compile(paramDef); this.exec = (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d05005b07..4d5ed9fb6 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; +import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; @@ -170,6 +171,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; +import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_apps from './endpoints/i/apps.js'; @@ -276,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; import * as ep___ping from './endpoints/ping.js'; import * as ep___pinnedUsers from './endpoints/pinned-users.js'; import * as ep___promo_read from './endpoints/promo/read.js'; +import * as ep___roles_list from './endpoints/roles/list.js'; +import * as ep___roles_show from './endpoints/roles/show.js'; +import * as ep___roles_users from './endpoints/roles/users.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; @@ -380,6 +385,7 @@ const eps = [ ['admin/roles/assign', ep___admin_roles_assign], ['admin/roles/unassign', ep___admin_roles_unassign], ['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], + ['admin/roles/users', ep___admin_roles_users], ['announcements', ep___announcements], ['antennas/create', ep___antennas_create], ['antennas/delete', ep___antennas_delete], @@ -484,6 +490,7 @@ const eps = [ ['i/2fa/password-less', ep___i_2fa_passwordLess], ['i/2fa/register-key', ep___i_2fa_registerKey], ['i/2fa/register', ep___i_2fa_register], + ['i/2fa/update-key', ep___i_2fa_updateKey], ['i/2fa/remove-key', ep___i_2fa_removeKey], ['i/2fa/unregister', ep___i_2fa_unregister], ['i/apps', ep___i_apps], @@ -590,6 +597,9 @@ const eps = [ ['ping', ep___ping], ['pinned-users', ep___pinnedUsers], ['promo/read', ep___promo_read], + ['roles/list', ep___roles_list], + ['roles/show', ep___roles_show], + ['roles/users', ep___roles_users], ['request-reset-password', ep___requestResetPassword], ['reset-db', ep___resetDb], ['reset-password', ep___resetPassword], diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index 6376cb153..85b566aab 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { DriveFilesRepository } from '@/models/index.js'; +import type { DriveFilesRepository, UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; @@ -161,6 +161,9 @@ export default class extends Endpoint { @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { @@ -178,7 +181,12 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchFile); } - const isModerator = await this.roleService.isModerator(me); + const owner = file.userId ? await this.usersRepository.findOneByOrFail({ + id: file.userId, + }) : null; + + const iAmModerator = await this.roleService.isModerator(me); + const ownerIsModerator = owner ? await this.roleService.isModerator(owner) : false; return { id: file.id, @@ -207,8 +215,8 @@ export default class extends Endpoint { name: file.name, md5: file.md5, createdAt: file.createdAt.toISOString(), - requestIp: isModerator ? file.requestIp : null, - requestHeaders: isModerator ? file.requestHeaders : null, + requestIp: iAmModerator ? file.requestIp : null, + requestHeaders: iAmModerator && !ownerIsModerator ? file.requestHeaders : null, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/list.ts b/packages/backend/src/server/api/endpoints/admin/roles/list.ts index ac56de56b..edaf638ea 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/list.ts @@ -32,7 +32,7 @@ export default class extends Endpoint { const roles = await this.rolesRepository.find({ order: { lastUsedAt: 'DESC' }, }); - return await this.roleEntityService.packMany(roles, me, { detail: false }); + return await this.roleEntityService.packMany(roles, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/show.ts b/packages/backend/src/server/api/endpoints/admin/roles/show.ts index c83f96191..01028a086 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/show.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/show.ts @@ -39,12 +39,12 @@ export default class extends Endpoint { private roleEntityService: RoleEntityService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); if (role == null) { throw new ApiError(meta.errors.noSuchRole); } - return await this.roleEntityService.pack(role); + return await this.roleEntityService.pack(role, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts new file mode 100644 index 000000000..bb016a842 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -0,0 +1,71 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin', 'role', 'users'], + + requireCredential: false, + requireAdmin: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: '224eff5e-2488-4b18-b3e7-f50d94421648', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + + private queryService: QueryService, + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) + .andWhere('assign.roleId = :roleId', { roleId: role.id }) + .innerJoinAndSelect('assign.user', 'user'); + + const assigns = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(assigns.map(async assign => ({ + id: assign.id, + user: await this.userEntityService.pack(assign.user!, me, { detail: true }), + }))); + }); + } +} 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 823af6d8b..9d19efbbc 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -59,12 +59,6 @@ export default class extends Endpoint { throw new Error('cannot show info of admin'); } - if (!await this.roleService.isAdministrator(_me)) { - return { - isSuspended: user.isSuspended, - }; - } - const signins = await this.signinsRepository.findBy({ userId: user.id }); const roles = await this.roleService.getUserRoles(user.id); @@ -89,7 +83,7 @@ export default class extends Endpoint { moderationNote: profile.moderationNote, signins, policies: await this.roleService.getUserPolicies(user.id), - roles: await this.roleEntityService.packMany(roles, me, { detail: false }), + roles: await this.roleEntityService.packMany(roles, me), }; }); } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index ec9ac1ef9..6c31075e0 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -1,7 +1,10 @@ -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from 'otpauth'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; export const meta = { @@ -22,8 +25,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const token = ps.token.replace(/\s/g, ''); @@ -34,13 +43,14 @@ export default class extends Endpoint { throw new Error('二段階認証の設定が開始されていません'); } - const verified = (speakeasy as any).totp.verify({ - secret: profile.twoFactorTempSecret, - encoding: 'base32', - token: token, + const delta = OTPAuth.TOTP.validate({ + secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret), + digits: 6, + token, + window: 1, }); - if (!verified) { + if (delta === null) { throw new Error('not verified'); } @@ -48,6 +58,12 @@ export default class extends Endpoint { twoFactorSecret: profile.twoFactorTempSecret, twoFactorEnabled: true, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } 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 6e0849f2b..ad33398da 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 @@ -25,7 +25,7 @@ export const paramDef = { attestationObject: { type: 'string' }, password: { type: 'string' }, challengeId: { type: 'string' }, - name: { type: 'string' }, + name: { type: 'string', minLength: 1, maxLength: 30 }, }, required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'], } as const; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts index 0655a8635..0ee9f556a 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -1,12 +1,23 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, secure: true, + + errors: { + noKey: { + message: 'No security key.', + code: 'NO_SECURITY_KEY', + id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512', + }, + }, } as const; export const paramDef = { @@ -23,11 +34,45 @@ export default class extends Endpoint { constructor( @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { + if (ps.value === true) { + // セキュリティキーがなければパスワードレスを有効にはできない + const keyCount = await this.userSecurityKeysRepository.count({ + where: { + userId: me.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }); + + if (keyCount === 0) { + await this.userProfilesRepository.update(me.id, { + usePasswordLessLogin: false, + }); + + throw new ApiError(meta.errors.noKey); + } + } + await this.userProfilesRepository.update(me.id, { usePasswordLessLogin: ps.value, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index a539c5c22..eb4d7f9c1 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs'; -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; import { Inject, Injectable } from '@nestjs/common'; import type { UserProfilesRepository } from '@/models/index.js'; @@ -42,25 +42,24 @@ export default class extends Endpoint { } // Generate user's secret key - const secret = speakeasy.generateSecret({ - length: 32, - }); + const secret = new OTPAuth.Secret(); await this.userProfilesRepository.update(me.id, { twoFactorTempSecret: secret.base32, }); // Get the data URL of the authenticator URL - const url = speakeasy.otpauthURL({ - secret: secret.base32, - encoding: 'base32', + const totp = new OTPAuth.TOTP({ + secret, + digits: 6, label: me.username, issuer: this.config.host, }); - const dataUrl = await QRCode.toDataURL(url); + const url = totp.toString(); + const qr = await QRCode.toDataURL(url); return { - qr: dataUrl, + qr, url, secret: secret.base32, label: me.username, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index 0f2b0fd7e..4b726aed8 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -50,6 +50,24 @@ export default class extends Endpoint { id: ps.credentialId, }); + // 使われているキーがなくなったらパスワードレスログインをやめる + const keyCount = await this.userSecurityKeysRepository.count({ + where: { + userId: me.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }); + + if (keyCount === 0) { + await this.userProfilesRepository.update(me.id, { + usePasswordLessLogin: false, + }); + } + // Publish meUpdated event this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { detail: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 4c5b151f7..e0e7ba665 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -1,7 +1,9 @@ import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; export const meta = { @@ -24,6 +26,9 @@ export default class extends Endpoint { constructor( @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); @@ -38,7 +43,14 @@ export default class extends Endpoint { await this.userProfilesRepository.update(me.id, { twoFactorSecret: null, twoFactorEnabled: false, + usePasswordLessLogin: false, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } 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 new file mode 100644 index 000000000..d98f60fa5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -0,0 +1,78 @@ +import bcrypt from 'bcryptjs'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + requireCredential: true, + + secure: true, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: 'f9c5467f-d492-4d3c-9a8g-a70dacc86512', + }, + + accessDenied: { + message: 'You do not have edit privilege of the channel.', + code: 'ACCESS_DENIED', + id: '1fb7cb09-d46a-4fff-b8df-057708cce513', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 30 }, + credentialId: { type: 'string' }, + }, + required: ['name', 'credentialId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + const key = await this.userSecurityKeysRepository.findOneBy({ + id: ps.credentialId, + }); + + if (key == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + if (key.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await this.userSecurityKeysRepository.update(key.id, { + name: ps.name, + }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); + + return {}; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/roles/list.ts b/packages/backend/src/server/api/endpoints/roles/list.ts new file mode 100644 index 000000000..d61c6b8dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/list.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RolesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; + +export const meta = { + tags: ['role'], + + requireCredential: true, +} 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.rolesRepository) + private rolesRepository: RolesRepository, + + private roleEntityService: RoleEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const roles = await this.rolesRepository.findBy({ + isPublic: true, + }); + return await this.roleEntityService.packMany(roles, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/roles/show.ts b/packages/backend/src/server/api/endpoints/roles/show.ts new file mode 100644 index 000000000..cc755dcc7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/show.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { RolesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['role', 'users'], + + requireCredential: false, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: 'de5502bf-009a-4639-86c1-fec349e46dcb', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private roleEntityService: RoleEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + isPublic: true, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + return await this.roleEntityService.pack(role, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts new file mode 100644 index 000000000..6e221b6c6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -0,0 +1,71 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['role', 'users'], + + requireCredential: false, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: '30aaaee3-4792-48dc-ab0d-cf501a575ac5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + + private queryService: QueryService, + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + isPublic: true, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) + .andWhere('assign.roleId = :roleId', { roleId: role.id }) + .innerJoinAndSelect('assign.user', 'user'); + + const assigns = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(assigns.map(async assign => ({ + id: assign.id, + user: await this.userEntityService.pack(assign.user!, me, { detail: true }), + }))); + }); + } +} 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 8b22f913d..1cefcf270 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 @@ -1,6 +1,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; import type { User } from '@/models/entities/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -36,13 +37,13 @@ export const paramDef = { properties: { username: { type: 'string', nullable: true }, }, - required: ['username'] + required: ['username'], }, { properties: { host: { type: 'string', nullable: true }, }, - required: ['host'] + required: ['host'], }, ], } as const; @@ -53,6 +54,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -62,79 +66,76 @@ export default class extends Endpoint { private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - - if (ps.host) { - const q = this.usersRepository.createQueryBuilder('user') - .where('user.isSuspended = FALSE') - .andWhere('user.host LIKE :host', { host: sqlLikeEscape(ps.host.toLowerCase()) + '%' }); - + const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => { if (ps.username) { - q.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); + query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); } - q.andWhere('user.updatedAt IS NOT NULL'); - q.orderBy('user.updatedAt', 'DESC'); - - const users = await q.take(ps.limit).getMany(); - - return await this.userEntityService.packMany(users, me, { detail: ps.detail }); - } else if (ps.username) { - let users: User[] = []; - - if (me) { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - - const query = this.usersRepository.createQueryBuilder('user') - .where(`user.id IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) - .andWhere(new Brackets(qb => { qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })); - - query.setParameters(followingQuery.getParameters()); - - users = await query - .orderBy('user.usernameLower', 'ASC') - .take(ps.limit) - .getMany(); - - if (users.length < ps.limit) { - const otherQuery = await this.usersRepository.createQueryBuilder('user') - .where(`user.id NOT IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) - .andWhere('user.updatedAt IS NOT NULL'); - - otherQuery.setParameters(followingQuery.getParameters()); - - const otherUsers = await otherQuery - .orderBy('user.updatedAt', 'DESC') - .take(ps.limit - users.length) - .getMany(); - - users = users.concat(otherUsers); + if (ps.host) { + if (ps.host === this.config.hostname || ps.host === '.') { + query.andWhere('user.host IS NULL'); + } else { + query.andWhere('user.host LIKE :host', { + host: sqlLikeEscape(ps.host.toLowerCase()) + '%', + }); } - } else { - users = await this.usersRepository.createQueryBuilder('user') - .where('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) - .andWhere('user.updatedAt IS NOT NULL') + } + + return query; + }; + + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + let users: User[] = []; + + if (me) { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const query = setUsernameAndHostQuery() + .andWhere(`user.id IN (${ followingQuery.getQuery() })`) + .andWhere('user.id != :meId', { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })); + + query.setParameters(followingQuery.getParameters()); + + users = await query + .orderBy('user.usernameLower', 'ASC') + .take(ps.limit) + .getMany(); + + if (users.length < ps.limit) { + const otherQuery = setUsernameAndHostQuery() + .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.updatedAt IS NOT NULL'); + + otherQuery.setParameters(followingQuery.getParameters()); + + const otherUsers = await otherQuery .orderBy('user.updatedAt', 'DESC') .take(ps.limit - users.length) .getMany(); - } - return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); + users = users.concat(otherUsers); + } + } else { + const query = setUsernameAndHostQuery() + .andWhere('user.isSuspended = FALSE') + .andWhere('user.updatedAt IS NOT NULL'); + + users = await query + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit - users.length) + .getMany(); } - return []; + return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); }); } } diff --git a/packages/backend/test/tests/mfm.ts b/packages/backend/test/tests/mfm.ts deleted file mode 100644 index 884f39d7f..000000000 --- a/packages/backend/test/tests/mfm.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as assert from 'assert'; -import * as mfm from 'mfm-js'; - -import { toHtml } from '../../src/mfm/to-html.js'; -import { fromHtml } from '../../src/mfm/from-html.js'; - -describe('toHtml', () => { - test('br', () => { - const input = 'foo\nbar\nbaz'; - const output = '

foo
bar
baz

'; - assert.equal(toHtml(mfm.parse(input)), output); - }); - - test('br alt', () => { - const input = 'foo\r\nbar\rbaz'; - const output = '

foo
bar
baz

'; - assert.equal(toHtml(mfm.parse(input)), output); - }); -}); - -describe('fromHtml', () => { - test('p', () => { - assert.deepStrictEqual(fromHtml('

a

b

'), 'a\n\nb'); - }); - - test('block element', () => { - assert.deepStrictEqual(fromHtml('
a
b
'), 'a\nb'); - }); - - test('inline element', () => { - assert.deepStrictEqual(fromHtml('
  • a
  • b
'), 'a\nb'); - }); - - test('block code', () => { - assert.deepStrictEqual(fromHtml('
a\nb
'), '```\na\nb\n```'); - }); - - test('inline code', () => { - assert.deepStrictEqual(fromHtml('a'), '`a`'); - }); - - test('quote', () => { - assert.deepStrictEqual(fromHtml('
a\nb
'), '> a\n> b'); - }); - - test('br', () => { - assert.deepStrictEqual(fromHtml('

abc

d

'), 'abc\n\nd'); - }); - - test('link with different text', () => { - assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c](https://example.com/b) d'); - }); - - test('link with different text, but not encoded', () => { - assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c]() d'); - }); - - test('link with same text', () => { - assert.deepStrictEqual(fromHtml('

a https://example.com/b d

'), 'a https://example.com/b d'); - }); - - test('link with same text, but not encoded', () => { - assert.deepStrictEqual(fromHtml('

a https://example.com/ä d

'), 'a d'); - }); - - test('link with no url', () => { - assert.deepStrictEqual(fromHtml('

a c d

'), 'a [c](b) d'); - }); - - test('link without href', () => { - assert.deepStrictEqual(fromHtml('

a c d

'), 'a c d'); - }); - - test('link without text', () => { - assert.deepStrictEqual(fromHtml('

a d

'), 'a https://example.com/b d'); - }); - - test('link without both', () => { - assert.deepStrictEqual(fromHtml('

a d

'), 'a d'); - }); - - test('mention', () => { - assert.deepStrictEqual(fromHtml('

a @user d

'), 'a @user@example.com d'); - }); - - test('hashtag', () => { - assert.deepStrictEqual(fromHtml('

a #a d

', ['#a']), 'a #a d'); - }); -}); diff --git a/packages/backend/test/tests/reaction-lib.ts b/packages/backend/test/tests/reaction-lib.ts deleted file mode 100644 index 2e767f769..000000000 --- a/packages/backend/test/tests/reaction-lib.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* -import * as assert from 'assert'; - -import { toDbReaction } from '../src/misc/reaction-lib.js'; - -describe('toDbReaction', async () => { - test('既存の文字列リアクションはそのまま', async () => { - assert.strictEqual(await toDbReaction('like'), 'like'); - }); - - test('Unicodeプリンは寿司化不能とするため文字列化しない', async () => { - assert.strictEqual(await toDbReaction('🍮'), '🍮'); - }); - - test('プリン以外の既存のリアクションは文字列化する like', async () => { - assert.strictEqual(await toDbReaction('👍'), 'like'); - }); - - test('プリン以外の既存のリアクションは文字列化する love', async () => { - assert.strictEqual(await toDbReaction('❤️'), 'love'); - }); - - test('プリン以外の既存のリアクションは文字列化する love 異体字セレクタなし', async () => { - assert.strictEqual(await toDbReaction('❤'), 'love'); - }); - - test('プリン以外の既存のリアクションは文字列化する laugh', async () => { - assert.strictEqual(await toDbReaction('😆'), 'laugh'); - }); - - test('プリン以外の既存のリアクションは文字列化する hmm', async () => { - assert.strictEqual(await toDbReaction('🤔'), 'hmm'); - }); - - test('プリン以外の既存のリアクションは文字列化する surprise', async () => { - assert.strictEqual(await toDbReaction('😮'), 'surprise'); - }); - - test('プリン以外の既存のリアクションは文字列化する congrats', async () => { - assert.strictEqual(await toDbReaction('🎉'), 'congrats'); - }); - - test('プリン以外の既存のリアクションは文字列化する angry', async () => { - assert.strictEqual(await toDbReaction('💢'), 'angry'); - }); - - test('プリン以外の既存のリアクションは文字列化する confused', async () => { - assert.strictEqual(await toDbReaction('😥'), 'confused'); - }); - - test('プリン以外の既存のリアクションは文字列化する rip', async () => { - assert.strictEqual(await toDbReaction('😇'), 'rip'); - }); - - test('それ以外はUnicodeのまま', async () => { - assert.strictEqual(await toDbReaction('🍅'), '🍅'); - }); - - test('異体字セレクタ除去', async () => { - assert.strictEqual(await toDbReaction('㊗️'), '㊗'); - }); - - test('異体字セレクタ除去 必要なし', async () => { - assert.strictEqual(await toDbReaction('㊗'), '㊗'); - }); - - test('fallback - undefined', async () => { - assert.strictEqual(await toDbReaction(undefined), 'like'); - }); - - test('fallback - null', async () => { - assert.strictEqual(await toDbReaction(null), 'like'); - }); - - test('fallback - empty', async () => { - assert.strictEqual(await toDbReaction(''), 'like'); - }); - - test('fallback - unknown', async () => { - assert.strictEqual(await toDbReaction('unknown'), 'like'); - }); -}); -*/ diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts new file mode 100644 index 000000000..549673877 --- /dev/null +++ b/packages/backend/test/unit/MfmService.ts @@ -0,0 +1,102 @@ +import * as assert from 'assert'; +import * as mfm from 'mfm-js'; +import { Test } from '@nestjs/testing'; + +import { CoreModule } from '@/core/CoreModule.js'; +import { MfmService } from '@/core/MfmService.js'; +import { GlobalModule } from '@/GlobalModule.js'; + +describe('MfmService', () => { + let mfmService: MfmService; + + beforeAll(async () => { + const app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + mfmService = app.get(MfmService); + }); + + describe('toHtml', () => { + test('br', () => { + const input = 'foo\nbar\nbaz'; + const output = '

foo
bar
baz

'; + assert.equal(mfmService.toHtml(mfm.parse(input)), output); + }); + + test('br alt', () => { + const input = 'foo\r\nbar\rbaz'; + const output = '

foo
bar
baz

'; + assert.equal(mfmService.toHtml(mfm.parse(input)), output); + }); + }); + + describe('fromHtml', () => { + test('p', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a

b

'), 'a\n\nb'); + }); + + test('block element', () => { + assert.deepStrictEqual(mfmService.fromHtml('
a
b
'), 'a\nb'); + }); + + test('inline element', () => { + assert.deepStrictEqual(mfmService.fromHtml('
  • a
  • b
'), 'a\nb'); + }); + + test('block code', () => { + assert.deepStrictEqual(mfmService.fromHtml('
a\nb
'), '```\na\nb\n```'); + }); + + test('inline code', () => { + assert.deepStrictEqual(mfmService.fromHtml('a'), '`a`'); + }); + + test('quote', () => { + assert.deepStrictEqual(mfmService.fromHtml('
a\nb
'), '> a\n> b'); + }); + + test('br', () => { + assert.deepStrictEqual(mfmService.fromHtml('

abc

d

'), 'abc\n\nd'); + }); + + test('link with different text', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c](https://example.com/b) d'); + }); + + test('link with different text, but not encoded', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c]() d'); + }); + + test('link with same text', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a https://example.com/b d

'), 'a https://example.com/b d'); + }); + + test('link with same text, but not encoded', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a https://example.com/ä d

'), 'a d'); + }); + + test('link with no url', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a [c](b) d'); + }); + + test('link without href', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a c d

'), 'a c d'); + }); + + test('link without text', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a d

'), 'a https://example.com/b d'); + }); + + test('link without both', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a d

'), 'a d'); + }); + + test('mention', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a @user d

'), 'a @user@example.com d'); + }); + + test('hashtag', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a #a d

', ['#a']), 'a #a d'); + }); + }); +}); diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts new file mode 100644 index 000000000..6a20a1e08 --- /dev/null +++ b/packages/backend/test/unit/ReactionService.ts @@ -0,0 +1,92 @@ +import * as assert from 'assert'; +import { Test } from '@nestjs/testing'; + +import { CoreModule } from '@/core/CoreModule.js'; +import { ReactionService } from '@/core/ReactionService.js'; +import { GlobalModule } from '@/GlobalModule.js'; + +describe('ReactionService', () => { + let reactionService: ReactionService; + + beforeAll(async () => { + const app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + reactionService = app.get(ReactionService); + }); + + describe('toDbReaction', () => { + test('絵文字リアクションはそのまま', async () => { + assert.strictEqual(await reactionService.toDbReaction('👍'), '👍'); + assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅'); + }); + + test('既存のリアクションは絵文字化する pudding', async () => { + assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮'); + }); + + test('既存のリアクションは絵文字化する like', async () => { + assert.strictEqual(await reactionService.toDbReaction('like'), '👍'); + }); + + test('既存のリアクションは絵文字化する love', async () => { + assert.strictEqual(await reactionService.toDbReaction('love'), '❤'); + }); + + test('既存のリアクションは絵文字化する laugh', async () => { + assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆'); + }); + + test('既存のリアクションは絵文字化する hmm', async () => { + assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔'); + }); + + test('既存のリアクションは絵文字化する surprise', async () => { + assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮'); + }); + + test('既存のリアクションは絵文字化する congrats', async () => { + assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉'); + }); + + test('既存のリアクションは絵文字化する angry', async () => { + assert.strictEqual(await reactionService.toDbReaction('angry'), '💢'); + }); + + test('既存のリアクションは絵文字化する confused', async () => { + assert.strictEqual(await reactionService.toDbReaction('confused'), '😥'); + }); + + test('既存のリアクションは絵文字化する rip', async () => { + assert.strictEqual(await reactionService.toDbReaction('rip'), '😇'); + }); + + test('既存のリアクションは絵文字化する star', async () => { + assert.strictEqual(await reactionService.toDbReaction('star'), '⭐'); + }); + + test('異体字セレクタ除去', async () => { + assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗'); + }); + + test('異体字セレクタ除去 必要なし', async () => { + assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗'); + }); + + test('fallback - undefined', async () => { + assert.strictEqual(await reactionService.toDbReaction(undefined), '👍'); + }); + + test('fallback - null', async () => { + assert.strictEqual(await reactionService.toDbReaction(null), '👍'); + }); + + test('fallback - empty', async () => { + assert.strictEqual(await reactionService.toDbReaction(''), '👍'); + }); + + test('fallback - unknown', async () => { + assert.strictEqual(await reactionService.toDbReaction('unknown'), '👍'); + }); + }); +}); diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/unit/chart.ts index 1e9a51bc8..5ac4cc18a 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -19,7 +19,7 @@ import Logger from '@/logger.js'; describe('Chart', () => { const config = loadConfig(); const appLockService = { - getChartInsertLock: jest.fn().mockImplementation(() => Promise.resolve(() => {})), + getChartInsertLock: () => () => Promise.resolve(() => {}), } as unknown as jest.Mocked; let db: DataSource | undefined; diff --git a/packages/backend/test/tests/extract-mentions.ts b/packages/backend/test/unit/extract-mentions.ts similarity index 81% rename from packages/backend/test/tests/extract-mentions.ts rename to packages/backend/test/unit/extract-mentions.ts index e81d04c2d..66d32be1c 100644 --- a/packages/backend/test/tests/extract-mentions.ts +++ b/packages/backend/test/unit/extract-mentions.ts @@ -1,11 +1,11 @@ import * as assert from 'assert'; import { parse } from 'mfm-js'; -import { extractMentions } from '../../src/misc/extract-mentions.js'; +import { extractMentions } from '@/misc/extract-mentions.js'; describe('Extract mentions', () => { test('simple', () => { - const ast = parse('@foo @bar @baz')!; + const ast = parse('@foo @bar @baz'); const mentions = extractMentions(ast); assert.deepStrictEqual(mentions, [{ username: 'foo', @@ -23,7 +23,7 @@ describe('Extract mentions', () => { }); test('nested', () => { - const ast = parse('@foo **@bar** @baz')!; + const ast = parse('@foo **@bar** @baz'); const mentions = extractMentions(ast); assert.deepStrictEqual(mentions, [{ username: 'foo', diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 544b529e9..6f335a244 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "allowJs": true, - "noEmitOnError": false, + "noEmitOnError": true, "noImplicitAny": true, "noImplicitReturns": true, "noUnusedParameters": false, diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js index 6c3bfb5a6..e8e0e57d2 100644 --- a/packages/frontend/.eslintrc.js +++ b/packages/frontend/.eslintrc.js @@ -55,6 +55,7 @@ module.exports = { 'vue/multi-word-component-names': 'warn', 'vue/require-v-for-key': 'warn', 'vue/no-unused-components': 'warn', + 'vue/no-unused-vars': 'warn', 'vue/valid-v-for': 'warn', 'vue/return-in-computed-property': 'warn', 'vue/no-setup-props-destructure': 'warn', diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 2c35304c7..de167908d 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,11 +19,11 @@ "@vue/compiler-sfc": "3.2.47", "autobind-decorator": "2.4.0", "autosize": "5.0.2", - "blurhash": "2.0.4", + "blurhash": "2.0.5", "broadcast-channel": "4.20.2", "browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3", "canvas-confetti": "1.6.0", - "chart.js": "4.2.0", + "chart.js": "4.2.1", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", @@ -38,7 +38,7 @@ "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", "json5": "2.2.3", - "matter-js": "0.18.0", + "matter-js": "0.19.0", "mfm-js": "git+https://github.com/sim1222/mfm.js.git", "misetehoshii": "https://github.com/melt-adzuki/misetehoshii", "misskey-js": "0.0.15", @@ -47,13 +47,12 @@ "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.14.0", + "rollup": "3.17.2", "s-age": "1.1.2", - "sanitize-html": "2.9.0", - "sass": "1.58.0", + "sanitize-html": "2.10.0", + "sass": "1.58.3", "seedrandom": "3.0.5", "strict-event-emitter-types": "2.0.0", - "stringz": "2.1.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", "three": "0.149.0", @@ -66,7 +65,7 @@ "uuid": "9.0.0", "vanilla-tilt": "1.8.0", "vue-plyr": "7.0.0", - "vite": "4.1.1", + "vite": "4.1.2", "vue": "3.2.47", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" @@ -77,7 +76,7 @@ "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", "@types/matter-js": "0.18.2", - "@types/node": "18.13.0", + "@types/node": "18.14.0", "@types/punycode": "2.1.0", "@types/sanitize-html": "2.8.0", "@types/seedrandom": "3.0.4", @@ -86,16 +85,16 @@ "@types/uuid": "9.0.0", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.51.0", - "@typescript-eslint/parser": "5.51.0", + "@typescript-eslint/eslint-plugin": "5.52.0", + "@typescript-eslint/parser": "5.52.0", "@vue/runtime-core": "3.2.47", "cross-env": "7.0.3", - "cypress": "12.5.1", - "eslint": "8.33.0", + "cypress": "12.6.0", + "eslint": "8.34.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-vue": "9.9.0", - "start-server-and-test": "1.15.3", + "start-server-and-test": "1.15.4", "vue-eslint-parser": "9.1.0", - "vue-tsc": "1.0.24" + "vue-tsc": "1.1.4" } } diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index a76a1e0f5..9f2bf9933 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -43,7 +43,7 @@ const emit = defineEmits<{ }>(); const uiWindow = shallowRef>(); -const comment = ref(props.initialComment || ''); +const comment = ref(props.initialComment ?? ''); function send() { os.apiWithDialog('users/report-abuse', { diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 53b040636..5dd5f6489 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -209,7 +209,7 @@ function exec() { } } else if (props.type === 'hashtag') { if (!props.q || props.q === '') { - hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') || '[]'); + hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'); fetching.value = false; } else { const cacheKey = `autocomplete:hashtag:${props.q}`; diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 8db2e54e8..c72cc2ab1 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -69,7 +69,7 @@ const captcha = computed(() => window[variable.value] || {} as unknown if (loaded) { available.value = true; } else { - (document.getElementById(scriptId.value) || document.head.appendChild(Object.assign(document.createElement('script'), { + (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { async: true, id: scriptId.value, src: src.value, diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 2283e652d..da6439fd2 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -22,9 +22,6 @@ import * as game from '@/scripts/clicker-game'; import number from '@/filters/number'; import { claimAchievement } from '@/scripts/achievements'; -defineProps<{ -}>(); - const saveData = game.saveData; const cookies = computed(() => saveData.value?.cookies); let cps = $ref(0); diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index e0885f555..7d5579040 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -7,7 +7,6 @@ @@ -38,6 +44,11 @@ const props = defineProps<{ margin-right: 8px; } +.badge { + height: 1.3em; + vertical-align: -20%; +} + .name { font-weight: bold; } diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index cb64b1e48..2de890186 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -34,7 +34,7 @@ import { useInterval } from '@/scripts/use-interval'; import { i18n } from '@/i18n'; const props = defineProps<{ - modelValue: string; + modelValue: string | null; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -48,7 +48,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'change', _ev: KeyboardEvent): void; - (ev: 'update:modelValue', value: string): void; + (ev: 'update:modelValue', value: string | null): void; }>(); const slots = useSlots(); diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index ae4f38e56..ffc5e82b5 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -10,7 +10,7 @@ - + @@ -28,11 +28,11 @@

{{ i18n.ts.twoStepAuthentication }}

- + - + diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 1ba48bf77..87f7c61a9 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -1,10 +1,10 @@ diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index e6ef09668..891934d70 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -1,216 +1,258 @@ diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index a492a8258..41563c441 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -124,11 +124,11 @@ function saveFields() { function save() { os.apiWithDialog('i/update', { - name: profile.name || null, - description: profile.description || null, - location: profile.location || null, - birthday: profile.birthday || null, - lang: profile.lang || null, + name: profile.name ?? null, + description: profile.description ?? null, + location: profile.location ?? null, + birthday: profile.birthday ?? null, + lang: profile.lang ?? null, isBot: !!profile.isBot, isCat: !!profile.isCat, showTimelineReplies: !!profile.showTimelineReplies, diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index b09c4ffd2..0cc2df09c 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -5,11 +5,8 @@ {{ i18n.ts.changePassword }} - - - - - + + @@ -56,18 +53,21 @@ async function change() { const { canceled: canceled1, result: currentPassword } = await os.inputText({ title: i18n.ts.currentPassword, type: 'password', + autocomplete: 'current-password', }); if (canceled1) return; const { canceled: canceled2, result: newPassword } = await os.inputText({ title: i18n.ts.newPassword, type: 'password', + autocomplete: 'new-password', }); if (canceled2) return; const { canceled: canceled3, result: newPassword2 } = await os.inputText({ title: i18n.ts.newPasswordRetype, type: 'password', + autocomplete: 'new-password', }); if (canceled3) return; @@ -109,7 +109,7 @@ definePageMetadata({