diff --git a/.github/PULL_REQUEST_TEMPLATE/03_release.md b/.github/PULL_REQUEST_TEMPLATE/03_release.md index 46d410138..b5b832e1d 100644 --- a/.github/PULL_REQUEST_TEMPLATE/03_release.md +++ b/.github/PULL_REQUEST_TEMPLATE/03_release.md @@ -10,6 +10,7 @@ https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md#release - 他にこのリリースに含めなければならない変更は無いか - 全体的な変更内容を俯瞰し問題は無いか - レビューされていないコミットがある場合は、それが問題ないか +- 最終的な動作確認を行い問題は無いか などを確認し、リリースする準備が整っていると思われる場合は approve してください。 diff --git a/.github/reviewer-lottery.yml b/.github/reviewer-lottery.yml new file mode 100644 index 000000000..fd2fb1913 --- /dev/null +++ b/.github/reviewer-lottery.yml @@ -0,0 +1,10 @@ +groups: + - name: devs + reviewers: 2 + internal_reviewers: 1 + usernames: + - syuilo + - acid-chicken + - EbiseLutica + - rinsuki + - tamaina diff --git a/.github/workflows/reviewer_lottery.yml b/.github/workflows/reviewer_lottery.yml new file mode 100644 index 000000000..33228d746 --- /dev/null +++ b/.github/workflows/reviewer_lottery.yml @@ -0,0 +1,13 @@ +name: "Reviewer lottery" +on: + pull_request_target: + types: [opened, ready_for_review, reopened] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: uesteibar/reviewer-lottery@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9ad1c10..9a5e6da4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ You should also include the user name that made the change. --> +## 13.9.0 (2023/03/03) + +### Improvements +- 時限ロール +- アンテナでCWも検索対象にするように +- ノートの操作部をホバー時のみ表示するオプションを追加 +- サウンドを追加 +- サーバーのパフォーマンスを改善 + +### Bugfixes +- 外部メディアプロキシ使用時にアバタークロップができない問題を修正 +- fix(server): メールアドレス更新時にバリデーションが正しく行われていないのを修正 +- fix(server): チャンネルでミュートが正しく機能していないのを修正 +- プッシュ通知でカスタム絵文字リアクションを表示できなかった問題を修正 + ## 13.8.1 (2023/02/26) ### Bugfixes diff --git a/cypress/e2e/basic.cy.js b/cypress/e2e/basic.cy.js index eb5195c4b..b1b856119 100644 --- a/cypress/e2e/basic.cy.js +++ b/cypress/e2e/basic.cy.js @@ -10,14 +10,14 @@ describe('Before setup instance', () => { }); it('successfully loads', () => { - cy.visit('/'); + cy.visitHome(); }); it('setup instance', () => { - cy.visit('/'); + cy.visitHome(); cy.intercept('POST', '/api/admin/accounts/create').as('signup'); - + cy.get('[data-cy-admin-username] input').type('admin'); cy.get('[data-cy-admin-password] input').type('admin1234'); cy.get('[data-cy-admin-ok]').click(); @@ -43,11 +43,11 @@ describe('After setup instance', () => { }); it('successfully loads', () => { - cy.visit('/'); + cy.visitHome(); }); it('signup', () => { - cy.visit('/'); + cy.visitHome(); cy.intercept('POST', '/api/signup').as('signup'); @@ -79,11 +79,11 @@ describe('After user signup', () => { }); it('successfully loads', () => { - cy.visit('/'); + cy.visitHome(); }); it('signin', () => { - cy.visit('/'); + cy.visitHome(); cy.intercept('POST', '/api/signin').as('signin'); @@ -101,7 +101,7 @@ describe('After user signup', () => { userId: this.alice.id, }); - cy.visit('/'); + cy.visitHome(); cy.get('[data-cy-signin]').click(); cy.get('[data-cy-signin-username] input').type('alice'); @@ -112,7 +112,7 @@ describe('After user signup', () => { }); }); -describe('After user singed in', () => { +describe('After user signed in', () => { beforeEach(() => { cy.resetState(); @@ -141,6 +141,19 @@ describe('After user singed in', () => { cy.get('[data-cy-open-post-form-submit]').click(); cy.contains('Hello, Misskey!'); + }); + + it('open note form with hotkey', () => { + // Wait until the page loads + cy.get('[data-cy-open-post-form]').should('be.visible'); + // Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards. + cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" }); + // See if the form is opened + cy.get('[data-cy-post-form-text]').should('be.visible'); + // Close it + cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" }); + // See if the form is closed + cy.get('[data-cy-post-form-text]').should('not.be.visible'); }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 04a6d98b0..91a4d7abe 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -24,6 +24,11 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +Cypress.Commands.add('visitHome', () => { + cy.visit('/'); + cy.get('button', { timeout: 30000 }).should('be.visible'); +}) + Cypress.Commands.add('resetState', () => { cy.window(win => { win.indexedDB.deleteDatabase('keyval-store'); @@ -43,7 +48,7 @@ Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => { }); Cypress.Commands.add('login', (username, password) => { - cy.visit('/'); + cy.visitHome(); cy.intercept('POST', '/api/signin').as('signin'); diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 3425b9c82..5254b20ef 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -785,6 +785,7 @@ size: "الحجم" numberOfColumn: "عدد الأعمدة" searchByGoogle: "غوغل" mutePeriod: "مدة الكتم" +period: "ينتهي استطلاع الرأي في" indefinitely: "أبدًا" tenMinutes: "10 دقائق" oneHour: "ساعة" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index da17fc48d..49b76b8ab 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -819,6 +819,7 @@ instanceDefaultLightTheme: "ইন্সট্যান্সের ডিফল instanceDefaultDarkTheme: "ইন্সট্যান্সের ডিফল্ট ডার্ক থিম" instanceDefaultThemeDescription: "অবজেক্ট ফরম্যাটে থিম কোড লিখুন" mutePeriod: "মিউটের সময়কাল" +period: "পোলের সময়সীমা" indefinitely: "অনির্দিষ্ট" tenMinutes: "১০ মিনিট" oneHour: "১ ঘণ্টা" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index e5f8e9327..c5ddf334c 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -457,6 +457,7 @@ aboutX: "Über {x}" emojiStyle: "Emoji-Stil" native: "Nativ" disableDrawer: "Keine ausfahrbaren Menüs verwenden" +showNoteActionsOnlyHover: "Aktionen für Notizen nur bei Mouseover anzeigen" noHistory: "Kein Verlauf gefunden" signinHistory: "Anmeldungsverlauf" enableAdvancedMfm: "Erweitertes MFM aktivieren" @@ -847,11 +848,13 @@ instanceDefaultLightTheme: "Instanzweites Standardfarbschema (Hell)" instanceDefaultDarkTheme: "Instanzweites Standardfarbschema (Dunkel)" instanceDefaultThemeDescription: "Gib den Farbschemencode im Objektformat ein." mutePeriod: "Stummschaltungsdauer" +period: "Zeitlimit" indefinitely: "Dauerhaft" tenMinutes: "10 Minuten" oneHour: "Eine Stunde" oneDay: "Einen Tag" oneWeek: "Eine Woche" +oneMonth: "1 Monat" reflectMayTakeTime: "Es kann etwas dauern, bis sich dies widerspiegelt." failedToFetchAccountInformation: "Benutzerkontoinformationen konnten nicht abgefragt werden" rateLimitExceeded: "Versuchsanzahl überschritten" @@ -951,6 +954,7 @@ joinThisServer: "Bei dieser Instanz registrieren" exploreOtherServers: "Eine andere Instanz finden" letsLookAtTimeline: "Die Chronik durchstöbern" disableFederationWarn: "Dies deaktiviert Föderation, aber alle Notizen bleiben, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird diese Option nicht benötigt." +invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst einen validen Einladungscode eingeben, um dich zu registrieren." _achievements: earnedAt: "Freigeschaltet am" _types: diff --git a/locales/en-US.yml b/locales/en-US.yml index f118ddba7..48cce35a4 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -457,6 +457,7 @@ aboutX: "About {x}" emojiStyle: "Emoji style" native: "Native" disableDrawer: "Don't use drawer-style menus" +showNoteActionsOnlyHover: "Only show note actions on hover" noHistory: "No history available" signinHistory: "Login history" enableAdvancedMfm: "Enable advanced MFM" @@ -847,11 +848,13 @@ instanceDefaultLightTheme: "Instance-wide default light theme" instanceDefaultDarkTheme: "Instance-wide default dark theme" instanceDefaultThemeDescription: "Enter the theme code in object format." mutePeriod: "Mute duration" +period: "Time limit" indefinitely: "Permanently" tenMinutes: "10 minutes" oneHour: "One hour" oneDay: "One day" oneWeek: "One week" +oneMonth: "One month" reflectMayTakeTime: "It may take some time for this to be reflected." failedToFetchAccountInformation: "Could not fetch account information" rateLimitExceeded: "Rate limit exceeded" @@ -951,6 +954,7 @@ joinThisServer: "Sign up at this instance" exploreOtherServers: "Look for another instance" letsLookAtTimeline: "Have a look at the timeline" disableFederationWarn: "This will disable federation, but posts will continue to be public unless set otherwise. You usually do not need to use this setting." +invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up." _achievements: earnedAt: "Unlocked at" _types: diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 2acf47cb4..b29ffc12d 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -457,6 +457,7 @@ aboutX: "Acerca de {x}" emojiStyle: "Estilo de emoji" native: "Nativo" disableDrawer: "No mostrar los menús en cajones" +showNoteActionsOnlyHover: "Mostrar acciones de la nota sólo al pasar el cursor" noHistory: "No hay datos en el historial" signinHistory: "Historial de ingresos" enableAdvancedMfm: "Habilitar MFM avanzado" @@ -847,6 +848,7 @@ instanceDefaultLightTheme: "Tema claro por defecto de la instancia" instanceDefaultDarkTheme: "Tema oscuro por defecto de la instancia" instanceDefaultThemeDescription: "Ingrese el código del tema en formato objeto" mutePeriod: "Período de silenciamiento" +period: "Termina el" indefinitely: "Sin límite de tiempo" tenMinutes: "10 minutos" oneHour: "1 hora" @@ -951,6 +953,7 @@ joinThisServer: "Registrarse en esta instancia" exploreOtherServers: "Buscar otra instancia" letsLookAtTimeline: "Mirar la línea de tiempo local" disableFederationWarn: "Esto desactivará la federación, pero las publicaciones segurán siendo públicas al menos que se configure diferente. Usualmente no necesitas usar esta configuración." +invitationRequiredToRegister: "Esta instancia está configurada sólo por invitación, tienes que ingresar un código de invitación válido." _achievements: earnedAt: "Desbloqueado el" _types: diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 72fc0ff39..88f20dfc4 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -833,6 +833,7 @@ instanceDefaultLightTheme: "Thème clair par défaut sur toute l’instance" instanceDefaultDarkTheme: "Thème sombre par défaut sur toute l’instance" instanceDefaultThemeDescription: "Saisissez le code du thème en format objet." mutePeriod: "Durée de mise en sourdine" +period: "Fin du sondage" indefinitely: "Illimité" tenMinutes: "10 minutes" oneHour: "1 heure" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 18941c5c3..5d74cf538 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -839,6 +839,7 @@ instanceDefaultLightTheme: "Bawaan instan tema terang" instanceDefaultDarkTheme: "Bawaan instan tema gelap" instanceDefaultThemeDescription: "Masukkan kode tema di format obyek." mutePeriod: "Batas waktu bisu" +period: "Batas akhir" indefinitely: "Selamanya" tenMinutes: "10 Menit" oneHour: "1 Jam" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 7b2addc6b..89d456d1f 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -75,7 +75,7 @@ lists: "Liste" noLists: "Nessuna lista" note: "Nota" notes: "Note" -following: "Lǝ segui" +following: "Follow" followers: "Followers" followsYou: "Ti segue" createList: "Aggiungi una nuova lista" @@ -94,7 +94,7 @@ defaultNoteVisibility: "Privacy predefinita delle note" follow: "Segui" followRequest: "Richiesta di follow" followRequests: "Richieste di follow" -unfollow: "Smetti di seguire" +unfollow: "Non seguire" followRequestPending: "Richiesta in approvazione" enterEmoji: "Inserisci emoji" renote: "Rinota" @@ -154,14 +154,14 @@ flagShowTimelineRepliesDescription: "Se è attiva, la timeline mostra le rispost autoAcceptFollowed: "Accetta automaticamente le richieste di follow da utenti che già segui" addAccount: "Aggiungi profilo" loginFailed: "Accesso non riuscito" -showOnRemote: "Visualizza sull'istanza remota" +showOnRemote: "Leggi sull'istanza remota" general: "Generali" wallpaper: "Sfondo" setWallpaper: "Imposta sfondo" removeWallpaper: "Elimina lo sfondo" searchWith: "Cerca: {q}" youHaveNoLists: "Non hai ancora creato nessuna lista" -followConfirm: "Sei sicur@ di voler seguire {name}?" +followConfirm: "Vuoi seguire {name}?" proxyAccount: "Profilo proxy" proxyAccountDescription: "Un profilo proxy funziona come follower per i profili remoti, sotto certe condizioni. Ad esempio, quando un profilo locale ne inserisce uno remoto in una lista (senza seguirlo), se nessun altro segue quel profilo remoto, le attività non possono essere distribuite. Dunque, il profilo proxy le seguirà per tutti." host: "Server remoto" @@ -221,7 +221,7 @@ subscribing: "Iscrizione" publishing: "Pubblicazione" notResponding: "Nessuna risposta" instanceFollowing: "Seguiti dall'istanza" -instanceFollowers: "Followers dell'istanza" +instanceFollowers: "Follower dell'istanza" instanceUsers: "Utenti dell'istanza" changePassword: "Aggiorna Password" security: "Sicurezza" @@ -393,13 +393,19 @@ about: "Informazioni" aboutMisskey: "Informazioni di Misskey" administrator: "Amministratore" token: "Token" +2fa: "Autenticazione a due fattori" +totp: "App di autenticazione" +totpDescription: "Inserisci un codice OTP tramite un'app di autenticazione" moderator: "Moderatore" moderation: "moderazione" nUsersMentioned: "{n} profili menzionati" +securityKeyAndPasskey: "Chiave di sicurezza e accesso" securityKey: "Chiave di sicurezza" lastUsed: "Ultima attività" +lastUsedAt: "Uso più recente: {t}" unregister: "Annulla l'iscrizione" passwordLessLogin: "Accedi senza password" +passwordLessLoginDescription: "Accedi senza password, usando la chiave di sicurezza" resetPassword: "Reimposta password" newPasswordIs: "La tua nuova password è「{password}」" reduceUiAnimation: "Ridurre le animazioni dell'interfaccia" @@ -451,6 +457,7 @@ aboutX: "Informazioni su {x}" emojiStyle: "Stile emoji" native: "Nativo" disableDrawer: "Non mostrare il menù sul drawer" +showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mouse" noHistory: "Nessuna cronologia" signinHistory: "Storico degli accessi al profilo" enableAdvancedMfm: "Attiva MFM avanzati" @@ -773,6 +780,7 @@ popularPosts: "Le più visualizzate" shareWithNote: "Condividere in nota" ads: "Pubblicità" expiration: "Scadenza" +startingperiod: "Periodo di inizio" memo: "Promemoria" priority: "Priorità" high: "Alta" @@ -804,7 +812,8 @@ pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" resolved: "Risolto" unresolved: "Non risolto" -breakFollow: "Smetti di seguire" +breakFollow: "Non seguire" +breakFollowConfirm: "Vuoi davvero togliere follower?" itsOn: "Abilitato" itsOff: "Disabilitato" emailRequiredForSignup: "L'ndirizzo e-mail è obbligatorio per registrarsi" @@ -839,11 +848,13 @@ instanceDefaultLightTheme: "Istanza, tema luminoso predefinito." instanceDefaultDarkTheme: "Istanza, tema scuro predefinito." instanceDefaultThemeDescription: "Compilare il codice del tema nel modulo dell'oggetto." mutePeriod: "Durata del mute" +period: "Scadenza" indefinitely: "Non scade" tenMinutes: "10 minuti" oneHour: "1 ora" oneDay: "1 giorno" oneWeek: "1 settimana" +oneMonth: "Un mese" reflectMayTakeTime: "Potrebbe essere necessario un po' di tempo perché ciò abbia effetto." failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul profilo" rateLimitExceeded: "Superato il limite di richieste." @@ -939,6 +950,11 @@ 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" +joinThisServer: "Registrati su questa istanza" +exploreOtherServers: "Trova altre istanze" +letsLookAtTimeline: "Sbircia la timeline" +disableFederationWarn: "Disabilita la federazione. Questo cambiamento non rende le pubblicazioni private. Di solito non è necessario abilitare questa opzione." +invitationRequiredToRegister: "L'accesso a questo nodo è solo ad invito. Devi inserire un codice d'invito valido. Puoi richiedere un codice all'amministratore." _achievements: earnedAt: "Data di conseguimento" _types: @@ -1075,7 +1091,7 @@ _achievements: description: "Hai seguito 300 profili" _followers1: title: "Il primo profilo tuo Follower" - description: "Hai ottenuto il tuo primo Follower" + description: "Hai ottenuto il tuo primo profilo Follower" _followers10: title: "Follow me!" description: "Hai ottenuto 10 profili Follower" @@ -1486,13 +1502,28 @@ _tutorial: step8_3: "Potrai modificare questa impostazione." _2fa: alreadyRegistered: "La configurazione è stata già completata." + registerTOTP: "Registra un'app di autenticazione" + passwordToTOTP: "Inserire la password" step1: "Innanzitutto, installare sul dispositivo un'applicazione di autenticazione come {a} o {b}." step2: "Quindi, scansionare il codice QR visualizzato con l'app." + step2Click: "Cliccando sul codice QR, puoi registrarlo con l'app di autenticazione o il portachiavi installato sul tuo dispositivo." step2Url: "Nell'applicazione desktop inserire il seguente URL: " + step3Title: "Inserisci il codice di verifica" step3: "Inserite il token visualizzato nell'app e il gioco è fatto." step4: "D'ora in poi, quando si accede, si inserisce il token nello stesso modo." + securityKeyNotSupported: "Il tuo browser non supporta le chiavi di sicurezza." + registerTOTPBeforeKey: "Ti occorre un'app di autenticazione con OTP, prima di registrare la chiave di sicurezza." securityKeyInfo: "È possibile impostare il dispositivo per accedere utilizzando una chiave di sicurezza hardware che supporta FIDO2 o un'impronta digitale o un PIN sul dispositivo." + chromePasskeyNotSupported: "Le passkey di Chrome non sono attualmente supportate." + registerSecurityKey: "Registra la chiave di sicurezza" + securityKeyName: "Inserisci il nome della chiave" + tapSecurityKey: "Segui le istruzioni del browser e registra la chiave di sicurezza." + removeKey: "Elimina la chiave di sicurezza" removeKeyConfirm: "Vuoi davvero eliminare \"{name}\"?" + whyTOTPOnlyRenew: "Se c'è una chiave di sicurezza attiva, non è possibile rimuovere l'app di autenticazione." + renewTOTP: "Riconfigura l'app di autenticazione" + renewTOTPConfirm: "I codici di verifica nelle app di autenticazione esistenti smetteranno di funzionare" + renewTOTPOk: "Ripristina" renewTOTPCancel: "No grazie" _permissions: "read:account": "Visualizza le informazioni sul profilo" @@ -1504,7 +1535,7 @@ _permissions: "read:favorites": "Visualizza i tuoi preferiti" "write:favorites": "Gestisci i tuoi preferiti" "read:following": "Vedi le informazioni di follow" - "write:following": "Seguiti/ Smetti di seguire" + "write:following": "Seguire / Non seguire altri profili" "read:messaging": "Visualizzare la chat" "write:messaging": "Gestire la chat" "read:mutes": "Vedi i profili silenziati" @@ -1612,10 +1643,12 @@ _visibility: publicDescription: "Visibile per tutti sul Fediverso" home: "Home" homeDescription: "Visibile solo sulla timeline \"Home\"" - followers: "Followers" - followersDescription: "Visibile solo per i tuoi followers" + followers: "Follower" + followersDescription: "Visibile solo per i tuoi follower" specified: "Nota diretta" specifiedDescription: "Visibile solo ai profili menzionati" + disableFederation: "Interrompi la federazione" + disableFederationDescription: "Non spedire attività alle altre istanze remote" _postForm: replyPlaceholder: "Rispondi a questa nota..." quotePlaceholder: "Cita questa nota..." @@ -1642,7 +1675,7 @@ _profile: _exportOrImport: allNotes: "Tutte le note" favoritedNotes: "Note preferite" - followingList: "Follows" + followingList: "Follow" muteList: "Elenco profili silenziati" blockingList: "Elenco profili bloccati" userLists: "Liste" @@ -1771,6 +1804,7 @@ _notification: pollEnded: "Sondaggio chiuso." receiveFollowRequest: "Richiesta di follow ricevuta" followRequestAccepted: "Richiesta di follow accettata" + achievementEarned: "Risultato raggiunto" app: "Notifiche da applicazioni" _actions: followBack: "Segui" @@ -1803,3 +1837,6 @@ _deck: channel: "Canale" mentions: "Menzioni" direct: "Diretta" +_dialog: + charactersExceeded: "Hai superato il limite di {max} caratteri! ({corrente})" + charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({corrente})" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 73f9718aa..8fee2726e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2,7 +2,7 @@ _lang_: "日本語" headlineMisskey: "ノートでつながるネットワーク" introMisskey: "ようこそ!Misskeyは、オープンソースの分散型マイクロブログサービスです。\n「ノート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀" -poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyを使ったサービス(Misskeyインスタンスと呼ばれます)のひとつです。" +poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつです。" monthAndDay: "{month}月 {day}日" search: "検索" notifications: "通知" @@ -18,7 +18,7 @@ enterUsername: "ユーザー名を入力" renotedBy: "{user}がRenote" noNotes: "ノートはありません" noNotifications: "通知はありません" -instance: "インスタンス" +instance: "サーバー" settings: "設定" basicSettings: "基本設定" otherSettings: "その他の設定" @@ -163,13 +163,13 @@ searchWith: "検索: {q}" youHaveNoLists: "リストがありません" followConfirm: "{name}をフォローしますか?" proxyAccount: "プロキシアカウント" -proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがインスタンスに配達されないため、代わりにプロキシアカウントがフォローするようにします。" +proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがサーバーに配達されないため、代わりにプロキシアカウントがフォローするようにします。" host: "ホスト" selectUser: "ユーザーを選択" recipient: "宛先" annotation: "注釈" federation: "連合" -instances: "インスタンス" +instances: "サーバー" registeredAt: "初観測" latestRequestReceivedAt: "直近のリクエスト受信" latestStatus: "直近のステータス" @@ -178,7 +178,7 @@ charts: "チャート" perHour: "1時間ごと" perDay: "1日ごと" stopActivityDelivery: "アクティビティの配送を停止" -blockThisInstance: "このインスタンスをブロック" +blockThisInstance: "このサーバーをブロック" operations: "操作" software: "ソフトウェア" version: "バージョン" @@ -189,15 +189,15 @@ jobQueue: "ジョブキュー" cpuAndMemory: "CPUとメモリ" network: "ネットワーク" disk: "ディスク" -instanceInfo: "インスタンス情報" +instanceInfo: "サーバー情報" statistics: "統計" clearQueue: "キューをクリア" clearQueueConfirmTitle: "キューをクリアしますか?" clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。" clearCachedFiles: "キャッシュをクリア" clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?" -blockedInstances: "ブロックしたインスタンス" -blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。サブドメインもブロックされます。" +blockedInstances: "ブロックしたサーバー" +blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定します。ブロックされたサーバーは、このサーバーとやり取りできなくなります。サブドメインもブロックされます。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" @@ -220,9 +220,9 @@ all: "全て" subscribing: "購読中" publishing: "配信中" notResponding: "応答なし" -instanceFollowing: "インスタンスのフォロー" -instanceFollowers: "インスタンスのフォロワー" -instanceUsers: "インスタンスのユーザー" +instanceFollowing: "サーバーのフォロー" +instanceFollowers: "サーバーのフォロワー" +instanceUsers: "サーバーのユーザー" changePassword: "パスワードを変更" security: "セキュリティ" retypedNotMatch: "入力が一致しません。" @@ -314,8 +314,8 @@ unwatch: "ウォッチ解除" accept: "許可" reject: "拒否" normal: "正常" -instanceName: "インスタンス名" -instanceDescription: "インスタンスの紹介" +instanceName: "サーバー名" +instanceDescription: "サーバーの紹介" maintainerName: "管理者の名前" maintainerEmail: "管理者のメールアドレス" tosUrl: "利用規約URL" @@ -345,7 +345,7 @@ basicInfo: "基本情報" pinnedUsers: "ピン留めユーザー" pinnedUsersDescription: "「みつける」ページなどにピン留めしたいユーザーを改行で区切って記述します。" pinnedPages: "ピン留めページ" -pinnedPagesDescription: "インスタンスのトップページにピン留めしたいページのパスを改行で区切って記述します。" +pinnedPagesDescription: "サーバーのトップページにピン留めしたいページのパスを改行で区切って記述します。" pinnedClipId: "ピン留めするクリップのID" pinnedNotes: "ピン留めされたノート" hcaptcha: "hCaptcha" @@ -457,6 +457,7 @@ aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" disableDrawer: "メニューをドロワーで表示しない" +showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示する" noHistory: "履歴はありません" signinHistory: "ログイン履歴" enableAdvancedMfm: "高度なMFMを有効にする" @@ -537,7 +538,7 @@ updateRemoteUser: "リモートユーザー情報の更新" deleteAllFiles: "すべてのファイルを削除" deleteAllFilesConfirm: "すべてのファイルを削除しますか?" removeAllFollowing: "フォローを全解除" -removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのインスタンスがもう存在しなくなった場合などに実行してください。" +removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのサーバーがもう存在しなくなった場合などに実行してください。" userSuspended: "このユーザーは凍結されています。" userSilenced: "このユーザーはサイレンスされています。" yourAccountSuspendedTitle: "アカウントが凍結されています" @@ -603,7 +604,7 @@ testEmail: "配信テスト" wordMute: "ワードミュート" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" -instanceMute: "インスタンスミュート" +instanceMute: "サーバーミュート" userSaysSomething: "{name}が何かを言いました" makeActive: "アクティブにする" display: "表示" @@ -634,15 +635,15 @@ abuseReported: "内容が送信されました。ご報告ありがとうござ reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートインスタンスに通報を転送する" -forwardReportIsAnonymous: "リモートインスタンスからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。" +forwardReport: "リモートサーバーに通報を転送する" +forwardReportIsAnonymous: "リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。" send: "送信" abuseMarkAsResolved: "対応済みにする" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。" -instanceTicker: "ノートのインスタンス情報" +instanceTicker: "ノートのサーバー情報" waitingFor: "{x}を待っています" random: "ランダム" system: "システム" @@ -731,7 +732,7 @@ capacity: "容量" inUse: "使用中" editCode: "コードを編集" apply: "適用" -receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" +receiveAnnouncementFromInstance: "サーバーからのお知らせを受け取る" emailNotification: "メール通知" publish: "公開" inChannelSearch: "チャンネル内検索" @@ -759,7 +760,7 @@ active: "アクティブ" offline: "オフライン" notRecommended: "非推奨" botProtection: "Botプロテクション" -instanceBlocking: "インスタンスブロック" +instanceBlocking: "サーバーブロック" selectAccount: "アカウントを選択" switchAccount: "アカウントを切り替え" enabled: "有効" @@ -843,15 +844,17 @@ themeColor: "テーマカラー" size: "サイズ" numberOfColumn: "列の数" searchByGoogle: "検索" -instanceDefaultLightTheme: "インスタンスデフォルトのライトテーマ" -instanceDefaultDarkTheme: "インスタンスデフォルトのダークテーマ" +instanceDefaultLightTheme: "サーバーデフォルトのライトテーマ" +instanceDefaultDarkTheme: "サーバーデフォルトのダークテーマ" instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入します。" mutePeriod: "ミュートする期限" +period: "期限" indefinitely: "無期限" tenMinutes: "10分" oneHour: "1時間" oneDay: "1日" oneWeek: "1週間" +oneMonth: "1ヶ月" reflectMayTakeTime: "反映されるまで時間がかかる場合があります。" failedToFetchAccountInformation: "アカウント情報の取得に失敗しました" rateLimitExceeded: "レート制限を超えました" @@ -895,7 +898,7 @@ cannotUploadBecauseInappropriate: "不適切な内容を含む可能性がある cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" beta: "ベータ" enableAutoSensitive: "自動NSFW判定" -enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、インスタンスによっては自動で設定されることがあります。" +enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。" navbar: "ナビゲーションバー" shuffle: "シャッフル" @@ -905,7 +908,7 @@ pushNotification: "プッシュ通知" subscribePushNotification: "プッシュ通知を有効化" unsubscribePushNotification: "プッシュ通知を停止する" pushNotificationAlreadySubscribed: "プッシュ通知は有効です" -pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に非対応" +pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応" sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" windowMaximize: "最大化" @@ -951,6 +954,7 @@ joinThisServer: "このサーバーに登録する" exploreOtherServers: "他のサーバーを探す" letsLookAtTimeline: "タイムラインを見てみる" disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。" +invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。" _achievements: earnedAt: "獲得日時" @@ -1142,7 +1146,7 @@ _achievements: description: "ホームタイムラインの流速が20npmを越す" _viewInstanceChart: title: "アナリスト" - description: "インスタンスのチャートを表示した" + description: "サーバーのチャートを表示した" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "スクラッチパッドで hello world を出力した" @@ -1179,7 +1183,7 @@ _achievements: _loggedInOnNewYearsDay: title: "あけましておめでとうございます" description: "元日にログインした" - flavor: "今年も弊インスタンスをよろしくお願いします" + flavor: "今年も弊サーバーをよろしくお願いします" _cookieClicked: title: "クッキーをクリックするゲーム" description: "クッキーをクリックした" @@ -1195,7 +1199,7 @@ _role: name: "ロール名" description: "ロールの説明" permission: "ロールの権限" - descriptionOfPermission: "モデレーターは基本的なモデレーションに関する操作を行えます。\n管理者はインスタンスの全ての設定を変更できます。" + descriptionOfPermission: "モデレーターは基本的なモデレーションに関する操作を行えます。\n管理者はサーバーの全ての設定を変更できます。" assignTarget: "アサイン" descriptionOfAssignTarget: "マニュアルは誰がこのロールに含まれるかを手動で管理します。\nコンディショナルは条件を設定し、それに合致するユーザーが自動で含まれるようになります。" manual: "マニュアル" @@ -1223,7 +1227,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" - canInvite: "インスタンス招待コードの発行" + canInvite: "サーバー招待コードの発行" canManageCustomEmojis: "カスタム絵文字の管理" driveCapacity: "ドライブ容量" pinMax: "ノートのピン留めの最大数" @@ -1292,7 +1296,7 @@ _ad: _forgotPassword: enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。" ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。" - contactAdmin: "このインスタンスではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。" + contactAdmin: "このサーバーではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。" _gallery: my: "自分の投稿" @@ -1390,10 +1394,10 @@ _wordMute: mutedNotes: "ミュートされたノート" _instanceMute: - instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートします。" + instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのノートとRenoteをミュートします。" instanceMuteDescription2: "改行で区切って設定します" - title: "設定したインスタンスのノートを隠します。" - heading: "ミュートするインスタンス" + title: "設定したサーバーのノートを隠します。" + heading: "ミュートするサーバー" _theme: explore: "テーマを探す" @@ -1613,7 +1617,7 @@ _weekday: _widgets: profile: "プロフィール" - instanceInfo: "インスタンス情報" + instanceInfo: "サーバー情報" memo: "付箋" notifications: "通知" timeline: "タイムライン" @@ -1627,7 +1631,7 @@ _widgets: digitalClock: "デジタル時計" unixClock: "UNIX時計" federation: "連合" - instanceCloud: "インスタンスクラウド" + instanceCloud: "サーバークラウド" postForm: "投稿フォーム" slideshow: "スライドショー" button: "ボタン" @@ -1681,7 +1685,7 @@ _visibility: specified: "ダイレクト" specifiedDescription: "指定したユーザーのみに公開" disableFederation: "連合なし" - disableFederationDescription: "他インスタンスへの配信を行いません" + disableFederationDescription: "他サーバーへの配信を行いません" _postForm: replyPlaceholder: "このノートに返信..." diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 73f007e2b..862453dd5 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -457,6 +457,7 @@ aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" disableDrawer: "メニューをドロワーで表示せぇへん" +showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで" noHistory: "履歴はあらへんねぇ。" signinHistory: "ログイン履歴" enableAdvancedMfm: "ややこしいMFMもありにする" @@ -847,11 +848,13 @@ instanceDefaultLightTheme: "インスタンスの最初の明るいテーマ" instanceDefaultDarkTheme: "インスタンスの最初の暗いテーマ" instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入するで。" mutePeriod: "ミュートする期間" +period: "期限" indefinitely: "無期限" tenMinutes: "10分" oneHour: "1時間" oneDay: "1日" oneWeek: "1週間" +oneMonth: "1ヶ月" reflectMayTakeTime: "反映されるまで時間がかかることがあるで" failedToFetchAccountInformation: "アカウントの取得に失敗したみたいや…" rateLimitExceeded: "レート制限が超えたみたいやで" @@ -951,6 +954,7 @@ joinThisServer: "このサーバーに登録するわ" exploreOtherServers: "他のサーバー見てみる" letsLookAtTimeline: "タイムライン見てみーや" disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。" +invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。" _achievements: earnedAt: "貰った日ぃ" _types: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 3e39980db..b43e5e7ab 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -393,13 +393,19 @@ about: "정보" aboutMisskey: "Misskey에 대하여" administrator: "관리자" token: "토큰" +2fa: "2단계 인증" +totp: "인증 앱" +totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" moderator: "모더레이터" moderation: "모더레이션" nUsersMentioned: "{n}명이 언급함" +securityKeyAndPasskey: "보안 키 또는 패스 키" securityKey: "보안 키" lastUsed: "마지막 사용" +lastUsedAt: "마지막 사용: {t}" unregister: "등록 해제" passwordLessLogin: "비밀번호 없이 로그인" +passwordLessLoginDescription: "비밀번호를 사용하지 않고 보안 키 또는 패스 키 등으로만 로그인합니다." resetPassword: "비밀번호 재설정" newPasswordIs: "새로운 비밀번호는 \"{password}\" 입니다" reduceUiAnimation: "UI의 애니메이션을 줄이기" @@ -773,6 +779,7 @@ popularPosts: "인기 포스트" shareWithNote: "노트로 공유" ads: "광고" expiration: "기한" +startingperiod: "시작 기간" memo: "메모" priority: "우선순위" high: "높음" @@ -805,6 +812,7 @@ lastCommunication: "마지막 통신" resolved: "해결됨" unresolved: "해결되지 않음" breakFollow: "팔로워 해제" +breakFollowConfirm: "팔로우를 해제하시겠습니까?" itsOn: "켜짐" itsOff: "꺼짐" emailRequiredForSignup: "가입할 때 이메일 주소 입력을 필수로 하기" @@ -839,11 +847,13 @@ instanceDefaultLightTheme: "인스턴스 기본 라이트 테마" instanceDefaultDarkTheme: "인스턴스 기본 다크 테마" instanceDefaultThemeDescription: "객체 형식의 테마 코드를 입력해 주세요." mutePeriod: "뮤트할 기간" +period: "투표 기한" indefinitely: "무기한" tenMinutes: "10분" oneHour: "1시간" oneDay: "1일" oneWeek: "일주일" +oneMonth: "1개월" reflectMayTakeTime: "반영되기까지 시간이 걸릴 수 있습니다." failedToFetchAccountInformation: "계정 정보를 가져오지 못했습니다" rateLimitExceeded: "요청 제한 횟수를 초과하였습니다" @@ -939,6 +949,11 @@ collapseRenotes: "이미 본 리노트를 간략화하기" internalServerError: "내부 서버 오류" internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했습니다." copyErrorInfo: "오류 정보 복사" +joinThisServer: "이 서버에 가입" +exploreOtherServers: "다른 서버 둘러보기" +letsLookAtTimeline: "타임라인 구경하기" +disableFederationWarn: "연합이 비활성화됩니다. 비활성화해도 게시물이 비공개가 되지는 않습니다. 대부분의 경우 이 옵션을 활성화할 필요가 없습니다." +invitationRequiredToRegister: "현재 이 서버는 비공개입니다. 회원가입을 하시려면 초대번호가 필요합니다." _achievements: earnedAt: "달성 일시" _types: @@ -1452,7 +1467,7 @@ _ago: weeksAgo: "{n}주 전" monthsAgo: "{n}개월 전" yearsAgo: "{n}년 전" - invalid: "아무것도 없습니다" + invalid: "없음" _time: second: "초" minute: "분" @@ -1486,14 +1501,29 @@ _tutorial: step8_3: "알림 설정은 나중에도 변경할 수 있습니다." _2fa: alreadyRegistered: "이미 설정이 완료되었습니다." + registerTOTP: "인증 앱 설정 시작" + passwordToTOTP: "비밀번호를 입력하세요." step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다." step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다." + step2Click: "QR 코드를 클릭하면 기기에 설치된 인증 앱에 등록할 수 있습니다." step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:" + step3Title: "인증 코드 입력" step3: "앱에 표시된 토큰을 입력하시면 완료됩니다." step4: "다음 로그인부터는 토큰을 입력해야 합니다." + securityKeyNotSupported: "이 브라우저는 보안 키를 지원하지 않습니다." + registerTOTPBeforeKey: "보안 키 또는 패스키를 등록하려면 인증 앱을 등록하십시오." securityKeyInfo: "FIDO2를 지원하는 하드웨어 보안 키 혹은 디바이스의 지문인식이나 화면잠금 PIN을 이용해서 로그인하도록 설정할 수 있습니다." + chromePasskeyNotSupported: "현재 Chrome의 패스키는 지원되지 않습니다." + registerSecurityKey: "보안 키 또는 패스키 등록" + securityKeyName: "키 이름 입력" + tapSecurityKey: "브라우저의 지시에 따라 보안 키 또는 패스키를 등록하여 주십시오" + removeKey: "보안 키를 삭제" removeKeyConfirm: "{name} 을(를) 삭제하시겠습니까?" - renewTOTPCancel: "나중에" + whyTOTPOnlyRenew: "보안 키가 등록되어 있는 경우 인증 앱을 해제할 수 없습니다." + renewTOTP: "인증 앱 재설정" + renewTOTPConfirm: "기존에 등록되어 있던 인증 키는 사용하지 못하게 됩니다." + renewTOTPOk: "재설정" + renewTOTPCancel: "취소" _permissions: "read:account": "계정의 정보를 봅니다" "write:account": "계정의 정보를 변경합니다" @@ -1616,6 +1646,8 @@ _visibility: followersDescription: "팔로워에게만 공개" specified: "다이렉트" specifiedDescription: "지정한 유저에게만 공개" + disableFederation: "연합에 보내지 않기" + disableFederationDescription: "다른 인스턴스로 보내지 않습니다" _postForm: replyPlaceholder: "이 노트에 답글..." quotePlaceholder: "이 노트를 인용..." @@ -1771,6 +1803,7 @@ _notification: pollEnded: "투표가 종료됨" receiveFollowRequest: "팔로우 요청을 받았을 때" followRequestAccepted: "팔로우 요청이 승인되었을 때" + achievementEarned: "도전 과제 획득" app: "연동된 앱을 통한 알림" _actions: followBack: "팔로우" @@ -1803,3 +1836,6 @@ _deck: channel: "채널" mentions: "받은 멘션" direct: "다이렉트" +_dialog: + charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {min}" + charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 1c19c4f3e..1dc818d45 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -810,6 +810,7 @@ auto: "Automatycznie" size: "Rozmiar" numberOfColumn: "Liczba kolumn" searchByGoogle: "Szukaj" +period: "Ankieta kończy się" indefinitely: "Nigdy" tenMinutes: "10 minut" oneHour: "1 godzina" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 86a60ee79..81ea01179 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -393,13 +393,19 @@ about: "Описание" aboutMisskey: "О Misskey" administrator: "Администратор" token: "Токен" +2fa: "2-х факторная аутентификация" +totp: "Приложение-аутентификатор" +totpDescription: "Описание приложения-аутентификатора" moderator: "Модератор" moderation: "Модерация" nUsersMentioned: "Упомянуло пользователей: {n}" +securityKeyAndPasskey: "Ключ безопасности и парольная фраза" securityKey: "Ключ безопасности" lastUsed: "Последнее использование" +lastUsedAt: "Последнее использование: {t}" unregister: "Отписаться" passwordLessLogin: "Настроить вход без пароля" +passwordLessLoginDescription: "Вход без пароля" resetPassword: "Сброс пароля:" newPasswordIs: "Новый пароль — «{password}»." reduceUiAnimation: "Уменьшить анимацию в пользовательском интерфейсе" @@ -773,6 +779,7 @@ popularPosts: "Популярные публикации" shareWithNote: "Поделиться заметкой" ads: "Реклама" expiration: "Опрос длится" +startingperiod: "Начальный период" memo: "Памятка" priority: "Приоритет" high: "Высокий" @@ -805,6 +812,7 @@ lastCommunication: "Последнее сообщение" resolved: "Решено" unresolved: "Без решения" breakFollow: "Отписка" +breakFollowConfirm: "Удалить из подписок пользователя ?" itsOn: "Включено" itsOff: "Выключено" emailRequiredForSignup: "Для регистрации учётной записи нужен адрес электронной почты" @@ -839,6 +847,7 @@ instanceDefaultLightTheme: "Светлая тема по умолчанию" instanceDefaultDarkTheme: "Темная тема по умолчанию" instanceDefaultThemeDescription: "Описание темы по умолчанию для инстанса" mutePeriod: "Продолжительность скрытия" +period: "Опрос длится" indefinitely: "вечно" tenMinutes: "10 минут" oneHour: "1 час" @@ -939,6 +948,10 @@ collapseRenotes: "Свернуть репосты" internalServerError: "Внутренняя ошибка сервера" internalServerErrorDescription: "Внутри сервера произошла непредвиденная ошибка." copyErrorInfo: "Скопировать код ошибки" +joinThisServer: "Присоединяйтесь к этому серверу" +exploreOtherServers: "Искать другие сервера" +letsLookAtTimeline: "Давайте посмотрим на ленту" +disableFederationWarn: "Объединение отключено. Если вы отключите это, сообщение не будет приватным. В большинстве случаев вам не нужно включать эту опцию." _achievements: earnedAt: "Разблокировано в" _types: @@ -1486,13 +1499,28 @@ _tutorial: step8_3: "Эту настройку вы всегда сможете поменять" _2fa: alreadyRegistered: "Двухфакторная аутентификация уже настроена." + registerTOTP: "Начните настраивать приложение-аутентификатор" + passwordToTOTP: "Пожалуйста, введите свой пароль" step1: "Прежде всего, установите на устройство приложение для аутентификации, например, {a} или {b}." step2: "Далее отсканируйте отображаемый QR-код при помощи приложения." + step2Click: "Нажав на QR-код, вы можете зарегистрироваться с помощью приложения для аутентификации или брелка для ключей, установленного на вашем устройстве." step2Url: "Если пользуетесь приложением на компьютере, можете ввести в него эту строку (URL):" + step3Title: "Введите проверочный код" step3: "И наконец, введите код, который покажет приложение." step4: "Теперь при каждом входе на сайт вам нужно будет вводить код из приложения аналогичным образом." + securityKeyNotSupported: "Ваш браузер не поддерживает ключи безопасности." + registerTOTPBeforeKey: "Чтобы зарегистрировать ключ безопасности и пароль, сначала настройте приложение аутентификации." securityKeyInfo: "Вы можете настроить вход с помощью аппаратного ключа безопасности, поддерживающего FIDO2, или отпечатка пальца или PIN-кода на устройстве." + chromePasskeyNotSupported: "В настоящее время Chrome не поддерживает пароль-ключи." + registerSecurityKey: "Зарегистрируйте ключ безопасности ・Passkey" + securityKeyName: "Введите имя для ключа" + tapSecurityKey: "Пожалуйста, следуйте инструкциям в вашем браузере, чтобы зарегистрировать свой ключ безопасности или пароль" + removeKey: "Удалить ключ безопасности" removeKeyConfirm: "Удалить резервную копию «{name}»?" + whyTOTPOnlyRenew: "Если ключ безопасности зарегистрирован, вы не сможете отключить приложение аутентификации." + renewTOTP: "Перенастроите приложение аутентификации" + renewTOTPConfirm: "Проверочный код предыдущего приложения для аутентификации больше не будет доступен" + renewTOTPOk: "Настроить" renewTOTPCancel: "Нет, спасибо" _permissions: "read:account": "Просматривать данные учётной записи" @@ -1616,6 +1644,8 @@ _visibility: followersDescription: "Только вашим подписчикам" specified: "Личное" specifiedDescription: "Тем, кого укажете" + disableFederation: "Отключить федерацию" + disableFederationDescription: "Не доставляет в другие экземпляры" _postForm: replyPlaceholder: "Ответ на заметку..." quotePlaceholder: "Пояснение к цитате..." @@ -1771,6 +1801,7 @@ _notification: pollEnded: "Окончания опросов" receiveFollowRequest: "Получен запрос на подписку" followRequestAccepted: "Запрос на подписку одобрен" + achievementEarned: "Получение достижений" app: "Уведомления из приложений" _actions: followBack: "отвечает взаимной подпиской" @@ -1803,3 +1834,6 @@ _deck: channel: "Каналы" mentions: "Упоминания" direct: "Личное" +_dialog: + charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}" + charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 525eb9ca0..d4be5540b 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -103,6 +103,8 @@ renoted: "Preposlané." cantRenote: "Tento príspevok sa nedá preposlať." cantReRenote: "Odpoveď nemôže byť odstránená." quote: "Citovať" +inChannelRenote: "Preposlania v kanáli" +inChannelQuote: "Citácie v kanáli" pinnedNote: "Pripnuté poznámky" pinned: "Pripnúť" you: "Vy" @@ -129,6 +131,7 @@ unblockConfirm: "Naozaj chcete odblokovať tento účet?" suspendConfirm: "Naozaj chcete zmraziť tento účet?" unsuspendConfirm: "Naozaj chcete odmraziť tento účet?" selectList: "Vyberte zoznam" +selectChannel: "Zvoľte kanál" selectAntenna: "Vyberte anténu" selectWidget: "Vyberte widget" editWidgets: "Upraviť widget" @@ -256,6 +259,8 @@ noMoreHistory: "To je všetko" startMessaging: "Začať chat" nUsersRead: "prečítané {n} používateľmi" agreeTo: "Súhlasím s {0}" +agreeBelow: "Súhlasím s nasledovným" +basicNotesBeforeCreateAccount: "Základné bezpečnostné opatrenia" tos: "Podmienky používania" start: "Začať" home: "Domov" @@ -388,13 +393,19 @@ about: "Informácie" aboutMisskey: "O Misskey" administrator: "Administrátor" token: "Token" +2fa: "Dvojfaktorové overenie (2FA)" +totp: "Overovacia aplikácia" +totpDescription: "Zadajte jednorazové heslo z overovacej aplikácie" moderator: "Moderátor" moderation: "Moderovanie" nUsersMentioned: "{n} používateľov spomenulo" +securityKeyAndPasskey: "Bezpečnostný kľúč/heslo" securityKey: "Bezpečnostný kľúč" lastUsed: "Naposledy použité" +lastUsedAt: "Naposledy použité: {t}" unregister: "Odregistrovať" passwordLessLogin: "Nastaviť bezheslové prihlásenie" +passwordLessLoginDescription: "Prihlásenie bez hesla, len bezpečnostným kľúčom alebo prístupovým kľúčom" resetPassword: "Resetovať heslo" newPasswordIs: "Nové heslo je \"{password}\"" reduceUiAnimation: "Menej UI animácií" @@ -446,8 +457,11 @@ aboutX: "O {x}" emojiStyle: "Štýl emoji" native: "Natívne" disableDrawer: "Nepoužívať šuflíkové menu" +showNoteActionsOnlyHover: "Ovládacie prvky poznámky sa zobrazujú len po nabehnutí myši" noHistory: "Žiadna história" signinHistory: "História prihlásení" +enableAdvancedMfm: "Povolenie pokročilého MFM" +enableAnimatedMfm: "Povoliť animované MFM" doing: "Pracujem..." category: "Kategórie" tags: "Značky" @@ -556,6 +570,7 @@ manage: "Administrácia" plugins: "Pluginy" preferencesBackups: "Zálohy nastavení" deck: "Deck" +undeck: "Oddokovať" useBlurEffectForModal: "Použiť efekt rozmazania na okná" useFullReactionPicker: "Použiť plnú veľkosť výberu reakcií" width: "Šírka" @@ -765,6 +780,7 @@ popularPosts: "Populárne príspevky" shareWithNote: "Zdieľať s poznámkou" ads: "Reklamy" expiration: "Ukončiť hlasovanie" +startingperiod: "Začiatok" memo: "Memo" priority: "Priorita" high: "Vysoká" @@ -831,11 +847,13 @@ instanceDefaultLightTheme: "Predvolená svetlá téma" instanceDefaultDarkTheme: "Predvolená tmavá téma" instanceDefaultThemeDescription: "Vložte kód témy v objektovom formáte" mutePeriod: "Trvanie stíšenia" +period: "Ukončiť hlasovanie" indefinitely: "Navždy" tenMinutes: "10 minút" oneHour: "1 hodina" oneDay: "1 deň" oneWeek: "1 týždeň" +oneMonth: "1 mesiac" reflectMayTakeTime: "Zmeny môžu chvíľu trvať kým sa prejavia." failedToFetchAccountInformation: "Nepodarilo sa načítať informácie o účte." rateLimitExceeded: "Prekročený limit rýchlosti" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index a56d7451b..7dcc15824 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -457,6 +457,7 @@ aboutX: "เกี่ยวกับ {x}" emojiStyle: "สไตล์อิโมจิ" native: "ภาษาแม่" disableDrawer: "อย่าใช้ลิ้นชักสไตล์เมนู" +showNoteActionsOnlyHover: "แสดงการดำเนินการเฉพาะโน้ตเมื่อโฮเวอร์" noHistory: "ไม่มีรายการ" signinHistory: "ประวัติการเข้าสู่ระบบ" enableAdvancedMfm: "เปิดใช้งาน MFM ขั้นสูง" @@ -847,11 +848,13 @@ instanceDefaultLightTheme: "ธีมสว่างค่าเริ่มต instanceDefaultDarkTheme: "ธีมมืดค่าเริ่มต้นอินสแตนซ์" instanceDefaultThemeDescription: "ป้อนรหัสธีมในรูปแบบออบเจ็กต์" mutePeriod: "ระยะเวลาปิดเสียง" +period: "สิ้นสุดการสำรวจความคิดเห็น" indefinitely: "ตลอดไป" tenMinutes: "10 นาที" oneHour: "1 ชั่วโมง" oneDay: "1 วัน" oneWeek: "1 สัปดาห์" +oneMonth: "หนึ่งเดือน" reflectMayTakeTime: "อาจจำเป็นต้องใช้เวลาสักระยะหนึ่งจึงจะเห็นแสดงผลได้นะ" failedToFetchAccountInformation: "ไม่สามารถเรียกดึงข้อมูลบัญชีได้" rateLimitExceeded: "เกินขีดจำกัดอัตรา" @@ -951,6 +954,7 @@ joinThisServer: "ลงชื่อสมัครใช้ในอินสแ exploreOtherServers: "มองหาอินสแตนซ์อื่น" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ" +invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" _achievements: earnedAt: "ได้รับเมื่อ" _types: diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index c47ad4539..56e3f024a 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -831,6 +831,7 @@ searchByGoogle: "Пошук" instanceDefaultLightTheme: "Світла тема за промовчанням" instanceDefaultDarkTheme: "Темна тема за промовчанням" mutePeriod: "Тривалість приховування" +period: "Опитування закінчується" indefinitely: "Ніколи" tenMinutes: "10 хвилин" oneHour: "1 година" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 174b1ddf1..89d94961d 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -824,6 +824,7 @@ instanceDefaultLightTheme: "Theme máy chủ Sáng-Rộng" instanceDefaultDarkTheme: "Theme máy chủ Tối-Rộng" instanceDefaultThemeDescription: "Nhập mã theme trong định dạng đối tượng." mutePeriod: "Thời hạn ẩn" +period: "Thời hạn" indefinitely: "Vĩnh viễn" tenMinutes: "10 phút" oneHour: "1 giờ" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index acc69e72e..517f5a9ef 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -457,6 +457,7 @@ aboutX: "关于 {x}" emojiStyle: "emoji 的样式" native: "原生" disableDrawer: "不显示抽屉菜单" +showNoteActionsOnlyHover: "仅在悬停时显示帖子操作" noHistory: "没有历史记录" signinHistory: "登录历史" enableAdvancedMfm: "启用扩展MFM" @@ -806,7 +807,7 @@ translatedFrom: "从 {x} 翻译" accountDeletionInProgress: "正在删除账户" usernameInfo: "在服务器上唯一标识您的帐户的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。" aiChanMode: "小蓝模式" -keepCw: "保留CW" +keepCw: "回复时维持隐藏内容" pubSub: "Pub/Sub账户" lastCommunication: "最近通信" resolved: "已解决" @@ -847,11 +848,13 @@ instanceDefaultLightTheme: "实例默认浅色主题" instanceDefaultDarkTheme: "实例默认深色主题" instanceDefaultThemeDescription: "以对象格式键入主题代码" mutePeriod: "屏蔽期限" +period: "截止时间" indefinitely: "永久" tenMinutes: "10分钟" oneHour: "1小时" oneDay: "1天" oneWeek: "1周" +oneMonth: "1 个月" reflectMayTakeTime: "可能需要一些时间才能体现出效果。" failedToFetchAccountInformation: "获取账户信息失败" rateLimitExceeded: "已超過速率限制" @@ -951,6 +954,7 @@ joinThisServer: "在本实例上注册" exploreOtherServers: "探索其他实例" letsLookAtTimeline: "时间线" disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。" +invitationRequiredToRegister: "此实例目前只允许拥有邀请码的人注册。" _achievements: earnedAt: "达成时间" _types: @@ -1123,7 +1127,7 @@ _achievements: title: "休息一下!" description: "启动客户端超过30分钟" _noteDeletedWithin1min: - title: "无话可说" + title: "欲言又止" description: "发帖后一分钟内就将其删除" _postedAtLateNight: title: "夜猫子" @@ -1430,9 +1434,9 @@ _theme: infoFg: "信息文本" infoWarnBg: "警告背景" infoWarnFg: "警告文本" - cwBg: "CW 按钮背景" - cwFg: "CW 按钮文本" - cwHoverBg: "CW 按钮背景(悬停)" + cwBg: "隐藏内容按钮背景" + cwFg: "隐藏内容按钮文本" + cwHoverBg: "隐藏内容按钮背景(悬停)" toastBg: "Toast通知背景" toastFg: "Toast通知文本" buttonBg: "按钮背景" @@ -1660,7 +1664,7 @@ _profile: name: "昵称" username: "用户名" description: "个人简介" - youCanIncludeHashtags: "你可以在个人简介中包含一个#标签。" + youCanIncludeHashtags: "你可以在个人简介中包含一些#标签。" metadata: "附加信息" metadataEdit: "附加信息编辑" metadataDescription: "最多可以在个人资料中以表格形式显示四条其他信息。" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 623e36d6e..218df5d9f 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -847,6 +847,7 @@ instanceDefaultLightTheme: "實例預設的淺色主題" instanceDefaultDarkTheme: "實例預設的深色主題" instanceDefaultThemeDescription: "輸入物件形式的主题代碼" mutePeriod: "靜音的期限" +period: "期限" indefinitely: "無期限" tenMinutes: "10分鐘" oneHour: "1小時" diff --git a/package.json b/package.json index 2dec3f31f..b870d8507 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "13.8.1", + "version": "13.9.0", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs index 8a11ad848..6b1afec73 100644 --- a/packages/backend/jest.config.cjs +++ b/packages/backend/jest.config.cjs @@ -91,7 +91,7 @@ module.exports = { // See https://github.com/swc-project/jest/issues/64#issuecomment-1029753225 // TODO: Use `--allowImportingTsExtensions` on TypeScript 5.0 so that we can // directly import `.ts` files without this hack. - '^(\\.{1,2}/.*)\\.js$': '$1', + '^((?:\\.{1,2}|[A-Z:])*/.*)\\.js$': '$1', }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader @@ -160,7 +160,7 @@ module.exports = { testMatch: [ "/test/unit/**/*.ts", "/src/**/*.test.ts", - //"/test/e2e/**/*.ts" + "/test/e2e/**/*.ts", ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped @@ -207,4 +207,13 @@ module.exports = { // watchman: true, extensionsToTreatAsEsm: ['.ts'], + + testTimeout: 60000, + + // Let Jest kill the test worker whenever it grows too much + // (It seems there's a known memory leak issue in Node.js' vm.Script used by Jest) + // https://github.com/facebook/jest/issues/11956 + maxWorkers: 1, // Make it use worker (that can be killed and restarted) + logHeapUsage: true, // To debug when out-of-memory happens on CI + workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB) }; diff --git a/packages/backend/migration/1677570181236-role-assignment-expires-at.js b/packages/backend/migration/1677570181236-role-assignment-expires-at.js new file mode 100644 index 000000000..3ac2edab0 --- /dev/null +++ b/packages/backend/migration/1677570181236-role-assignment-expires-at.js @@ -0,0 +1,13 @@ +export class roleAssignmentExpiresAt1677570181236 { + name = 'roleAssignmentExpiresAt1677570181236' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role_assignment" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`CREATE INDEX "IDX_539b6c08c05067599743bb6389" ON "role_assignment" ("expiresAt") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_539b6c08c05067599743bb6389"`); + await queryRunner.query(`ALTER TABLE "role_assignment" DROP COLUMN "expiresAt"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 9fa1e68a4..42efb881e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -15,8 +15,8 @@ "typecheck": "tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", - "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand --detectOpenHandles", - "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand --detectOpenHandles", + "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit", + "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit", "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", "test": "pnpm jest", "test-and-coverage": "pnpm jest-and-coverage" @@ -146,7 +146,6 @@ }, "devDependencies": { "@jest/globals": "29.4.3", - "@redocly/openapi-core": "1.0.0-beta.123", "@swc/jest": "0.2.24", "@types/accepts": "1.3.5", "@types/archiver": "5.3.1", diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 35416209a..801f1db74 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -1,3 +1,4 @@ +import { setTimeout } from 'node:timers/promises'; import { Global, Inject, Module } from '@nestjs/common'; import Redis from 'ioredis'; import { DataSource } from 'typeorm'; @@ -57,6 +58,14 @@ export class GlobalModule implements OnApplicationShutdown { ) {} async onApplicationShutdown(signal: string): Promise { + if (process.env.NODE_ENV === 'test') { + // XXX: + // Shutting down the existing connections causes errors on Jest as + // Misskey has asynchronous postgres/redis connections that are not + // awaited. + // Let's wait for some random time for them to finish. + await setTimeout(5000); + } await Promise.all([ this.db.destroy(), this.redisClient.disconnect(), diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts index 04aa26e65..279a1fe59 100644 --- a/packages/backend/src/boot/common.ts +++ b/packages/backend/src/boot/common.ts @@ -16,12 +16,14 @@ export async function server() { app.enableShutdownHooks(); const serverService = app.get(ServerService); - serverService.launch(); + await serverService.launch(); app.get(ChartManagementService).start(); app.get(JanitorService).start(); app.get(QueueStatsService).start(); app.get(ServerStatsService).start(); + + return app; } export async function jobQueue() { diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 0e7254593..05930350f 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -171,13 +171,15 @@ export class AntennaService implements OnApplicationShutdown { .filter(xs => xs.length > 0); if (keywords.length > 0) { - if (note.text == null) return false; + if (note.text == null && note.cw == null) return false; + + const _text = (note.text ?? '') + '\n' + (note.cw ?? ''); const matched = keywords.some(and => and.every(keyword => antenna.caseSensitive - ? note.text!.includes(keyword) - : note.text!.toLowerCase().includes(keyword.toLowerCase()), + ? _text.includes(keyword) + : _text.toLowerCase().includes(keyword.toLowerCase()), )); if (!matched) return false; @@ -189,13 +191,15 @@ export class AntennaService implements OnApplicationShutdown { .filter(xs => xs.length > 0); if (excludeKeywords.length > 0) { - if (note.text == null) return false; - + if (note.text == null && note.cw == null) return false; + + const _text = (note.text ?? '') + '\n' + (note.cw ?? ''); + const matched = excludeKeywords.some(and => and.every(keyword => antenna.caseSensitive - ? note.text!.includes(keyword) - : note.text!.toLowerCase().includes(keyword.toLowerCase()), + ? _text.includes(keyword) + : _text.toLowerCase().includes(keyword.toLowerCase()), )); if (matched) return false; diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts index cd47844a7..eba7171fb 100644 --- a/packages/backend/src/core/CreateNotificationService.ts +++ b/packages/backend/src/core/CreateNotificationService.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import type { Notification } from '@/models/entities/Notification.js'; @@ -10,7 +11,9 @@ import { PushNotificationService } from '@/core/PushNotificationService.js'; import { bindThis } from '@/decorators.js'; @Injectable() -export class CreateNotificationService { +export class CreateNotificationService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -40,11 +43,11 @@ export class CreateNotificationService { if (data.notifierId && (notifieeId === data.notifierId)) { return null; } - + const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); - + const isMuted = profile?.mutingNotificationTypes.includes(type); - + // Create notification const notification = await this.notificationsRepository.insert({ id: this.idService.genId(), @@ -56,18 +59,18 @@ export class CreateNotificationService { ...data, } as Partial) .then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); - + const packed = await this.notificationEntityService.pack(notification, {}); - + // Publish notification event this.globalEventService.publishMainStream(notifieeId, 'notification', packed); - + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する - setTimeout(async () => { + setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); if (fresh == null) return; // 既に削除されているかもしれない if (fresh.isRead) return; - + //#region ただしミュートしているユーザーからの通知なら無視 const mutings = await this.mutingsRepository.findBy({ muterId: notifieeId, @@ -76,14 +79,14 @@ export class CreateNotificationService { return; } //#endregion - + this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); - + if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); - }, 2000); - + }, () => { /* aborted, ignore it */ }); + return notification; } @@ -103,7 +106,7 @@ export class CreateNotificationService { sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); */ } - + @bindThis private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { /* @@ -115,4 +118,8 @@ export class CreateNotificationService { sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); */ } + + onApplicationShutdown(signal?: string | undefined): void { + this.#shutdownController.abort(); + } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 54c135a7c..4c4261ba7 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,6 +1,7 @@ +import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; @@ -137,7 +138,9 @@ type Option = { }; @Injectable() -export class NoteCreateService { +export class NoteCreateService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( @Inject(DI.config) private config: Config, @@ -313,7 +316,10 @@ export class NoteCreateService { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); - setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!)); + setImmediate('post created', { signal: this.#shutdownController.signal }).then( + () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); return note; } @@ -756,4 +762,8 @@ export class NoteCreateService { return mentionedUsers; } + + onApplicationShutdown(signal?: string | undefined) { + this.#shutdownController.abort(); + } } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 84983d600..d23fb8238 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In, IsNull, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; @@ -15,7 +16,9 @@ import { AntennaService } from './AntennaService.js'; import { PushNotificationService } from './PushNotificationService.js'; @Injectable() -export class NoteReadService { +export class NoteReadService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -60,14 +63,14 @@ export class NoteReadService { }); if (mute.map(m => m.muteeId).includes(note.userId)) return; //#endregion - + // スレッドミュート const threadMute = await this.noteThreadMutingsRepository.findOneBy({ userId: userId, threadId: note.threadId ?? note.id, }); if (threadMute) return; - + const unread = { id: this.idService.genId(), noteId: note.id, @@ -77,15 +80,15 @@ export class NoteReadService { noteChannelId: note.channelId, noteUserId: note.userId, }; - + await this.noteUnreadsRepository.insert(unread); - + // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(async () => { + setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id }); - + if (exist == null) return; - + if (params.isMentioned) { this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); } @@ -95,8 +98,8 @@ export class NoteReadService { if (note.channelId) { this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id); } - }, 2000); - } + }, () => { /* aborted, ignore it */ }); + } @bindThis public async read( @@ -113,24 +116,24 @@ export class NoteReadService { }, select: ['followeeId'], })).map(x => x.followeeId)); - + const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const readMentions: (Note | Packed<'Note'>)[] = []; const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; const readChannelNotes: (Note | Packed<'Note'>)[] = []; const readAntennaNotes: (Note | Packed<'Note'>)[] = []; - + for (const note of notes) { if (note.mentions && note.mentions.includes(userId)) { readMentions.push(note); } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { readSpecifiedNotes.push(note); } - + if (note.channelId && followingChannels.has(note.channelId)) { readChannelNotes.push(note); } - + if (note.user != null) { // たぶんnullになることは無いはずだけど一応 for (const antenna of myAntennas) { if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) { @@ -139,14 +142,14 @@ export class NoteReadService { } } } - + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { // Remove the record await this.noteUnreadsRepository.delete({ userId: userId, noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), }); - + // TODO: ↓まとめてクエリしたい this.noteUnreadsRepository.countBy({ @@ -183,7 +186,7 @@ export class NoteReadService { noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), }); } - + if (readAntennaNotes.length > 0) { await this.antennaNotesRepository.update({ antennaId: In(myAntennas.map(a => a.id)), @@ -191,14 +194,14 @@ export class NoteReadService { }, { read: true, }); - + // TODO: まとめてクエリしたい for (const antenna of myAntennas) { const count = await this.antennaNotesRepository.countBy({ antennaId: antenna.id, read: false, }); - + if (count === 0) { this.globalEventService.publishMainStream(userId, 'readAntenna', antenna); this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); @@ -213,4 +216,8 @@ export class NoteReadService { }); } } + + onApplicationShutdown(signal?: string | undefined): void { + this.#shutdownController.abort(); + } } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index b84d5e758..714959119 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -11,6 +11,8 @@ import { UserCacheService } from '@/core/UserCacheService.js'; import type { RoleCondFormulaValue } from '@/models/entities/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { @@ -56,6 +58,9 @@ export class RoleService implements OnApplicationShutdown { private rolesCache: Cache; private roleAssignmentByUserIdCache: Cache; + public static AlreadyAssignedError = class extends Error {}; + public static NotAssignedError = class extends Error {}; + constructor( @Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, @@ -72,6 +77,8 @@ export class RoleService implements OnApplicationShutdown { private metaService: MetaService, private userCacheService: UserCacheService, private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private idService: IdService, ) { //this.onMessage = this.onMessage.bind(this); @@ -128,6 +135,7 @@ export class RoleService implements OnApplicationShutdown { cached.push({ ...body, createdAt: new Date(body.createdAt), + expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, }); } break; @@ -193,7 +201,10 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getUserRoles(userId: User['id']) { - const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + const now = Date.now(); + let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + // 期限切れのロールを除外 + assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); @@ -207,7 +218,10 @@ export class RoleService implements OnApplicationShutdown { */ @bindThis public async getUserBadgeRoles(userId: User['id']) { - const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + const now = Date.now(); + let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + // 期限切れのロールを除外 + assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); @@ -316,6 +330,65 @@ export class RoleService implements OnApplicationShutdown { return users; } + @bindThis + public async assign(userId: User['id'], roleId: Role['id'], expiresAt: Date | null = null): Promise { + const now = new Date(); + + const existing = await this.roleAssignmentsRepository.findOneBy({ + roleId: roleId, + userId: userId, + }); + + if (existing) { + if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) { + await this.roleAssignmentsRepository.delete({ + roleId: roleId, + userId: userId, + }); + } else { + throw new RoleService.AlreadyAssignedError(); + } + } + + const created = await this.roleAssignmentsRepository.insert({ + id: this.idService.genId(), + createdAt: now, + expiresAt: expiresAt, + roleId: roleId, + userId: userId, + }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); + + this.rolesRepository.update(roleId, { + lastUsedAt: new Date(), + }); + + this.globalEventService.publishInternalEvent('userRoleAssigned', created); + } + + @bindThis + public async unassign(userId: User['id'], roleId: Role['id']): Promise { + const now = new Date(); + + const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId }); + if (existing == null) { + throw new RoleService.NotAssignedError(); + } else if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) { + await this.roleAssignmentsRepository.delete({ + roleId: roleId, + userId: userId, + }); + throw new RoleService.NotAssignedError(); + } + + await this.roleAssignmentsRepository.delete(existing.id); + + this.rolesRepository.update(roleId, { + lastUsedAt: now, + }); + + this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); + } + @bindThis public onApplicationShutdown(signal?: string | undefined) { this.redisSubscriber.off('message', this.onMessage); diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 30caa9682..ac1e413de 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -47,6 +47,7 @@ export class WebhookService implements OnApplicationShutdown { this.webhooks.push({ ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }); } break; @@ -57,11 +58,13 @@ export class WebhookService implements OnApplicationShutdown { this.webhooks[i] = { ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }; } else { this.webhooks.push({ ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }); } } else { diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts index dbde75767..03e361265 100644 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -62,8 +62,10 @@ export class ChartManagementService implements OnApplicationShutdown { async onApplicationShutdown(signal: string): Promise { clearInterval(this.saveIntervalId); - await Promise.all( - this.charts.map(chart => chart.save()), - ); + if (process.env.NODE_ENV !== 'test') { + await Promise.all( + this.charts.map(chart => chart.save()), + ); + } } } diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts index 1e2a579df..d8966f34c 100644 --- a/packages/backend/src/core/chart/charts/per-user-notes.ts +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -45,8 +45,8 @@ export default class PerUserNotesChart extends Chart { } @bindThis - public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean): Promise { - await this.commit({ + public update(user: { id: User['id'] }, note: Note, isAdditional: boolean): void { + this.commit({ 'total': isAdditional ? 1 : -1, 'inc': isAdditional ? 1 : 0, 'dec': isAdditional ? 0 : 1, diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 158fafa9d..f5b1f9815 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -1,5 +1,5 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -21,6 +21,7 @@ type PackOptions = { }; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; +import { isNotNull } from '@/misc/is-not-null.js'; @Injectable() export class DriveFileEntityService { @@ -255,10 +256,29 @@ export class DriveFileEntityService { @bindThis public async packMany( - files: (DriveFile['id'] | DriveFile)[], + files: DriveFile[], options?: PackOptions, ): Promise[]> { const items = await Promise.all(files.map(f => this.packNullable(f, options))); return items.filter((x): x is Packed<'DriveFile'> => x != null); } + + @bindThis + public async packManyByIdsMap( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise['id'], Packed<'DriveFile'>>> { + const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); + const packedFiles = await this.packMany(files, options); + return new Map(packedFiles.map(f => [f.id, f])); + } + + @bindThis + public async packManyByIds( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise[]> { + const filesMap = await this.packManyByIdsMap(fileIds, options); + return fileIds.map(id => filesMap.get(id)).filter(isNotNull); + } } diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index ab29e7dba..fb147ae18 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -41,7 +41,8 @@ export class GalleryPostEntityService { title: post.title, description: post.description, fileIds: post.fileIds, - files: this.driveFileEntityService.packMany(post.fileIds), + // TODO: packMany causes N+1 queries + files: this.driveFileEntityService.packManyByIds(post.fileIds), tags: post.tags.length > 0 ? post.tags : undefined, isSensitive: post.isSensitive, likedCount: post.likedCount, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2ffe5f1c2..c732a98a1 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -257,6 +258,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; _hint_?: { myReactions: Map; + packedFiles: Map>; }; }, ): Promise> { @@ -284,6 +286,7 @@ export class NoteEntityService implements OnModuleInit { const reactionEmojiNames = Object.keys(note.reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); + const packedFiles = options?._hint_?.packedFiles; const packed: Packed<'Note'> = await awaitAll({ id: note.id, @@ -304,7 +307,7 @@ export class NoteEntityService implements OnModuleInit { emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, - files: this.driveFileEntityService.packMany(note.fileIds), + files: packedFiles != null ? note.fileIds.map(fi => packedFiles.get(fi)).filter(isNotNull) : this.driveFileEntityService.packManyByIds(note.fileIds), replyId: note.replyId, renoteId: note.renoteId, channelId: note.channelId ?? undefined, @@ -388,11 +391,14 @@ export class NoteEntityService implements OnModuleInit { } await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + const fileIds = notes.flatMap(n => n.fileIds); + const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, + packedFiles, }, }))); } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 33c76c693..be88a213f 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -1,19 +1,21 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Notification } from '@/models/entities/Notification.js'; -import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { Note } from '@/models/entities/Note.js'; import type { Packed } from '@/misc/schema.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; +import { notificationTypes } from '@/types.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]); + @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; @@ -48,13 +50,20 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: Notification['id'] | Notification, options: { - _hintForEachNotes_?: { - myReactions: Map; + _hint_?: { + packedNotes: Map>; }; }, ): Promise> { const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; + const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( + options._hint_?.packedNotes != null + ? options._hint_.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + }) + ) : undefined; return await awaitAll({ id: notification.id, @@ -63,43 +72,10 @@ export class NotificationEntityService implements OnModuleInit { isRead: notification.isRead, userId: notification.notifierId, user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, - ...(notification.type === 'mention' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'reply' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'renote' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'quote' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), + ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), reaction: notification.reaction, } : {}), - ...(notification.type === 'pollEnded' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), ...(notification.type === 'achievementEarned' ? { achievement: notification.achievement, } : {}), @@ -111,32 +87,32 @@ export class NotificationEntityService implements OnModuleInit { }); } + /** + * @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId + */ @bindThis public async packMany( notifications: Notification[], meId: User['id'], ) { if (notifications.length === 0) return []; - - const notes = notifications.filter(x => x.note != null).map(x => x.note!); - const noteIds = notes.map(n => n.id); - const myReactionsMap = new Map(); - const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); - const targets = [...noteIds, ...renoteIds]; - const myReactions = await this.noteReactionsRepository.findBy({ - userId: meId, - noteId: In(targets), - }); - - for (const target of targets) { - myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); + + for (const notification of notifications) { + if (meId !== notification.notifieeId) { + // because we call note packMany with meId, all notifieeId should be same as meId + throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION'); + } } - await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + const notes = notifications.map(x => x.note).filter(isNotNull); + const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { + detail: true, + }); + const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); return await Promise.all(notifications.map(x => this.pack(x, { - _hintForEachNotes_: { - myReactions: myReactionsMap, + _hint_: { + packedNotes, }, }))); } diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 80ef5ac1f..2f1d51fa1 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; @@ -28,9 +29,13 @@ export class RoleEntityService { ) { const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); - const assigns = await this.roleAssignmentsRepository.findBy({ - roleId: role.id, - }); + const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') + .where('assign.roleId = :roleId', { roleId: role.id }) + .andWhere(new Brackets(qb => { qb + .where('assign.expiresAt IS NULL') + .orWhere('assign.expiresAt > :now', { now: new Date() }); + })) + .getCount(); const policies = { ...role.policies }; for (const [k, v] of Object.entries(DEFAULT_POLICIES)) { @@ -57,7 +62,7 @@ export class RoleEntityService { asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, policies: policies, - usersCount: assigns.length, + usersCount: assignedCount, }); } diff --git a/packages/backend/src/misc/is-not-null.ts b/packages/backend/src/misc/is-not-null.ts new file mode 100644 index 000000000..d89a1957b --- /dev/null +++ b/packages/backend/src/misc/is-not-null.ts @@ -0,0 +1,5 @@ +// we are using {} as "any non-nullish value" as expected +// eslint-disable-next-line @typescript-eslint/ban-types +export function isNotNull(input: T | undefined | null): input is T { + return input != null; +} diff --git a/packages/backend/src/models/entities/RoleAssignment.ts b/packages/backend/src/models/entities/RoleAssignment.ts index e86f2a899..972810940 100644 --- a/packages/backend/src/models/entities/RoleAssignment.ts +++ b/packages/backend/src/models/entities/RoleAssignment.ts @@ -39,4 +39,10 @@ export class RoleAssignment { }) @JoinColumn() public role: Role | null; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public expiresAt: Date | null; } diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index 406184cbd..7fd2cde9c 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import { LessThan } from 'typeorm'; +import { In, LessThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, UserIpsRepository } from '@/models/index.js'; +import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; @@ -29,6 +29,9 @@ export class CleanProcessorService { @Inject(DI.antennaNotesRepository) private antennaNotesRepository: AntennaNotesRepository, + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + private queueLoggerService: QueueLoggerService, private idService: IdService, ) { @@ -56,6 +59,17 @@ export class CleanProcessorService { id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), }); + const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign') + .where('assign.expiresAt IS NOT NULL') + .andWhere('assign.expiresAt < :now', { now: new Date() }) + .getMany(); + + if (expiredRoleAssignments.length > 0) { + await this.roleAssignmentsRepository.delete({ + id: In(expiredRoleAssignments.map(x => x.id)), + }); + } + this.logger.succ('Cleaned.'); done(); } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index c12ae9b82..e5eefac1f 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -226,7 +226,10 @@ export class FileServerService { return; } - if (this.config.externalMediaProxyEnabled) { + // アバタークロップなど、どうしてもオリジンである必要がある場合 + const mustOrigin = 'origin' in request.query; + + if (this.config.externalMediaProxyEnabled && !mustOrigin) { // 外部のメディアプロキシが有効なら、そちらにリダイレクト reply.header('Cache-Control', 'public, max-age=259200'); // 3 days diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 8200b24fd..e61383468 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -1,7 +1,7 @@ import cluster from 'node:cluster'; import * as fs from 'node:fs'; -import { Inject, Injectable } from '@nestjs/common'; -import Fastify from 'fastify'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import Fastify, { FastifyInstance } from 'fastify'; import { IsNull } from 'typeorm'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; @@ -23,8 +23,9 @@ import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; @Injectable() -export class ServerService { +export class ServerService implements OnApplicationShutdown { private logger: Logger; + #fastify: FastifyInstance; constructor( @Inject(DI.config) @@ -54,11 +55,12 @@ export class ServerService { } @bindThis - public launch() { + public async launch() { const fastify = Fastify({ trustProxy: true, logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''), }); + this.#fastify = fastify; // HSTS // 6months (15552000sec) @@ -75,7 +77,7 @@ export class ServerService { fastify.register(this.nodeinfoServerService.createServer); fastify.register(this.wellKnownServerService.createServer); - fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { + fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { const path = request.params.path; reply.header('Cache-Control', 'public, max-age=86400'); @@ -105,11 +107,19 @@ export class ServerService { } } - const url = new URL(`${this.config.mediaProxy}/emoji.webp`); - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); - url.searchParams.set('emoji', '1'); - if ('static' in request.query) url.searchParams.set('static', '1'); + let url: URL; + if ('badge' in request.query) { + url = new URL(`${this.config.mediaProxy}/emoji.png`); + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); + url.searchParams.set('badge', '1'); + } else { + url = new URL(`${this.config.mediaProxy}/emoji.webp`); + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); + url.searchParams.set('emoji', '1'); + if ('static' in request.query) url.searchParams.set('static', '1'); + } return await reply.redirect( 301, @@ -195,5 +205,11 @@ export class ServerService { }); fastify.listen({ port: this.config.port, host: '0.0.0.0' }); + + await fastify.ready(); + } + + async onApplicationShutdown(signal: string): Promise { + await this.#fastify.close(); } } diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 6d8540dd4..f84a3aa59 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -100,9 +100,12 @@ export class ApiCallService implements OnApplicationShutdown { request: FastifyRequest<{ Body: Record, Querystring: Record }>, reply: FastifyReply, ) { - const multipartData = await request.file(); + const multipartData = await request.file().catch(() => { + /* Fastify throws if the remote didn't send multipart data. Return 400 below. */ + }); if (multipartData == null) { reply.code(400); + reply.send(); return; } diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 2b99da01b..115d60986 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -73,28 +73,32 @@ export class ApiServerService { Params: { endpoint: string; }, Body: Record, Querystring: Record, - }>('/' + endpoint.name, (request, reply) => { + }>('/' + endpoint.name, async (request, reply) => { if (request.method === 'GET' && !endpoint.meta.allowGet) { reply.code(405); reply.send(); return; } - - this.apiCallService.handleMultipartRequest(ep, request, reply); + + // Await so that any error can automatically be translated to HTTP 500 + await this.apiCallService.handleMultipartRequest(ep, request, reply); + return reply; }); } else { fastify.all<{ Params: { endpoint: string; }, Body: Record, Querystring: Record, - }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, (request, reply) => { + }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, async (request, reply) => { if (request.method === 'GET' && !endpoint.meta.allowGet) { reply.code(405); reply.send(); return; } - - this.apiCallService.handleRequest(ep, request, reply); + + // Await so that any error can automatically be translated to HTTP 500 + await this.apiCallService.handleRequest(ep, request, reply); + return reply; }); } } @@ -160,6 +164,22 @@ export class ApiServerService { } }); + // Make sure any unknown path under /api returns HTTP 404 Not Found, + // because otherwise ClientServerService will return the base client HTML + // page with HTTP 200. + fastify.get('*', (request, reply) => { + reply.code(404); + // Mock ApiCallService.send's error handling + reply.send({ + error: { + message: 'Unknown API endpoint.', + code: 'UNKNOWN_API_ENDPOINT', + id: '2ca3b769-540a-4f08-9dd5-b5a825b6d0f1', + kind: 'client', + }, + }); + }); + done(); } } diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4d5ed9fb6..4f521148e 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -741,8 +741,8 @@ export interface IEndpoint { const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { return { name: name, - meta: ep.meta ?? {}, - params: ep.paramDef, + get meta() { return ep.meta ?? {}; }, + get params() { return ep.paramDef; }, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts index 7bfb2f662..b80aaba12 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts @@ -1,10 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; +import type { RolesRepository, UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { RoleService } from '@/core/RoleService.js'; export const meta = { @@ -39,6 +37,10 @@ export const paramDef = { properties: { roleId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' }, + expiresAt: { + type: 'integer', + nullable: true, + }, }, required: [ 'roleId', @@ -56,12 +58,7 @@ export default class extends Endpoint { @Inject(DI.rolesRepository) private rolesRepository: RolesRepository, - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private globalEventService: GlobalEventService, private roleService: RoleService, - private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); @@ -78,19 +75,11 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchUser); } - const date = new Date(); - const created = await this.roleAssignmentsRepository.insert({ - id: this.idService.genId(), - createdAt: date, - roleId: role.id, - userId: user.id, - }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); + if (ps.expiresAt && ps.expiresAt <= Date.now()) { + return; + } - this.rolesRepository.update(ps.roleId, { - lastUsedAt: new Date(), - }); - - this.globalEventService.publishInternalEvent('userRoleAssigned', created); + await this.roleService.assign(user.id, role.id, ps.expiresAt ? new Date(ps.expiresAt) : null); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts index 141cc5ee8..45c4f7694 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts @@ -1,10 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; +import type { RolesRepository, UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { RoleService } from '@/core/RoleService.js'; export const meta = { @@ -62,12 +60,7 @@ export default class extends Endpoint { @Inject(DI.rolesRepository) private rolesRepository: RolesRepository, - @Inject(DI.roleAssignmentsRepository) - private roleAssignmentsRepository: RoleAssignmentsRepository, - - private globalEventService: GlobalEventService, private roleService: RoleService, - private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); @@ -84,18 +77,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchUser); } - const roleAssignment = await this.roleAssignmentsRepository.findOneBy({ userId: user.id, roleId: role.id }); - if (roleAssignment == null) { - throw new ApiError(meta.errors.notAssigned); - } - - await this.roleAssignmentsRepository.delete(roleAssignment.id); - - this.rolesRepository.update(ps.roleId, { - lastUsedAt: new Date(), - }); - - this.globalEventService.publishInternalEvent('userRoleUnassigned', roleAssignment); + await this.roleService.unassign(user.id, role.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts index bb016a842..35edca546 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; @@ -56,6 +57,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) .andWhere('assign.roleId = :roleId', { roleId: role.id }) + .andWhere(new Brackets(qb => { qb + .where('assign.expiresAt IS NULL') + .orWhere('assign.expiresAt > :now', { now: new Date() }); + })) .innerJoinAndSelect('assign.user', 'user'); const assigns = await query @@ -64,7 +69,9 @@ export default class extends Endpoint { return await Promise.all(assigns.map(async assign => ({ id: assign.id, + createdAt: assign.createdAt, user: await this.userEntityService.pack(assign.user!, me, { detail: true }), + expiresAt: assign.expiresAt, }))); }); } diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 58f883527..cdaa40013 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -82,6 +82,12 @@ export default class extends Endpoint { .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .leftJoinAndSelect('note.channel', 'channel'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } //#endregion const timeline = await query.take(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index b656c5c51..4f543a647 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -73,8 +73,8 @@ export default class extends Endpoint { } if (ps.email != null) { - const available = await this.emailService.validateEmailForAccount(ps.email); - if (!available) { + const res = await this.emailService.validateEmailForAccount(ps.email); + if (!res.available) { throw new ApiError(meta.errors.unavailable); } } diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts index 6e221b6c6..607dc2420 100644 --- a/packages/backend/src/server/api/endpoints/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; @@ -56,6 +57,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) .andWhere('assign.roleId = :roleId', { roleId: role.id }) + .andWhere(new Brackets(qb => { qb + .where('assign.expiresAt IS NULL') + .orWhere('assign.expiresAt > :now', { now: new Date() }); + })) .innerJoinAndSelect('assign.user', 'user'); const assigns = await query diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 9287952cb..c45077305 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -178,7 +178,14 @@ type EventUnionFromDictionary< // redis通すとDateのインスタンスはstringに変換されるので type Serialized = { - [K in keyof T]: T[K] extends Date ? string : T[K] extends Record ? Serialized : T[K]; + [K in keyof T]: + T[K] extends Date + ? string + : T[K] extends (Date | null) + ? (string | null) + : T[K] extends Record + ? Serialized + : T[K]; }; type SerializedAll = { diff --git a/packages/backend/test/_e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts similarity index 94% rename from packages/backend/test/_e2e/api-visibility.ts rename to packages/backend/test/e2e/api-visibility.ts index d29b9acb3..4e162f42d 100644 --- a/packages/backend/test/_e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -1,18 +1,18 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, startServer, shutdownServer } from '../utils.js'; +import { signup, api, post, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('API visibility', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; beforeAll(async () => { p = await startServer(); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); describe('Note visibility', () => { @@ -60,7 +60,7 @@ describe('API visibility', () => { //#endregion const show = async (noteId: any, by: any) => { - return await request('/notes/show', { + return await api('/notes/show', { noteId, }, by); }; @@ -75,7 +75,7 @@ describe('API visibility', () => { target2 = await signup({ username: 'target2' }); // follow alice <= follower - await request('/following/create', { userId: alice.id }, follower); + await api('/following/create', { userId: alice.id }, follower); // normal posts pub = await post(alice, { text: 'x', visibility: 'public' }); @@ -413,21 +413,21 @@ describe('API visibility', () => { //#region HTL test('[HTL] public-post が 自分が見れる', async () => { - const res = await request('/notes/timeline', { limit: 100 }, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === pub.id); assert.strictEqual(notes[0].text, 'x'); }); test('[HTL] public-post が 非フォロワーから見れない', async () => { - const res = await request('/notes/timeline', { limit: 100 }, other); + const res = await api('/notes/timeline', { limit: 100 }, other); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === pub.id); assert.strictEqual(notes.length, 0); }); test('[HTL] followers-post が フォロワーから見れる', async () => { - const res = await request('/notes/timeline', { limit: 100 }, follower); + const res = await api('/notes/timeline', { limit: 100 }, follower); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === fol.id); assert.strictEqual(notes[0].text, 'x'); @@ -436,21 +436,21 @@ describe('API visibility', () => { //#region RTL test('[replies] followers-reply が フォロワーから見れる', async () => { - const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); + const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { - const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other); + const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, other); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes.length, 0); }); test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { - const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target); + const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); @@ -459,14 +459,14 @@ describe('API visibility', () => { //#region MTL test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { - const res = await request('/notes/mentions', { limit: 100 }, target); + const res = await api('/notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { - const res = await request('/notes/mentions', { limit: 100 }, target); + const res = await api('/notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id === folM.id); assert.strictEqual(notes[0].text, '@target x'); @@ -474,4 +474,4 @@ describe('API visibility', () => { //#endregion }); }); -*/ + diff --git a/packages/backend/test/_e2e/api.ts b/packages/backend/test/e2e/api.ts similarity index 55% rename from packages/backend/test/_e2e/api.ts rename to packages/backend/test/e2e/api.ts index 7542c34db..6ceccf66e 100644 --- a/packages/backend/test/_e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -1,11 +1,11 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from '../utils.js'; +import { signup, api, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('API', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; let alice: any; let bob: any; let carol: any; @@ -15,69 +15,69 @@ describe('API', () => { alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); describe('General validation', () => { - test('wrong type', async(async () => { - const res = await request('/test', { + test('wrong type', async () => { + const res = await api('/test', { required: true, string: 42, }); assert.strictEqual(res.status, 400); - })); + }); - test('missing require param', async(async () => { - const res = await request('/test', { + test('missing require param', async () => { + const res = await api('/test', { string: 'a', }); assert.strictEqual(res.status, 400); - })); + }); - test('invalid misskey:id (empty string)', async(async () => { - const res = await request('/test', { + test('invalid misskey:id (empty string)', async () => { + const res = await api('/test', { required: true, id: '', }); assert.strictEqual(res.status, 400); - })); + }); - test('valid misskey:id', async(async () => { - const res = await request('/test', { + test('valid misskey:id', async () => { + const res = await api('/test', { required: true, id: '8wvhjghbxu', }); assert.strictEqual(res.status, 200); - })); + }); - test('default value', async(async () => { - const res = await request('/test', { + test('default value', async () => { + const res = await api('/test', { required: true, string: 'a', }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.default, 'hello'); - })); + }); - test('can set null even if it has default value', async(async () => { - const res = await request('/test', { + test('can set null even if it has default value', async () => { + const res = await api('/test', { required: true, nullableDefault: null, }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.nullableDefault, null); - })); + }); - test('cannot set undefined if it has default value', async(async () => { - const res = await request('/test', { + test('cannot set undefined if it has default value', async () => { + const res = await api('/test', { required: true, nullableDefault: undefined, }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.nullableDefault, 'hello'); - })); + }); }); }); diff --git a/packages/backend/test/_e2e/block.ts b/packages/backend/test/e2e/block.ts similarity index 77% rename from packages/backend/test/_e2e/block.ts rename to packages/backend/test/e2e/block.ts index c5f43e153..4e9030f85 100644 --- a/packages/backend/test/_e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -1,11 +1,11 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, startServer, shutdownServer } from '../utils.js'; +import { signup, api, post, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Block', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; // alice blocks bob let alice: any; @@ -17,14 +17,14 @@ describe('Block', () => { alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); test('Block作成', async () => { - const res = await request('/blocking/create', { + const res = await api('/blocking/create', { userId: bob.id, }, alice); @@ -32,7 +32,7 @@ describe('Block', () => { }); test('ブロックされているユーザーをフォローできない', async () => { - const res = await request('/following/create', { userId: alice.id }, bob); + const res = await api('/following/create', { userId: alice.id }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); @@ -41,7 +41,7 @@ describe('Block', () => { test('ブロックされているユーザーにリアクションできない', async () => { const note = await post(alice, { text: 'hello' }); - const res = await request('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); + const res = await api('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); @@ -50,7 +50,7 @@ describe('Block', () => { test('ブロックされているユーザーに返信できない', async () => { const note = await post(alice, { text: 'hello' }); - const res = await request('/notes/create', { replyId: note.id, text: 'yo' }, bob); + const res = await api('/notes/create', { replyId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); @@ -59,7 +59,7 @@ describe('Block', () => { test('ブロックされているユーザーのノートをRenoteできない', async () => { const note = await post(alice, { text: 'hello' }); - const res = await request('/notes/create', { renoteId: note.id, text: 'yo' }, bob); + const res = await api('/notes/create', { renoteId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); @@ -74,7 +74,7 @@ describe('Block', () => { const bobNote = await post(bob); const carolNote = await post(carol); - const res = await request('/notes/local-timeline', {}, bob); + const res = await api('/notes/local-timeline', {}, bob); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); diff --git a/packages/backend/test/_e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts similarity index 81% rename from packages/backend/test/_e2e/endpoints.ts rename to packages/backend/test/e2e/endpoints.ts index aed980d6c..e864eab6c 100644 --- a/packages/backend/test/_e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -1,29 +1,35 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import * as openapi from '@redocly/openapi-core'; -import { startServer, signup, post, request, simpleGet, port, shutdownServer, api } from '../utils.js'; +// node-fetch only supports it's own Blob yet +// https://github.com/node-fetch/node-fetch/pull/1664 +import { Blob } from 'node-fetch'; +import { startServer, signup, post, api, uploadFile } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Endpoints', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; let alice: any; let bob: any; + let carol: any; + let dave: any; beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); - }, 1000 * 30); + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); describe('signup', () => { test('不正なユーザー名でアカウントが作成できない', async () => { - const res = await request('api/signup', { + const res = await api('signup', { username: 'test.', password: 'test', }); @@ -31,7 +37,7 @@ describe('Endpoints', () => { }); test('空のパスワードでアカウントが作成できない', async () => { - const res = await request('api/signup', { + const res = await api('signup', { username: 'test', password: '', }); @@ -44,7 +50,7 @@ describe('Endpoints', () => { password: 'test1', }; - const res = await request('api/signup', me); + const res = await api('signup', me); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); @@ -52,7 +58,7 @@ describe('Endpoints', () => { }); test('同じユーザー名のアカウントは作成できない', async () => { - const res = await request('api/signup', { + const res = await api('signup', { username: 'test1', password: 'test1', }); @@ -63,7 +69,7 @@ describe('Endpoints', () => { describe('signin', () => { test('間違ったパスワードでサインインできない', async () => { - const res = await request('api/signin', { + const res = await api('signin', { username: 'test1', password: 'bar', }); @@ -72,7 +78,7 @@ describe('Endpoints', () => { }); test('クエリをインジェクションできない', async () => { - const res = await request('api/signin', { + const res = await api('signin', { username: 'test1', password: { $gt: '', @@ -83,7 +89,7 @@ describe('Endpoints', () => { }); test('正しい情報でサインインできる', async () => { - const res = await request('api/signin', { + const res = await api('signin', { username: 'test1', password: 'test1', }); @@ -111,11 +117,12 @@ describe('Endpoints', () => { assert.strictEqual(res.body.birthday, myBirthday); }); - test('名前を空白にできない', async () => { + test('名前を空白にできる', async () => { const res = await api('/i/update', { name: ' ', }, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, ' '); }); test('誕生日の設定を削除できる', async () => { @@ -201,7 +208,6 @@ describe('Endpoints', () => { test('リアクションできる', async () => { const bobPost = await post(bob); - const alice = await signup({ username: 'alice' }); const res = await api('/notes/reactions/create', { noteId: bobPost.id, reaction: '🚀', @@ -214,7 +220,7 @@ describe('Endpoints', () => { }, alice); assert.strictEqual(resNote.status, 200); - assert.strictEqual(resNote.body.reactions['🚀'], [alice.id]); + assert.strictEqual(resNote.body.reactions['🚀'], 1); }); test('自分の投稿にもリアクションできる', async () => { @@ -228,7 +234,7 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 204); }); - test('二重にリアクションできない', async () => { + test('二重にリアクションすると上書きされる', async () => { const bobPost = await post(bob); await api('/notes/reactions/create', { @@ -241,7 +247,14 @@ describe('Endpoints', () => { reaction: '🚀', }, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 204); + + const resNote = await api('/notes/show', { + noteId: bobPost.id, + }, alice); + + assert.strictEqual(resNote.status, 200); + assert.deepStrictEqual(resNote.body.reactions, { '🚀': 1 }); }); test('存在しない投稿にはリアクションできない', async () => { @@ -369,57 +382,22 @@ describe('Endpoints', () => { }); }); - /* - describe('/i', () => { - test('', async () => { - }); - }); - */ -}); - -/* -process.env.NODE_ENV = 'test'; - -import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js'; - -describe('API: Endpoints', () => { - let p: childProcess.ChildProcess; - let alice: any; - let bob: any; - let carol: any; - - before(async () => { - p = await startServer(); - alice = await signup({ username: 'alice' }); - bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }); - - after(async () => { - await shutdownServer(p); - }); - describe('drive', () => { test('ドライブ情報を取得できる', async () => { - await uploadFile({ - userId: alice.id, - size: 256 + await uploadFile(alice, { + blob: new Blob([new Uint8Array(256)]), }); - await uploadFile({ - userId: alice.id, - size: 512 + await uploadFile(alice, { + blob: new Blob([new Uint8Array(512)]), }); - await uploadFile({ - userId: alice.id, - size: 1024 + await uploadFile(alice, { + blob: new Blob([new Uint8Array(1024)]), }); const res = await api('/drive', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - expect(res.body).have.property('usage').eql(1792); - })); + expect(res.body).toHaveProperty('usage', 1792); + }); }); describe('drive/files/create', () => { @@ -428,397 +406,392 @@ describe('API: Endpoints', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'Lenna.png'); - })); + assert.strictEqual(res.body.name, 'Lenna.jpg'); + }); test('ファイルに名前を付けられる', async () => { - const res = await assert.request(server) - .post('/drive/files/create') - .field('i', alice.token) - .field('name', 'Belmond.png') - .attach('file', fs.readFileSync(__dirname + '/resources/Lenna.png'), 'Lenna.png'); + const res = await uploadFile(alice, { name: 'Belmond.png' }); - expect(res).have.status(200); - expect(res.body).be.a('object'); - expect(res.body).have.property('name').eql('Belmond.png'); - })); + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'Belmond.png'); + }); test('ファイル無しで怒られる', async () => { const res = await api('/drive/files/create', {}, alice); assert.strictEqual(res.status, 400); - })); + }); test('SVGファイルを作成できる', async () => { - const res = await uploadFile(alice, __dirname + '/resources/image.svg'); + const res = await uploadFile(alice, { path: 'image.svg' }); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.name, 'image.svg'); assert.strictEqual(res.body.type, 'image/svg+xml'); - })); + }); }); describe('drive/files/update', () => { test('名前を更新できる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const newName = 'いちごパスタ.png'; const res = await api('/drive/files/update', { fileId: file.id, - name: newName + name: newName, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.name, newName); - })); + }); test('他人のファイルは更新できない', async () => { - const file = await uploadFile(bob); + const file = (await uploadFile(alice)).body; const res = await api('/drive/files/update', { fileId: file.id, - name: 'いちごパスタ.png' - }, alice); + name: 'いちごパスタ.png', + }, bob); assert.strictEqual(res.status, 400); - })); + }); test('親フォルダを更新できる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/files/update', { fileId: file.id, - folderId: folder.id + folderId: folder.id, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.folderId, folder.id); - })); + }); test('親フォルダを無しにできる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; await api('/drive/files/update', { fileId: file.id, - folderId: folder.id + folderId: folder.id, }, alice); const res = await api('/drive/files/update', { fileId: file.id, - folderId: null + folderId: null, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.folderId, null); - })); + }); test('他人のフォルダには入れられない', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, bob)).body; const res = await api('/drive/files/update', { fileId: file.id, - folderId: folder.id + folderId: folder.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('存在しないフォルダで怒られる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const res = await api('/drive/files/update', { fileId: file.id, - folderId: '000000000000000000000000' + folderId: '000000000000000000000000', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('不正なフォルダIDで怒られる', async () => { - const file = await uploadFile(alice); + const file = (await uploadFile(alice)).body; const res = await api('/drive/files/update', { fileId: file.id, - folderId: 'foo' + folderId: 'foo', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('ファイルが存在しなかったら怒る', async () => { const res = await api('/drive/files/update', { fileId: '000000000000000000000000', - name: 'いちごパスタ.png' + name: 'いちごパスタ.png', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('間違ったIDで怒られる', async () => { const res = await api('/drive/files/update', { fileId: 'kyoppie', - name: 'いちごパスタ.png' + name: 'いちごパスタ.png', }, alice); assert.strictEqual(res.status, 400); - })); + }); }); describe('drive/folders/create', () => { test('フォルダを作成できる', async () => { const res = await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.name, 'test'); - })); + }); }); describe('drive/folders/update', () => { test('名前を更新できる', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - name: 'new name' + name: 'new name', }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.name, 'new name'); - })); + }); test('他人のフォルダを更新できない', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, bob)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - name: 'new name' + name: 'new name', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('親フォルダを更新できる', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const parentFolder = (await api('/drive/folders/create', { - name: 'parent' + name: 'parent', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: parentFolder.id + parentId: parentFolder.id, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.parentId, parentFolder.id); - })); + }); test('親フォルダを無しに更新できる', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const parentFolder = (await api('/drive/folders/create', { - name: 'parent' + name: 'parent', }, alice)).body; await api('/drive/folders/update', { folderId: folder.id, - parentId: parentFolder.id + parentId: parentFolder.id, }, alice); const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: null + parentId: null, }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.parentId, null); - })); + }); test('他人のフォルダを親フォルダに設定できない', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const parentFolder = (await api('/drive/folders/create', { - name: 'parent' + name: 'parent', }, bob)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: parentFolder.id + parentId: parentFolder.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('フォルダが循環するような構造にできない', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const parentFolder = (await api('/drive/folders/create', { - name: 'parent' + name: 'parent', }, alice)).body; await api('/drive/folders/update', { folderId: parentFolder.id, - parentId: folder.id + parentId: folder.id, }, alice); const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: parentFolder.id + parentId: parentFolder.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('フォルダが循環するような構造にできない(再帰的)', async () => { const folderA = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const folderB = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const folderC = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; await api('/drive/folders/update', { folderId: folderB.id, - parentId: folderA.id + parentId: folderA.id, }, alice); await api('/drive/folders/update', { folderId: folderC.id, - parentId: folderB.id + parentId: folderB.id, }, alice); const res = await api('/drive/folders/update', { folderId: folderA.id, - parentId: folderC.id + parentId: folderC.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('フォルダが循環するような構造にできない(自身)', async () => { const folderA = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folderA.id, - parentId: folderA.id + parentId: folderA.id, }, alice); assert.strictEqual(res.status, 400); - })); + }); test('存在しない親フォルダを設定できない', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: '000000000000000000000000' + parentId: '000000000000000000000000', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('不正な親フォルダIDで怒られる', async () => { const folder = (await api('/drive/folders/create', { - name: 'test' + name: 'test', }, alice)).body; const res = await api('/drive/folders/update', { folderId: folder.id, - parentId: 'foo' + parentId: 'foo', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('存在しないフォルダを更新できない', async () => { const res = await api('/drive/folders/update', { - folderId: '000000000000000000000000' + folderId: '000000000000000000000000', }, alice); assert.strictEqual(res.status, 400); - })); + }); test('不正なフォルダIDで怒られる', async () => { const res = await api('/drive/folders/update', { - folderId: 'foo' + folderId: 'foo', }, alice); assert.strictEqual(res.status, 400); - })); + }); }); describe('notes/replies', () => { test('自分に閲覧権限のない投稿は含まれない', async () => { const alicePost = await post(alice, { - text: 'foo' + text: 'foo', }); await post(bob, { replyId: alicePost.id, text: 'bar', visibility: 'specified', - visibleUserIds: [alice.id] + visibleUserIds: [alice.id], }); const res = await api('/notes/replies', { - noteId: alicePost.id + noteId: alicePost.id, }, carol); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(res.body.length, 0); - })); + }); }); describe('notes/timeline', () => { test('フォロワー限定投稿が含まれる', async () => { await api('/following/create', { - userId: alice.id - }, bob); + userId: carol.id, + }, dave); - const alicePost = await post(alice, { + const carolPost = await post(carol, { text: 'foo', - visibility: 'followers' + visibility: 'followers', }); - const res = await api('/notes/timeline', {}, bob); + const res = await api('/notes/timeline', {}, dave); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body[0].id, alicePost.id); - })); + assert.strictEqual(res.body[0].id, carolPost.id); + }); }); }); -*/ diff --git a/packages/backend/test/_e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts similarity index 83% rename from packages/backend/test/_e2e/fetch-resource.ts rename to packages/backend/test/e2e/fetch-resource.ts index b8ba3f247..6b3c79523 100644 --- a/packages/backend/test/_e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -1,9 +1,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import * as openapi from '@redocly/openapi-core'; -import { startServer, signup, post, request, simpleGet, port, shutdownServer } from '../utils.js'; +import { startServer, signup, post, api, simpleGet } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; // Request Accept const ONLY_AP = 'application/activity+json'; @@ -13,11 +12,10 @@ const UNSPECIFIED = '*/*'; // Response Content-Type const AP = 'application/activity+json; charset=utf-8'; -const JSON = 'application/json; charset=utf-8'; const HTML = 'text/html; charset=utf-8'; describe('Fetch resource', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; let alice: any; let alicesPost: any; @@ -28,15 +26,15 @@ describe('Fetch resource', () => { alicesPost = await post(alice, { text: 'test', }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); describe('Common', () => { test('meta', async () => { - const res = await request('/meta', { + const res = await api('/meta', { }); assert.strictEqual(res.status, 200); @@ -54,36 +52,26 @@ describe('Fetch resource', () => { assert.strictEqual(res.type, HTML); }); - test('GET api-doc', async () => { + test('GET api-doc (廃止)', async () => { const res = await simpleGet('/api-doc'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); + assert.strictEqual(res.status, 404); }); - test('GET api.json', async () => { + test('GET api.json (廃止)', async () => { const res = await simpleGet('/api.json'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, JSON); + assert.strictEqual(res.status, 404); }); - test('Validate api.json', async () => { - const config = await openapi.loadConfig(); - const result = await openapi.bundle({ - config, - ref: `http://localhost:${port}/api.json`, - }); - - for (const problem of result.problems) { - console.log(`${problem.message} - ${problem.location[0]?.pointer}`); - } - - assert.strictEqual(result.problems.length, 0); + test('GET api/foo (存在しない)', async () => { + const res = await simpleGet('/api/foo'); + assert.strictEqual(res.status, 404); + assert.strictEqual(res.body.error.code, 'UNKNOWN_API_ENDPOINT'); }); test('GET favicon.ico', async () => { const res = await simpleGet('/favicon.ico'); assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/x-icon'); + assert.strictEqual(res.type, 'image/vnd.microsoft.icon'); }); test('GET apple-touch-icon.png', async () => { diff --git a/packages/backend/test/_e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts similarity index 70% rename from packages/backend/test/_e2e/ff-visibility.ts rename to packages/backend/test/e2e/ff-visibility.ts index 84a5b5ef2..d53919ca1 100644 --- a/packages/backend/test/_e2e/ff-visibility.ts +++ b/packages/backend/test/e2e/ff-visibility.ts @@ -1,36 +1,34 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from '../utils.js'; +import { signup, api, startServer, simpleGet } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('FF visibility', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; let alice: any; let bob: any; - let carol: any; beforeAll(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); test('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'public', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, bob); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, bob); @@ -41,14 +39,14 @@ describe('FF visibility', () => { }); test('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'followers', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, alice); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, alice); @@ -59,14 +57,14 @@ describe('FF visibility', () => { }); test('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'followers', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, bob); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, bob); @@ -75,18 +73,18 @@ describe('FF visibility', () => { }); test('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'followers', }, alice); - await request('/following/create', { + await api('/following/create', { userId: alice.id, }, bob); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, bob); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, bob); @@ -97,14 +95,14 @@ describe('FF visibility', () => { }); test('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'private', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, alice); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, alice); @@ -115,14 +113,14 @@ describe('FF visibility', () => { }); test('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async () => { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'private', }, alice); - const followingRes = await request('/users/following', { + const followingRes = await api('/users/following', { userId: alice.id, }, bob); - const followersRes = await request('/users/followers', { + const followersRes = await api('/users/followers', { userId: alice.id, }, bob); @@ -133,7 +131,7 @@ describe('FF visibility', () => { describe('AP', () => { test('ffVisibility が public 以外ならばAPからは取得できない', async () => { { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'public', }, alice); @@ -143,22 +141,22 @@ describe('FF visibility', () => { assert.strictEqual(followersRes.status, 200); } { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'followers', }, alice); - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); assert.strictEqual(followingRes.status, 403); assert.strictEqual(followersRes.status, 403); } { - await request('/i/update', { + await api('/i/update', { ffVisibility: 'private', }, alice); - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); assert.strictEqual(followingRes.status, 403); assert.strictEqual(followersRes.status, 403); } diff --git a/packages/backend/test/_e2e/mute.ts b/packages/backend/test/e2e/mute.ts similarity index 82% rename from packages/backend/test/_e2e/mute.ts rename to packages/backend/test/e2e/mute.ts index 8f7f72bb9..6654a290b 100644 --- a/packages/backend/test/_e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -1,11 +1,11 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, react, startServer, shutdownServer, waitFire } from '../utils.js'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Mute', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; // alice mutes carol let alice: any; @@ -17,14 +17,14 @@ describe('Mute', () => { alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); test('ミュート作成', async () => { - const res = await request('/mute/create', { + const res = await api('/mute/create', { userId: carol.id, }, alice); @@ -35,7 +35,7 @@ describe('Mute', () => { const bobNote = await post(bob, { text: '@alice hi' }); const carolNote = await post(carol, { text: '@alice hi' }); - const res = await request('/notes/mentions', {}, alice); + const res = await api('/notes/mentions', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); @@ -45,11 +45,11 @@ describe('Mute', () => { test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); await post(carol, { text: '@alice hi' }); - const res = await request('/i', {}, alice); + const res = await api('/i', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.hasUnreadMentions, false); @@ -57,7 +57,7 @@ describe('Mute', () => { test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention'); @@ -66,8 +66,8 @@ describe('Mute', () => { test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); - await request('/notifications/mark-all-as-read', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); + await api('/notifications/mark-all-as-read', {}, alice); const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification'); @@ -80,7 +80,7 @@ describe('Mute', () => { const bobNote = await post(bob); const carolNote = await post(carol); - const res = await request('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); @@ -96,7 +96,7 @@ describe('Mute', () => { renoteId: carolNote.id, }); - const res = await request('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); @@ -112,7 +112,7 @@ describe('Mute', () => { await react(bob, aliceNote, 'like'); await react(carol, aliceNote, 'like'); - const res = await request('/i/notifications', {}, alice); + const res = await api('/i/notifications', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); diff --git a/packages/backend/test/_e2e/note.ts b/packages/backend/test/e2e/note.ts similarity index 74% rename from packages/backend/test/_e2e/note.ts rename to packages/backend/test/e2e/note.ts index 47af6808f..98ee34d8d 100644 --- a/packages/backend/test/_e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -1,12 +1,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { Note } from '../../src/models/entities/note.js'; -import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from '../utils.js'; +import { Note } from '@/models/entities/Note.js'; +import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Note', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; let Notes: any; let alice: any; @@ -18,10 +18,10 @@ describe('Note', () => { Notes = connection.getRepository(Note); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); test('投稿できる', async () => { @@ -29,7 +29,7 @@ describe('Note', () => { text: 'test', }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); @@ -39,7 +39,7 @@ describe('Note', () => { test('ファイルを添付できる', async () => { const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); - const res = await request('/notes/create', { + const res = await api('/notes/create', { fileIds: [file.id], }, alice); @@ -48,37 +48,37 @@ describe('Note', () => { assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]); }, 1000 * 10); - test('他人のファイルは無視', async () => { + test('他人のファイルで怒られる', async () => { const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); - const res = await request('/notes/create', { + const res = await api('/notes/create', { text: 'test', fileIds: [file.id], }, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.deepStrictEqual(res.body.createdNote.fileIds, []); + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); + assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }, 1000 * 10); - test('存在しないファイルは無視', async () => { - const res = await request('/notes/create', { + test('存在しないファイルで怒られる', async () => { + const res = await api('/notes/create', { text: 'test', fileIds: ['000000000000000000000000'], }, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.deepStrictEqual(res.body.createdNote.fileIds, []); + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); + assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }); - test('不正なファイルIDは無視', async () => { - const res = await request('/notes/create', { + test('不正なファイルIDで怒られる', async () => { + const res = await api('/notes/create', { fileIds: ['kyoppie'], }, alice); - assert.strictEqual(res.status, 200); - assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.deepStrictEqual(res.body.createdNote.fileIds, []); + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); + assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }); test('返信できる', async () => { @@ -91,7 +91,7 @@ describe('Note', () => { replyId: bobPost.id, }; - const res = await request('/notes/create', alicePost, alice); + const res = await api('/notes/create', alicePost, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); @@ -109,7 +109,7 @@ describe('Note', () => { renoteId: bobPost.id, }; - const res = await request('/notes/create', alicePost, alice); + const res = await api('/notes/create', alicePost, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); @@ -127,7 +127,7 @@ describe('Note', () => { renoteId: bobPost.id, }; - const res = await request('/notes/create', alicePost, alice); + const res = await api('/notes/create', alicePost, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); @@ -140,7 +140,7 @@ describe('Note', () => { const post = { text: '!'.repeat(3000), }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 200); }); @@ -148,7 +148,7 @@ describe('Note', () => { const post = { text: '!'.repeat(3001), }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 400); }); @@ -157,7 +157,7 @@ describe('Note', () => { text: 'test', replyId: '000000000000000000000000', }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 400); }); @@ -165,7 +165,7 @@ describe('Note', () => { const post = { renoteId: '000000000000000000000000', }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 400); }); @@ -174,7 +174,7 @@ describe('Note', () => { text: 'test', replyId: 'foo', }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 400); }); @@ -182,7 +182,7 @@ describe('Note', () => { const post = { renoteId: 'foo', }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 400); }); @@ -191,7 +191,7 @@ describe('Note', () => { text: '@ghost yo', }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); @@ -203,7 +203,7 @@ describe('Note', () => { text: '@bob @bob @bob yo', }; - const res = await request('/notes/create', post, alice); + const res = await api('/notes/create', post, alice); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); @@ -215,7 +215,7 @@ describe('Note', () => { describe('notes/create', () => { test('投票を添付できる', async () => { - const res = await request('/notes/create', { + const res = await api('/notes/create', { text: 'test', poll: { choices: ['foo', 'bar'], @@ -228,14 +228,14 @@ describe('Note', () => { }); test('投票の選択肢が無くて怒られる', async () => { - const res = await request('/notes/create', { + const res = await api('/notes/create', { poll: {}, }, alice); assert.strictEqual(res.status, 400); }); test('投票の選択肢が無くて怒られる (空の配列)', async () => { - const res = await request('/notes/create', { + const res = await api('/notes/create', { poll: { choices: [], }, @@ -244,7 +244,7 @@ describe('Note', () => { }); test('投票の選択肢が1つで怒られる', async () => { - const res = await request('/notes/create', { + const res = await api('/notes/create', { poll: { choices: ['Strawberry Pasta'], }, @@ -253,14 +253,14 @@ describe('Note', () => { }); test('投票できる', async () => { - const { body } = await request('/notes/create', { + const { body } = await api('/notes/create', { text: 'test', poll: { choices: ['sakura', 'izumi', 'ako'], }, }, alice); - const res = await request('/notes/polls/vote', { + const res = await api('/notes/polls/vote', { noteId: body.createdNote.id, choice: 1, }, alice); @@ -269,19 +269,19 @@ describe('Note', () => { }); test('複数投票できない', async () => { - const { body } = await request('/notes/create', { + const { body } = await api('/notes/create', { text: 'test', poll: { choices: ['sakura', 'izumi', 'ako'], }, }, alice); - await request('/notes/polls/vote', { + await api('/notes/polls/vote', { noteId: body.createdNote.id, choice: 0, }, alice); - const res = await request('/notes/polls/vote', { + const res = await api('/notes/polls/vote', { noteId: body.createdNote.id, choice: 2, }, alice); @@ -290,7 +290,7 @@ describe('Note', () => { }); test('許可されている場合は複数投票できる', async () => { - const { body } = await request('/notes/create', { + const { body } = await api('/notes/create', { text: 'test', poll: { choices: ['sakura', 'izumi', 'ako'], @@ -298,17 +298,17 @@ describe('Note', () => { }, }, alice); - await request('/notes/polls/vote', { + await api('/notes/polls/vote', { noteId: body.createdNote.id, choice: 0, }, alice); - await request('/notes/polls/vote', { + await api('/notes/polls/vote', { noteId: body.createdNote.id, choice: 1, }, alice); - const res = await request('/notes/polls/vote', { + const res = await api('/notes/polls/vote', { noteId: body.createdNote.id, choice: 2, }, alice); @@ -317,7 +317,7 @@ describe('Note', () => { }); test('締め切られている場合は投票できない', async () => { - const { body } = await request('/notes/create', { + const { body } = await api('/notes/create', { text: 'test', poll: { choices: ['sakura', 'izumi', 'ako'], @@ -327,7 +327,7 @@ describe('Note', () => { await new Promise(x => setTimeout(x, 2)); - const res = await request('/notes/polls/vote', { + const res = await api('/notes/polls/vote', { noteId: body.createdNote.id, choice: 1, }, alice); diff --git a/packages/backend/test/_e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts similarity index 92% rename from packages/backend/test/_e2e/streaming.ts rename to packages/backend/test/e2e/streaming.ts index 790451d9b..23c431f2e 100644 --- a/packages/backend/test/_e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -1,12 +1,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { Following } from '../../src/models/entities/following.js'; -import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from '../utils.js'; +import { Following } from '@/models/entities/Following.js'; +import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Streaming', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; let Followings: any; const follow = async (follower: any, followee: any) => { @@ -71,10 +71,10 @@ describe('Streaming', () => { listId: list.id, userId: kyoko.id, }, chitose); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); describe('Events', () => { @@ -404,43 +404,45 @@ describe('Streaming', () => { }); })); - test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; - - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - } - }, { - q: [ - ['foo', 'bar'], - ], - }); - - post(chitose, { - text: '#foo', - }); - - post(chitose, { - text: '#bar', - }); - - post(chitose, { - text: '#foo #bar', - }); - - setTimeout(() => { - assert.strictEqual(fooCount, 0); - assert.strictEqual(barCount, 0); - assert.strictEqual(fooBarCount, 1); - ws.close(); - done(); - }, 3000); - })); + // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" + + // test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { + // let fooCount = 0; + // let barCount = 0; + // let fooBarCount = 0; + + // const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + // if (type === 'note') { + // if (body.text === '#foo') fooCount++; + // if (body.text === '#bar') barCount++; + // if (body.text === '#foo #bar') fooBarCount++; + // } + // }, { + // q: [ + // ['foo', 'bar'], + // ], + // }); + + // post(chitose, { + // text: '#foo', + // }); + + // post(chitose, { + // text: '#bar', + // }); + + // post(chitose, { + // text: '#foo #bar', + // }); + + // setTimeout(() => { + // assert.strictEqual(fooCount, 0); + // assert.strictEqual(barCount, 0); + // assert.strictEqual(fooBarCount, 1); + // ws.close(); + // done(); + // }, 3000); + // })); test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => { let fooCount = 0; diff --git a/packages/backend/test/_e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts similarity index 78% rename from packages/backend/test/_e2e/thread-mute.ts rename to packages/backend/test/e2e/thread-mute.ts index 890b52a8c..792436d88 100644 --- a/packages/backend/test/_e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -1,11 +1,11 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, react, connectStream, startServer, shutdownServer } from '../utils.js'; +import { signup, api, post, connectStream, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('Note thread mute', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; let alice: any; let bob: any; @@ -16,22 +16,22 @@ describe('Note thread mute', () => { alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async () => { - await shutdownServer(p); + await p.close(); }); test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); - await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); - const res = await request('/notes/mentions', {}, alice); + const res = await api('/notes/mentions', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); @@ -42,27 +42,27 @@ describe('Note thread mute', () => { test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); const bobNote = await post(bob, { text: '@alice @carol root note' }); - await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - const res = await request('/i', {}, alice); + const res = await api('/i', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.hasUnreadMentions, false); }); - test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { + test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { // 状態リセット - await request('/i/read-all-unread-notes', {}, alice); + await api('/i/read-all-unread-notes', {}, alice); const bobNote = await post(bob, { text: '@alice @carol root note' }); - await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); let fired = false; @@ -86,12 +86,12 @@ describe('Note thread mute', () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); - await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); + await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice); const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); - const res = await request('/i/notifications', {}, alice); + const res = await api('/i/notifications', {}, alice); assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); diff --git a/packages/backend/test/_e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts similarity index 84% rename from packages/backend/test/_e2e/user-notes.ts rename to packages/backend/test/e2e/user-notes.ts index a6cc1057f..690cba174 100644 --- a/packages/backend/test/_e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -1,11 +1,11 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; -import { signup, request, post, uploadUrl, startServer, shutdownServer } from '../utils.js'; +import { signup, api, post, uploadUrl, startServer } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; describe('users/notes', () => { - let p: childProcess.ChildProcess; + let p: INestApplicationContext; let alice: any; let jpgNote: any; @@ -26,14 +26,14 @@ describe('users/notes', () => { jpgPngNote = await post(alice, { fileIds: [jpg.id, png.id], }); - }, 1000 * 30); + }, 1000 * 60 * 2); afterAll(async() => { - await shutdownServer(p); + await p.close(); }); test('ファイルタイプ指定 (jpg)', async () => { - const res = await request('/users/notes', { + const res = await api('/users/notes', { userId: alice.id, fileType: ['image/jpeg'], }, alice); @@ -46,7 +46,7 @@ describe('users/notes', () => { }); test('ファイルタイプ指定 (jpg or png)', async () => { - const res = await request('/users/notes', { + const res = await api('/users/notes', { userId: alice.id, fileType: ['image/jpeg', 'image/png'], }, alice); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 221f743d3..6fe04274e 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -3,16 +3,18 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; -import { DataSource } from 'typeorm'; +import * as lolex from '@sinonjs/fake-timers'; import rndstr from 'rndstr'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; -import { CoreModule } from '@/core/CoreModule.js'; import { MetaService } from '@/core/MetaService.js'; import { genAid } from '@/misc/id/aid.js'; import { UserCacheService } from '@/core/UserCacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { sleep } from '../utils.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -25,6 +27,7 @@ describe('RoleService', () => { let rolesRepository: RolesRepository; let roleAssignmentsRepository: RoleAssignmentsRepository; let metaService: jest.Mocked; + let clock: lolex.InstalledClock; function createUser(data: Partial = {}) { const un = rndstr('a-z0-9', 16); @@ -50,16 +53,12 @@ describe('RoleService', () => { .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); } - async function assign(roleId: Role['id'], userId: User['id']) { - await roleAssignmentsRepository.insert({ - id: genAid(new Date()), - createdAt: new Date(), - roleId, - userId, - }); - } - beforeEach(async () => { + clock = lolex.install({ + now: new Date(), + shouldClearNativeTimers: true, + }); + app = await Test.createTestingModule({ imports: [ GlobalModule, @@ -67,6 +66,8 @@ describe('RoleService', () => { providers: [ RoleService, UserCacheService, + IdService, + GlobalEventService, ], }) .useMocker((token) => { @@ -92,12 +93,15 @@ describe('RoleService', () => { }); afterEach(async () => { + clock.uninstall(); + await Promise.all([ app.get(DI.metasRepository).delete({}), usersRepository.delete({}), rolesRepository.delete({}), roleAssignmentsRepository.delete({}), ]); + await app.close(); }); @@ -115,7 +119,7 @@ describe('RoleService', () => { expect(result.canManageCustomEmojis).toBe(false); }); - test('instance default policies 2', async () => { + test('instance default policies 2', async () => { const user = await createUser(); metaService.fetch.mockResolvedValue({ policies: { @@ -128,7 +132,7 @@ describe('RoleService', () => { expect(result.canManageCustomEmojis).toBe(true); }); - test('with role', async () => { + test('with role', async () => { const user = await createUser(); const role = await createRole({ name: 'a', @@ -140,7 +144,7 @@ describe('RoleService', () => { }, }, }); - await assign(role.id, user.id); + await roleService.assign(user.id, role.id); metaService.fetch.mockResolvedValue({ policies: { canManageCustomEmojis: false, @@ -152,7 +156,7 @@ describe('RoleService', () => { expect(result.canManageCustomEmojis).toBe(true); }); - test('priority', async () => { + test('priority', async () => { const user = await createUser(); const role1 = await createRole({ name: 'role1', @@ -174,8 +178,8 @@ describe('RoleService', () => { }, }, }); - await assign(role1.id, user.id); - await assign(role2.id, user.id); + await roleService.assign(user.id, role1.id); + await roleService.assign(user.id, role2.id); metaService.fetch.mockResolvedValue({ policies: { driveCapacityMb: 50, @@ -187,7 +191,7 @@ describe('RoleService', () => { expect(result.driveCapacityMb).toBe(100); }); - test('conditional role', async () => { + test('conditional role', async () => { const user1 = await createUser({ createdAt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 365)), }); @@ -228,5 +232,42 @@ describe('RoleService', () => { expect(user1Policies.canManageCustomEmojis).toBe(false); expect(user2Policies.canManageCustomEmojis).toBe(true); }); + + test('expired role', async () => { + const user = await createUser(); + const role = await createRole({ + name: 'a', + policies: { + canManageCustomEmojis: { + useDefault: false, + priority: 0, + value: true, + }, + }, + }); + await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24))); + metaService.fetch.mockResolvedValue({ + policies: { + canManageCustomEmojis: false, + }, + } as any); + + const result = await roleService.getUserPolicies(user.id); + expect(result.canManageCustomEmojis).toBe(true); + + clock.tick('25:00:00'); + + const resultAfter25h = await roleService.getUserPolicies(user.id); + expect(resultAfter25h.canManageCustomEmojis).toBe(false); + + await roleService.assign(user.id, role.id); + + // ストリーミング経由で反映されるまでちょっと待つ + clock.uninstall(); + await sleep(100); + + const resultAfter25hAgain = await roleService.getUserPolicies(user.id); + expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true); + }); }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 50988939a..8203e4935 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -1,87 +1,50 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import * as childProcess from 'child_process'; -import * as http from 'node:http'; -import { SIGKILL } from 'constants'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, basename } from 'node:path'; import WebSocket from 'ws'; -import fetch from 'node-fetch'; -import FormData from 'form-data'; +import fetch, { Blob, File, RequestInit } from 'node-fetch'; import { DataSource } from 'typeorm'; -import got, { RequestError } from 'got'; -import loadConfig from '../src/config/load.js'; -import { entities } from '@/postgres.js'; +import { entities } from '../src/postgres.js'; +import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); +export { server as startServer } from '@/boot/common.js'; const config = loadConfig(); export const port = config.port; export const api = async (endpoint: string, params: any, me?: any) => { - endpoint = endpoint.replace(/^\//, ''); - - const auth = me ? { - i: me.token, - } : {}; - - try { - const res = await got(`http://localhost:${port}/api/${endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(Object.assign(auth, params)), - retry: { - limit: 0, - }, - }); - - const status = res.statusCode; - const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; - - return { - status, - body, - }; - } catch (err: unknown) { - if (err instanceof RequestError && err.response) { - const status = err.response.statusCode; - const body = await JSON.parse(err.response.body as string); - - return { - status, - body, - }; - } else { - throw err; - } - } + const normalized = endpoint.replace(/^\//, ''); + return await request(`api/${normalized}`, params, me); }; -export const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { +const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const auth = me ? { i: me.token, } : {}; - const res = await fetch(`http://localhost:${port}/${path}`, { + const res = await relativeFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(Object.assign(auth, params)), + redirect: 'manual', }); const status = res.status; - const body = res.status === 200 ? await res.json().catch() : null; + const body = res.headers.get('content-type') === 'application/json; charset=utf-8' + ? await res.json() + : null; return { body, status, }; }; +const relativeFetch = async (path: string, init?: RequestInit | undefined) => { + return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); +}; + export const signup = async (params?: any): Promise => { const q = Object.assign({ username: 'test', @@ -110,30 +73,46 @@ export const react = async (user: any, note: any, reaction: string): Promise => { - const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`; +export const uploadFile = async (user: any, { path, name, blob }: UploadOptions = {}): Promise => { + const absPath = path == null + ? new URL('resources/Lenna.jpg', import.meta.url) + : isAbsolute(path.toString()) + ? new URL(path) + : new URL(path, new URL('resources/', import.meta.url)); - const formData = new FormData() as any; + const formData = new FormData(); formData.append('i', user.token); - formData.append('file', fs.createReadStream(absPath)); + formData.append('file', blob ?? + new File([await readFile(absPath)], basename(absPath.toString()))); formData.append('force', 'true'); + if (name) { + formData.append('name', name); + } - const res = await got(`http://localhost:${port}/api/drive/files/create`, { + const res = await relativeFetch('api/drive/files/create', { method: 'POST', body: formData, - retry: { - limit: 0, - }, }); - const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; + const body = res.status !== 204 ? await res.json() : null; - return body; + return { + status: res.status, + body, + }; }; export const uploadUrl = async (user: any, url: string) => { @@ -160,7 +139,7 @@ export const uploadUrl = async (user: any, url: string) => { export function connectStream(user: any, channel: string, listener: (message: Record) => any, params?: any): Promise { return new Promise((res, rej) => { - const ws = new WebSocket(`ws://localhost:${port}/streaming?i=${user.token}`); + const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`); ws.on('open', () => { ws.on('message', data => { @@ -187,7 +166,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record) => boolean, params?: any) => { return new Promise(async (res, rej) => { - let timer: NodeJS.Timeout; + let timer: NodeJS.Timeout | null = null; let ws: WebSocket; try { @@ -219,41 +198,25 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond }); }; -export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => { - // node-fetchだと3xxを取れない - return await new Promise((resolve, reject) => { - const req = http.request(`http://localhost:${port}${path}`, { - headers: { - Accept: accept, - }, - }, res => { - if (res.statusCode! >= 400) { - reject(res); - } else { - resolve({ - status: res.statusCode, - type: res.headers['content-type'], - location: res.headers.location, - }); - } - }); - - req.end(); +export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status: number, body: any, type: string | null, location: string | null }> => { + const res = await relativeFetch(path, { + headers: { + Accept: accept, + }, + redirect: 'manual', }); -}; -export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProcess) => void, moreProcess: () => Promise = async () => {}) { - return (done: (err?: Error) => any) => { - const p = childProcess.spawn('node', [_dirname + '/../index.js'], { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], - env: { NODE_ENV: 'test', PATH: process.env.PATH }, - }); - callbackSpawnedProcess(p); - p.on('message', message => { - if (message === 'ok') moreProcess().then(() => done()).catch(e => done(e)); - }); + const body = res.headers.get('content-type') === 'application/json; charset=utf-8' + ? await res.json() + : null; + + return { + status: res.status, + body, + type: res.headers.get('content-type'), + location: res.headers.get('location'), }; -} +}; export async function initTestDb(justBorrow = false, initEntities?: any[]) { if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; @@ -275,46 +238,6 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) { return db; } -export function startServer(timeout = 60 * 1000): Promise { - return new Promise((res, rej) => { - const t = setTimeout(() => { - p.kill(SIGKILL); - rej('timeout to start'); - }, timeout); - - const p = childProcess.spawn('node', [_dirname + '/../built/index.js'], { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], - env: { NODE_ENV: 'test', PATH: process.env.PATH }, - }); - - p.on('error', e => rej(e)); - - p.on('message', message => { - if (message === 'ok') { - clearTimeout(t); - res(p); - } - }); - }); -} - -export function shutdownServer(p: childProcess.ChildProcess | undefined, timeout = 20 * 1000) { - if (p == null) return Promise.resolve('nop'); - return new Promise((res, rej) => { - const t = setTimeout(() => { - p.kill(SIGKILL); - res('force exit'); - }, timeout); - - p.once('exit', () => { - clearTimeout(t); - res('exited'); - }); - - p.kill(); - }); -} - export function sleep(msec: number) { return new Promise(res => { setTimeout(() => { diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3 new file mode 100644 index 000000000..2ef7024be Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 new file mode 100644 index 000000000..a8ad11287 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 new file mode 100644 index 000000000..8cc2ead02 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 new file mode 100644 index 000000000..59cbcaf91 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-aec.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec.mp3 new file mode 100644 index 000000000..7aec0516e Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-aec.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 new file mode 100644 index 000000000..98fe354d6 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 new file mode 100644 index 000000000..7b69b3410 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 new file mode 100644 index 000000000..44f2deee3 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 new file mode 100644 index 000000000..1342a56f8 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-cea.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea.mp3 new file mode 100644 index 000000000..88d641fd6 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-cea.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 new file mode 100644 index 000000000..468b82bc2 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 new file mode 100644 index 000000000..3869e894d Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 new file mode 100644 index 000000000..f268b7ee8 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 new file mode 100644 index 000000000..d6e895e67 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 new file mode 100644 index 000000000..8e055db91 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-ea.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea.mp3 new file mode 100644 index 000000000..c13d13247 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-ea.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 new file mode 100644 index 000000000..06577d543 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 new file mode 100644 index 000000000..1622033d6 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 new file mode 100644 index 000000000..a3a9f3a9f Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 new file mode 100644 index 000000000..4efa84840 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/n-eca.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca.mp3 new file mode 100644 index 000000000..1a3979cc6 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/n-eca.mp3 differ diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index d1b5cc511..043a614e4 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -18,7 +18,7 @@
- +
@@ -49,7 +49,7 @@ const props = defineProps<{ aspectRatio: number; }>(); -const imgUrl = getProxiedImageUrl(props.file.url); +const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); let dialogEl = $shallowRef>(); let imgEl = $shallowRef(); let cropper: Cropper | null = null; diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index b97e36cd5..a54a1c230 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -43,8 +43,8 @@ import { nextTick, onMounted } from 'vue'; const props = withDefaults(defineProps<{ - defaultOpen: boolean; - maxHeight: number | null; + defaultOpen?: boolean; + maxHeight?: number | null; }>(), { defaultOpen: false, maxHeight: null, diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index fafa0bd23..c768a086c 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -13,7 +13,7 @@ + + diff --git a/packages/frontend/src/components/MkUserPreview.vue b/packages/frontend/src/components/MkUserPreview.vue deleted file mode 100644 index 1086a2c65..000000000 --- a/packages/frontend/src/components/MkUserPreview.vue +++ /dev/null @@ -1,199 +0,0 @@ - - - - - diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 7e53507f2..e7ad2b9a4 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -23,7 +23,7 @@ -
+
@@ -465,7 +465,7 @@ defineExpose({ -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); //border-bottom: solid 1px var(--divider); - font-size: 95%; + font-size: 90%; font-weight: bold; } diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 589ca92d7..4d968db6a 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -96,7 +96,7 @@ function onTabClick(): void { } const calcBg = () => { - const rawBg = metadata?.bg ?? 'var(--bg)'; + const rawBg = 'var(--bg)'; const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); tinyBg.setAlpha(0.85); bg.value = tinyBg.toRgbString(); diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index a3fee91a3..44c02088d 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -6,20 +6,19 @@
+
+ +
- - + + diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue index bd7c20351..cbe38b2d8 100644 --- a/packages/frontend/src/pages/admin/object-storage.vue +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -1,6 +1,6 @@ @@ -79,6 +86,7 @@ import * as os from '@/os'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkButton from '@/components/MkButton.vue'; let useObjectStorage: boolean = $ref(false); let objectStorageBaseUrl: string | null = $ref(null); @@ -131,13 +139,6 @@ function save() { }); } -const headerActions = $computed(() => [{ - asFullButton: true, - icon: 'ti ti-check', - text: i18n.ts.save, - handler: save, -}]); - const headerTabs = $computed(() => []); definePageMetadata({ @@ -145,3 +146,10 @@ definePageMetadata({ icon: 'ti ti-cloud', }); + + diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index ae884c011..2a65a7518 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -1,22 +1,31 @@ diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 4eea827de..2fb605f8c 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -1,19 +1,19 @@ diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 7840c55ee..12f341c01 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -1,7 +1,7 @@ @@ -150,6 +157,7 @@ import * as os from '@/os'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkButton from '@/components/MkButton.vue'; let name: string | null = $ref(null); let description: string | null = $ref(null); @@ -223,13 +231,6 @@ function save() { }); } -const headerActions = $computed(() => [{ - asFullButton: true, - icon: 'ti ti-check', - text: i18n.ts.save, - handler: save, -}]); - const headerTabs = $computed(() => []); definePageMetadata({ @@ -237,3 +238,10 @@ definePageMetadata({ icon: 'ti ti-settings', }); + + diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index fc1c1c1dc..819ced826 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -3,48 +3,48 @@ -
-
-
- - - - - - - - - - - - - - - - - - - - - -
-
- - - - - - - - -
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + +
- - + +
+ - -
+
+
@@ -138,33 +138,20 @@ definePageMetadata(computed(() => ({ }))); - diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 3528e7e14..76201aa85 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -27,11 +27,11 @@
- + - +
@@ -62,7 +62,7 @@ import MkAsUi from '@/components/MkAsUi.vue'; import { AsUiComponent, AsUiRoot, registerAsUiLib } from '@/scripts/aiscript/ui'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; import MkFolder from '@/components/MkFolder.vue'; -import MkTextarea from '@/components/MkTextarea.vue'; +import MkCode from '@/components/MkCode.vue'; const props = defineProps<{ id: string; diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index f47b4bf90..205434971 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -1,39 +1,30 @@ @@ -44,6 +35,8 @@ import * as os from '@/os'; import { mainRouter } from '@/router'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; +import { userPage } from '@/filters/user'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; const props = defineProps<{ listId: string; @@ -76,13 +69,20 @@ function addUser() { }); } -function removeUser(user) { - os.api('users/lists/pull', { - listId: list.id, - userId: user.id, - }).then(() => { - users = users.filter(x => x.id !== user.id); - }); +async function removeUser(user, ev) { + os.popupMenu([{ + text: i18n.ts.remove, + icon: 'ti ti-x', + danger: true, + action: async () => { + os.api('users/lists/pull', { + listId: list.id, + userId: user.id, + }).then(() => { + users = users.filter(x => x.id !== user.id); + }); + }, + }], ev.currentTarget ?? ev.target); } async function renameList() { @@ -126,37 +126,34 @@ definePageMetadata(computed(() => list ? { } : null)); - diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 1b492b15c..a578c5c74 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -45,6 +45,7 @@
+ {{ i18n.ts.showNoteActionsOnlyHover }} {{ i18n.ts.collapseRenotes }} {{ i18n.ts.enableAdvancedMfm }} {{ i18n.ts.enableAnimatedMfm }} @@ -140,6 +141,7 @@ async function reloadAsk() { const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); +const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover')); const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes')); const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 0512a8d0c..e3cf1aefb 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -59,6 +59,8 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'tl', 'overridedDeviceKind', 'serverDisconnectedBehavior', + 'collapseRenotes', + 'showNoteActionsOnlyHover', 'nsfw', 'animation', 'animatedMfm', @@ -420,7 +422,6 @@ onUnmounted(() => { definePageMetadata(computed(() => ({ title: ts.preferencesBackups, icon: 'ti ti-device-floppy', - bg: 'var(--bg)', }))); diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue index cb46858c5..f5a090a63 100644 --- a/packages/frontend/src/pages/settings/statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -47,6 +47,5 @@ const headerTabs = $computed(() => []); definePageMetadata({ title: i18n.ts.statusbar, icon: 'ti ti-list', - bg: 'var(--bg)', }); diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 13a06286f..373af193d 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -337,7 +337,31 @@ async function assignRole() { }); if (canceled) return; - await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id }); + const { canceled: canceled2, result: period } = await os.select({ + title: i18n.ts.period, + items: [{ + value: 'indefinitely', text: i18n.ts.indefinitely, + }, { + value: 'oneHour', text: i18n.ts.oneHour, + }, { + value: 'oneDay', text: i18n.ts.oneDay, + }, { + value: 'oneWeek', text: i18n.ts.oneWeek, + }, { + value: 'oneMonth', text: i18n.ts.oneMonth, + }], + default: 'indefinitely', + }); + if (canceled2) return; + + const expiresAt = period === 'indefinitely' ? null + : period === 'oneHour' ? Date.now() + (1000 * 60 * 60) + : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24) + : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7) + : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30) + : null; + + await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id, expiresAt }); refreshUser(); } diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index f62a6461c..b6f9b3eb2 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -26,6 +26,9 @@
+
+ {{ i18n.ts.invitationRequiredToRegister }} +
{{ i18n.ts.joinThisServer }} {{ i18n.ts.exploreOtherServers }} @@ -62,6 +65,7 @@ import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import MkTimeline from '@/components/MkTimeline.vue'; +import MkInfo from '@/components/MkInfo.vue'; import { instanceName } from '@/config'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -249,6 +253,10 @@ function exploreOtherServers() { padding: 0 32px; } + > .warn { + padding: 32px 32px 0 32px; + } + > .action { padding: 32px; diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index 17eb99be2..a1a36480f 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -1,12 +1,12 @@ import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; import { inputText } from '@/os'; -import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store'; +import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store'; const parser = new Parser(); const pluginContexts = new Map(); -export function install(plugin) { +export function install(plugin: Plugin): void { // 後方互換性のため if (plugin.src == null) return; console.info('Plugin installed:', plugin.name, 'v' + plugin.version); @@ -15,7 +15,7 @@ export function install(plugin) { plugin: plugin, storageKey: 'plugins:' + plugin.id, }), { - in: (q) => { + in: (q): Promise => { return new Promise(ok => { inputText({ title: q, @@ -28,10 +28,10 @@ export function install(plugin) { }); }); }, - out: (value) => { + out: (value): void => { console.log(value); }, - log: (type, params) => { + log: (): void => { }, }); @@ -40,9 +40,9 @@ export function install(plugin) { aiscript.exec(parser.parse(plugin.src)); } -function createPluginEnv(opts) { - const config = new Map(); - for (const [k, v] of Object.entries(opts.plugin.config || {})) { +function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record { + const config = new Map(); + for (const [k, v] of Object.entries(opts.plugin.config ?? {})) { config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); } @@ -50,22 +50,28 @@ function createPluginEnv(opts) { ...createAiScriptEnv({ ...opts, token: opts.plugin.token }), //#region Deprecated 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + utils.assertString(title); registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); }), 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { + utils.assertString(title); registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); }), 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { + utils.assertString(title); registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); }), //#endregion 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + utils.assertString(title); registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); }), 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + utils.assertString(title); registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); }), 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + utils.assertString(title); registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); }), 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { @@ -75,54 +81,78 @@ function createPluginEnv(opts) { registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); }), 'Plugin:open_url': values.FN_NATIVE(([url]) => { + utils.assertString(url); window.open(url.value, '_blank'); }), 'Plugin:config': values.OBJ(config), }; } -function initPlugin({ plugin, aiscript }) { +function initPlugin({ plugin, aiscript }): void { pluginContexts.set(plugin.id, aiscript); } -function registerPostFormAction({ pluginId, title, handler }) { +function registerPostFormAction({ pluginId, title, handler }): void { postFormActions.push({ title, handler: (form, update) => { - pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { - update(key.value, value.value); + const pluginContext = pluginContexts.get(pluginId); + if (!pluginContext) { + return; + } + pluginContext.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + if (!key || !value) { + return; + } + update(utils.valToJs(key), utils.valToJs(value)); })]); }, }); } -function registerUserAction({ pluginId, title, handler }) { +function registerUserAction({ pluginId, title, handler }): void { userActions.push({ title, handler: (user) => { - pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]); + const pluginContext = pluginContexts.get(pluginId); + if (!pluginContext) { + return; + } + pluginContext.execFn(handler, [utils.jsToVal(user)]); }, }); } -function registerNoteAction({ pluginId, title, handler }) { +function registerNoteAction({ pluginId, title, handler }): void { noteActions.push({ title, handler: (note) => { - pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); + const pluginContext = pluginContexts.get(pluginId); + if (!pluginContext) { + return; + } + pluginContext.execFn(handler, [utils.jsToVal(note)]); }, }); } -function registerNoteViewInterruptor({ pluginId, handler }) { +function registerNoteViewInterruptor({ pluginId, handler }): void { noteViewInterruptors.push({ handler: async (note) => { - return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + const pluginContext = pluginContexts.get(pluginId); + if (!pluginContext) { + return; + } + return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)])); }, }); } -function registerNotePostInterruptor({ pluginId, handler }) { +function registerNotePostInterruptor({ pluginId, handler }): void { notePostInterruptors.push({ handler: async (note) => { - return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + const pluginContext = pluginContexts.get(pluginId); + if (!pluginContext) { + return; + } + return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)])); }, }); } diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 6c6baf826..5170ca4c8 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -143,8 +143,32 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router return roles.filter(r => r.target === 'manual').map(r => ({ text: r.name, - action: () => { - os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id }); + action: async () => { + const { canceled, result: period } = await os.select({ + title: i18n.ts.period, + items: [{ + value: 'indefinitely', text: i18n.ts.indefinitely, + }, { + value: 'oneHour', text: i18n.ts.oneHour, + }, { + value: 'oneDay', text: i18n.ts.oneDay, + }, { + value: 'oneWeek', text: i18n.ts.oneWeek, + }, { + value: 'oneMonth', text: i18n.ts.oneMonth, + }], + default: 'indefinitely', + }); + if (canceled) return; + + const expiresAt = period === 'indefinitely' ? null + : period === 'oneHour' ? Date.now() + (1000 * 60 * 60) + : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24) + : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7) + : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30) + : null; + + os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt }); }, })); }, diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts index 4a0ded637..b7238016c 100644 --- a/packages/frontend/src/scripts/hotkey.ts +++ b/packages/frontend/src/scripts/hotkey.ts @@ -53,10 +53,10 @@ const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, c return result; }); -const ignoreElemens = ['input', 'textarea']; +const ignoreElements = ['input', 'textarea']; function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean { - const key = ev.code.toLowerCase(); + const key = ev.key.toLowerCase(); return patterns.some(pattern => pattern.which.includes(key) && pattern.ctrl === ev.ctrlKey && pattern.shift === ev.shiftKey && @@ -70,7 +70,7 @@ export const makeHotkey = (keymap: Keymap) => { return (ev: KeyboardEvent) => { if (document.activeElement) { - if (ignoreElemens.some(el => document.activeElement!.matches(el))) return; + if (ignoreElements.some(el => document.activeElement!.matches(el))) return; if (document.activeElement.attributes['contenteditable']) return; } diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts index 69f6a8280..35813edbd 100644 --- a/packages/frontend/src/scripts/keycode.ts +++ b/packages/frontend/src/scripts/keycode.ts @@ -16,18 +16,3 @@ export const aliases = { 'right': 'ArrowRight', 'plus': ['NumpadAdd', 'Semicolon'], }; - -/*! -* Programmatically add the following -*/ - -// lower case chars -for (let i = 97; i < 123; i++) { - const char = String.fromCharCode(i); - aliases[char] = `Key${char.toUpperCase()}`; -} - -// numbers -for (let i = 0; i < 10; i++) { - aliases[i] = [`Numpad${i}`, `Digit${i}`]; -} diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts index 274e96e0a..2fe5bdcf8 100644 --- a/packages/frontend/src/scripts/media-proxy.ts +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -1,20 +1,20 @@ -import { query, appendQuery } from '@/scripts/url'; +import { query } from '@/scripts/url'; import { url } from '@/config'; import { instance } from '@/instance'; -export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string { - if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/')) { - // もう既にproxyっぽそうだったらsearchParams付けるだけ - return appendQuery(imageUrl, query({ - fallback: '1', - ...(type ? { [type]: '1' } : {}), - })); +export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigin: boolean = false): string { + const localProxy = `${url}/proxy`; + + if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) { + // もう既にproxyっぽそうだったらurlを取り出す + imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl; } - return `${instance.mediaProxy}/image.webp?${query({ + return `${mustOrigin ? localProxy : instance.mediaProxy}/image.webp?${query({ url: imageUrl, fallback: '1', ...(type ? { [type]: '1' } : {}), + ...(mustOrigin ? { origin: '1' } : {}), })}`; } diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts index 0db8369f9..8810e2696 100644 --- a/packages/frontend/src/scripts/page-metadata.ts +++ b/packages/frontend/src/scripts/page-metadata.ts @@ -10,7 +10,6 @@ export type PageMetadata = { icon?: string | null; avatar?: misskey.entities.User | null; userName?: misskey.entities.User | null; - bg?: string; }; export function definePageMetadata(metadata: PageMetadata | null | Ref | ComputedRef): void { diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 9d1f60323..b08982fac 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -4,6 +4,27 @@ const cache = new Map(); export const soundsTypes = [ null, + 'syuilo/n-aec', + 'syuilo/n-aec-4va', + 'syuilo/n-aec-4vb', + 'syuilo/n-aec-8va', + 'syuilo/n-aec-8vb', + 'syuilo/n-cea', + 'syuilo/n-cea-4va', + 'syuilo/n-cea-4vb', + 'syuilo/n-cea-8va', + 'syuilo/n-cea-8vb', + 'syuilo/n-eca', + 'syuilo/n-eca-4va', + 'syuilo/n-eca-4vb', + 'syuilo/n-eca-8va', + 'syuilo/n-eca-8vb', + 'syuilo/n-ea', + 'syuilo/n-ea-4va', + 'syuilo/n-ea-4vb', + 'syuilo/n-ea-8va', + 'syuilo/n-ea-8vb', + 'syuilo/n-ea-harmony', 'syuilo/up', 'syuilo/down', 'syuilo/pope1', diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 54c159ed6..a6ad1774f 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -273,6 +273,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 5, }, + showNoteActionsOnlyHover: { + where: 'device', + default: false, + }, aiChanMode: { where: 'device', default: false, @@ -283,12 +287,15 @@ export const defaultStore = markRaw(new Storage('base', { const PREFIX = 'miux:' as const; -type Plugin = { +export type Plugin = { id: string; name: string; active: boolean; + config?: Record; configData: Record; token: string; + src: string | null; + version: string; ast: any[]; }; @@ -312,14 +319,14 @@ export class ColdDeviceStorage { syncDeviceDarkMode: true, plugins: [] as Plugin[], mediaVolume: 0.5, - sound_masterVolume: 0.3, - sound_note: { type: 'syuilo/down', volume: 1 }, - sound_noteMy: { type: 'syuilo/up', volume: 1 }, - sound_notification: { type: 'syuilo/pope2', volume: 1 }, - sound_chat: { type: 'syuilo/pope1', volume: 1 }, - sound_chatBg: { type: 'syuilo/waon', volume: 1 }, - sound_antenna: { type: 'syuilo/triple', volume: 1 }, - sound_channel: { type: 'syuilo/square-pico', volume: 1 }, + sound_masterVolume: 0.5, + sound_note: { type: 'syuilo/n-aec', volume: 0.5 }, + sound_noteMy: { type: 'syuilo/n-cea', volume: 0.5 }, + sound_notification: { type: 'syuilo/n-ea', volume: 0.5 }, + sound_chat: { type: 'syuilo/pope1', volume: 0.5 }, + sound_chatBg: { type: 'syuilo/waon', volume: 0.5 }, + sound_antenna: { type: 'syuilo/triple', volume: 0.5 }, + sound_channel: { type: 'syuilo/square-pico', volume: 0.5 }, }; public static watchers: Watcher[] = []; diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 38ee37de2..e895847bd 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -22,7 +22,7 @@ -
+
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index beae799f5..11d1c85e3 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -2,7 +2,7 @@
- +
@@ -18,7 +18,7 @@ -
+
@@ -84,7 +84,7 @@