diff --git a/.github/PULL_REQUEST_TEMPLATE/01_bug.md b/.github/PULL_REQUEST_TEMPLATE/01_bug.md index 79ca97dfa..0739fee70 100644 --- a/.github/PULL_REQUEST_TEMPLATE/01_bug.md +++ b/.github/PULL_REQUEST_TEMPLATE/01_bug.md @@ -4,14 +4,20 @@ Thank you for your PR! Before creating a PR, please check the contribution guide https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md --> -# What +## What -# Why +## Why -# Additional info (optional) +## Additional info (optional) + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/PULL_REQUEST_TEMPLATE/02_enhance.md b/.github/PULL_REQUEST_TEMPLATE/02_enhance.md index 79ca97dfa..0739fee70 100644 --- a/.github/PULL_REQUEST_TEMPLATE/02_enhance.md +++ b/.github/PULL_REQUEST_TEMPLATE/02_enhance.md @@ -4,14 +4,20 @@ Thank you for your PR! Before creating a PR, please check the contribution guide https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md --> -# What +## What -# Why +## Why -# Additional info (optional) +## Additional info (optional) + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..0739fee70 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ + + +## What + + + +## Why + + + +## Additional info (optional) + + + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml new file mode 100644 index 000000000..44b6b4ba7 --- /dev/null +++ b/.github/workflows/test-backend.yml @@ -0,0 +1,59 @@ +name: Test (backend) + +on: + push: + branches: + - master + - develop + pull_request: + +jobs: + jest: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + services: + postgres: + image: postgres:13 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:6 + ports: + - 56312:6379 + + steps: + - uses: actions/checkout@v3.3.0 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 7 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .github/misskey/test.yml .config + - name: Build + run: pnpm build + - name: Test + run: pnpm jest-and-coverage + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/coverage/coverage-final.json diff --git a/.github/workflows/test.yml b/.github/workflows/test-frontend.yml similarity index 89% rename from .github/workflows/test.yml rename to .github/workflows/test-frontend.yml index 9135b4f60..18c1a31ae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test-frontend.yml @@ -1,4 +1,4 @@ -name: Test +name: Test (frontend) on: push: @@ -8,26 +8,13 @@ on: pull_request: jobs: - jest: + vitest: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x] - services: - postgres: - image: postgres:13 - ports: - - 54312:5432 - env: - POSTGRES_DB: test-misskey - POSTGRES_HOST_AUTH_METHOD: trust - redis: - image: redis:6 - ports: - - 56312:6379 - steps: - uses: actions/checkout@v3.3.0 with: @@ -51,12 +38,12 @@ jobs: - name: Build run: pnpm build - name: Test - run: pnpm jest-and-coverage + run: pnpm --filter frontend test-and-coverage - name: Upload Coverage uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./packages/backend/coverage/coverage-final.json + files: ./packages/frontend/coverage/coverage-final.json e2e: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index f067de292..c5fe52bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,71 @@ +## 13.10.0 + +### General +- ユーザーごとにRenoteをミュートできるように +- ノートごとに絵文字リアクションを受け取るか設定できるように +- クリップをお気に入りに登録できるように +- ノート検索の利用可否をロールで制御可能に(デフォルトでオフ) +- ロールの並び順を設定可能に +- カスタム絵文字にライセンス情報を付与できるように +- 指定した文字列を含む投稿の公開範囲をホームにできるように +- 使われてないアンテナは自動停止されるように + +### Client +- 設定から自分のロールを確認できるように +- 広告一覧ページを追加 +- ドライブクリーナーを追加 +- DM作成時にメンションも含むように +- フォロー申請のボタンのデザインを改善 +- 付箋ウィジェットの高さを設定可能に +- APオブジェクトを入力してフェッチする機能とユーザーやノートの検索機能を分離 +- ナビゲーションバーの項目に「プロフィール」を追加できるように +- ナビゲーションバーのカスタマイズをドラッグ&ドロップで行えるように +- ジョブキューの再試行をワンクリックでできるように +- AiScriptを0.13.1に更新 +- oEmbedをサポートしているウェブサイトのプレビューができるように + - YouTubeをoEmbedでロードし、プレビューで共有ボタンを押すとOSの共有画面がでるように + - ([FirefoxでSpotifyのプレビューを開けるとフルサイズじゃなくプレビューサイズだけ再生できる問題](https://bugzilla.mozilla.org/show_bug.cgi?id=1792395)があります) + - (すでにブラウザーでキャッシュされたリンクに対しては以前のプレビュー行動が行われてます。その場合、ブラウザーのキャッシュをクリアしてまた試してください。) +- プロフィールで設定した情報が削除できない問題を修正 +- ロールで広告を無効にするとadmin/adsでプレビューがでてこない問題を修正 +- /api-consoleページにアクセスすると404が出る問題を修正 +- Safariでプラグインが複数ある場合に正常に読み込まれない問題を修正 +- Bookwyrmのユーザーのプロフィールページで「リモートで表示」をタップしても反応がない問題を修正 +- 非ログイン時の「Misskeyについて」の表示を修正 +- PC版にて「設定」「コントロールパネル」のリンクを2度以上続けてクリックした際に空白のページが表示される問題を修正 + +### Server +- OpenAPIエンドポイントを復旧 +- WebP/AVIF/JPEGのweb公開用画像は、サーバーサイドではJPEGではなくWebPに変換するように +- アニメーション画像のサムネイルを生成するように +- アクティブユーザー数チャートの記録上限値を拡張 +- Playのソースコード上限文字数を2倍に拡張 +- 配送先サーバーが410 Goneで応答してきた場合は自動で配送停止をするように +- avatarBlurHash/bannerBlurHashの型をstringに限定 +- タイムライン取得時のパフォーマンスを改善 +- SMTP Login id length is too short +- API上で`visibility`を`followers`に設定してrenoteすると連合や削除で不具合が発生する問題を修正 +- AWS S3からのファイル削除でNoSuchKeyエラーが出ると進めらない状態になる問題を修正 +- `disableCache: true`を設定している場合に絵文字管理操作でエラーが出る問題を修正 +- リテンション分析が上手く機能しないことがあるのを修正 +- 空のアンテナが作成できないように修正 +- 特定の条件で通報が見れない問題を修正 +- 絵文字の名前に任意の文字が使用できる問題を修正 + ## 13.9.2 (2023/03/06) ### Improvements @@ -246,8 +302,8 @@ You should also include the user name that made the change. ## 13.3.2 (2023/02/04) ### Improvements -- 外部メディアプロキシへの対応を強化しました - 外部メディアプロキシのFastify実装を作りました +- 外部メディアプロキシへの対応を強化しました + 外部メディアプロキシのFastify実装を作りました https://github.com/misskey-dev/media-proxy - Server: improve performance @@ -410,7 +466,7 @@ You should also include the user name that made the change. - ユーザーごとのドライブ容量設定はロールに統合されました。 - インスタンスデフォルトのドライブ容量設定はロールに統合されました。アップデート後、ベースロールもしくはコンディショナルロールでドライブ容量を編集してください。 - LTL/GTLの解放状態はロールに統合されました。 -- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。 +- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。 https://github.com/misskey-dev/misskey/pull/9560 #### For users @@ -638,7 +694,7 @@ You should also include the user name that made the change. ## 12.112.2 (2022/07/08) ### Bugfixes -- Fix Docker doesn't work @mei23 +- Fix Docker doesn't work @mei23 Still not working on arm64 environment. (See 12.112.0) ## 12.112.1 (2022/07/07) @@ -680,7 +736,7 @@ same as 12.112.0 - Improve player detection in URL preview @mei23 - Add Badge Image to Push Notification #8012 @tamaina - Server: Improve performance -- Server: Supports IPv6 on Redis transport. @mei23 +- Server: Supports IPv6 on Redis transport. @mei23 IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`. - Server: Add possibility to log IP addresses of users @syuilo - Add additional drive capacity change support @CyberRex0 diff --git a/cypress/e2e/basic.cy.js b/cypress/e2e/basic.cy.js index b1b856119..8dc07c180 100644 --- a/cypress/e2e/basic.cy.js +++ b/cypress/e2e/basic.cy.js @@ -52,13 +52,30 @@ describe('After setup instance', () => { cy.intercept('POST', '/api/signup').as('signup'); cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-username] input').type('alice'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-password] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-password-retype] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('not.be.disabled'); cy.get('[data-cy-signup-submit]').click(); cy.wait('@signup'); }); + + it('signup with duplicated username', () => { + cy.registerUser('alice', 'alice1234'); + + cy.visitHome(); + + // ユーザー名が重複している場合の挙動確認 + cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-username] input').type('alice'); + cy.get('[data-cy-signup-password] input').type('alice1234'); + cy.get('[data-cy-signup-password-retype] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); + }); }); describe('After user signup', () => { diff --git a/cypress/e2e/widgets.cy.js b/cypress/e2e/widgets.cy.js index 7d2039ff9..a39ea85e1 100644 --- a/cypress/e2e/widgets.cy.js +++ b/cypress/e2e/widgets.cy.js @@ -29,17 +29,17 @@ describe('After user signed in', () => { it('first widget should be removed', () => { cy.get('.mk-widget-edit').click(); - cy.get('.data-cy-customize-container:first-child .data-cy-customize-container-remove._button').click(); - cy.get('.data-cy-customize-container').should('have.length', 2); + cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click(); + cy.get('[data-cy-customize-container]').should('have.length', 2); }); function buildWidgetTest(widgetName) { it(`${widgetName} widget should get added`, () => { cy.get('.mk-widget-edit').click(); cy.get('.mk-widget-select select').select(widgetName, { force: true }); - cy.get('.data-cy-bg._modalBg.data-cy-transparent').click({ multiple: true, force: true }); + cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true }); cy.get('.mk-widget-add').click({ force: true }); - cy.get(`.data-cy-mkw-${widgetName}`).should('exist'); + cy.get(`[data-cy-mkw-${widgetName}]`).should('exist'); }); } diff --git a/locales/de-DE.yml b/locales/de-DE.yml index eae9389ac..0716bcc4a 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "Als nicht NSFW markieren" enterFileName: "Dateinamen eingeben" mute: "Stummschalten" unmute: "Stummschaltung aufheben" +renoteMute: "Renotes stummschalten" +renoteUnmute: "Renote-Stummschaltung aufheben" block: "Blockieren" unblock: "Blockierung aufheben" suspend: "Sperren" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "Antworten in der Chronik anzeigen" flagShowTimelineRepliesDescription: "Ist diese Option aktiviert, so werden Antworten von Benutzern auf die Notizen anderer Benutzer in der Chronik angezeigt." autoAcceptFollowed: "Follow-Anfragen von Benutzern, denen du folgst, automatisch akzeptieren" addAccount: "Benutzerkonto hinzufügen" +reloadAccountsList: "Benutzerkontoliste aktualisieren" loginFailed: "Anmeldung fehlgeschlagen" showOnRemote: "Auf Ursprungsinstanz ansehen" general: "Allgemein" @@ -544,6 +547,10 @@ userSuspended: "Dieser Benutzer wurde gesperrt." userSilenced: "Dieser Benutzer wurde instanzweit stummgeschaltet." yourAccountSuspendedTitle: "Dieses Benutzerkonto ist gesperrt" yourAccountSuspendedDescription: "Dieses Benutzerkonto wurde gesperrt, da es gegen die Nutzungsbedingungen dieses Servers verstoßen hat. Trete mit dem Betreiber in Kontakt, falls du weitere Details erfahren möchtest. Bitte erstelle kein neues Benutzerkonto." +tokenRevoked: "Ungültiger Token" +tokenRevokedDescription: "Der Token ist abgelaufen. Bitte melde dich erneut an." +accountDeleted: "Benutzerkonto wurde gelöscht" +accountDeletedDescription: "Dieses Konto wurde gelöscht." menu: "Menü" divider: "Trenner" addItem: "Element hinzufügen" @@ -959,6 +966,18 @@ invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst ein emailNotSupported: "Diese Instanz unterstützt das Versenden von Emails nicht" postToTheChannel: "In Kanal senden" cannotBeChangedLater: "Kann später nicht mehr geändert werden." +reactionAcceptance: "Reaktionsannahme" +likeOnly: "Nur \"Gefällt mir\"" +likeOnlyForRemote: "Nur \"Gefällt mir\" für fremde Instanzen" +rolesAssignedToMe: "Mir zugewiesene Rollen" +resetPasswordConfirm: "Wirklich Passwort zurücksetzen?" +sensitiveWords: "Sensible Wörter" +sensitiveWordsDescription: "Die Notizsichtbarkeit aller Notizen, die diese Wörter enthalten, wird automatisch auf \"Startseite\" gesetzt. Durch Zeilenumbrüche können mehrere konfiguriert werden." +notesSearchNotAvailable: "Die Notizsuche ist nicht verfügbar." +license: "Lizenz" +unfavoriteConfirm: "Wirklich aus Favoriten entfernen?" +myClips: "Meine Clips" +drivecleaner: "Drive-Reiniger" _achievements: earnedAt: "Freigeschaltet am" _types: @@ -1106,7 +1125,7 @@ _achievements: title: "Beliebt" description: "Die Anzahl deiner Follower hat 100 überschritten" _followers300: - title: "Stellt euch bitte in einer Reihe auf" + title: "Eine geordnete Reihe, bitte!" description: "Die Anzahl deiner Follower hat 300 überschritten" _followers500: title: "Funkmast" @@ -1218,6 +1237,8 @@ _role: iconUrl: "Icon-URL" asBadge: "Als Abzeichen anzeigen" descriptionOfAsBadge: "Ist dies aktiviert, so wird das Icon dieser Rolle an der Seite der Namen von Benutzern mit dieser Rolle angezeigt." + displayOrder: "Position" + descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position." canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen" descriptionOfCanEditMembersByModerator: "Wenn aktiviert, so können Moderatoren und Adminstratoren anderen Benutzern diese Rolle zuweisen bzw. diese Zuweisung aufheben. Wenn deaktiviert, so ist es nur Administratoren möglich, Zuweisungen dieser Rolle zu verwalten." priority: "Priorität" @@ -1243,6 +1264,7 @@ _role: rateLimitFactor: "Versuchsanzahl" descriptionOfRateLimitFactor: "Je niedriger desto weniger restriktiv, je höher destro restriktiver." canHideAds: "Kann Werbung ausblenden" + canSearchNotes: "Nutzung der Notizsuchfunktion" _condition: isLocal: "Lokaler Benutzer" isRemote: "Benutzer fremder Instanz" @@ -1844,3 +1866,9 @@ _deck: _dialog: charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" +_disabledTimeline: + title: "Chronik deaktiviert" + description: "Mit deinen jetzigen Rollen ist diese Chronik nicht verfügbar." +_drivecleaner: + orderBySizeDesc: "Absteigende Dateigrößen" + orderByCreatedAtAsc: "Aufsteigendes Erstelldatum" diff --git a/locales/en-US.yml b/locales/en-US.yml index 53a30c741..5efb7b7d1 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -67,7 +67,7 @@ import: "Import" export: "Export" files: "Files" download: "Download" -driveFileDeleteConfirm: "Are you sure you want to delete the file \"{name}\"? Notes with this file attached will also be deleted." +driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted." unfollowConfirm: "Are you sure you want to unfollow {name}?" exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed." importRequested: "You've requested an import. This may take a while." @@ -122,6 +122,8 @@ unmarkAsSensitive: "Unmark as NSFW" enterFileName: "Enter filename" mute: "Mute" unmute: "Unmute" +renoteMute: "Mute Renotes" +renoteUnmute: "Unmute Renotes" block: "Block" unblock: "Unblock" suspend: "Suspend" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "Show replies in timeline" flagShowTimelineRepliesDescription: "Shows replies of users to notes of other users in the timeline if turned on." autoAcceptFollowed: "Automatically approve follow requests from users you're following" addAccount: "Add account" +reloadAccountsList: "Reload account list" loginFailed: "Failed to sign in" showOnRemote: "View on remote instance" general: "General" @@ -527,7 +530,7 @@ nothing: "There's nothing to see here" installedDate: "Authorized at" lastUsedDate: "Last used at" state: "State" -sort: "Sort" +sort: "Sorting order" ascendingOrder: "Ascending" descendingOrder: "Descending" scratchpad: "Scratchpad" @@ -544,6 +547,10 @@ userSuspended: "This user has been suspended." userSilenced: "This user is being silenced." yourAccountSuspendedTitle: "This account is suspended" yourAccountSuspendedDescription: "This account has been suspended due to breaking the server's terms of services or similar. Contact the administrator if you would like to know a more detailed reason. Please do not create a new account." +tokenRevoked: "Invalid token" +tokenRevokedDescription: "This token has expired. Please log in again." +accountDeleted: "Account deleted" +accountDeletedDescription: "This account has been deleted." menu: "Menu" divider: "Divider" addItem: "Add Item" @@ -959,6 +966,18 @@ invitationRequiredToRegister: "This instance is invite-only. You must enter a va emailNotSupported: "This instance does not support sending emails" postToTheChannel: "Post to channel" cannotBeChangedLater: "This cannot be changed later." +reactionAcceptance: "Reaction Acceptance" +likeOnly: "Only likes" +likeOnlyForRemote: "Only likes for remote instances" +rolesAssignedToMe: "Roles assigned to me" +resetPasswordConfirm: "Really reset your password?" +sensitiveWords: "Sensitive words" +sensitiveWordsDescription: "The visibility of all notes containing any of the configured words will be set to \"Home\" automatically. You can list multiple by separating them via line breaks." +notesSearchNotAvailable: "Note search is unavailable." +license: "License" +unfavoriteConfirm: "Really remove from favorites?" +myClips: "My clips" +drivecleaner: "Drive Cleaner" _achievements: earnedAt: "Unlocked at" _types: @@ -1218,6 +1237,8 @@ _role: iconUrl: "Icon URL" asBadge: "Show as badge" descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on." + displayOrder: "Position" + descriptionOfDisplayOrder: "The higher the number, the higher its UI position." canEditMembersByModerator: "Allow moderators to edit the list of members for this role" descriptionOfCanEditMembersByModerator: "When turned on, moderators as well as administrators will be able to assign and unassign users to this role. When turned off, only administrators will be able to assign users." priority: "Priority" @@ -1243,6 +1264,7 @@ _role: rateLimitFactor: "Rate limit" descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. " canHideAds: "Can hide ads" + canSearchNotes: "Usage of note search" _condition: isLocal: "Local user" isRemote: "Remote user" @@ -1844,3 +1866,9 @@ _deck: _dialog: charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." +_disabledTimeline: + title: "Timeline disabled" + description: "You cannot use this timeline under your current roles." +_drivecleaner: + orderBySizeDesc: "Descending Filesizes" + orderByCreatedAtAsc: "Ascending Dates" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 3ad2c21ff..3e73e4c5e 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "Desmarcar como sensible" enterFileName: "Ingrese el nombre del archivo" mute: "Silenciar" unmute: "Dejar de silenciar" +renoteMute: "Silenciar renota" +renoteUnmute: "Desilenciar renota" block: "Bloquear" unblock: "Dejar de bloquear" suspend: "Suspender" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "Mostrar respuestas a las notas en la biografía" flagShowTimelineRepliesDescription: "Cuando se marca, la línea de tiempo muestra respuestas a otras notas además de las notas del usuario" autoAcceptFollowed: "Aceptar automáticamente las solicitudes de seguimiento de los usuarios que sigues" addAccount: "Agregar Cuenta" +reloadAccountsList: "Recargar lista de cuentas" loginFailed: "Error al iniciar sesión." showOnRemote: "Ver en una instancia remota" general: "General" @@ -506,6 +509,7 @@ objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir " serverLogs: "Registros del servidor" deleteAll: "Eliminar todos" showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo" +showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)" newNoteRecived: "Tienes una nota nueva" sounds: "Sonidos" sound: "Sonidos" @@ -543,6 +547,10 @@ userSuspended: "Este usuario ha sido suspendido." userSilenced: "Este usuario ha sido silenciado." yourAccountSuspendedTitle: "Esta cuenta ha sido suspendida" yourAccountSuspendedDescription: "Esta cuenta ha sido suspendida debido a violaciones de los términos de servicio del servidor y otras razones. Para más información, póngase en contacto con el administrador. Por favor, no cree una nueva cuenta." +tokenRevoked: "Token inválido" +tokenRevokedDescription: "Este token expiró, vuelve a iniciar sesión." +accountDeleted: "Cuenta borrada" +accountDeletedDescription: "Esta cuenta ha sido borrada." menu: "Menú" divider: "Divisor" addItem: "Agregar elemento" @@ -955,6 +963,16 @@ 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." +emailNotSupported: "Esta instancia no soporta el envío de correo electrónico" +postToTheChannel: "Publicar en el canal" +cannotBeChangedLater: "Esto no podrá ser cambiado después." +reactionAcceptance: "Aceptación de reacciones" +likeOnly: "Sólo 'me gusta'" +likeOnlyForRemote: "Sólo reacciones de instancias remotas" +rolesAssignedToMe: "Roles asignados a mí" +resetPasswordConfirm: "¿Realmente quieres cambiar la contraseña?" +sensitiveWords: "Palabras sensibles" +sensitiveWordsDescription: "La visibilidad de todas las notas que contienen cualquiera de las palabras configuradas serán puestas en \"Inicio\" automáticamente. Puedes enumerás varias separándolas con saltos de línea" _achievements: earnedAt: "Desbloqueado el" _types: @@ -1214,6 +1232,8 @@ _role: iconUrl: "URL del ícono" asBadge: "Mostrar como emblema" descriptionOfAsBadge: "Este ícono de rol se mostrará a lado del nombre de usuario cuando este rol se encuentre activo." + displayOrder: "Posición" + descriptionOfDisplayOrder: "Entre más alto el número, mayor es la posición en la interfaz." canEditMembersByModerator: "Permitir a los moderadores editar los miembros" descriptionOfCanEditMembersByModerator: "Si se activa, los moderadores, al igual que los administradores, serán capaces de asignar/quitar usuarios a éste rol. Si se desactiva, sólo los administradores podrán hacerlo." priority: "Prioridad" @@ -1239,6 +1259,7 @@ _role: rateLimitFactor: "Limitador" descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos" canHideAds: "Puede ocultar anuncios" + canSearchNotes: "Uso de la búsqueda de notas" _condition: isLocal: "Usuario local" isRemote: "Usuario remoto" @@ -1840,3 +1861,6 @@ _deck: _dialog: charactersExceeded: "¡Has excedido el límite de caracteres! Actualmente {current} de {max}." charactersBelow: "¡Estás por debajo del límite de caracteres! Actualmente {current} de {min}." +_disabledTimeline: + title: "Línea de tiempo deshabilitada" + description: "No puedes usar esta línea de tiempo con tus roles actuales." diff --git a/locales/it-IT.yml b/locales/it-IT.yml index d5638aeb6..44499fa3d 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "Segna come non sensibile" enterFileName: "Nome del file" mute: "Silenzia" unmute: "Riattiva l'audio" +renoteMute: "Silenzia i Rinota" +renoteUnmute: "Non silenziare i Rinota" block: "Blocca" unblock: "Sblocca" suspend: "Sospendi" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "Mostra le risposte alle note sulla timeline." flagShowTimelineRepliesDescription: "Se è attiva, la timeline mostra le risposte alle altre note dell'utente oltre a quelle dell'utente stesso." autoAcceptFollowed: "Accetta automaticamente le richieste di follow da utenti che già segui" addAccount: "Aggiungi profilo" +reloadAccountsList: "Ricarica l'elenco dei profili" loginFailed: "Accesso non riuscito" showOnRemote: "Leggi sull'istanza remota" general: "Generali" @@ -544,6 +547,10 @@ userSuspended: "L'utente è in sospensione" userSilenced: "L'utente è silenziat@." yourAccountSuspendedTitle: "Questo profilo è sospeso" yourAccountSuspendedDescription: "Questo profilo è stato sospeso a causa di una violazione del regolamento. Per informazioni, contattare l'amministrazione. Si prega di non creare un nuovo account." +tokenRevoked: "Il token non è valido" +tokenRevokedDescription: "Il token di accesso è scaduto. Per favore, accedi nuovamente." +accountDeleted: "Profilo eliminato" +accountDeletedDescription: "Questo profilo è stato eliminato." menu: "Menù" divider: "Linea di separazione" addItem: "Aggiungi elemento" @@ -959,6 +966,17 @@ invitationRequiredToRegister: "L'accesso a questo nodo è solo ad invito. Devi i emailNotSupported: "L'istanza non supporta l'invio di email" postToTheChannel: "Pubblica sul canale" cannotBeChangedLater: "Non sarà più modificabile" +reactionAcceptance: "Accettazione reazioni" +likeOnly: "Solo i Like" +likeOnlyForRemote: "Solo Like remoti" +rolesAssignedToMe: "I miei ruoli" +resetPasswordConfirm: "Vuoi reimpostare la password?" +sensitiveWords: "Parole sensibili" +sensitiveWordsDescription: "Imposta automaticamente \"Home\" alla visibilità delle Note che contengono una qualsiasi parola tra queste configurate. Puoi separarle per riga." +notesSearchNotAvailable: "Non è possibile cercare tra le Note." +license: "Licenza" +unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?" +myClips: "Le mie Clip" _achievements: earnedAt: "Data di conseguimento" _types: @@ -1218,6 +1236,8 @@ _role: iconUrl: "URL dell'icona" asBadge: "Mostra come badge" descriptionOfAsBadge: "Se indicato, accanto al nome utente viene visualizzata l'icona del ruolo." + displayOrder: "Ordine di visualizzazione" + descriptionOfDisplayOrder: "I valori più alti vengono visualizzati per primi" canEditMembersByModerator: "Anche i Moderatori assegnano profili a questo ruolo" descriptionOfCanEditMembersByModerator: "Se disattivo, potranno farlo solamente gli Amministratori." priority: "Priorità" @@ -1243,6 +1263,7 @@ _role: rateLimitFactor: "Limite del rapporto" descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più." canHideAds: "Può nascondere i banner" + canSearchNotes: "Ricercare nelle Note" _condition: isLocal: "Profilo locale" isRemote: "Profilo remoto" @@ -1844,3 +1865,6 @@ _deck: _dialog: charactersExceeded: "Hai superato il limite di {max} caratteri! ({corrente})" charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({corrente})" +_disabledTimeline: + title: "Timeline disabilitata" + description: "Il tuo ruolo non ha i permessi per accedere a questa timeline" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index e441055cb..2011ca363 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -67,7 +67,7 @@ import: "インポート" export: "エクスポート" files: "ファイル" download: "ダウンロード" -driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを添付したノートも消えます。" +driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを使用した全てのコンテンツからも削除されます。" unfollowConfirm: "{name}のフォローを解除しますか?" exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。" importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。" @@ -122,6 +122,8 @@ unmarkAsSensitive: "閲覧注意を解除する" enterFileName: "ファイル名を入力" mute: "ミュート" unmute: "ミュート解除" +renoteMute: "リノートをミュート" +renoteUnmute: "リノートのミュートを解除" block: "ブロック" unblock: "ブロック解除" suspend: "凍結" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "タイムラインにノートへの返信を表示す flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" addAccount: "アカウントを追加" +reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗しました" showOnRemote: "リモートで表示" general: "全般" @@ -544,6 +547,10 @@ userSuspended: "このユーザーは凍結されています。" userSilenced: "このユーザーはサイレンスされています。" yourAccountSuspendedTitle: "アカウントが凍結されています" yourAccountSuspendedDescription: "このアカウントは、サーバーの利用規約に違反したなどの理由により、凍結されています。詳細については管理者までお問い合わせください。新しいアカウントを作らないでください。" +tokenRevoked: "トークンが無効です" +tokenRevokedDescription: "ログイントークンが失効しています。ログインし直してください。" +accountDeleted: "アカウントは削除されています" +accountDeletedDescription: "このアカウントは削除されています。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" @@ -959,6 +966,21 @@ invitationRequiredToRegister: "現在このサーバーは招待制です。招 emailNotSupported: "このサーバーではメール配信はサポートされていません" postToTheChannel: "チャンネルに投稿" cannotBeChangedLater: "後から変更できません。" +reactionAcceptance: "リアクションの受け入れ" +likeOnly: "いいねのみ" +likeOnlyForRemote: "リモートからはいいねのみ" +rolesAssignedToMe: "自分に割り当てられたロール" +resetPasswordConfirm: "パスワードリセットしますか?" +sensitiveWords: "センシティブワード" +sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" +notesSearchNotAvailable: "ノート検索は利用できません。" +license: "ライセンス" +unfavoriteConfirm: "お気に入り解除しますか?" +myClips: "自分のクリップ" +drivecleaner: "ドライブクリーナー" +retryAllQueuesNow: "すべてのキューを今すぐ再試行" +retryAllQueuesConfirmTitle: "今すぐ再試行しますか?" +retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" _achievements: earnedAt: "獲得日時" @@ -1220,6 +1242,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして表示" descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。" + displayOrder: "表示順" + descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" priority: "優先度" @@ -1245,6 +1269,7 @@ _role: rateLimitFactor: "レートリミット" descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。" canHideAds: "広告の非表示" + canSearchNotes: "ノート検索の利用可否" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1897,3 +1922,11 @@ _deck: _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}" + +_disabledTimeline: + title: "無効化されたタイムライン" + description: "現在のロールでは、このタイムラインを使用することはできません。" + +_drivecleaner: + orderBySizeDesc: "サイズが大きい順" + orderByCreatedAtAsc: "追加日が古い順" \ No newline at end of file diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 081457c2f..bd9ae46d3 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -2,7 +2,7 @@ _lang_: "日本語 (関西弁)" headlineMisskey: "ノートでつながるネットワーク" introMisskey: "ようお越し!Misskeyは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな新しい世界を探検しよか🚀" -poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyを使ったサービス(Misskeyインスタンスと呼ばれるやつや)のひとつやで。" +poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつなんやで。" monthAndDay: "{month}月 {day}日" search: "探す" notifications: "通知" @@ -15,13 +15,13 @@ gotIt: "ほい" cancel: "やめとく" noThankYou: "やめとく" enterUsername: "ユーザー名を入れてや" -renotedBy: "{user}がRenote" -noNotes: "ノートはあらへん" -noNotifications: "通知はあらへん" -instance: "インスタンス" +renotedBy: "{user}がRenoteしたで" +noNotes: "ノートなんてあらへんで" +noNotifications: "通知なんてあらへんで" +instance: "サーバー" settings: "設定" basicSettings: "基本設定" -otherSettings: "その他の設定" +otherSettings: "ほかの設定" openInWindow: "ウィンドウで開くで" profile: "プロフィール" timeline: "タイムライン" @@ -55,7 +55,7 @@ searchUser: "ユーザーを検索" reply: "返事" loadMore: "まだまだあるで!" showMore: "まだまだあるで!" -showLess: "閉じる" +showLess: "さいなら" youGotNewFollower: "フォローされたで" receiveFollowRequest: "フォローリクエストされたで" followRequestAccepted: "フォローが承認されたで" @@ -84,12 +84,12 @@ error: "エラー" somethingHappened: "なんかアカンことが起こったで" retry: "もっぺんやる?" pageLoadError: "ページの読み込みに失敗してもうたわ…" -pageLoadErrorDescription: "これは普通、ネットワークかブラウザキャッシュが原因やからね。キャッシュをクリアするか、もうちっとだけ待ってくれへんか?" +pageLoadErrorDescription: "これは普通ならネットワークかブラウザキャッシュが悪さしてるんよ。キャッシュをほかすか、もうちょっとだけ待ってくれへん?" serverIsDead: "サーバーからの応答がないで。もうちょい待ってから試してみてな。" youShouldUpgradeClient: "このページを表示するには、リロードして新しいバージョンのクライアントを使ってなー。" enterListName: "リスト名を入れてや" privacy: "プライバシー" -makeFollowManuallyApprove: "他人のフォローは許可してからや!" +makeFollowManuallyApprove: "ええって言わなフォローできへんようにする" defaultNoteVisibility: "もとからの公開範囲" follow: "フォロー" followRequest: "フォローを頼む" @@ -113,7 +113,7 @@ sensitive: "ちょっとアカンやつやで" add: "増やす" reaction: "リアクション" reactions: "リアクション" -reactionSetting: "Reaction that will be displayed in Picker. " +reactionSetting: "ピッカーに出しとくリアクション" reactionSettingDescription2: "ドラッグで並び替え、クリックで削除、+を押して追加やで。" rememberNoteVisibility: "公開範囲覚えといて" attachCancel: "のっけるのやめる" @@ -122,6 +122,8 @@ unmarkAsSensitive: "そこまでアカンことないやろ" enterFileName: "ファイル名を入れてや" mute: "ミュート" unmute: "ミュートやめたる" +renoteMute: "リノートは見いひん" +renoteUnmute: "リノートもやっぱ見るわ" block: "ブロック" unblock: "ブロックやめたる" suspend: "凍結" @@ -145,20 +147,21 @@ addEmoji: "絵文字を追加" settingGuide: "ええ感じの設定" cacheRemoteFiles: "リモートのファイルをキャッシュする" cacheRemoteFilesDescription: "この設定を切っとくと、リモートファイルをキャッシュせず直リンクするようになるで。サーバーの容量は節約できるけど、サムネイルが作られんくなるから通信量が増えるで。" -flagAsBot: "Botやで" -flagAsBotDescription: "もしこのアカウントがプログラムによって運用されるんやったら、このフラグをオンにしてたのむで。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったもんになるんやで。" +flagAsBot: "Botにするで" +flagAsBotDescription: "もしこのアカウントをプログラム使うて運用するんやったら、このフラグをオンにしてや。オンにすれば、反応がバーッて連鎖するのを避けるために開発者が使うたり、Misskeyのシステム上での扱いがBotに合ったもんになるからな。" flagAsCat: "Catやで" flagAsCatDescription: "ワレ、猫ちゃんならこのフラグをつけてみ?" flagShowTimelineReplies: "タイムラインにノートへの返信を表示するで" flagShowTimelineRepliesDescription: "オンにしたら、タイムラインにユーザーのノートの他にもそのユーザーの他のノートへの返信を表示するで。" autoAcceptFollowed: "フォローしとるユーザーからのフォローリクエストを勝手に許可しとく" addAccount: "アカウントを追加" +reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗してもうた…" showOnRemote: "リモートで見る" general: "全般" wallpaper: "壁紙" setWallpaper: "壁紙を設定" -removeWallpaper: "壁紙を削除" +removeWallpaper: "壁紙ほかす" searchWith: "検索: {q}" youHaveNoLists: "リストがあらへんで?" followConfirm: "{name}をフォローしてええか?" @@ -169,7 +172,7 @@ selectUser: "ユーザーを選ぶ" recipient: "宛先" annotation: "注釈" federation: "連合" -instances: "インスタンス" +instances: "サーバー" registeredAt: "初観測" latestRequestReceivedAt: "ちょっと前のリクエスト受信" latestStatus: "ちょっと前のステータス" @@ -178,7 +181,7 @@ charts: "チャート" perHour: "1時間ごと" perDay: "1日ごと" stopActivityDelivery: "アクティビティの配送をやめる" -blockThisInstance: "このインスタンスをブロック" +blockThisInstance: "このサーバーをブロックすんで" operations: "操作" software: "ソフトウェア" version: "バージョン" @@ -189,28 +192,28 @@ jobQueue: "ジョブキュー" cpuAndMemory: "CPUとメモリ" network: "ネットワーク" disk: "ディスク" -instanceInfo: "インスタンス情報" +instanceInfo: "サーバー情報" statistics: "統計" clearQueue: "キューにさいなら" clearQueueConfirmTitle: "キューをクリアしまっか?" -clearQueueConfirmText: "未配達の投稿は配送されなくなるで。通常この操作を行う必要はあらへんや。" +clearQueueConfirmText: "未配達の投稿は配送されなくなるで。ふつうこの操作を行う必要は無いんやけどな。" clearCachedFiles: "キャッシュにさいなら" clearCachedFilesConfirm: "キャッシュされとるリモートファイルをみんなほかしてええか?" -blockedInstances: "インスタンスブロック" -blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定してな。ブロックされてもうたインスタンスとはもう金輪際やり取りできひんくなるで。" +blockedInstances: "ブロックしたサーバー" +blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定してな。ブロックされてもうたサーバーとはもう金輪際やり取りできひんくなるで。ついでにそのサブドメインもブロックするで。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" -noUsers: "ユーザーはおらへん" +noUsers: "ユーザーはおらん" editProfile: "プロフィールをいじる" noteDeleteConfirm: "このノートを削除しまっか?" pinLimitExceeded: "これ以上ピン留めできひん" -intro: "Misskeyのインストールが完了してん!管理者アカウントを作ってや。" +intro: "Misskeyのインストールが完了したで!管理者アカウントを作ってや。" done: "でけた" processing: "処理しとる" preview: "プレビュー" default: "デフォルト" -defaultValueIs: "デフォルト" +defaultValueIs: "デフォルト: {value}" noCustomEmojis: "絵文字はあらへん" noJobs: "ジョブはあらへん" federating: "連合しとる" @@ -220,17 +223,17 @@ all: "みんな" subscribing: "購読しとる" publishing: "配信しとる" notResponding: "応答してへんで" -instanceFollowing: "インスタンスのフォロー" -instanceFollowers: "インスタンスのフォロワー\n" -instanceUsers: "インスタンスのユーザー" +instanceFollowing: "サーバーのフォロー" +instanceFollowers: "サーバーのフォロワー\n" +instanceUsers: "サーバーのユーザー" changePassword: "パスワード変える" security: "セキュリティ" -retypedNotMatch: "そやないねん。" +retypedNotMatch: "入れたやつ同じになってないで。" currentPassword: "今のパスワード" -newPassword: "今度のパスワード" +newPassword: "次のパスワード" newPasswordRetype: "今度のパスワード(もっぺん入れて)" attachFile: "ファイルのっける" -more: "他のやつ!" +more: "他のん" featured: "ハイライト" usernameOrUserId: "ユーザー名かユーザーID" noSuchUser: "ユーザーが見つからへんで" @@ -238,15 +241,15 @@ lookup: "見てきて" announcements: "お知らせ" imageUrl: "画像URL" remove: "ほかす" -removed: "削除したで!" +removed: "ほかしたで!" removeAreYouSure: "「{x}」はほかしてええか?" deleteAreYouSure: "「{x}」はほかしてええか?" resetAreYouSure: "リセットしてええん?" saved: "保存したで!" messaging: "チャット" upload: "アップロード" -keepOriginalUploading: "オリジナル画像を保持するわ" -keepOriginalUploadingDescription: "画像を上げるときにオリジナル版を保持するで。オフにしたら上げたときにブラウザでWeb公開用の画像を生成するで。 " +keepOriginalUploading: "オリジナル画像のまんま" +keepOriginalUploadingDescription: "画像を上げるときにオリジナル版のまんまにするで。オフにしたら、上げたときにブラウザでWeb公開用の画像を生成するで。 " fromDrive: "ドライブから" fromUrl: "URLから" uploadFromUrl: "URLアップロード" @@ -272,8 +275,8 @@ yearsOld: "{age}歳" registeredDate: "始めた日" location: "場所" theme: "テーマ" -themeForLightMode: "ライトモードではこのテーマつこて" -themeForDarkMode: "ダークモードではこのテーマつこて" +themeForLightMode: "ライトモードではこのテーマ使うて" +themeForDarkMode: "ダークモードではこのテーマ使うて" light: "ライト" dark: "ダーク" lightThemes: "デイゲーム" @@ -289,13 +292,13 @@ renameFile: "ファイル名をいらう" folderName: "フォルダー名" createFolder: "フォルダー作る" renameFolder: "フォルダー名を変える" -deleteFolder: "フォルダーを消してまう" +deleteFolder: "フォルダーをほかす" addFile: "ファイルを追加" emptyDrive: "ドライブにはなんも残っとらん" -emptyFolder: "ふぉろだーにはなんも残っとらん" +emptyFolder: "このフォルダーは空や" unableToDelete: "消そうおもってんけどな、あかんかったわ" inputNewFileName: "今度のファイル名は何にするん?" -inputNewDescription: "新しいキャプションを入力しましょ" +inputNewDescription: "新しいキャプションを入れてや" inputNewFolderName: "今度のフォルダ名は何にするん?" circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーや。" hasChildFilesOrFolders: "このフォルダ、まだなんか入っとるから消されへん" @@ -303,8 +306,8 @@ copyUrl: "URLをコピー" rename: "名前を変えるで" avatar: "アイコン" banner: "バナー" -nsfw: "閲覧注意" -whenServerDisconnected: "サーバーとの接続が切れたとき" +nsfw: "見るんは気いつけてな" +whenServerDisconnected: "サーバーとの接続が失くなってしもうたとき" disconnectedFromServer: "サーバーが機嫌悪いねん" reload: "リロード" doNothing: "何もせんとく" @@ -314,10 +317,10 @@ unwatch: "ウォッチやめる" accept: "ええで" reject: "あかん" normal: "ええ感じ" -instanceName: "インスタンス名" -instanceDescription: "インスタンスの紹介" -maintainerName: "管理者の名前" -maintainerEmail: "管理者のメールアドレス" +instanceName: "サーバー名" +instanceDescription: "サーバーの紹介" +maintainerName: "管理者はんの名前" +maintainerEmail: "管理者はんのメールアドレス" tosUrl: "利用規約のURL" thisYear: "今年" thisMonth: "今月" @@ -329,23 +332,23 @@ pages: "ページ" integration: "連携" connectService: "つなげるで" disconnectService: "切るで" -enableLocalTimeline: "ローカルタイムラインを使えるようにする" -enableGlobalTimeline: "グローバルタイムラインを使えるようにする" +enableLocalTimeline: "ローカルタイムラインを使えるようにするわ" +enableGlobalTimeline: "グローバルタイムラインを使えるようにするわ" disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。" registration: "登録" enableRegistration: "一見さんでも誰でもいらっしゃ~い" invite: "来てや" -driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" -driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量" +driveCapacityPerLocalAccount: "ローカルユーザーはんひとりあたりのドライブ容量" +driveCapacityPerRemoteAccount: "リモートユーザーはんひとりあたりのドライブ容量" inMb: "メガバイト単位" iconUrl: "アイコン画像のURL" bannerUrl: "バナー画像のURL" backgroundImageUrl: "背景画像のURL" basicInfo: "基本情報" pinnedUsers: "ピン留めしたユーザー" -pinnedUsersDescription: "「みつける」ページとかにピン留めしたいユーザーをここに書けばええんやで。他ん人との名前は改行で区切ればええんやで。" +pinnedUsersDescription: "「みつける」ページとかにピン留めしたいユーザーをここに書けばええんやで。ユーザー毎に改行してや。" pinnedPages: "ピン留めページ" -pinnedPagesDescription: "インスタンスのいっちゃん上にピン留めしたいページのパスを改行で区切って記述してな" +pinnedPagesDescription: "サーバーのいっちゃん上にピン留めしたいページのパスを改行で区切って記述してな" pinnedClipId: "ピン留めするクリップのID" pinnedNotes: "ピン留めされとるノート" hcaptcha: "hCaptcha(キャプチャ)" @@ -370,7 +373,7 @@ antennaExcludeKeywords: "除外キーワード" antennaKeywordsDescription: "スペースで区切ったるとAND指定で、改行で区切ったるとOR指定や" notifyAntenna: "新しいノートを通知すんで" withFileAntenna: "なんか添付されたノートだけ" -enableServiceworker: "ServiceWorkerをつこて" +enableServiceworker: "ブラウザにプッシュ通知が行くようにする" antennaUsersDescription: "ユーザー名を改行で区切ったってな" caseSensitive: "大文字と小文字は別もんや" withReplies: "返信も入れたって" @@ -395,23 +398,23 @@ administrator: "管理者" token: "トークン" 2fa: "二要素認証" totp: "認証アプリ" -totpDescription: "認証アプリ使てワンタイムパスワードを入れる" +totpDescription: "認証アプリ使うてワンタイムパスワードを入れる" moderator: "モデレーター" moderation: "モデレーション" nUsersMentioned: "{n}人が投稿" securityKeyAndPasskey: "セキュリティキー・パスキー" securityKey: "セキュリティキー" lastUsed: "最後につこうた日" -lastUsedAt: "最後に使たん: {t}" +lastUsedAt: "最後に使うたんは: {t}" unregister: "登録やめる" passwordLessLogin: "パスワード無くてもログインできるようにする" -passwordLessLoginDescription: "パスワードやなくて、セキュリティキーとかパスキーだけでログインするわ" +passwordLessLoginDescription: "パスワードなんかいらん、セキュリティキーとかパスキーだけでログインするわ" resetPassword: "パスワードをリセット" newPasswordIs: "今度のパスワードは「{password}」や" -reduceUiAnimation: "UIの動きやアニメーションを減らす" +reduceUiAnimation: "UIの動きやアニメーションを少なする" share: "わけわけ" notFound: "見つからへんね" -notFoundDescription: "指定されたURLに該当するページはあらへんやった。" +notFoundDescription: "言われたURLにはまるページはなかったで。" uploadFolder: "とりあえずアップロードしたやつ置いとく所" cacheClear: "キャッシュをほかす" markAsReadAllNotifications: "通知はもう全て読んだわっ" @@ -419,37 +422,37 @@ markAsReadAllUnreadNotes: "投稿は全て読んだわっ" markAsReadAllTalkMessages: "チャットはもうぜんぶ読んだわっ" help: "ヘルプ" inputMessageHere: "ここにメッセージ書いてや" -close: "閉じる" +close: "さいなら" invites: "来てや" -members: "メンバー" +members: "メンバーはん" transfer: "譲渡" title: "タイトル" text: "テキスト" enable: "有効にするで" next: "次" retype: "もっかい入力" -noteOf: "{user}のノート" +noteOf: "{user}はんのノート" quoteAttached: "引用付いとるで" quoteQuestion: "引用として添付してもええか?" noMessagesYet: "まだチャットはあらへんで" newMessageExists: "新しいメッセージがきたで" -onlyOneFileCanBeAttached: "すまん、メッセージに添付できるファイルはひとつだけなんや。" +onlyOneFileCanBeAttached: "ごめんな、メッセージに添付できるファイルはひとつだけなんよ。" signinRequired: "ログインしてくれへん?" invitations: "来てや" invitationCode: "招待コード" checking: "確認しとるで" -available: "利用できる\n" +available: "使えるで" unavailable: "利用できん" usernameInvalidFormat: "a~z、A~Z、0~9、_が使えるで" tooShort: "短すぎやろ!" tooLong: "長すぎやろ!" weakPassword: "へぼいパスワード" -normalPassword: "普通のパスワード" +normalPassword: "ぼちぼちのパスワード" strongPassword: "ええ感じのパスワード" passwordMatched: "よし!一致や!" passwordNotMatched: "一致しとらんで?" signinWith: "{x}でログイン" -signinFailed: "ログインできんかったで。もっかいユーザー名とパスワードを確認してみてな。" +signinFailed: "ログインできんかったで。もっかいユーザー名とパスワードを確認してみてや。" or: "それか" language: "言語" uiLanguage: "UIの表示言語" @@ -458,7 +461,7 @@ emojiStyle: "絵文字のスタイル" native: "ネイティブ" disableDrawer: "メニューをドロワーで表示せぇへん" showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで" -noHistory: "履歴はあらへんねぇ。" +noHistory: "履歴はないわ。" signinHistory: "ログイン履歴" enableAdvancedMfm: "ややこしいMFMもありにする" enableAnimatedMfm: "動きがやかましいMFMも許したる" @@ -466,12 +469,12 @@ doing: "やっとるがな" category: "カテゴリ" tags: "タグ" docSource: "このドキュメントのソース" -createAccount: "アカウントを作成" -existingAccount: "既存のアカウント" -regenerate: "再生成" -fontSize: "フォントサイズ" +createAccount: "アカウントを作るで" +existingAccount: "前に作ったアカウント" +regenerate: "もっぺん生成するで" +fontSize: "字の大きさ" noFollowRequests: "フォロー申請はあらへんで" -openImageInNewTab: "画像を新しいタブで開く" +openImageInNewTab: "画像を新しいタブで開くで" dashboard: "ダッシュボード" local: "ローカル" remote: "リモート" @@ -504,7 +507,7 @@ objectStorageUseProxy: "Proxyを使う" objectStorageUseProxyDesc: "API接続にproxy使わんのやったら切ってくれへん?" objectStorageSetPublicRead: "アップロードした時に'public-read'を設定してや" serverLogs: "サーバーログ" -deleteAll: "全て削除してや" +deleteAll: "全部ほかす" showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" showFixedPostFormInChannel: "タイムラインの上の方で投稿できるようにするわ(チャンネル)" newNoteRecived: "新しいノートがあるで" @@ -514,11 +517,11 @@ listen: "聴く" none: "なし" showInPage: "ページで表示" popout: "ポップアウト" -volume: "音量" -masterVolume: "全体の音量" +volume: "やかましさ" +masterVolume: "全体のやかましさ" details: "もっと" chooseEmoji: "絵文字を選ぶ" -unableToProcess: "なんか作業が止まってしまったようやね" +unableToProcess: "なんか奥の方で詰まってもうた" recentUsed: "最近使ったやつ" install: "インストール" uninstall: "アンインストール" @@ -536,14 +539,18 @@ output: "出力" script: "スクリプト" disablePagesScript: "Pagesのスクリプトを無効にしてや" updateRemoteUser: "リモートユーザー情報の更新してくれん?" -deleteAllFiles: "すべてのファイルを削除" -deleteAllFilesConfirm: "ホンマにすべてのファイルを削除するん?消したもんはもう戻ってこんのやで?" +deleteAllFiles: "ファイルを全部ほかす" +deleteAllFilesConfirm: "ホンマにファイル全部ほかすんか?消したもんはもう戻ってこんのやで?" removeAllFollowing: "フォローを全解除" removeAllFollowingDescription: "{host}からのフォローをすべて解除するで。そのインスタンスが消えて無くなった時とかには便利な機能やで。" userSuspended: "このユーザーは...凍結されとる。" userSilenced: "このユーザーは...サイレンスされとる。" yourAccountSuspendedTitle: "あんたのアカウント凍結されとるで" yourAccountSuspendedDescription: "あんたのアカウントは、サーバーの利用規約に違反したとかの理由で、凍結されとるで。細かいことは管理者までお問い合わせたってなー。絶対に新しいアカウント作ったらあかんで。絶対やで。" +tokenRevoked: "トークンが無効やで" +tokenRevokedDescription: "ログイントークンが失効しとるで。もっかいログインしてもろてもええか?" +accountDeleted: "アカウントは削除されとるで" +accountDeletedDescription: "このアカウントは削除されとるで。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" @@ -566,7 +573,7 @@ description: "説明" describeFile: "キャプションを付ける" enterFileDescription: "キャプションを入力" author: "作者" -leaveConfirm: "未保存の変更があるで!ほかしてええか?" +leaveConfirm: "あんた、いじったのにまだ保存してないで!ほかしてええか?" manage: "管理" plugins: "プラグイン" preferencesBackups: "設定のバックアップ" @@ -600,12 +607,12 @@ smtpUser: "ユーザー名" smtpPass: "パスワード" emptyToDisableSmtpAuth: "ユーザー名とパスワードになんも入れんかったら、SMTP認証を無効化するで" smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" -smtpSecureInfo: "STARTTLS使っとる時はオフにするで。" +smtpSecureInfo: "STARTTLS使っとる時はオフにしてや。" testEmail: "配信テスト" wordMute: "ワードミュート" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが出てきたで:" -instanceMute: "インスタンスミュート" +instanceMute: "サーバーミュート" userSaysSomething: "{name}が何か言うとるわ" makeActive: "使うで" display: "表示" @@ -624,7 +631,7 @@ useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使 other: "その他" regenerateLoginToken: "ログイントークンを再生成" regenerateLoginTokenDescription: "ログインに使われる内部トークンをもっかい作るで。いつもならこれをやる必要はないで。もっかい作ると、全部のデバイスでログアウトされるで気ぃつけてなー。" -setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できるで。" +setMultipleBySeparatingWithSpace: "スペースで区切って何個でも設定できるで。" fileIdOrUrl: "ファイルIDかURL" behavior: "動作" sample: "サンプル" @@ -636,7 +643,7 @@ abuseReported: "無事内容が送信されたみたいやで。おおきに〜 reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートインスタンスに通報を転送するで" +forwardReport: "リモートサーバーに通報を転送するで" forwardReportIsAnonymous: "リモートインスタンスからはあんたの情報は見れへんくって、匿名のシステムアカウントとして表示されるで。" send: "送信" abuseMarkAsResolved: "対応したで" @@ -644,7 +651,7 @@ openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" editTheseSettingsMayBreakAccount: "このへんの設定をようわからんままイジるとアカウントが壊れて使えんくなるかも知れへんで?" -instanceTicker: "ノートのインスタンス情報" +instanceTicker: "ノートのサーバー情報" waitingFor: "{x}を待っとるで" random: "ランダム" system: "システム" @@ -655,7 +662,7 @@ createNew: "新しく作るで" optional: "任意" createNewClip: "新しいクリップを作るで" unclip: "クリップ解除するで" -confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外したる?" +confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外しよか?" public: "パブリック" i18nInfo: "Misskeyは有志によっていろんな言語に翻訳されとるで。{link}で翻訳に協力したってやー。" manageAccessTokens: "アクセストークンの管理" @@ -672,15 +679,15 @@ receivedReactionsCount: "リアクションされた数" pollVotesCount: "アンケートに投票した数" pollVotedCount: "アンケートに投票された数" yes: "ええで" -no: "あかんで" +no: "あかん" driveFilesCount: "ドライブのファイル数" driveUsage: "ドライブ使用量やで" noCrawle: "クローラーによるインデックスを拒否するで" -noCrawleDescription: "検索エンジンにあんたのユーザーページ、ノート、Pagesとかのコンテンツを登録(インデックス)せぇへんように頼むで。" +noCrawleDescription: "検索エンジンにあんたのユーザーページ、ノート、Pagesとかのコンテンツを登録(インデックス)せんように頼むで。邪魔すんねんやったら帰って〜。" lockedAccountInfo: "フォローを承認制にしとっても、ノートの公開範囲を「フォロワー」にせぇへん限り、誰でもあんたのノートを見れるで。" alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にするで" loadRawImages: "添付画像のサムネイルをオリジナル画質にするで" -disableShowingAnimatedImages: "アニメーション画像を再生しやへんで" +disableShowingAnimatedImages: "アニメーション画像を再生せんとくで" verificationEmailSent: "無事確認のメールを送れたで。メールに書いてあるリンクにアクセスして、設定を完了してなー。" notSet: "未設定" emailVerified: "メールアドレスは確認されたで" @@ -690,14 +697,14 @@ pageLikedCount: "Pageにええやんと思ってくれた数" contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使うで" clips: "クリップ" -experimentalFeatures: "実験的機能やで" +experimentalFeatures: "おためし機能やで" developer: "開発者やで" makeExplorable: "アカウントを見つけやすくするで" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。" showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示するで" duplicate: "複製" left: "左" -center: "中央" +center: "真ん中" wide: "広い" narrow: "狭い" reloadToApplySetting: "設定はページリロード後に反映されるで。今リロードしとくか?" @@ -708,7 +715,7 @@ onlineUsersCount: "{n}人が起きとるで" nUsers: "{n}ユーザー" nNotes: "{n}ノート" sendErrorReports: "エラーリポートを送る" -sendErrorReportsDescription: "オンにしたら、なんか変なことが起きたときにエラーの詳細がMisskeyに共有されて、ソフトウェアの品質向上に役立てられるんや。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれるで。" +sendErrorReportsDescription: "オンにしたら、変なことが起きたときにエラーの詳細がMisskeyに送られて、ソフトウェアの品質向上に使えるようになるで。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴なんかが含まれるで。" myTheme: "マイテーマ" backgroundColor: "背景" accentColor: "アクセント" @@ -877,7 +884,7 @@ isSystemAccount: "システムが自動で作成・管理しとるアカウン typeToConfirm: "この操作をやるんなら {x} と入力してなー" deleteAccount: "アカウント削除するで" document: "ドキュメント" -numberOfPageCache: "ページキャッシュ数やで" +numberOfPageCache: "ページ、どんだけキャッシュすんの?" numberOfPageCacheDescription: "増やすと使いやすくなる、負荷とメモリ使用量が増えてくで。一長一短やな。" logoutConfirm: "ログアウトしまっか?" lastActiveDate: "最後に使った日時" @@ -947,7 +954,7 @@ thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。" thisPostMayBeAnnoyingHome: "ホームに投稿" thisPostMayBeAnnoyingCancel: "やめとく" thisPostMayBeAnnoyingIgnore: "このまま投稿" -collapseRenotes: "見たことあるRenoteは省略やで" +collapseRenotes: "見たことあるRenoteは飛ばして表示するで" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ" copyErrorInfo: "エラー情報をコピー" @@ -959,6 +966,17 @@ invitationRequiredToRegister: "今このサーバー招待制になってもう emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ" postToTheChannel: "チャンネルに投稿" cannotBeChangedLater: "後からは変えられへんで。" +reactionAcceptance: "リアクションの受け入れ" +likeOnly: "いいねだけ" +likeOnlyForRemote: "リモートからはいいねだけな" +rolesAssignedToMe: "自分に割り当てられたロール" +resetPasswordConfirm: "パスワード作り直すんでええな?" +sensitiveWords: "けったいな単語" +sensitiveWordsDescription: "設定した単語が入っとるノートの公開範囲をホームにしたるわ。改行で区切ったら複数設定できるで。" +notesSearchNotAvailable: "ノート検索は使われへんで。" +license: "ライセンス" +unfavoriteConfirm: "ほんまに気に入らんの?" +myClips: "自分のクリップ" _achievements: earnedAt: "貰った日ぃ" _types: @@ -976,7 +994,7 @@ _achievements: title: "ノートの生駒山" description: "ノートを500回投稿した" _notes1000: - title: "ノートの山" + title: "ノートの六甲山" description: "ノートを1,000回投稿した" _notes5000: title: "箕面の滝からノート" @@ -1218,6 +1236,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして見せる" descriptionOfAsBadge: "オンにすると、ユーザー名の横んとこにロールのアイコンが表示されるで。" + displayOrder: "表示順" + descriptionOfDisplayOrder: "数がでかいほど、UI上で先に表示されるで。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになるで。オフにすると管理者のみが行えるで。" priority: "優先度" @@ -1243,6 +1263,7 @@ _role: rateLimitFactor: "レートリミット" descriptionOfRateLimitFactor: "ちっちゃいほど制限が緩くなって、大きいほど制限されるで。" canHideAds: "広告を表示させへん" + canSearchNotes: "ノート検索を使わすかどうか" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1377,7 +1398,7 @@ _wordMute: mutedNotes: "ミュートされたノート" _instanceMute: instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。" - instanceMuteDescription2: "改行で区切って設定するで" + instanceMuteDescription2: "改行で区切って設定するんやで" title: "設定したインスタンスのノートを隠すで。" heading: "ミュートするインスタンス" _theme: @@ -1464,7 +1485,7 @@ _sfx: channel: "チャンネル通知" _ago: future: "未来" - justNow: "たった今" + justNow: "ついさっき" secondsAgo: "{n}秒前" minutesAgo: "{n}分前" hoursAgo: "{n}時間前" @@ -1587,7 +1608,7 @@ _weekday: saturday: "土曜日" _widgets: profile: "プロフィール" - instanceInfo: "インスタンス情報" + instanceInfo: "サーバー情報" memo: "付箋" notifications: "通知" timeline: "タイムライン" @@ -1690,7 +1711,7 @@ _charts: apRequest: "リクエスト" usersIncDec: "ユーザーの増減" usersTotal: "ユーザーの合計" - activeUsers: "アクティブユーザー数" + activeUsers: "いまおるユーザー数" notesIncDec: "ノートの増減" localNotesIncDec: "ローカルのノートの増減" remoteNotesIncDec: "リモートのノートの増減" @@ -1844,3 +1865,6 @@ _deck: _dialog: charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" +_disabledTimeline: + title: "使われへんタイムライン" + description: "あんたの今のロールやったら、このタイムラインは使われへんで。" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 9115afe5a..e52d619f8 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "열람주의 해제" enterFileName: "파일명을 입력" mute: "뮤트" unmute: "뮤트 해제" +renoteMute: "리노트를 뮤트" +renoteUnmute: "리노트 뮤트 해제" block: "차단" unblock: "차단 해제" suspend: "정지" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기" flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시합니다." autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" addAccount: "계정 추가" +reloadAccountsList: "계정 리스트 정보 갱신" loginFailed: "로그인에 실패했습니다" showOnRemote: "리모트에서 보기" general: "일반" @@ -506,6 +509,7 @@ objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" serverLogs: "서버 로그" deleteAll: "모두 삭제" showFixedPostForm: "타임라인 상단에 글 작성란을 표시" +showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란을 표시" newNoteRecived: "새 노트가 있습니다" sounds: "소리" sound: "소리" @@ -543,6 +547,8 @@ userSuspended: "이 계정은 정지된 상태입니다." userSilenced: "이 계정은 사일런스된 상태입니다." yourAccountSuspendedTitle: "계정이 정지되었습니다" yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반하거나, 기타 다른 이유로 인해 정지되었습니다. 자세한 사항은 관리자에게 문의해 주십시오. 계정을 새로 생성하지 마십시오." +accountDeleted: "계정이 정지되었습니다" +accountDeletedDescription: "이 계정이 삭제되었습니다." menu: "메뉴" divider: "구분선" addItem: "항목 추가" @@ -955,6 +961,9 @@ exploreOtherServers: "다른 서버 둘러보기" letsLookAtTimeline: "타임라인 구경하기" disableFederationWarn: "연합이 비활성화됩니다. 비활성화해도 게시물이 비공개가 되지는 않습니다. 대부분의 경우 이 옵션을 활성화할 필요가 없습니다." invitationRequiredToRegister: "현재 이 서버는 비공개입니다. 회원가입을 하시려면 초대 코드가 필요합니다." +emailNotSupported: "이 서버에서는 메일 전송을 지원하지 않습니다" +postToTheChannel: "채널에 게시하기" +cannotBeChangedLater: "나중에 변경할 수 없습니다." _achievements: earnedAt: "달성 일시" _types: diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 7798582db..73b36f8ec 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -2,7 +2,7 @@ _lang_: "中文(简体)" headlineMisskey: "通过帖子连接在一起的网络" introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n来探索新的世界吧!🚀" -poweredByMisskeyDescription: "{name} 由开源平台 Misskey 驱动(也被称为 Misskey 服务器)" +poweredByMisskeyDescription: "{name} 是开源平台 Misskey 的服务器之一。" monthAndDay: "{month}月 {day}日" search: "搜索" notifications: "通知" @@ -122,6 +122,8 @@ unmarkAsSensitive: "取消标记为敏感内容" enterFileName: "请输入文件名" mute: "屏蔽" unmute: "解除屏蔽" +renoteMute: "屏蔽转帖" +renoteUnmute: "解除屏蔽转帖" block: "拉黑" unblock: "取消拉黑" suspend: "冻结" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "在时间线上显示帖子的回复" flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的帖子外,还会显示其他用户对帖子的回复。" autoAcceptFollowed: "自动允许关注者的关注" addAccount: "添加账户" +reloadAccountsList: "更新账户列表" loginFailed: "登录失败" showOnRemote: "转到所在服务器显示" general: "常规设置" @@ -355,7 +358,7 @@ hcaptchaSecretKey: "hCaptcha 密钥(SecretKey)" recaptcha: "reCAPTCHA" enableRecaptcha: "启用 reCAPTCHA\n(请注意, 此功能在中国大陆不可用. 如果启用, 可能导致无法正常使用登录或注册等功能)" recaptchaSiteKey: "网站密钥" -recaptchaSecretKey: "reCAPTCHA 密钥" +recaptchaSecretKey: "reCAPTCHA 密钥(SecretKey)" turnstile: "Turnstile" enableTurnstile: "启用Turnstile" turnstileSiteKey: "网站密钥" @@ -489,7 +492,7 @@ showFeaturedNotesInTimeline: "在时间线上显示热门推荐" objectStorage: "对象存储" useObjectStorage: "使用对象存储" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "用于引用的URL。如果您正在使用CDN或反向代理,请指定其URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" +objectStorageBaseUrlDesc: "这里是用于参考的URL,如果您正在使用CDN或反向代理,请指定其URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" objectStorageBucket: "存储桶" objectStorageBucketDesc: "请指定使用的对象存储服务的存储桶名称。" objectStoragePrefix: "前缀" @@ -544,6 +547,10 @@ userSuspended: "该用户已被冻结。" userSilenced: "该用户已被禁言。" yourAccountSuspendedTitle: "账户已被冻结" yourAccountSuspendedDescription: "由于违反了服务器的服务条款或其他原因,该账户已被冻结。 您可以与管理员联系以了解更多信息。 请不要创建一个新的账户。" +tokenRevoked: "令牌无效" +tokenRevokedDescription: "登录令牌已经失效。请重新登录。" +accountDeleted: "帐户已删除" +accountDeletedDescription: "此帐户已经被删除。" menu: "菜单" divider: "分割线" addItem: "添加项目" @@ -959,6 +966,17 @@ invitationRequiredToRegister: "此服务器目前只允许拥有邀请码的人 emailNotSupported: "此服务器不支持发送邮件" postToTheChannel: "发布到频道" cannotBeChangedLater: "之后不能再更改。" +reactionAcceptance: "接受表情回应" +likeOnly: "仅点赞" +likeOnlyForRemote: "远程仅点赞" +rolesAssignedToMe: "指派给自己的角色" +resetPasswordConfirm: "确定重置密码?" +sensitiveWords: "敏感词" +sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。" +notesSearchNotAvailable: "帖子检索不可用" +license: "许可信息" +unfavoriteConfirm: "确定要取消收藏吗?" +myClips: "我的便签" _achievements: earnedAt: "达成时间" _types: @@ -1218,6 +1236,8 @@ _role: iconUrl: "图标URL" asBadge: "作为徽章显示" descriptionOfAsBadge: "开启后,用户名旁边将会出现角色图标。" + displayOrder: "显示顺序" + descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" canEditMembersByModerator: "允许监察者编辑成员" descriptionOfCanEditMembersByModerator: "如果选中,监察者和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" @@ -1243,6 +1263,7 @@ _role: rateLimitFactor: "速率限制" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" canHideAds: "可以隐藏广告" + canSearchNotes: "是否可以搜索帖子" _condition: isLocal: "是本地用户" isRemote: "是远程用户" @@ -1517,7 +1538,7 @@ _2fa: step4: "从现在开始,任何登录操作都将要求您提供动态口令。" securityKeyNotSupported: "您的浏览器不支持安全密钥。" registerTOTPBeforeKey: "要注册安全密钥或Passkey,请先设置验证器应用程序。" - securityKeyInfo: "您可以设置使用支持FIDO2的硬件安全密钥、设备上的指纹或PIN来保护您的登录过程。" + securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 码以及 Passkey 等。" chromePasskeyNotSupported: "目前不支持 Chrome 的Passkey。" registerSecurityKey: "注册安全密钥或Passkey" securityKeyName: "输入密钥名称" @@ -1844,3 +1865,6 @@ _deck: _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" +_disabledTimeline: + title: "时间线已禁用" + description: "您不能在当前角色使用时间线。" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 5c1512bd4..6109bdbee 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -122,14 +122,16 @@ unmarkAsSensitive: "取消標記為敏感內容" enterFileName: "請輸入檔案名稱" mute: "靜音" unmute: "解除靜音" +renoteMute: "將轉發貼文靜音" +renoteUnmute: "解除轉發貼文的靜音" block: "封鎖" unblock: "解除封鎖" suspend: "凍結" unsuspend: "解除凍結" blockConfirm: "確定要封鎖此用戶?" unblockConfirm: "確定解除封鎖此用戶?" -suspendConfirm: "確定凍結此帳號?" -unsuspendConfirm: "確定解凍此帳號?" +suspendConfirm: "確定凍結此帳戶?" +unsuspendConfirm: "確定解凍此帳戶?" selectList: "選擇清單" selectChannel: "選擇頻道" selectAntenna: "選擇天線" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" flagShowTimelineRepliesDescription: "啟用時,時間線除了顯示用戶的貼文以外,還會顯示用戶對其他貼文的回覆。" autoAcceptFollowed: "自動追隨中使用者的追隨請求" addAccount: "添加帳戶" +reloadAccountsList: "更新帳戶清單的資訊" loginFailed: "登入失敗" showOnRemote: "轉到所在實例顯示" general: "一般" @@ -169,7 +172,7 @@ selectUser: "選取使用者" recipient: "收件人" annotation: "註解" federation: "站台聯邦" -instances: "實例" +instances: "伺服器" registeredAt: "初次觀測" latestRequestReceivedAt: "上次收到的請求" latestStatus: "最後狀態" @@ -403,7 +406,7 @@ securityKeyAndPasskey: "安全金鑰・Passkey" securityKey: "安全金鑰" lastUsed: "上次使用" lastUsedAt: "最後使用:{t}" -unregister: "註銷帳號" +unregister: "註銷帳戶" passwordLessLogin: "設置無密碼登入" passwordLessLoginDescription: "不使用密碼,以安全金鑰或 Passkey 登入" resetPassword: "重置密碼" @@ -544,6 +547,10 @@ userSuspended: "該使用者已被停用" userSilenced: "該用戶已被禁言。" yourAccountSuspendedTitle: "帳戶已被凍結" yourAccountSuspendedDescription: "由於違反了伺服器的服務條款或其他原因,該帳戶已被凍結。 您可以與管理員連繫以了解更多訊息。 請不要創建一個新的帳戶。" +tokenRevoked: "權杖無效" +tokenRevokedDescription: "登入權杖失效,請重新登入。" +accountDeleted: "帳戶已被刪除" +accountDeletedDescription: "這個帳戶已被刪除。" menu: "選單" divider: "分割線" addItem: "新增項目" @@ -872,10 +879,10 @@ recommended: "推薦" check: "檢查" driveCapOverrideLabel: "更改這個使用者的雲端硬碟容量上限" driveCapOverrideCaption: "如果指定0以下的值,就會被取消。" -requireAdminForView: "必須以管理員帳號登入才可以檢視。" -isSystemAccount: "由系統自動建立與管理的帳號。" +requireAdminForView: "必須以管理員帳戶登入才可以檢視。" +isSystemAccount: "由系統自動建立與管理的帳戶。" typeToConfirm: "要執行這項操作,請輸入 {x} " -deleteAccount: "刪除帳號" +deleteAccount: "刪除帳戶" document: "文件" numberOfPageCache: "快取頁面數" numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" @@ -915,7 +922,7 @@ sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通 windowMaximize: "最大化" windowRestore: "復原" caption: "標題" -loggedInAsBot: "以機器人帳號登入中" +loggedInAsBot: "以機器人帳戶登入中" tools: "工具" cannotLoad: "無法載入" numberOfProfileView: "個人檔案檢視次數" @@ -958,6 +965,14 @@ disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼 invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" emailNotSupported: "這個伺服器不支援寄送郵件" postToTheChannel: "發布到頻道" +cannotBeChangedLater: "之後不能變更。" +reactionAcceptance: "接受表情反應" +likeOnly: "僅限讚" +likeOnlyForRemote: "遠端僅限讚" +rolesAssignedToMe: "指派給自己的角色" +resetPasswordConfirm: "重設密碼?" +sensitiveWords: "敏感詞" +sensitiveWordsDescription: "將含有設定詞彙的貼文可見性設為發送至首頁。可以用換行來進行複數的設定。" _achievements: earnedAt: "獲得日期" _types: @@ -1217,6 +1232,8 @@ _role: iconUrl: "圖示的URL" asBadge: "顯示為徽章" descriptionOfAsBadge: "開啟的話,角色圖示會顯示在用戶名旁邊。" + displayOrder: "顯示順序" + descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" canEditMembersByModerator: "允許編輯審查員的成員" descriptionOfCanEditMembersByModerator: "如果開啟,管理員與審查員都可以為使用者指派/解除指派該角色。如果關閉,則只有管理員可以執行。" priority: "優先級" @@ -1242,6 +1259,7 @@ _role: rateLimitFactor: "速率限制" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" canHideAds: "不顯示廣告" + canSearchNotes: "可否搜尋貼文" _condition: isLocal: "本地使用者" isRemote: "遠端使用者" @@ -1713,7 +1731,7 @@ _instanceCharts: _timelines: home: "首頁" local: "本地" - social: "社群" + social: "社交" global: "公開" _play: new: "新增Play" @@ -1843,3 +1861,6 @@ _deck: _dialog: charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" +_disabledTimeline: + title: "停用的時間軸" + description: "目前的角色無法使用這個時間軸。" diff --git a/package.json b/package.json index d3e34e782..f68608911 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "13.9.2", + "version": "13.10.0", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@7.27.0", + "packageManager": "pnpm@7.29.3", "workspaces": [ "packages/frontend", "packages/backend", @@ -31,8 +31,8 @@ "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "jest": "cd packages/backend && pnpm jest", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", - "test": "pnpm jest", - "test-and-coverage": "pnpm jest-and-coverage", + "test": "pnpm -r test", + "test-and-coverage": "pnpm -r test-and-coverage", "format": "pnpm exec gulp format", "clean": "node ./scripts/clean.js", "clean-all": "node ./scripts/clean-all.js", @@ -55,12 +55,12 @@ "devDependencies": { "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", - "@typescript-eslint/eslint-plugin": "5.53.0", - "@typescript-eslint/parser": "5.53.0", + "@typescript-eslint/eslint-plugin": "5.54.1", + "@typescript-eslint/parser": "5.54.1", "cross-env": "7.0.3", "cypress": "12.7.0", "eslint": "8.35.0", - "start-server-and-test": "1.15.4" + "start-server-and-test": "2.0.0" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.2.0" diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html index 9ee5a95c0..a9ebf662f 100644 --- a/packages/backend/assets/redoc.html +++ b/packages/backend/assets/redoc.html @@ -19,6 +19,6 @@ - + diff --git a/packages/backend/migration/1665091090561-add-renote-muting.js b/packages/backend/migration/1665091090561-add-renote-muting.js new file mode 100644 index 000000000..d2ed2bd2e --- /dev/null +++ b/packages/backend/migration/1665091090561-add-renote-muting.js @@ -0,0 +1,16 @@ + +export class addRenoteMuting1665091090561 { + constructor() { + this.name = 'addRenoteMuting1665091090561'; + } + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/migration/1675053125067-fixforeignkeyreports.js b/packages/backend/migration/1675053125067-fixforeignkeyreports.js new file mode 100644 index 000000000..ca5c10b11 --- /dev/null +++ b/packages/backend/migration/1675053125067-fixforeignkeyreports.js @@ -0,0 +1,15 @@ +export class fixforeignkeyreports1675053125067 { + name = 'fixforeignkeyreports1675053125067' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId")`); + await queryRunner.query(`DELETE FROM "abuse_user_report" WHERE "targetUserId" NOT IN (SELECT "id" FROM "user")`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT IF EXISTS "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_a9021cc2e1feb5f72d3db6e9f5"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + } +} diff --git a/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js new file mode 100644 index 000000000..f1765dd14 --- /dev/null +++ b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js @@ -0,0 +1,11 @@ +export class perNoteReactionAcceptance1678164627293 { + name = 'perNoteReactionAcceptance1678164627293' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "reactionAcceptance" character varying(64)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "reactionAcceptance"`); + } +} diff --git a/packages/backend/migration/1678426061773-tweak-varchar-length.js b/packages/backend/migration/1678426061773-tweak-varchar-length.js new file mode 100644 index 000000000..984c41dba --- /dev/null +++ b/packages/backend/migration/1678426061773-tweak-varchar-length.js @@ -0,0 +1,68 @@ +export class tweakVarcharLength1678426061773 { + name = 'tweakVarcharLength1678426061773' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "name" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "maintainerName" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "maintainerEmail" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "langs" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "pinnedUsers" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hiddenTags" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "blockedHosts" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "themeColor" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "mascotImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "bannerUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "backgroundImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "logoImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "errorImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "iconUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hcaptchaSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hcaptchaSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "recaptchaSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "recaptchaSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "turnstileSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "turnstileSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "summalyProxy" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "email" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpHost" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPublicKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPrivateKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "deeplAuthKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" RENAME COLUMN "ToSUrl" TO "termsOfServiceUrl"`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "termsOfServiceUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "feedbackUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageBucket" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStoragePrefix" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageBaseUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageEndpoint" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageRegion" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageAccessKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(65536)`, undefined); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___readWrite" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___read" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___write" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___readWrite" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___read" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___write" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideYear" TYPE integer`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" RENAME COLUMN "termsOfServiceUrl" TO "ToSUrl"`); + } +} diff --git a/packages/backend/migration/1678427401214-remove-unused.js b/packages/backend/migration/1678427401214-remove-unused.js new file mode 100644 index 000000000..ee643e777 --- /dev/null +++ b/packages/backend/migration/1678427401214-remove-unused.js @@ -0,0 +1,13 @@ +export class removeUnused1678427401214 { + name = 'removeUnused1678427401214' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedPages"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedClipId"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedClipId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{/featured,/channels,/explore,/pages,/about-misskey}'`); + } +} diff --git a/packages/backend/migration/1678602320354-role-display-order.js b/packages/backend/migration/1678602320354-role-display-order.js new file mode 100644 index 000000000..de8f6f103 --- /dev/null +++ b/packages/backend/migration/1678602320354-role-display-order.js @@ -0,0 +1,11 @@ +export class roleDisplayOrder1678602320354 { + name = 'roleDisplayOrder1678602320354' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "displayOrder" integer NOT NULL DEFAULT '0'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "displayOrder"`); + } +} diff --git a/packages/backend/migration/1678694614599-sensitive-words.js b/packages/backend/migration/1678694614599-sensitive-words.js new file mode 100644 index 000000000..6d4c5730c --- /dev/null +++ b/packages/backend/migration/1678694614599-sensitive-words.js @@ -0,0 +1,11 @@ +export class sensitiveWords1678694614599 { + name = 'sensitiveWords1678694614599' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveWords" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveWords"`); + } +} diff --git a/packages/backend/migration/1678869617549-retention-date-key.js b/packages/backend/migration/1678869617549-retention-date-key.js new file mode 100644 index 000000000..1a31b9a75 --- /dev/null +++ b/packages/backend/migration/1678869617549-retention-date-key.js @@ -0,0 +1,14 @@ +export class retentionDateKey1678869617549 { + name = 'retentionDateKey1678869617549' + + async up(queryRunner) { + await queryRunner.query(`TRUNCATE TABLE "retention_aggregation"`, undefined); + await queryRunner.query(`ALTER TABLE "retention_aggregation" ADD "dateKey" character varying(512) NOT NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_f7c3576b37bd2eec966ae24477" ON "retention_aggregation" ("dateKey") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_f7c3576b37bd2eec966ae24477"`); + await queryRunner.query(`ALTER TABLE "retention_aggregation" DROP COLUMN "dateKey"`); + } +} diff --git a/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js b/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js new file mode 100644 index 000000000..656a92177 --- /dev/null +++ b/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js @@ -0,0 +1,11 @@ +export class addPropsForCustomEmoji1678945242650 { + name = 'addPropsForCustomEmoji1678945242650' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" ADD "license" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "license"`); + } +} diff --git a/packages/backend/migration/1678953978856-clip-favorite.js b/packages/backend/migration/1678953978856-clip-favorite.js new file mode 100644 index 000000000..aa5dc93a6 --- /dev/null +++ b/packages/backend/migration/1678953978856-clip-favorite.js @@ -0,0 +1,23 @@ +export class clipFavorite1678953978856 { + name = 'clipFavorite1678953978856' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "clip_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "clipId" character varying(32) NOT NULL, CONSTRAINT "PK_1b539f43906f05ebcabe752a977" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_25a31662b0b0cc9af6549a9d71" ON "clip_favorite" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b1754a39d0b281e07ed7c078ec" ON "clip_favorite" ("userId", "clipId") `); + await queryRunner.query(`ALTER TABLE "clip" ADD "lastClippedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`CREATE INDEX "IDX_a3eac04ae2aa9e221e7596114a" ON "clip" ("lastClippedAt") `); + await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_fce61c7986cee54393e79f1d849" FOREIGN KEY ("clipId") REFERENCES "clip"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_fce61c7986cee54393e79f1d849"`); + await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711"`); + await queryRunner.query(`DROP INDEX "public"."IDX_a3eac04ae2aa9e221e7596114a"`); + await queryRunner.query(`ALTER TABLE "clip" DROP COLUMN "lastClippedAt"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1754a39d0b281e07ed7c078ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_25a31662b0b0cc9af6549a9d71"`); + await queryRunner.query(`DROP TABLE "clip_favorite"`); + } +} diff --git a/packages/backend/migration/1679309757174-antenna-active.js b/packages/backend/migration/1679309757174-antenna-active.js new file mode 100644 index 000000000..69e845c14 --- /dev/null +++ b/packages/backend/migration/1679309757174-antenna-active.js @@ -0,0 +1,17 @@ +export class antennaActive1679309757174 { + name = 'antennaActive1679309757174' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT 'now'`); + await queryRunner.query(`ALTER TABLE "antenna" ADD "isActive" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`CREATE INDEX "IDX_084c2abb8948ef59a37dce6ac1" ON "antenna" ("lastUsedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_36ef5192a1ce55ed0e40aa4db5" ON "antenna" ("isActive") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_36ef5192a1ce55ed0e40aa4db5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_084c2abb8948ef59a37dce6ac1"`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "isActive"`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "lastUsedAt"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 35e8dc5c6..5a3dcfb5e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -23,29 +23,29 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "^1.3.11", - "@swc/core-darwin-arm64": "^1.3.36", - "@swc/core-darwin-x64": "^1.3.36", - "@swc/core-linux-arm-gnueabihf": "^1.3.36", - "@swc/core-linux-arm64-gnu": "^1.3.36", - "@swc/core-linux-arm64-musl": "^1.3.36", - "@swc/core-linux-x64-gnu": "^1.3.36", - "@swc/core-linux-x64-musl": "^1.3.36", - "@swc/core-win32-arm64-msvc": "^1.3.36", - "@swc/core-win32-ia32-msvc": "^1.3.36", - "@swc/core-win32-x64-msvc": "^1.3.36", + "@swc/core-darwin-arm64": "^1.3.38", + "@swc/core-darwin-x64": "^1.3.38", + "@swc/core-linux-arm-gnueabihf": "^1.3.38", + "@swc/core-linux-arm64-gnu": "^1.3.38", + "@swc/core-linux-arm64-musl": "^1.3.38", + "@swc/core-linux-x64-gnu": "^1.3.38", + "@swc/core-linux-x64-musl": "^1.3.38", + "@swc/core-win32-arm64-msvc": "^1.3.38", + "@swc/core-win32-ia32-msvc": "^1.3.38", + "@swc/core-win32-x64-msvc": "^1.3.38", "@tensorflow/tfjs": "4.2.0", "@tensorflow/tfjs-node": "4.2.0" }, "dependencies": { - "@bull-board/api": "4.12.1", - "@bull-board/fastify": "4.12.1", - "@bull-board/ui": "4.12.1", + "@bull-board/api": "5.0.0", + "@bull-board/fastify": "5.0.0", + "@bull-board/ui": "5.0.0", "@discordapp/twemoji": "14.0.2", "@fastify/accepts": "4.1.0", "@fastify/cookie": "8.3.0", "@fastify/cors": "8.2.0", "@fastify/http-proxy": "8.4.0", - "@fastify/multipart": "7.4.1", + "@fastify/multipart": "7.4.2", "@fastify/static": "6.9.0", "@fastify/view": "7.4.1", "@nestjs/common": "9.3.9", @@ -54,7 +54,7 @@ "@peertube/http-signature": "1.7.0", "@sinonjs/fake-timers": "10.0.2", "@swc/cli": "0.1.62", - "@swc/core": "1.3.36", + "@swc/core": "1.3.38", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", @@ -74,12 +74,12 @@ "date-fns": "2.29.3", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", - "fastify": "4.13.0", + "fastify": "4.14.1", "feed": "4.2.2", "file-type": "18.2.1", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", - "got": "12.5.3", + "got": "12.6.0", "happy-dom": "8.9.0", "hpagent": "1.2.0", "ioredis": "4.28.5", @@ -102,7 +102,7 @@ "os-utils": "0.0.14", "otpauth": "^9.0.2", "parse5": "7.1.2", - "pg": "8.9.0", + "pg": "8.10.0", "private-ip": "3.0.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", @@ -128,10 +128,10 @@ "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.17.10", + "systeminformation": "5.17.12", "tinycolor2": "1.6.0", "tmp": "0.2.1", - "tsc-alias": "1.8.2", + "tsc-alias": "1.8.3", "tsconfig-paths": "4.1.2", "twemoji-parser": "14.0.0", "typeorm": "0.3.11", @@ -146,7 +146,7 @@ "xev": "3.0.2" }, "devDependencies": { - "@jest/globals": "29.4.3", + "@jest/globals": "29.5.0", "@swc/jest": "0.2.24", "@types/accepts": "1.3.5", "@types/archiver": "5.3.1", @@ -164,7 +164,7 @@ "@types/jsonld": "1.5.8", "@types/jsrsasign": "10.5.5", "@types/mime-types": "2.1.1", - "@types/node": "18.14.1", + "@types/node": "18.15.0", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.7", "@types/oauth": "0.9.1", @@ -176,7 +176,7 @@ "@types/ratelimiter": "3.4.4", "@types/redis": "4.0.11", "@types/rename": "1.0.4", - "@types/sanitize-html": "2.8.0", + "@types/sanitize-html": "2.8.1", "@types/semver": "7.3.13", "@types/sharp": "0.31.1", "@types/sinonjs__fake-timers": "8.1.2", @@ -188,13 +188,13 @@ "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.52.0", - "@typescript-eslint/parser": "5.53.0", + "@typescript-eslint/eslint-plugin": "5.54.1", + "@typescript-eslint/parser": "5.54.1", "cross-env": "7.0.3", "eslint": "8.35.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", - "jest": "29.4.3", - "jest-mock": "29.4.3" + "jest": "29.5.0", + "jest-mock": "29.5.0" } } diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 2ebee0f7e..1ca38d8bb 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -3,7 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const ACHIEVEMENT_TYPES = [ 'notes1', @@ -90,7 +90,7 @@ export class AchievementService { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, ) { } @@ -114,7 +114,7 @@ export class AchievementService { }], }); - this.createNotificationService.createNotification(userId, 'achievementEarned', { + this.notificationService.createNotification(userId, 'achievementEarned', { achievement: type, }); } diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 05930350f..aaa26a832 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -10,7 +10,7 @@ import { isUserRelated } from '@/misc/is-user-related.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { PushNotificationService } from '@/core/PushNotificationService.js'; import * as Acct from '@/misc/acct.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -71,12 +71,14 @@ export class AntennaService implements OnApplicationShutdown { this.antennas.push({ ...body, createdAt: new Date(body.createdAt), + lastUsedAt: new Date(body.lastUsedAt), }); break; case 'antennaUpdated': this.antennas[this.antennas.findIndex(a => a.id === body.id)] = { ...body, createdAt: new Date(body.createdAt), + lastUsedAt: new Date(body.lastUsedAt), }; break; case 'antennaDeleted': @@ -217,7 +219,9 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async getAntennas() { if (!this.antennasFetched) { - this.antennas = await this.antennasRepository.find(); + this.antennas = await this.antennasRepository.findBy({ + isActive: true, + }); this.antennasFetched = true; } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 491d8ab11..d67e80fc1 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -5,7 +5,6 @@ import { AntennaService } from './AntennaService.js'; import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; import { CaptchaService } from './CaptchaService.js'; -import { CreateNotificationService } from './CreateNotificationService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; @@ -82,6 +81,7 @@ import { HashtagEntityService } from './entities/HashtagEntityService.js'; import { InstanceEntityService } from './entities/InstanceEntityService.js'; import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; import { MutingEntityService } from './entities/MutingEntityService.js'; +import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; @@ -125,7 +125,6 @@ const $AntennaService: Provider = { provide: 'AntennaService', useExisting: Ante const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; -const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService }; const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; @@ -203,6 +202,7 @@ const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useEx const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; +const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; @@ -248,7 +248,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AppLockService, AchievementService, CaptchaService, - CreateNotificationService, CreateSystemUserService, CustomEmojiService, DeleteAccountService, @@ -325,6 +324,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -365,7 +365,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AppLockService, $AchievementService, $CaptchaService, - $CreateNotificationService, $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, @@ -442,6 +441,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, @@ -483,7 +483,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AppLockService, AchievementService, CaptchaService, - CreateNotificationService, CreateSystemUserService, CustomEmojiService, DeleteAccountService, @@ -559,6 +558,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -599,7 +599,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AppLockService, $AchievementService, $CaptchaService, - $CreateNotificationService, $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, @@ -675,6 +674,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, @@ -708,4 +708,4 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#endregion ], }) -export class CoreModule {} +export class CoreModule { } diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts deleted file mode 100644 index eba7171fb..000000000 --- a/packages/backend/src/core/CreateNotificationService.ts +++ /dev/null @@ -1,125 +0,0 @@ -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'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; -import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; -import { PushNotificationService } from '@/core/PushNotificationService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class CreateNotificationService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - private notificationEntityService: NotificationEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - private pushNotificationService: PushNotificationService, - ) { - } - - @bindThis - public async createNotification( - notifieeId: User['id'], - type: Notification['type'], - data: Partial, - ): Promise { - 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(), - createdAt: new Date(), - notifieeId: notifieeId, - type: type, - // 相手がこの通知をミュートしているようなら、既読を予めつけておく - isRead: isMuted, - ...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(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, - }); - if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { - 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! })); - }, () => { /* aborted, ignore it */ }); - - return notification; - } - - // TODO - //const locales = await import('../../../../locales/index.js'); - - // TODO: locale ファイルをクライアント用とサーバー用で分けたい - - @bindThis - private async emailNotificationFollow(userId: User['id'], follower: User) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; - const locale = locales[userProfile.lang ?? 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - 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) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; - const locale = locales[userProfile.lang ?? 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - 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/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index a1a257fbd..b404848d7 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -44,6 +44,7 @@ export class CustomEmojiService { category: string | null; aliases: string[]; host: string | null; + license: string | null; }): Promise { const emoji = await this.emojisRepository.insert({ id: this.idService.genId(), @@ -55,10 +56,11 @@ export class CustomEmojiService { originalUrl: data.driveFile.url, publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, type: data.driveFile.webpublicType ?? data.driveFile.type, + license: data.license, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); if (data.host == null) { - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: await this.emojiEntityService.packDetailed(emoji.id), diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index f4a06faeb..f1e93d6dd 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; import sharp from 'sharp'; +import { sharpBmp } from 'sharp-read-bmp'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; @@ -33,8 +34,9 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { FileInfoService } from '@/core/FileInfoService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; -import type S3 from 'aws-sdk/clients/s3.js'; import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import type S3 from 'aws-sdk/clients/s3.js'; type AddFileArgs = { /** User who wish to add file */ @@ -274,8 +276,8 @@ export class DriveService { } } - if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/svg+xml'].includes(type)) { - this.registerLogger.debug('web image and thumbnail not created (not an required file)'); + if (!isMimeImage(type, 'sharp-convertible-image-with-bmp')) { + this.registerLogger.debug('web image and thumbnail not created (cannot convert by sharp)'); return { webpublic: null, thumbnail: null, @@ -284,22 +286,16 @@ export class DriveService { let img: sharp.Sharp | null = null; let satisfyWebpublic: boolean; + let isAnimated: boolean; try { - img = sharp(path); + img = await sharpBmp(path, type); const metadata = await img.metadata(); - const isAnimated = metadata.pages && metadata.pages > 1; - - // skip animated - if (isAnimated) { - return { - webpublic: null, - thumbnail: null, - }; - } + isAnimated = !!(metadata.pages && metadata.pages > 1); satisfyWebpublic = !!( - type !== 'image/svg+xml' && type !== 'image/webp' && type !== 'image/avif' && + type !== 'image/svg+xml' && // security reason + type !== 'image/avif' && // not supported by Mastodon and MS Edge !(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && metadata.width && metadata.width <= 2048 && metadata.height && metadata.height <= 2048 @@ -315,15 +311,13 @@ export class DriveService { // #region webpublic let webpublic: IImage | null = null; - if (generateWeb && !satisfyWebpublic) { + if (generateWeb && !satisfyWebpublic && !isAnimated) { this.registerLogger.info('creating web image'); try { if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048); - } else if (['image/png'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); - } else if (['image/svg+xml'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048); + } else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) { webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); } else { this.registerLogger.debug('web image not created (not an required image)'); @@ -333,6 +327,7 @@ export class DriveService { } } else { if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)'); + else if (isAnimated) this.registerLogger.info('web image not created (animated image)'); else this.registerLogger.info('web image not created (from remote)'); } // #endregion webpublic @@ -341,10 +336,10 @@ export class DriveService { let thumbnail: IImage | null = null; try { - if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(type)) { - thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280); + if (isAnimated) { + thumbnail = await this.imageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 }); } else { - this.registerLogger.debug('thumbnail not created (not an required file)'); + thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422); } } catch (err) { this.registerLogger.warn('thumbnail not created (an error occured)', err as Error); @@ -476,7 +471,7 @@ export class DriveService { // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 // extを付加してデータベースの文字数制限に当たることはまずない (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', - info.type.ext + info.type.ext, ); if (user && !force) { @@ -728,10 +723,20 @@ export class DriveService { const s3 = this.s3Service.getS3(meta); - await s3.deleteObject({ - Bucket: meta.objectStorageBucket!, - Key: key, - }).promise(); + try { + await s3.deleteObject({ + Bucket: meta.objectStorageBucket!, + Key: key, + }).promise(); + } catch (err: any) { + if (err.code === 'NoSuchKey') { + console.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err); + return; + } + throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { + cause: err, + }); + } } @bindThis @@ -749,7 +754,7 @@ export class DriveService { }: UploadFromUrlArgs): Promise { // Create temp file const [path, cleanup] = await createTemp(); - + try { // write content at URL to temp file const { filename: name } = await this.downloadService.downloadUrl(url, path); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 65a69a023..d261a6c65 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -16,7 +16,7 @@ import type { UserListStreamTypes, UserStreamTypes, } from '@/server/api/stream/types.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts index 7c88f5e9a..3246475d1 100644 --- a/packages/backend/src/core/ImageProcessingService.ts +++ b/packages/backend/src/core/ImageProcessingService.ts @@ -15,15 +15,28 @@ export type IImageStream = { type: string; }; -export type IImageStreamable = IImage | IImageStream; +export type IImageSharp = { + data: sharp.Sharp; + ext: string | null; + type: string; +}; + +export type IImageStreamable = IImage | IImageStream | IImageSharp; export const webpDefault: sharp.WebpOptions = { - quality: 85, + quality: 77, alphaQuality: 95, lossless: false, nearLossless: false, smartSubsample: true, mixed: true, + effort: 2, +}; + +export const avifDefault: sharp.AvifOptions = { + quality: 60, + lossless: false, + effort: 2, }; import { bindThis } from '@/decorators.js'; @@ -37,36 +50,6 @@ export class ImageProcessingService { ) { } - /** - * Convert to JPEG - * with resize, remove metadata, resolve orientation, stop animation - */ - @bindThis - public async convertToJpeg(path: string, width: number, height: number): Promise { - return this.convertSharpToJpeg(await sharp(path), width, height); - } - - @bindThis - public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .jpeg({ - quality: 85, - progressive: true, - }) - .toBuffer(); - - return { - data, - ext: 'jpg', - type: 'image/jpeg', - }; - } - /** * Convert to WebP * with resize, remove metadata, resolve orientation, stop animation @@ -78,29 +61,22 @@ export class ImageProcessingService { @bindThis public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .webp(options) - .toBuffer(); + const result = this.convertSharpToWebpStream(sharp, width, height, options); return { - data, - ext: 'webp', - type: 'image/webp', + data: await result.data.toBuffer(), + ext: result.ext, + type: result.type, }; } @bindThis - public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { + public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { return this.convertSharpToWebpStream(sharp(path), width, height, options); } @bindThis - public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { + public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { const data = sharp .resize(width, height, { fit: 'inside', @@ -115,13 +91,56 @@ export class ImageProcessingService { type: 'image/webp', }; } + + /** + * Convert to Avif + * with resize, remove metadata, resolve orientation, stop animation + */ + @bindThis + public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise { + return this.convertSharpToAvif(sharp(path), width, height, options); + } + + @bindThis + public async convertSharpToAvif(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise { + const result = this.convertSharpToAvifStream(sharp, width, height, options); + + return { + data: await result.data.toBuffer(), + ext: result.ext, + type: result.type, + }; + } + + @bindThis + public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { + return this.convertSharpToAvifStream(sharp(path), width, height, options); + } + + @bindThis + public convertSharpToAvifStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { + const data = sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .avif(options); + + return { + data, + ext: 'avif', + type: 'image/avif', + }; + } + /** * Convert to PNG * with resize, remove metadata, resolve orientation, stop animation */ @bindThis public async convertToPng(path: string, width: number, height: number): Promise { - return this.convertSharpToPng(await sharp(path), width, height); + return this.convertSharpToPng(sharp(path), width, height); } @bindThis diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 4c4261ba7..2fc2a3d54 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -30,7 +30,7 @@ import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { WebhookService } from '@/core/WebhookService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { AntennaService } from '@/core/AntennaService.js'; @@ -44,6 +44,7 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; +import { MetaService } from '@/core/MetaService.js'; const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); @@ -59,7 +60,7 @@ class NotificationManager { constructor( private mutingsRepository: MutingsRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, notifier: { id: User['id']; }, note: Note, ) { @@ -100,7 +101,7 @@ class NotificationManager { // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する if (!mentioneesMutedUserIds.includes(this.notifier.id)) { - this.createNotificationService.createNotification(x.target, x.reason, { + this.notificationService.createNotification(x.target, x.reason, { notifierId: this.notifier.id, noteId: this.note.id, }); @@ -125,6 +126,7 @@ type Option = { files?: DriveFile[] | null; poll?: IPoll | null; localOnly?: boolean | null; + reactionAcceptance?: Note['reactionAcceptance']; cw?: string | null; visibility?: string; visibleUsers?: MinimumUser[] | null; @@ -181,7 +183,7 @@ export class NoteCreateService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private queueService: QueueService, private noteReadService: NoteReadService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, private hashtagService: HashtagService, @@ -191,11 +193,12 @@ export class NoteCreateService implements OnApplicationShutdown { private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private roleService: RoleService, + private metaService: MetaService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private activeUsersChart: ActiveUsersChart, private instanceChart: InstanceChart, - ) {} + ) { } @bindThis public async create(user: { @@ -229,7 +232,9 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.channel != null) data.localOnly = true; if (data.visibility === 'public' && data.channel == null) { - if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { + if ((data.text != null) && (await this.metaService.fetch()).sensitiveWords.some(w => data.text!.includes(w))) { + data.visibility = 'home'; + } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { data.visibility = 'home'; } } @@ -346,6 +351,7 @@ export class NoteCreateService implements OnApplicationShutdown { emojis, userId: user.id, localOnly: data.localOnly!, + reactionAcceptance: data.reactionAcceptance, visibility: data.visibility as any, visibleUserIds: data.visibility === 'specified' ? data.visibleUsers @@ -385,7 +391,7 @@ export class NoteCreateService implements OnApplicationShutdown { // 投稿を作成 try { if (insert.hasPoll) { - // Start transaction + // Start transaction await this.db.transaction(async transactionalEntityManager => { await transactionalEntityManager.insert(Note, insert); @@ -408,7 +414,7 @@ export class NoteCreateService implements OnApplicationShutdown { return insert; } catch (e) { - // duplicate key error + // duplicate key error if (isDuplicateKeyValueError(e)) { const err = new Error('Duplicated note'); err.name = 'duplicated'; @@ -552,7 +558,7 @@ export class NoteCreateService implements OnApplicationShutdown { } }); - const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note); + const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); await this.createMentionedEvents(mentionedUsers, note, nm); diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index d23fb8238..22d72815e 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -4,7 +4,7 @@ import { In, IsNull, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; import type { Channel } from '@/models/entities/Channel.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Note } from '@/models/entities/Note.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 88173c230..48f2c6584 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -1,21 +1,37 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { NotificationsRepository } from '@/models/index.js'; +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'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import { GlobalEventService } from './GlobalEventService.js'; -import { PushNotificationService } from './PushNotificationService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; +import { IdService } from '@/core/IdService.js'; @Injectable() -export class NotificationService { +export class NotificationService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + @Inject(DI.notificationsRepository) private notificationsRepository: NotificationsRepository, + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private notificationEntityService: NotificationEntityService, private userEntityService: UserEntityService, + private idService: IdService, private globalEventService: GlobalEventService, private pushNotificationService: PushNotificationService, ) { @@ -67,4 +83,93 @@ export class NotificationService { private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds }); } + + @bindThis + public async createNotification( + notifieeId: User['id'], + type: Notification['type'], + data: Partial, + ): Promise { + 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(), + createdAt: new Date(), + notifieeId: notifieeId, + type: type, + // 相手がこの通知をミュートしているようなら、既読を予めつけておく + isRead: isMuted, + ...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(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, + }); + if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { + 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! })); + }, () => { /* aborted, ignore it */ }); + + return notification; + } + + // TODO + //const locales = await import('../../../../locales/index.js'); + + // TODO: locale ファイルをクライアント用とサーバー用で分けたい + + @bindThis + private async emailNotificationFollow(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + 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) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + 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/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 2cad1bc07..32c38ad48 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import push from 'web-push'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema'; +import type { Packed } from '@/misc/json-schema'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import type { SwSubscriptionsRepository } from '@/models/index.js'; import { MetaService } from '@/core/MetaService.js'; diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index c334d749e..0cee2076b 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository } from '@/models/index.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -29,6 +29,9 @@ export class QueryService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, ) { } @@ -269,5 +272,24 @@ export class QueryService { q.setParameters({ meId: me.id }); } } -} + @bindThis + public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') + .select('renote_muting.muteeId') + .where('renote_muting.muterId = :muterId', { muterId: me.id }); + + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb.where('note.renoteId IS NOT NULL'); + qb.andWhere('note.text IS NULL'); + qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); + })) + .orWhere('note.renoteId IS NULL') + .orWhere('note.text IS NOT NULL'); + })); + + q.setParameters(mutingQuery.getParameters()); + } +} diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 4bf41e0ac..498ceced7 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -26,7 +26,7 @@ export class QueueService { ) {} @bindThis - public deliver(user: ThinUser, content: IActivity | null, to: string | null) { + public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { if (content == null) return null; if (to == null) return null; @@ -36,6 +36,7 @@ export class QueueService { }, content, to, + isSharedInbox, }; return this.deliverQueue.add(data, { diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 9fccc14ee..271ba7917 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -9,7 +9,7 @@ import { IdService } from '@/core/IdService.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; import { emojiRegex } from '@/misc/emoji-regex.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; @@ -79,7 +79,7 @@ export class ReactionService { private globalEventService: GlobalEventService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private perUserReactionsChart: PerUserReactionsChart, ) { } @@ -93,15 +93,19 @@ export class ReactionService { throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); } } - + // check visibility if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } - - // TODO: cache - reaction = await this.toDbReaction(reaction, user.host); - + + if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { + reaction = '❤️'; + } else { + // TODO: cache + reaction = await this.toDbReaction(reaction, user.host); + } + const record: NoteReaction = { id: this.idService.genId(), createdAt: new Date(), @@ -109,7 +113,7 @@ export class ReactionService { userId: user.id, reaction, }; - + // Create reaction try { await this.noteReactionsRepository.insert(record); @@ -119,7 +123,7 @@ export class ReactionService { noteId: note.id, userId: user.id, }); - + if (exists.reaction !== reaction) { // 別のリアクションがすでにされていたら置き換える await this.delete(user, note); @@ -132,7 +136,7 @@ export class ReactionService { throw e; } } - + // Increment reactions count const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; await this.notesRepository.createQueryBuilder().update() @@ -142,12 +146,12 @@ export class ReactionService { }) .where('id = :id', { id: note.id }) .execute(); - + this.perUserReactionsChart.update(user, note); - + // カスタム絵文字リアクションだったら絵文字情報も送る const decodedReaction = this.decodeReaction(reaction); - + const emoji = await this.emojisRepository.findOne({ where: { name: decodedReaction.name, @@ -155,7 +159,7 @@ export class ReactionService { }, select: ['name', 'host', 'originalUrl', 'publicUrl'], }); - + this.globalEventService.publishNoteStream(note.id, 'reacted', { reaction: decodedReaction.reaction, emoji: emoji != null ? { @@ -165,16 +169,16 @@ export class ReactionService { } : null, userId: user.id, }); - + // リアクションされたユーザーがローカルユーザーなら通知を作成 if (note.userHost === null) { - this.createNotificationService.createNotification(note.userId, 'reaction', { + this.notificationService.createNotification(note.userId, 'reaction', { notifierId: user.id, noteId: note.id, reaction: reaction, }); } - + //#region 配信 if (this.userEntityService.isLocalUser(user) && !note.localOnly) { const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note)); @@ -183,7 +187,7 @@ export class ReactionService { const reactee = await this.usersRepository.findOneBy({ id: note.userId }); dm.addDirectRecipe(reactee as RemoteUser); } - + if (['public', 'home', 'followers'].includes(note.visibility)) { dm.addFollowersRecipe(); } else if (note.visibility === 'specified') { @@ -192,7 +196,7 @@ export class ReactionService { dm.addDirectRecipe(u as RemoteUser); } } - + dm.execute(); } //#endregion @@ -205,18 +209,18 @@ export class ReactionService { noteId: note.id, userId: user.id, }); - + if (exist == null) { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } - + // Delete reaction const result = await this.noteReactionsRepository.delete(exist.id); - + if (result.affected !== 1) { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } - + // Decrement reactions count const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; await this.notesRepository.createQueryBuilder().update() @@ -225,14 +229,14 @@ export class ReactionService { }) .where('id = :id', { id: note.id }) .execute(); - + if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1); - + this.globalEventService.publishNoteStream(note.id, 'unreacted', { reaction: this.decodeReaction(exist.reaction).reaction, userId: user.id, }); - + //#region 配信 if (this.userEntityService.isLocalUser(user) && !note.localOnly) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user)); @@ -246,7 +250,7 @@ export class ReactionService { } //#endregion } - + @bindThis public async getFallbackReaction(): Promise { const meta = await this.metaService.fetch(); @@ -296,7 +300,7 @@ export class ReactionService { // Unicode絵文字 const match = emojiRegex.exec(reaction); if (match) { - // 合字を含む1つの絵文字 + // 合字を含む1つの絵文字 const unicode = match[0]; // 異体字セレクタ除去 diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 2e07825e9..86f983cc7 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -57,7 +57,7 @@ export class RelayService { const relayActor = await this.getRelayActor(); const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); const activity = this.apRendererService.addContext(follow); - this.queueService.deliver(relayActor, activity, relay.inbox); + this.queueService.deliver(relayActor, activity, relay.inbox, false); return relay; } @@ -76,7 +76,7 @@ export class RelayService { const follow = this.apRendererService.renderFollowRelay(relay, relayActor); const undo = this.apRendererService.renderUndo(follow, relayActor); const activity = this.apRendererService.addContext(undo); - this.queueService.deliver(relayActor, activity, relay.inbox); + this.queueService.deliver(relayActor, activity, relay.inbox, false); await this.relaysRepository.delete(relay.id); } @@ -120,7 +120,7 @@ export class RelayService { const signed = await this.apRendererService.attachLdSignature(copy, user); for (const relay of relays) { - this.queueService.deliver(user, signed, relay.inbox); + this.queueService.deliver(user, signed, relay.inbox, false); } } } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 714959119..4775196c6 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -21,6 +21,7 @@ export type RolePolicies = { canPublicNote: boolean; canInvite: boolean; canManageCustomEmojis: boolean; + canSearchNotes: boolean; canHideAds: boolean; driveCapacityMb: number; pinLimit: number; @@ -40,6 +41,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canPublicNote: true, canInvite: false, canManageCustomEmojis: false, + canSearchNotes: false, canHideAds: false, driveCapacityMb: 100, pinLimit: 5, @@ -264,6 +266,7 @@ export class RoleService implements OnApplicationShutdown { canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), + canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index 0ce69aaa7..cc8f95081 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -19,12 +19,14 @@ export class S3Service { @bindThis public getS3(meta: Meta) { - const u = meta.objectStorageEndpoint != null + const u = meta.objectStorageEndpoint ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; - + return new S3({ - endpoint: meta.objectStorageEndpoint ?? undefined, + endpoint: meta.objectStorageEndpoint && meta.objectStorageEndpoint.length > 0 + ? meta.objectStorageEndpoint + : undefined, accessKeyId: meta.objectStorageAccessKey!, secretAccessKey: meta.objectStorageSecretKey!, region: meta.objectStorageRegion ?? undefined, diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 90a718690..d7bc05b8b 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -90,7 +90,7 @@ export class SignupService { cipher: undefined, passphrase: undefined, }, - } as any, (err, publicKey, privateKey) => + }, (err, publicKey, privateKey) => err ? rej(err) : res([publicKey, privateKey]), )); diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index be37bad52..92408da34 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -118,7 +118,7 @@ export class UserBlockingService implements OnApplicationShutdown { if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { const content = this.apRendererService.addContext(this.apRendererService.renderBlock(blocking)); - this.queueService.deliver(blocker, content, blockee.inbox); + this.queueService.deliver(blocker, content, blockee.inbox, false); } } @@ -163,13 +163,13 @@ export class UserBlockingService implements OnApplicationShutdown { // リモートにフォローリクエストをしていたらUndoFollow送信 if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } // リモートからフォローリクエストを受けていたらReject送信 if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } } @@ -211,13 +211,13 @@ export class UserBlockingService implements OnApplicationShutdown { // リモートにフォローをしていたらUndoFollow送信 if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } // リモートからフォローをされていたらRejectFollow送信 if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } } @@ -262,7 +262,7 @@ export class UserBlockingService implements OnApplicationShutdown { // deliver if remote bloking if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker)); - this.queueService.deliver(blocker, content, blockee.inbox); + this.queueService.deliver(blocker, content, blockee.inbox, false); } } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index d8426512b..1c8550435 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -6,11 +6,11 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { WebhookService } from '@/core/WebhookService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -57,7 +57,7 @@ export class UserFollowingService { private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private federatedInstanceService: FederatedInstanceService, private webhookService: WebhookService, private apRendererService: ApRendererService, @@ -82,7 +82,7 @@ export class UserFollowingService { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) { // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); return; } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) { // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 @@ -131,7 +131,7 @@ export class UserFollowingService { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } } @@ -145,15 +145,15 @@ export class UserFollowingService { }, ): Promise { if (follower.id === followee.id) return; - + let alreadyFollowed = false as boolean; - + await this.followingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), followerId: follower.id, followeeId: followee.id, - + // 非正規化 followerHost: follower.host, followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null, @@ -169,35 +169,35 @@ export class UserFollowingService { throw err; } }); - + const req = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, }); - + if (req) { await this.followRequestsRepository.delete({ followeeId: followee.id, followerId: follower.id, }); - + // 通知を作成 - this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', { + this.notificationService.createNotification(follower.id, 'followRequestAccepted', { notifierId: followee.id, }); } - + if (alreadyFollowed) return; this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); - + //#region Increment counts await Promise.all([ this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), ]); //#endregion - + //#region Update instance stats if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { this.federatedInstanceService.fetch(follower.host).then(i => { @@ -211,9 +211,9 @@ export class UserFollowingService { }); } //#endregion - + this.perUserFollowingChart.update(follower, followee, true); - + // Publish follow event if (this.userEntityService.isLocalUser(follower)) { this.userEntityService.pack(followee.id, follower, { @@ -221,7 +221,7 @@ export class UserFollowingService { }).then(async packed => { this.globalEventService.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); this.globalEventService.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); - + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'follow', { @@ -230,12 +230,12 @@ export class UserFollowingService { } }); } - + // Publish followed event if (this.userEntityService.isLocalUser(followee)) { this.userEntityService.pack(follower.id, followee).then(async packed => { this.globalEventService.publishMainStream(followee.id, 'followed', packed); - + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'followed', { @@ -243,9 +243,9 @@ export class UserFollowingService { }); } }); - + // 通知を作成 - this.createNotificationService.createNotification(followee.id, 'follow', { + this.notificationService.createNotification(followee.id, 'follow', { notifierId: follower.id, }); } @@ -265,16 +265,16 @@ export class UserFollowingService { followerId: follower.id, followeeId: followee.id, }); - + if (following == null) { logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } - + await this.followingsRepository.delete(following.id); - + this.decrementFollowing(follower, followee); - + // Publish unfollow event if (!silent && this.userEntityService.isLocalUser(follower)) { this.userEntityService.pack(followee.id, follower, { @@ -282,7 +282,7 @@ export class UserFollowingService { }).then(async packed => { this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'unfollow', { @@ -291,33 +291,33 @@ export class UserFollowingService { } }); } - + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } - + if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { // local user has null host const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } } - + @bindThis private async decrementFollowing( - follower: {id: User['id']; host: User['host']; }, + follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, ): Promise { this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id }); - + //#region Decrement following / followers counts await Promise.all([ this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), ]); //#endregion - + //#region Update instance stats if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { this.federatedInstanceService.fetch(follower.host).then(i => { @@ -331,7 +331,7 @@ export class UserFollowingService { }); } //#endregion - + this.perUserFollowingChart.update(follower, followee, false); } @@ -346,23 +346,23 @@ export class UserFollowingService { requestId?: string, ): Promise { if (follower.id === followee.id) return; - + // check blocking const [blocking, blocked] = await Promise.all([ this.userBlockingService.checkBlocked(follower.id, followee.id), this.userBlockingService.checkBlocked(followee.id, follower.id), ]); - + if (blocking) throw new Error('blocking'); if (blocked) throw new Error('blocked'); - + const followRequest = await this.followRequestsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), followerId: follower.id, followeeId: followee.id, requestId, - + // 非正規化 followerHost: follower.host, followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined, @@ -371,25 +371,25 @@ export class UserFollowingService { followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined, followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined, }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0])); - + // Publish receiveRequest event if (this.userEntityService.isLocalUser(followee)) { this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventService.publishMainStream(followee.id, 'receiveFollowRequest', packed)); - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); - + // 通知を作成 - this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', { + this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { notifierId: follower.id, followRequestId: followRequest.id, }); } - + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)); - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } } @@ -404,26 +404,26 @@ export class UserFollowingService { ): Promise { if (this.userEntityService.isRemoteUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - + if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } } - + const request = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, }); - + if (request == null) { throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); } - + await this.followRequestsRepository.delete({ followeeId: followee.id, followerId: follower.id, }); - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); @@ -440,18 +440,18 @@ export class UserFollowingService { followeeId: followee.id, followerId: follower.id, }); - + if (request == null) { throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); } - + await this.insertFollowingDoc(followee, follower); - + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); @@ -466,13 +466,13 @@ export class UserFollowingService { const requests = await this.followRequestsRepository.findBy({ followeeId: user.id, }); - + for (const request of requests) { const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId }); this.acceptFollowRequest(user, follower); } } - + /** * API following/request/reject */ @@ -557,7 +557,7 @@ export class UserFollowingService { }); const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } /** diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 02903a059..d00bb89c7 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -54,7 +54,7 @@ export class UserSuspendService { } for (const inbox of queue) { - this.queueService.deliver(user, content, inbox); + this.queueService.deliver(user, content, inbox, true); } } } @@ -84,7 +84,7 @@ export class UserSuspendService { } for (const inbox of queue) { - this.queueService.deliver(user as any, content, inbox); + this.queueService.deliver(user as any, content, inbox, true); } } } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 5e6ea6984..70a6d32fe 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -157,7 +157,8 @@ class DeliverManager { public async execute() { if (!this.userEntityService.isLocalUser(this.actor)) return; - const inboxes = new Set(); + // The value flags whether it is shared or not. + const inboxes = new Map(); /* build inbox list @@ -185,7 +186,7 @@ class DeliverManager { for (const following of followers) { const inbox = following.followerSharedInbox ?? following.followerInbox; - inboxes.add(inbox); + inboxes.set(inbox, following.followerSharedInbox === null); } } @@ -197,11 +198,12 @@ class DeliverManager { // check that they actually have an inbox && recipe.to.inbox != null, ) - .forEach(recipe => inboxes.add(recipe.to.inbox!)); + .forEach(recipe => inboxes.set(recipe.to.inbox!, false)); // deliver for (const inbox of inboxes) { - this.queueService.deliver(this.actor, this.activity, inbox); + // inbox[0]: inbox, inbox[1]: whether it is sharedInbox + this.queueService.deliver(this.actor, this.activity, inbox[0], inbox[1]); } } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 6d9569bce..055bffe73 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -140,7 +140,7 @@ export class ApInboxService { } else if (isFlag(activity)) { await this.flag(actor, activity); } else { - this.logger.warn(`unrecognized activity type: ${(activity as any).type}`); + this.logger.warn(`unrecognized activity type: ${activity.type}`); } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 6a1f233bd..0d03e3d90 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -91,6 +91,9 @@ export class ApRendererService { } else if (note.visibility === 'home') { to = [`${attributedTo}/followers`]; cc = ['https://www.w3.org/ns/activitystreams#Public']; + } else if (note.visibility === 'followers') { + to = [`${attributedTo}/followers`]; + cc = []; } else { throw new Error('renderAnnounce: cannot render non-public note'); } @@ -116,7 +119,7 @@ export class ApRendererService { if (block.blockee?.uri == null) { throw new Error('renderBlock: missing blockee uri'); } - + return { type: 'Block', id: `${this.config.url}/blocks/${block.id}`, @@ -134,10 +137,10 @@ export class ApRendererService { published: note.createdAt.toISOString(), object, } as ICreate; - + if (object.to) activity.to = object.to; if (object.cc) activity.cc = object.cc; - + return activity; } @@ -155,7 +158,7 @@ export class ApRendererService { public renderDocument(file: DriveFile): IApDocument { return { type: 'Document', - mediaType: file.type, + mediaType: file.webpublicType ?? file.type, url: this.driveFileEntityService.getPublicUrl(file), name: file.comment, }; @@ -297,16 +300,16 @@ export class ApRendererService { const items = await this.driveFilesRepository.findBy({ id: In(ids) }); return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; }; - + let inReplyTo; let inReplyToNote: Note | null; - + if (note.replyId) { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); - + if (inReplyToNote != null) { const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - + if (inReplyToUser != null) { if (inReplyToNote.uri) { inReplyTo = inReplyToNote.uri; @@ -322,24 +325,24 @@ export class ApRendererService { } else { inReplyTo = null; } - + let quote; - + if (note.renoteId) { const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); - + if (renote) { quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; } } - + const attributedTo = `${this.config.url}/users/${note.userId}`; - + const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); - + let to: string[] = []; let cc: string[] = []; - + if (note.visibility === 'public') { to = ['https://www.w3.org/ns/activitystreams#Public']; cc = [`${attributedTo}/followers`].concat(mentions); @@ -352,44 +355,44 @@ export class ApRendererService { } else { to = mentions; } - + const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({ id: In(note.mentions), }) : []; - + const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); const mentionTags = mentionedUsers.map(u => this.renderMention(u)); - + const files = await getPromisedFiles(note.fileIds); - + const text = note.text ?? ''; let poll: Poll | null = null; - + if (note.hasPoll) { poll = await this.pollsRepository.findOneBy({ noteId: note.id }); } - + let apText = text; - + if (quote) { apText += `\n\nRE: ${quote}`; } - + const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - + const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { text: apText, })); - + const emojis = await this.getEmojis(note.emojis); const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); - + const tag = [ ...hashtagTags, ...mentionTags, ...apemojis, ]; - + const asPoll = poll ? { type: 'Question', content: this.apMfmService.getNoteHtml(Object.assign({}, note, { @@ -601,7 +604,7 @@ export class ApRendererService { if (typeof x === 'object' && x.id == null) { x.id = `${this.config.url}/${uuid()}`; } - + return Object.assign({ '@context': [ 'https://www.w3.org/ns/activitystreams', @@ -634,18 +637,18 @@ export class ApRendererService { ], }, x as T & { id: string; }); } - + @bindThis public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise { const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); - + const ldSignature = this.ldSignatureService.use(); ldSignature.debug = false; activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); - + return activity; } - + /** * Render OrderedCollectionPage * @param id URL of self @@ -686,11 +689,11 @@ export class ApRendererService { type: 'OrderedCollection', totalItems, }; - + if (first) page.first = first; if (last) page.last = last; if (orderedItems) page.orderedItems = orderedItems; - + return page; } diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index c36e8d4ed..28bcbc6fa 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -124,7 +124,7 @@ export class ApNoteService { throw new Error('invalid note'); } - const note: IPost = object as any; + const note = object as IPost; this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); @@ -180,7 +180,7 @@ export class ApNoteService { const reply: Note | null = note.inReplyTo ? await this.resolveNote(note.inReplyTo, resolver).then(x => { if (x == null) { - this.logger.warn('Specified inReplyTo, but nout found'); + this.logger.warn('Specified inReplyTo, but not found'); throw new Error('inReplyTo not found'); } else { return x; diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index a1fdd7a19..d06958da0 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -164,6 +164,9 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: wrong name'); } x.name = truncate(x.name, nameLength); + } else if (x.name === '') { + // Mastodon emits empty string when the name is not set. + x.name = undefined; } if (x.summary) { if (!(typeof x.summary === 'string' && x.summary.length > 0)) { diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 7f2ca9c05..885194633 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -195,7 +195,8 @@ export const isPropertyValue = (object: IObject): object is IApPropertyValue => object && getApType(object) === 'PropertyValue' && typeof object.name === 'string' && - typeof (object as any).value === 'string'; + 'value' in object && + typeof object.value === 'string'; export interface IApMention extends IObject { type: 'Mention'; diff --git a/packages/backend/src/core/chart/charts/entities/active-users.ts b/packages/backend/src/core/chart/charts/entities/active-users.ts index 5767b76f8..e291e37c1 100644 --- a/packages/backend/src/core/chart/charts/entities/active-users.ts +++ b/packages/backend/src/core/chart/charts/entities/active-users.ts @@ -3,15 +3,15 @@ import Chart from '../../core.js'; export const name = 'activeUsers'; export const schema = { - 'readWrite': { intersection: ['read', 'write'], range: 'small' }, - 'read': { uniqueIncrement: true, range: 'small' }, - 'write': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinWeek': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinMonth': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinYear': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideWeek': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideMonth': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideYear': { uniqueIncrement: true, range: 'small' }, + 'readWrite': { intersection: ['read', 'write'] }, + 'read': { uniqueIncrement: true }, + 'write': { uniqueIncrement: true }, + 'registeredWithinWeek': { uniqueIncrement: true }, + 'registeredWithinMonth': { uniqueIncrement: true }, + 'registeredWithinYear': { uniqueIncrement: true }, + 'registeredOutsideWeek': { uniqueIncrement: true }, + 'registeredOutsideMonth': { uniqueIncrement: true }, + 'registeredOutsideYear': { uniqueIncrement: true }, } as const; export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index c8a98c0be..e02daefd6 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Antenna } from '@/models/entities/Antenna.js'; import { bindThis } from '@/decorators.js'; @@ -37,6 +37,7 @@ export class AntennaEntityService { notify: antenna.notify, withReplies: antenna.withReplies, withFile: antenna.withFile, + isActive: antenna.isActive, hasUnreadNote, }; } diff --git a/packages/backend/src/core/entities/AppEntityService.ts b/packages/backend/src/core/entities/AppEntityService.ts index 36cd48f3c..0b4c3935c 100644 --- a/packages/backend/src/core/entities/AppEntityService.ts +++ b/packages/backend/src/core/entities/AppEntityService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, AppsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { App } from '@/models/entities/App.js'; import type { User } from '@/models/entities/User.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts index c9e15207b..e169c7e90 100644 --- a/packages/backend/src/core/entities/BlockingEntityService.ts +++ b/packages/backend/src/core/entities/BlockingEntityService.ts @@ -2,11 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { BlockingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class BlockingEntityService { diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 0a9bcf85c..6048492f0 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -1,13 +1,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Channel } from '@/models/entities/Channel.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ChannelEntityService { diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index 63c50865e..33d3c5380 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ClipsRepository } from '@/models/index.js'; +import type { ClipFavoritesRepository, ClipsRepository, User } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { Clip } from '@/models/entities/Clip.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class ClipEntityService { @@ -14,6 +14,9 @@ export class ClipEntityService { @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + private userEntityService: UserEntityService, ) { } @@ -21,25 +24,31 @@ export class ClipEntityService { @bindThis public async pack( src: Clip['id'] | Clip, + me?: { id: User['id'] } | null | undefined, ): Promise> { + const meId = me ? me.id : null; const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); return await awaitAll({ id: clip.id, createdAt: clip.createdAt.toISOString(), + lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, userId: clip.userId, user: this.userEntityService.pack(clip.user ?? clip.userId), name: clip.name, description: clip.description, isPublic: clip.isPublic, + favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), + isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined, }); } @bindThis public packMany( clips: Clip[], + me?: { id: User['id'] } | null | undefined, ) { - return Promise.all(clips.map(x => this.pack(x))); + return Promise.all(clips.map(x => this.pack(x, me))); } } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index f769ddd5e..2d40f444c 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -3,7 +3,7 @@ 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'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { User } from '@/models/entities/User.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; @@ -89,9 +89,7 @@ export class DriveFileEntityService { if (file.type.startsWith('video')) { if (file.thumbnailUrl) return file.thumbnailUrl; - if (this.config.videoThumbnailGenerator == null) { - return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); - } + return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); } else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { // 動画ではなくリモートかつメディアプロキシ return this.getProxiedUrl(file.uri, 'static'); @@ -106,7 +104,7 @@ export class DriveFileEntityService { const url = file.webpublicUrl ?? file.url; - return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? this.getProxiedUrl(url, 'static') : null); + return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? url : null); } @bindThis diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts index 93c52c91f..13929b145 100644 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { DriveFolder } from '@/models/entities/DriveFolder.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index f5c8f2d4b..3bad048bc 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { EmojisRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { Emoji } from '@/models/entities/Emoji.js'; import { bindThis } from '@/decorators.js'; @@ -50,6 +50,7 @@ export class EmojiEntityService { host: emoji.host, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, + license: emoji.license, }; } diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index 61bd18c04..e52a59188 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Flash } from '@/models/entities/Flash.js'; diff --git a/packages/backend/src/core/entities/FollowingEntityService.ts b/packages/backend/src/core/entities/FollowingEntityService.ts index a833ae719..55ba4e67a 100644 --- a/packages/backend/src/core/entities/FollowingEntityService.ts +++ b/packages/backend/src/core/entities/FollowingEntityService.ts @@ -2,10 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Following } from '@/models/entities/Following.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; type LocalFollowerFollowing = Following & { @@ -31,7 +32,6 @@ type RemoteFolloweeFollowing = Following & { followeeInbox: string; followeeSharedInbox: string; }; -import { bindThis } from '@/decorators.js'; @Injectable() export class FollowingEntityService { diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index fb147ae18..632c75304 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -2,13 +2,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { GalleryPost } from '@/models/entities/GalleryPost.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class GalleryPostEntityService { diff --git a/packages/backend/src/core/entities/HashtagEntityService.ts b/packages/backend/src/core/entities/HashtagEntityService.ts index 88b1ff3a3..2cd79b8f8 100644 --- a/packages/backend/src/core/entities/HashtagEntityService.ts +++ b/packages/backend/src/core/entities/HashtagEntityService.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { HashtagsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { Hashtag } from '@/models/entities/Hashtag.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class HashtagEntityService { diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 8a9706816..3bf84ed37 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { InstancesRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { Instance } from '@/models/entities/Instance.js'; import { MetaService } from '@/core/MetaService.js'; -import { UtilityService } from '../UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { UtilityService } from '../UtilityService.js'; @Injectable() export class InstanceEntityService { diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts index 4f02ef408..561d53292 100644 --- a/packages/backend/src/core/entities/MutingEntityService.ts +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { MutingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Muting } from '@/models/entities/Muting.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class MutingEntityService { diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 4ec10df9a..566060069 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -3,7 +3,7 @@ import { DataSource, In } from 'typeorm'; import * as mfm from 'mfm-js'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { nyaize } from '@/misc/nyaize.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { User } from '@/models/entities/User.js'; @@ -314,6 +314,7 @@ export class NoteEntityService implements OnModuleInit { cw: note.cw, visibility: note.visibility, localOnly: note.localOnly ?? undefined, + reactionAcceptance: note.reactionAcceptance, visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 977948967..8f943ba24 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -1,7 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { NoteReactionsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; import type { OnModuleInit } from '@nestjs/common'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; @@ -10,7 +11,6 @@ import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import { ModuleRef } from '@nestjs/core'; -import { bindThis } from '@/decorators.js'; @Injectable() export class NoteReactionEntityService implements OnModuleInit { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index be88a213f..70e56cb3d 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -5,7 +5,7 @@ import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepo import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Notification } from '@/models/entities/Notification.js'; import type { Note } from '@/models/entities/Note.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { notificationTypes } from '@/types.js'; diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 48e45dd01..d6da85663 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -2,14 +2,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, PagesRepository, PageLikesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Page } from '@/models/entities/Page.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class PageEntityService { diff --git a/packages/backend/src/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts new file mode 100644 index 000000000..f8871e049 --- /dev/null +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class RenoteMutingEntityService { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: RenoteMuting['id'] | RenoteMuting, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: this.userEntityService.pack(muting.muteeId, me, { + detail: true, + }), + }); + } + + @bindThis + public packMany( + mutings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 2f1d51fa1..e111a10b7 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -61,6 +61,7 @@ export class RoleEntityService { isModerator: role.isModerator, asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, + displayOrder: role.displayOrder, policies: policies, usersCount: assignedCount, }); diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 363564321..068ffad09 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -4,7 +4,7 @@ import Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; @@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -78,6 +78,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -195,6 +198,13 @@ export class UserEntityService implements OnModuleInit { }, take: 1, }).then(n => n > 0), + isRenoteMuted: this.renoteMutingsRepository.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then(n => n > 0), }); } @@ -380,9 +390,10 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.map(r => ({ + badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ name: r.name, iconUrl: r.iconUrl, + displayOrder: r.displayOrder, }))) : undefined, ...(opts.detail ? { @@ -419,7 +430,7 @@ export class UserEntityService implements OnModuleInit { userId: user.id, }).then(result => result >= 1) : false, - roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).map(role => ({ + roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, color: role.color, @@ -427,6 +438,7 @@ export class UserEntityService implements OnModuleInit { description: role.description, isModerator: role.isModerator, isAdministrator: role.isAdministrator, + displayOrder: role.displayOrder, }))), } : {}), @@ -493,6 +505,7 @@ export class UserEntityService implements OnModuleInit { isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, + isRenoteMuted: relation.isRenoteMuted, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index 3c1087881..2461cb2c1 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { UserList } from '@/models/entities/UserList.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class UserListEntityService { diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 05603093b..0879735b1 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -36,6 +36,7 @@ export const DI = { notificationsRepository: Symbol('notificationsRepository'), metasRepository: Symbol('metasRepository'), mutingsRepository: Symbol('mutingsRepository'), + renoteMutingsRepository: Symbol('renoteMutingsRepository'), blockingsRepository: Symbol('blockingsRepository'), swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), hashtagsRepository: Symbol('hashtagsRepository'), @@ -51,6 +52,7 @@ export const DI = { moderationLogsRepository: Symbol('moderationLogsRepository'), clipsRepository: Symbol('clipsRepository'), clipNotesRepository: Symbol('clipNotesRepository'), + clipFavoritesRepository: Symbol('clipFavoritesRepository'), antennasRepository: Symbol('antennasRepository'), antennaNotesRepository: Symbol('antennaNotesRepository'), promoNotesRepository: Symbol('promoNotesRepository'), diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts index 3357d8c1b..23a0699f3 100644 --- a/packages/backend/src/misc/correct-filename.ts +++ b/packages/backend/src/misc/correct-filename.ts @@ -1,15 +1,15 @@ // 与えられた拡張子とファイル名が一致しているかどうかを確認し、 // 一致していない場合は拡張子を付与して返す export function correctFilename(filename: string, ext: string | null) { - const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown'; - if (filename.endsWith(dotExt)) { - return filename; - } - if (ext === 'jpg' && filename.endsWith('.jpeg')) { - return filename; - } - if (ext === 'tif' && filename.endsWith('.tiff')) { - return filename; - } - return `${filename}${dotExt}`; + const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown'; + if (filename.endsWith(dotExt)) { + return filename; + } + if (ext === 'jpg' && filename.endsWith('.jpeg')) { + return filename; + } + if (ext === 'tif' && filename.endsWith('.tiff')) { + return filename; + } + return `${filename}${dotExt}`; } diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 85bc2ec94..964f20b25 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -1,4 +1,4 @@ -import type { Packed } from './schema.js'; +import type { Packed } from './json-schema.js'; /** * 投稿を表す文字列を取得します。 diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts index e11a18bb7..73ad0b3b8 100644 --- a/packages/backend/src/misc/is-instance-muted.ts +++ b/packages/backend/src/misc/is-instance-muted.ts @@ -1,4 +1,4 @@ -import type { Packed } from './schema.js'; +import type { Packed } from './json-schema.js'; export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set): boolean { if (mutedInstances.has(note.user.host ?? '')) return true; diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts index 0b6d147dc..46a66efc0 100644 --- a/packages/backend/src/misc/is-mime-image.ts +++ b/packages/backend/src/misc/is-mime-image.ts @@ -2,10 +2,10 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; const dictionary = { 'safe-file': FILE_TYPE_BROWSERSAFE, - 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], - 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], - 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], - 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], + 'sharp-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-animation-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], + 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], }; export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/json-schema.ts similarity index 78% rename from packages/backend/src/misc/schema.ts rename to packages/backend/src/misc/json-schema.ts index 6a0802f8a..e748f93a2 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -6,28 +6,29 @@ import { packedMeDetailedSchema, packedUserDetailedSchema, packedUserSchema, -} from '@/models/schema/user.js'; -import { packedNoteSchema } from '@/models/schema/note.js'; -import { packedUserListSchema } from '@/models/schema/user-list.js'; -import { packedAppSchema } from '@/models/schema/app.js'; -import { packedNotificationSchema } from '@/models/schema/notification.js'; -import { packedDriveFileSchema } from '@/models/schema/drive-file.js'; -import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js'; -import { packedFollowingSchema } from '@/models/schema/following.js'; -import { packedMutingSchema } from '@/models/schema/muting.js'; -import { packedBlockingSchema } from '@/models/schema/blocking.js'; -import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js'; -import { packedHashtagSchema } from '@/models/schema/hashtag.js'; -import { packedPageSchema } from '@/models/schema/page.js'; -import { packedNoteFavoriteSchema } from '@/models/schema/note-favorite.js'; -import { packedChannelSchema } from '@/models/schema/channel.js'; -import { packedAntennaSchema } from '@/models/schema/antenna.js'; -import { packedClipSchema } from '@/models/schema/clip.js'; -import { packedFederationInstanceSchema } from '@/models/schema/federation-instance.js'; -import { packedQueueCountSchema } from '@/models/schema/queue.js'; -import { packedGalleryPostSchema } from '@/models/schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/schema/emoji.js'; -import { packedFlashSchema } from '@/models/schema/flash.js'; +} from '@/models/json-schema/user.js'; +import { packedNoteSchema } from '@/models/json-schema/note.js'; +import { packedUserListSchema } from '@/models/json-schema/user-list.js'; +import { packedAppSchema } from '@/models/json-schema/app.js'; +import { packedNotificationSchema } from '@/models/json-schema/notification.js'; +import { packedDriveFileSchema } from '@/models/json-schema/drive-file.js'; +import { packedDriveFolderSchema } from '@/models/json-schema/drive-folder.js'; +import { packedFollowingSchema } from '@/models/json-schema/following.js'; +import { packedMutingSchema } from '@/models/json-schema/muting.js'; +import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; +import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; +import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; +import { packedHashtagSchema } from '@/models/json-schema/hashtag.js'; +import { packedPageSchema } from '@/models/json-schema/page.js'; +import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; +import { packedChannelSchema } from '@/models/json-schema/channel.js'; +import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; +import { packedClipSchema } from '@/models/json-schema/clip.js'; +import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; +import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; +import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; +import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { packedFlashSchema } from '@/models/json-schema/flash.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -48,6 +49,7 @@ export const refs = { DriveFolder: packedDriveFolderSchema, Following: packedFollowingSchema, Muting: packedMutingSchema, + RenoteMuting: packedRenoteMutingSchema, Blocking: packedBlockingSchema, Hashtag: packedHashtagSchema, Page: packedPageSchema, @@ -93,7 +95,7 @@ export interface Schema extends OfSchema { readonly example?: any; readonly format?: string; readonly ref?: keyof typeof refs; - readonly enum?: ReadonlyArray; + readonly enum?: ReadonlyArray; readonly default?: (this['type'] extends TypeStringef ? StringDefToType : any) | null; readonly maxLength?: number; readonly minLength?: number; @@ -159,7 +161,7 @@ export type SchemaTypeDef

= p['type'] extends 'integer' ? number : p['type'] extends 'number' ? number : p['type'] extends 'string' ? ( - p['enum'] extends readonly string[] ? + p['enum'] extends readonly (string | null)[] ? p['enum'][number] : p['format'] extends 'date-time' ? string : // Dateにする?? string diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 311f875ba..d00c8813c 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -190,6 +190,12 @@ const $mutingsRepository: Provider = { inject: [DI.db], }; +const $renoteMutingsRepository: Provider = { + provide: DI.renoteMutingsRepository, + useFactory: (db: DataSource) => db.getRepository(RenoteMuting), + inject: [DI.db], +}; + const $blockingsRepository: Provider = { provide: DI.blockingsRepository, useFactory: (db: DataSource) => db.getRepository(Blocking), @@ -280,6 +286,12 @@ const $clipNotesRepository: Provider = { inject: [DI.db], }; +const $clipFavoritesRepository: Provider = { + provide: DI.clipFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ClipFavorite), + inject: [DI.db], +}; + const $antennasRepository: Provider = { provide: DI.antennasRepository, useFactory: (db: DataSource) => db.getRepository(Antenna), @@ -423,6 +435,7 @@ const $roleAssignmentsRepository: Provider = { $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, @@ -438,6 +451,7 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, $antennaNotesRepository, $promoNotesRepository, @@ -489,6 +503,7 @@ const $roleAssignmentsRepository: Provider = { $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, @@ -504,6 +519,7 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, $antennaNotesRepository, $promoNotesRepository, diff --git a/packages/backend/src/models/entities/Antenna.ts b/packages/backend/src/models/entities/Antenna.ts index 5b2164ef1..e63e7f2c7 100644 --- a/packages/backend/src/models/entities/Antenna.ts +++ b/packages/backend/src/models/entities/Antenna.ts @@ -13,6 +13,10 @@ export class Antenna { }) public createdAt: Date; + @Index() + @Column('timestamp with time zone') + public lastUsedAt: Date; + @Index() @Column({ ...id(), @@ -83,4 +87,10 @@ export class Antenna { @Column('boolean') public notify: boolean; + + @Index() + @Column('boolean', { + default: true, + }) + public isActive: boolean; } diff --git a/packages/backend/src/models/entities/Clip.ts b/packages/backend/src/models/entities/Clip.ts index 57a310ac0..825a32c98 100644 --- a/packages/backend/src/models/entities/Clip.ts +++ b/packages/backend/src/models/entities/Clip.ts @@ -12,6 +12,12 @@ export class Clip { }) public createdAt: Date; + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public lastClippedAt: Date | null; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/entities/ClipFavorite.ts b/packages/backend/src/models/entities/ClipFavorite.ts new file mode 100644 index 000000000..623471e67 --- /dev/null +++ b/packages/backend/src/models/entities/ClipFavorite.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { Clip } from './Clip.js'; + +@Entity() +@Index(['userId', 'clipId'], { unique: true }) +export class ClipFavorite { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public clipId: Clip['id']; + + @ManyToOne(type => Clip, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public clip: Clip | null; +} diff --git a/packages/backend/src/models/entities/Emoji.ts b/packages/backend/src/models/entities/Emoji.ts index 7332dd185..dbb437d43 100644 --- a/packages/backend/src/models/entities/Emoji.ts +++ b/packages/backend/src/models/entities/Emoji.ts @@ -55,4 +55,9 @@ export class Emoji { array: true, length: 128, default: '{}', }) public aliases: string[]; + + @Column('varchar', { + length: 1024, nullable: true, + }) + public license: string | null; } diff --git a/packages/backend/src/models/entities/Flash.ts b/packages/backend/src/models/entities/Flash.ts index 192f7e7bc..4ccc908a6 100644 --- a/packages/backend/src/models/entities/Flash.ts +++ b/packages/backend/src/models/entities/Flash.ts @@ -43,7 +43,7 @@ export class Flash { public user: User | null; @Column('varchar', { - length: 32768, + length: 65536, }) public script: string; diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index 9d777c623..57338ecbd 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -12,7 +12,7 @@ export class Meta { public id: string; @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public name: string | null; @@ -25,7 +25,7 @@ export class Meta { * メンテナの名前 */ @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public maintainerName: string | null; @@ -33,7 +33,7 @@ export class Meta { * メンテナの連絡先 */ @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public maintainerEmail: string | null; @@ -48,76 +48,68 @@ export class Meta { public useStarForReactionFallback: boolean; @Column('varchar', { - length: 64, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public langs: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public pinnedUsers: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public hiddenTags: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public blockedHosts: string[]; @Column('varchar', { - length: 512, array: true, default: '{/featured,/channels,/explore,/pages,/about-misskey}', + length: 1024, array: true, default: '{}', }) - public pinnedPages: string[]; - - @Column({ - ...id(), - nullable: true, - }) - public pinnedClipId: Clip['id'] | null; + public sensitiveWords: string[]; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public themeColor: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, - default: '/assets/ai.png', }) public mascotImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public bannerUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public backgroundImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public logoImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, - default: 'https://xn--931a.moe/aiart/yubitun.png', }) public errorImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public iconUrl: string | null; @@ -150,13 +142,13 @@ export class Meta { public enableHcaptcha: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public hcaptchaSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public hcaptchaSecretKey: string | null; @@ -167,13 +159,13 @@ export class Meta { public enableRecaptcha: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public recaptchaSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public recaptchaSecretKey: string | null; @@ -184,13 +176,13 @@ export class Meta { public enableTurnstile: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public turnstileSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public turnstileSecretKey: string | null; @@ -218,7 +210,7 @@ export class Meta { public enableSensitiveMediaDetectionForVideos: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public summalyProxy: string | null; @@ -229,7 +221,7 @@ export class Meta { public enableEmail: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public email: string | null; @@ -240,7 +232,7 @@ export class Meta { public smtpSecure: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpHost: string | null; @@ -251,13 +243,13 @@ export class Meta { public smtpPort: number | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpUser: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpPass: string | null; @@ -268,19 +260,19 @@ export class Meta { public enableServiceWorker: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public swPublicKey: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public swPrivateKey: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public deeplAuthKey: string | null; @@ -291,20 +283,20 @@ export class Meta { public deeplIsPro: boolean; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) - public ToSUrl: string | null; + public termsOfServiceUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, default: 'https://github.com/misskey-dev/misskey', nullable: false, }) public repositoryUrl: string; @Column('varchar', { - length: 512, + length: 1024, default: 'https://github.com/misskey-dev/misskey/issues/new', nullable: true, }) @@ -328,43 +320,43 @@ export class Meta { public useObjectStorage: boolean; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageBucket: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStoragePrefix: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageBaseUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageEndpoint: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageRegion: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageAccessKey: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageSecretKey: string | null; diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index 82d042f0c..df508b4dc 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -87,6 +87,11 @@ export class Note { }) public localOnly: boolean; + @Column('varchar', { + length: 64, nullable: true, + }) + public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null; + @Column('smallint', { default: 0, }) diff --git a/packages/backend/src/models/entities/RenoteMuting.ts b/packages/backend/src/models/entities/RenoteMuting.ts new file mode 100644 index 000000000..2f803a5fa --- /dev/null +++ b/packages/backend/src/models/entities/RenoteMuting.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +@Index(['muterId', 'muteeId'], { unique: true }) +export class RenoteMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Muting.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The mutee user ID.', + }) + public muteeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The muter user ID.', + }) + public muterId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/entities/RetentionAggregation.ts b/packages/backend/src/models/entities/RetentionAggregation.ts index c79b762d7..c7bf38b3a 100644 --- a/packages/backend/src/models/entities/RetentionAggregation.ts +++ b/packages/backend/src/models/entities/RetentionAggregation.ts @@ -18,6 +18,12 @@ export class RetentionAggregation { }) public updatedAt: Date; + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: false, + }) + public dateKey: string; + @Column({ ...id(), array: true, diff --git a/packages/backend/src/models/entities/Role.ts b/packages/backend/src/models/entities/Role.ts index 399e9ead0..85ff26674 100644 --- a/packages/backend/src/models/entities/Role.ts +++ b/packages/backend/src/models/entities/Role.ts @@ -144,6 +144,12 @@ export class Role { }) public canEditMembersByModerator: boolean; + // UIに表示する際の並び順用(大きいほど先頭) + @Column('integer', { + default: 0, + }) + public displayOrder: number; + @Column('jsonb', { default: { }, }) diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 25ed9b89d..17083d7a0 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -13,6 +13,7 @@ import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; +import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; @@ -26,6 +27,7 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; @@ -80,6 +82,7 @@ export { ChannelNotePining, Clip, ClipNote, + ClipFavorite, DriveFile, DriveFolder, Emoji, @@ -93,6 +96,7 @@ export { ModerationLog, MutedNote, Muting, + RenoteMuting, Note, NoteFavorite, NoteReaction, @@ -146,6 +150,7 @@ export type ChannelFollowingsRepository = Repository; export type ChannelNotePiningsRepository = Repository; export type ClipsRepository = Repository; export type ClipNotesRepository = Repository; +export type ClipFavoritesRepository = Repository; export type DriveFilesRepository = Repository; export type DriveFoldersRepository = Repository; export type EmojisRepository = Repository; @@ -159,6 +164,7 @@ export type MetasRepository = Repository; export type ModerationLogsRepository = Repository; export type MutedNotesRepository = Repository; export type MutingsRepository = Repository; +export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; diff --git a/packages/backend/src/models/schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts similarity index 95% rename from packages/backend/src/models/schema/antenna.ts rename to packages/backend/src/models/json-schema/antenna.ts index f0994e48f..448351061 100644 --- a/packages/backend/src/models/schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -75,6 +75,10 @@ export const packedAntennaSchema = { type: 'boolean', optional: false, nullable: false, }, + isActive: { + type: 'boolean', + optional: false, nullable: false, + }, hasUnreadNote: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/app.ts b/packages/backend/src/models/json-schema/app.ts similarity index 100% rename from packages/backend/src/models/schema/app.ts rename to packages/backend/src/models/json-schema/app.ts diff --git a/packages/backend/src/models/schema/blocking.ts b/packages/backend/src/models/json-schema/blocking.ts similarity index 100% rename from packages/backend/src/models/schema/blocking.ts rename to packages/backend/src/models/json-schema/blocking.ts diff --git a/packages/backend/src/models/schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts similarity index 100% rename from packages/backend/src/models/schema/channel.ts rename to packages/backend/src/models/json-schema/channel.ts diff --git a/packages/backend/src/models/schema/clip.ts b/packages/backend/src/models/json-schema/clip.ts similarity index 72% rename from packages/backend/src/models/schema/clip.ts rename to packages/backend/src/models/json-schema/clip.ts index f0ee2ce0c..7310e5901 100644 --- a/packages/backend/src/models/schema/clip.ts +++ b/packages/backend/src/models/json-schema/clip.ts @@ -12,6 +12,11 @@ export const packedClipSchema = { optional: false, nullable: false, format: 'date-time', }, + lastClippedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, userId: { type: 'string', optional: false, nullable: false, @@ -34,5 +39,13 @@ export const packedClipSchema = { type: 'boolean', optional: false, nullable: false, }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, + favoritedCount: { + type: 'number', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/schema/drive-file.ts b/packages/backend/src/models/json-schema/drive-file.ts similarity index 100% rename from packages/backend/src/models/schema/drive-file.ts rename to packages/backend/src/models/json-schema/drive-file.ts diff --git a/packages/backend/src/models/schema/drive-folder.ts b/packages/backend/src/models/json-schema/drive-folder.ts similarity index 100% rename from packages/backend/src/models/schema/drive-folder.ts rename to packages/backend/src/models/json-schema/drive-folder.ts diff --git a/packages/backend/src/models/schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts similarity index 94% rename from packages/backend/src/models/schema/emoji.ts rename to packages/backend/src/models/json-schema/emoji.ts index c00c3dac1..db4fd62cf 100644 --- a/packages/backend/src/models/schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -59,5 +59,9 @@ export const packedEmojiDetailedSchema = { type: 'string', optional: false, nullable: false, }, + license: { + type: 'string', + optional: false, nullable: true, + }, }, } as const; diff --git a/packages/backend/src/models/schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts similarity index 100% rename from packages/backend/src/models/schema/federation-instance.ts rename to packages/backend/src/models/json-schema/federation-instance.ts diff --git a/packages/backend/src/models/schema/flash.ts b/packages/backend/src/models/json-schema/flash.ts similarity index 100% rename from packages/backend/src/models/schema/flash.ts rename to packages/backend/src/models/json-schema/flash.ts diff --git a/packages/backend/src/models/schema/following.ts b/packages/backend/src/models/json-schema/following.ts similarity index 100% rename from packages/backend/src/models/schema/following.ts rename to packages/backend/src/models/json-schema/following.ts diff --git a/packages/backend/src/models/schema/gallery-post.ts b/packages/backend/src/models/json-schema/gallery-post.ts similarity index 100% rename from packages/backend/src/models/schema/gallery-post.ts rename to packages/backend/src/models/json-schema/gallery-post.ts diff --git a/packages/backend/src/models/schema/hashtag.ts b/packages/backend/src/models/json-schema/hashtag.ts similarity index 100% rename from packages/backend/src/models/schema/hashtag.ts rename to packages/backend/src/models/json-schema/hashtag.ts diff --git a/packages/backend/src/models/schema/muting.ts b/packages/backend/src/models/json-schema/muting.ts similarity index 100% rename from packages/backend/src/models/schema/muting.ts rename to packages/backend/src/models/json-schema/muting.ts diff --git a/packages/backend/src/models/schema/note-favorite.ts b/packages/backend/src/models/json-schema/note-favorite.ts similarity index 100% rename from packages/backend/src/models/schema/note-favorite.ts rename to packages/backend/src/models/json-schema/note-favorite.ts diff --git a/packages/backend/src/models/schema/note-reaction.ts b/packages/backend/src/models/json-schema/note-reaction.ts similarity index 100% rename from packages/backend/src/models/schema/note-reaction.ts rename to packages/backend/src/models/json-schema/note-reaction.ts diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/json-schema/note.ts similarity index 97% rename from packages/backend/src/models/schema/note.ts rename to packages/backend/src/models/json-schema/note.ts index 72c0c6228..58ef425dc 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -141,6 +141,10 @@ export const packedNoteSchema = { type: 'boolean', optional: true, nullable: false, }, + reactionAcceptance: { + type: 'string', + optional: false, nullable: true, + }, reactions: { type: 'object', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts similarity index 100% rename from packages/backend/src/models/schema/notification.ts rename to packages/backend/src/models/json-schema/notification.ts diff --git a/packages/backend/src/models/schema/page.ts b/packages/backend/src/models/json-schema/page.ts similarity index 100% rename from packages/backend/src/models/schema/page.ts rename to packages/backend/src/models/json-schema/page.ts diff --git a/packages/backend/src/models/schema/queue.ts b/packages/backend/src/models/json-schema/queue.ts similarity index 100% rename from packages/backend/src/models/schema/queue.ts rename to packages/backend/src/models/json-schema/queue.ts diff --git a/packages/backend/src/models/json-schema/renote-muting.ts b/packages/backend/src/models/json-schema/renote-muting.ts new file mode 100644 index 000000000..69ed8510d --- /dev/null +++ b/packages/backend/src/models/json-schema/renote-muting.ts @@ -0,0 +1,26 @@ +export const packedRenoteMutingSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + muteeId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + mutee: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user-list.ts b/packages/backend/src/models/json-schema/user-list.ts similarity index 100% rename from packages/backend/src/models/schema/user-list.ts rename to packages/backend/src/models/json-schema/user-list.ts diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/json-schema/user.ts similarity index 98% rename from packages/backend/src/models/schema/user.ts rename to packages/backend/src/models/json-schema/user.ts index c390018b4..e8a7212c5 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -29,7 +29,7 @@ export const packedUserLiteSchema = { nullable: true, optional: false, }, avatarBlurhash: { - type: 'any', + type: 'string', nullable: true, optional: false, }, isAdmin: { @@ -93,7 +93,7 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: true, optional: false, }, bannerBlurhash: { - type: 'any', + type: 'string', nullable: true, optional: false, }, isLocked: { @@ -234,6 +234,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + isRenoteMuted: { + type: 'boolean', + nullable: false, optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index c2ee14b0f..d5428805d 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -21,6 +21,7 @@ import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; +import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; @@ -34,6 +35,7 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; @@ -139,6 +141,7 @@ export const entities = [ Following, FollowRequest, Muting, + RenoteMuting, Blocking, Note, NoteFavorite, @@ -163,6 +166,7 @@ export const entities = [ ModerationLog, Clip, ClipNote, + ClipFavorite, Antenna, AntennaNote, PromoNote, diff --git a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts index 02324c6cd..e2720b4fe 100644 --- a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts +++ b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts @@ -7,6 +7,7 @@ import { bindThis } from '@/decorators.js'; import type { RetentionAggregationsRepository, UsersRepository } from '@/models/index.js'; import { deepClone } from '@/misc/clone.js'; import { IdService } from '@/core/IdService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; @@ -49,13 +50,23 @@ export class AggregateRetentionProcessorService { }); const targetUserIds = targetUsers.map(u => u.id); - await this.retentionAggregationsRepository.insert({ - id: this.idService.genId(), - createdAt: now, - updatedAt: now, - userIds: targetUserIds, - usersCount: targetUserIds.length, - }); + try { + await this.retentionAggregationsRepository.insert({ + id: this.idService.genId(), + createdAt: now, + updatedAt: now, + dateKey, + userIds: targetUserIds, + usersCount: targetUserIds.length, + }); + } catch (err) { + if (isDuplicateKeyValueError(err)) { + this.logger.succ('Skip because it has already been processed by another worker.'); + done(); + return; + } + throw err; + } // 今日活動したユーザーを全て取得 const activeUsers = await this.usersRepository.findBy({ diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index 7fd2cde9c..9534454fd 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 { In, LessThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; +import type { AntennaNotesRepository, AntennasRepository, 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'; @@ -26,6 +26,9 @@ export class CleanProcessorService { @Inject(DI.mutedNotesRepository) private mutedNotesRepository: MutedNotesRepository, + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + @Inject(DI.antennaNotesRepository) private antennaNotesRepository: AntennaNotesRepository, @@ -55,8 +58,16 @@ export class CleanProcessorService { reason: 'word', }); - this.antennaNotesRepository.delete({ + this.mutedNotesRepository.delete({ id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + reason: 'word', + }); + + // 7日以上使われてないアンテナを停止 + this.antennasRepository.update({ + lastUsedAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 7))), + }, { + isActive: false, }); const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign') diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 2a053a12e..43a92bb26 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -115,6 +115,18 @@ export class DeliverProcessorService { if (res instanceof StatusError) { // 4xx if (res.isClientError) { + // 相手が閉鎖していることを明示しているため、配送停止する + if (job.data.isSharedInbox && res.statusCode === 410) { + this.federatedInstanceService.fetch(host).then(i => { + this.instancesRepository.update(i.id, { + isSuspended: true, + }); + this.federatedInstanceService.updateCachePartial(host, { + isSuspended: true, + }); + }); + return `${host} is gone`; + } // HTTPステータスコード4xxはクライアントエラーであり、それはつまり // 何回再送しても成功することはないということなのでエラーにはしないでおく return `${res.statusCode} ${res.statusMessage}`; diff --git a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts index 037dfa1a5..501ed4090 100644 --- a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts +++ b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts @@ -3,11 +3,11 @@ import { DI } from '@/di-symbols.js'; import type { PollVotesRepository, NotesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; import type { EndedPollNotificationJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class EndedPollNotificationProcessorService { @@ -23,7 +23,7 @@ export class EndedPollNotificationProcessorService { @Inject(DI.pollVotesRepository) private pollVotesRepository: PollVotesRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('ended-poll-notification'); @@ -47,7 +47,7 @@ export class EndedPollNotificationProcessorService { const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])]; for (const userId of userIds) { - this.createNotificationService.createNotification(userId, 'pollEnded', { + this.notificationService.createNotification(userId, 'pollEnded', { noteId: note.id, }); } diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index c65f0a97a..e9330772b 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -148,6 +148,7 @@ function serialize(favorite: NoteFavorite & { note: Note & { user: User } }, pol visibility: favorite.note.visibility, visibleUserIds: favorite.note.visibleUserIds, localOnly: favorite.note.localOnly, + reactionAcceptance: favorite.note.reactionAcceptance, uri: favorite.note.uri, url: favorite.note.url, user: { diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 3f4f16a2e..2f74dd63c 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { Poll } from '@/models/entities/Poll.js'; import type { Note } from '@/models/entities/Note.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; import type { DbUserJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ExportNotesProcessorService { @@ -141,5 +141,6 @@ function serialize(note: Note, poll: Poll | null = null): Record { const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); reply.header('Content-Type', 'image/jpeg'); @@ -297,7 +291,7 @@ export class FileServerService { }; } } else if ('static' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); } else if ('preview' in request.query) { image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); } else if ('badge' in request.query) { @@ -311,20 +305,20 @@ export class FileServerService { .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast .flatten({ background: '#000' }) .toColorspace('b-w'); - + const stats = await mask.clone().stats(); - + if (stats.entropy < 0.1) { // エントロピーがあまりない場合は404にする throw new StatusError('Skip to provide badge', 404); } - + const data = sharp({ create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, }) .pipelineColorspace('b-w') .boolean(await mask.png().toBuffer(), 'eor'); - + image = { data: await data.png().toBuffer(), ext: 'png', @@ -396,7 +390,7 @@ export class FileServerService { const { filename } = await this.downloadService.downloadUrl(url, path); const { mime, ext } = await this.fileInfoService.detectType(path); - + return { state: 'remote', mime, ext, diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 00a0d9309..364b46696 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -100,7 +100,7 @@ export class NodeinfoServerService { email: meta.maintainerEmail, }, langs: meta.langs, - tosUrl: meta.ToSUrl, + tosUrl: meta.termsOfServiceUrl, repositoryUrl: meta.repositoryUrl, feedbackUrl: meta.feedbackUrl, disableRegistration: meta.disableRegistration, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index a5a5f9e7f..6bae0bafd 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -33,6 +33,7 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; +import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; @Module({ imports: [ @@ -72,6 +73,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; QueueStatsChannelService, ServerStatsChannelService, UserListChannelService, + OpenApiServerService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index e61383468..3f116845c 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -1,7 +1,9 @@ import cluster from 'node:cluster'; import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import Fastify, { FastifyInstance } from 'fastify'; +import fastifyStatic from '@fastify/static'; import { IsNull } from 'typeorm'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; @@ -21,6 +23,9 @@ import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; +import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; + +const _dirname = fileURLToPath(new URL('.', import.meta.url)); @Injectable() export class ServerService implements OnApplicationShutdown { @@ -42,6 +47,7 @@ export class ServerService implements OnApplicationShutdown { private userEntityService: UserEntityService, private apiServerService: ApiServerService, + private openApiServerService: OpenApiServerService, private streamingApiServerService: StreamingApiServerService, private activityPubServerService: ActivityPubServerService, private wellKnownServerService: WellKnownServerService, @@ -71,7 +77,15 @@ export class ServerService implements OnApplicationShutdown { }); } + // Register non-serving static server so that the child services can use reply.sendFile. + // `root` here is just a placeholder and each call must use its own `rootPath`. + fastify.register(fastifyStatic, { + root: _dirname, + serve: false, + }); + fastify.register(this.apiServerService.createServer, { prefix: '/api' }); + fastify.register(this.openApiServerService.createServer); fastify.register(this.fileServerService.createServer); fastify.register(this.activityPubServerService.createServer); fastify.register(this.nodeinfoServerService.createServer); diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index f84a3aa59..bf5cb2091 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -75,7 +75,7 @@ export class ApiCallService implements OnApplicationShutdown { } this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -129,7 +129,7 @@ export class ApiCallService implements OnApplicationShutdown { }, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -321,7 +321,7 @@ export class ApiCallService implements OnApplicationShutdown { // API invoking return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { - if (err instanceof ApiError) { + if (err instanceof ApiError || err instanceof AuthenticationError) { throw err; } else { const errId = uuid(); diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 115d60986..b806ad5ca 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -167,7 +167,7 @@ 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) => { + fastify.get('/*', (request, reply) => { reply.code(404); // Mock ApiCallService.send's error handling reply.send({ diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index d3e2219bd..835e88419 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -42,6 +42,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; +import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; @@ -114,6 +115,9 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___clips_favorite from './endpoints/clips/favorite.js'; +import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; +import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -220,10 +224,14 @@ import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; +import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_list from './endpoints/mute/list.js'; +import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; +import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___notes from './endpoints/notes.js'; import * as ep___notes_children from './endpoints/notes/children.js'; @@ -363,6 +371,7 @@ const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useCla const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default }; +const $admin_queue_promote: Provider = { provide: 'ep:admin/queue/promote', useClass: ep___admin_queue_promote.default }; const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default }; const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default }; const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; @@ -435,6 +444,9 @@ const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_l const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default }; const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default }; const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default }; +const $clips_favorite: Provider = { provide: 'ep:clips/favorite', useClass: ep___clips_favorite.default }; +const $clips_unfavorite: Provider = { provide: 'ep:clips/unfavorite', useClass: ep___clips_unfavorite.default }; +const $clips_myFavorites: Provider = { provide: 'ep:clips/my-favorites', useClass: ep___clips_myFavorites.default }; const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default }; const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default }; const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default }; @@ -541,10 +553,14 @@ const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; +const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; +const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default }; +const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default }; +const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default }; const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default }; const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default }; const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default }; @@ -688,6 +704,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_queue_clear, $admin_queue_deliverDelayed, $admin_queue_inboxDelayed, + $admin_queue_promote, $admin_queue_stats, $admin_relays_add, $admin_relays_list, @@ -760,6 +777,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $clips_notes, $clips_show, $clips_update, + $clips_favorite, + $clips_unfavorite, + $clips_myFavorites, $drive, $drive_files, $drive_files_attachedNotes, @@ -866,10 +886,14 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_delete, $meta, $emojis, + $emoji, $miauth_genToken, $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, @@ -1007,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_queue_clear, $admin_queue_deliverDelayed, $admin_queue_inboxDelayed, + $admin_queue_promote, $admin_queue_stats, $admin_relays_add, $admin_relays_list, @@ -1079,6 +1104,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $clips_notes, $clips_show, $clips_update, + $clips_favorite, + $clips_unfavorite, + $clips_myFavorites, $drive, $drive_files, $drive_files_attachedNotes, @@ -1185,10 +1213,14 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_delete, $meta, $emojis, + $emoji, $miauth_genToken, $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 41e8365d0..fbabf47af 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; @@ -15,6 +15,7 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { bindThis } from '@/decorators.js'; import { SigninService } from './SigninService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; +import { IsNull } from 'typeorm'; @Injectable() export class SignupApiService { @@ -31,6 +32,9 @@ export class SignupApiService { @Inject(DI.userPendingsRepository) private userPendingsRepository: UserPendingsRepository, + @Inject(DI.usedUsernamesRepository) + private usedUsernamesRepository: UsedUsernamesRepository, + @Inject(DI.registrationTicketsRepository) private registrationTicketsRepository: RegistrationTicketsRepository, @@ -124,12 +128,21 @@ export class SignupApiService { } if (instance.emailRequiredForSignup) { + if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { + throw new FastifyReplyError(400, 'USED_USERNAME'); + } + const code = rndstr('a-z0-9', 16); - + // Generate hash of password const salt = await bcrypt.genSalt(8); const hash = await bcrypt.hash(password, salt); - + await this.userPendingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), @@ -138,13 +151,13 @@ export class SignupApiService { username: username, password: hash, }); - + const link = `${this.config.url}/signup-complete/${code}`; - + this.emailService.sendEmail(emailAddress!, 'Signup', `To complete signup, please click this link:
${link}`, `To complete signup, please click this link: ${link}`); - + reply.code(204); return; } else { diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 487eef2d5..13526f277 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -3,17 +3,17 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import * as websocket from 'websocket'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js'; +import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { bindThis } from '@/decorators.js'; import { AuthenticateService } from './AuthenticateService.js'; import MainStreamConnection from './stream/index.js'; import { ChannelsService } from './stream/ChannelsService.js'; import type { ParsedUrlQuery } from 'querystring'; import type * as http from 'node:http'; -import { bindThis } from '@/decorators.js'; @Injectable() export class StreamingApiServerService { @@ -33,6 +33,9 @@ export class StreamingApiServerService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -84,6 +87,7 @@ export class StreamingApiServerService { const main = new MainStreamConnection( this.followingsRepository, this.mutingsRepository, + this.renoteMutingsRepository, this.blockingsRepository, this.channelFollowingsRepository, this.userProfilesRepository, diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index ed283eb83..1555a3ca4 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import Ajv from 'ajv'; -import type { Schema, SchemaType } from '@/misc/schema.js'; +import type { Schema, SchemaType } from '@/misc/json-schema.js'; import type { LocalUser } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import { ApiError } from './error.js'; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4f521148e..f6fc79fc7 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -1,4 +1,4 @@ -import type { Schema } from '@/misc/schema.js'; +import type { Schema } from '@/misc/json-schema.js'; import { RolePolicies } from '@/core/RoleService.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; @@ -42,6 +42,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; +import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; @@ -114,6 +115,9 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___clips_favorite from './endpoints/clips/favorite.js'; +import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; +import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -220,10 +224,14 @@ import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; +import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_list from './endpoints/mute/list.js'; +import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; +import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___notes from './endpoints/notes.js'; import * as ep___notes_children from './endpoints/notes/children.js'; @@ -361,6 +369,7 @@ const eps = [ ['admin/queue/clear', ep___admin_queue_clear], ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], ['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed], + ['admin/queue/promote', ep___admin_queue_promote], ['admin/queue/stats', ep___admin_queue_stats], ['admin/relays/add', ep___admin_relays_add], ['admin/relays/list', ep___admin_relays_list], @@ -433,6 +442,9 @@ const eps = [ ['clips/notes', ep___clips_notes], ['clips/show', ep___clips_show], ['clips/update', ep___clips_update], + ['clips/favorite', ep___clips_favorite], + ['clips/unfavorite', ep___clips_unfavorite], + ['clips/my-favorites', ep___clips_myFavorites], ['drive', ep___drive], ['drive/files', ep___drive_files], ['drive/files/attached-notes', ep___drive_files_attachedNotes], @@ -539,10 +551,14 @@ const eps = [ ['i/webhooks/delete', ep___i_webhooks_delete], ['meta', ep___meta], ['emojis', ep___emojis], + ['emoji', ep___emoji], ['miauth/gen-token', ep___miauth_genToken], ['mute/create', ep___mute_create], ['mute/delete', ep___mute_delete], ['mute/list', ep___mute_list], + ['renote-mute/create', ep___renoteMute_create], + ['renote-mute/delete', ep___renoteMute_delete], + ['renote-mute/list', ep___renoteMute_list], ['my/apps', ep___my_apps], ['notes', ep___notes], ['notes/children', ep___notes_children], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index 0cc60e919..4e4f845b0 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -53,7 +53,7 @@ export default class extends Endpoint { }); } - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ps.ids), diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 04c58050f..2fb3e489e 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -56,6 +56,7 @@ export default class extends Endpoint { category: null, aliases: [], host: null, + license: null, }); this.moderationLogService.insertModerationLog(me, 'addEmoji', { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index 8885a40fd..fea11a67d 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -87,9 +87,10 @@ export default class extends Endpoint { originalUrl: driveFile.url, publicUrl: driveFile.webpublicUrl ?? driveFile.url, type: driveFile.webpublicType ?? driveFile.type, + license: emoji.license, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: await this.emojiEntityService.packDetailed(copied.id), diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts index f298baaed..84aad020a 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { for (const emoji of emojis) { await this.emojisRepository.delete(emoji.id); - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { emoji: emoji, }); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index a5fbe3f4e..90a5856a1 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -54,7 +54,7 @@ export default class extends Endpoint { await this.emojisRepository.delete(emoji.id); - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); this.globalEventService.publishBroadcastStream('emojiDeleted', { emojis: [await this.emojiEntityService.packDetailed(emoji)], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index 66547024f..393518350 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -53,7 +53,7 @@ export default class extends Endpoint { }); } - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ps.ids), diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index c8992eeb0..6a875f9c8 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -49,7 +49,7 @@ export default class extends Endpoint { aliases: ps.aliases, }); - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ps.ids), diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index 8a538c100..d3b999c0e 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { category: ps.category, }); - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ps.ids), diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 809bf77d6..1c649db93 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -19,6 +19,11 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', }, + alreadyexistsemoji: { + message: 'Emoji already exists', + code: 'EMOJI_ALREADY_EXISTS', + id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8', + }, }, } as const; @@ -26,7 +31,7 @@ export const paramDef = { type: 'object', properties: { id: { type: 'string', format: 'misskey:id' }, - name: { type: 'string' }, + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, category: { type: 'string', nullable: true, @@ -35,6 +40,7 @@ export const paramDef = { aliases: { type: 'array', items: { type: 'string', } }, + license: { type: 'string', nullable: true }, }, required: ['id', 'name', 'aliases'], } as const; @@ -56,17 +62,18 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - + const emojiname = await this.emojisRepository.findOneBy({ name: ps.name }); if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - + if (emojiname != null && emojiname.id !== ps.id) throw new ApiError(meta.errors.alreadyexistsemoji); await this.emojisRepository.update(emoji.id, { updatedAt: new Date(), name: ps.name, category: ps.category, aliases: ps.aliases, + license: ps.license, }); - await this.db.queryResultCache!.remove(['meta_emojis']); + await this.db.queryResultCache?.remove(['meta_emojis']); const updated = await this.emojiEntityService.packDetailed(emoji.id); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 9eef1b29c..ce7e0d569 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -110,6 +110,14 @@ export const meta = { optional: false, nullable: false, }, }, + sensitiveWords: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, hcaptchaSecretKey: { type: 'string', optional: true, nullable: true, @@ -266,7 +274,7 @@ export default class extends Endpoint { uri: this.config.url, description: instance.description, langs: instance.langs, - tosUrl: instance.ToSUrl, + tosUrl: instance.termsOfServiceUrl, repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, @@ -290,13 +298,12 @@ export default class extends Endpoint { enableEmail: instance.enableEmail, enableServiceWorker: instance.enableServiceWorker, translatorAvailable: instance.deeplAuthKey != null, - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, useStarForReactionFallback: instance.useStarForReactionFallback, pinnedUsers: instance.pinnedUsers, hiddenTags: instance.hiddenTags, blockedHosts: instance.blockedHosts, + sensitiveWords: instance.sensitiveWords, hcaptchaSecretKey: instance.hcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, turnstileSecretKey: instance.turnstileSecretKey, diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts new file mode 100644 index 000000000..4e57e6613 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + type: { type: 'string', enum: ['deliver', 'inbox'] }, + }, + required: ['type'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + let delayedQueues; + + switch (ps.type) { + case 'deliver': + delayedQueues = await this.queueService.deliverQueue.getDelayed(); + for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { + const queue = delayedQueues[queueIndex]; + await queue.promote(); + } + break; + + case 'inbox': + delayedQueues = await this.queueService.inboxQueue.getDelayed(); + for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { + const queue = delayedQueues[queueIndex]; + await queue.promote(); + } + break; + } + + this.moderationLogService.insertModerationLog(me, 'promoteQueue'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts index d0d52089e..aead89461 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -49,7 +49,7 @@ export default class extends Endpoint { const actor = await this.instanceActorService.getInstanceActor(); const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); - this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment)), targetUser.inbox); + this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment)), targetUser.inbox, false); } await this.abuseUserReportsRepository.update(report.id, { diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index df60c6be9..135989463 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -27,6 +27,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, + displayOrder: { type: 'number' }, policies: { type: 'object', }, @@ -43,6 +44,7 @@ export const paramDef = { 'isAdministrator', 'asBadge', 'canEditMembersByModerator', + 'displayOrder', 'policies', ], } as const; @@ -76,6 +78,7 @@ export default class extends Endpoint { isModerator: ps.isModerator, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, + displayOrder: ps.displayOrder, policies: ps.policies, }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index b939ccdbf..37b68c4c4 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -35,6 +35,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, + displayOrder: { type: 'number' }, policies: { type: 'object', }, @@ -52,6 +53,7 @@ export const paramDef = { 'isAdministrator', 'asBadge', 'canEditMembersByModerator', + 'displayOrder', 'policies', ], } as const; @@ -85,6 +87,7 @@ export default class extends Endpoint { isAdministrator: ps.isAdministrator, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, + displayOrder: ps.displayOrder, policies: ps.policies, }); const updated = await this.rolesRepository.findOneByOrFail({ id: ps.roleId }); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index a7531aae8..2f23aca24 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -27,6 +27,9 @@ export const paramDef = { blockedHosts: { type: 'array', nullable: true, items: { type: 'string', } }, + sensitiveWords: { type: 'array', nullable: true, items: { + type: 'string', + } }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, @@ -56,10 +59,6 @@ export const paramDef = { proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, maintainerName: { type: 'string', nullable: true }, maintainerEmail: { type: 'string', nullable: true }, - pinnedPages: { type: 'array', items: { - type: 'string', - } }, - pinnedClipId: { type: 'string', format: 'misskey:id', nullable: true }, langs: { type: 'array', items: { type: 'string', } }, @@ -131,6 +130,10 @@ export default class extends Endpoint { set.blockedHosts = ps.blockedHosts.filter(Boolean).map(x => x.toLowerCase()); } + if (Array.isArray(ps.sensitiveWords)) { + set.sensitiveWords = ps.sensitiveWords.filter(Boolean); + } + if (ps.themeColor !== undefined) { set.themeColor = ps.themeColor; } @@ -247,14 +250,6 @@ export default class extends Endpoint { set.langs = ps.langs.filter(Boolean); } - if (Array.isArray(ps.pinnedPages)) { - set.pinnedPages = ps.pinnedPages.filter(Boolean); - } - - if (ps.pinnedClipId !== undefined) { - set.pinnedClipId = ps.pinnedClipId; - } - if (ps.summalyProxy !== undefined) { set.summalyProxy = ps.summalyProxy; } @@ -304,7 +299,7 @@ export default class extends Endpoint { } if (ps.tosUrl !== undefined) { - set.ToSUrl = ps.tosUrl; + set.termsOfServiceUrl = ps.tosUrl; } if (ps.repositoryUrl !== undefined) { diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index bc5d249ae..d147ddb7f 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -79,6 +79,10 @@ export default class extends Endpoint { private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { + if (ps.keywords.length === 0) { + throw new Error('invalid param'); + } + const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id, }); @@ -99,9 +103,12 @@ export default class extends Endpoint { } } + const now = new Date(); + const antenna = await this.antennasRepository.insert({ id: this.idService.genId(), - createdAt: new Date(), + createdAt: now, + lastUsedAt: now, userId: me.id, name: ps.name, src: ps.src, diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index fbb5acf61..039ba1115 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -101,6 +101,10 @@ export default class extends Endpoint { this.noteReadService.read(me.id, notes); } + this.antennasRepository.update(antenna.id, { + lastUsedAt: new Date(), + }); + return await this.noteEntityService.packMany(notes, me); }); } diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 61e05531e..a103d4196 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -5,7 +5,7 @@ import type { UsersRepository, NotesRepository } from '@/models/index.js'; import type { Note } from '@/models/entities/Note.js'; import type { LocalUser, User } from '@/models/entities/User.js'; import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; -import type { SchemaType } from '@/misc/schema.js'; +import type { SchemaType } from '@/misc/json-schema.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { MetaService } from '@/core/MetaService.js'; diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index d006e89bd..a86cc2565 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -4,6 +4,7 @@ import type { DriveFilesRepository, ChannelsRepository } from '@/models/index.js import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['channels'], @@ -61,7 +62,9 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private channelEntityService: ChannelEntityService, - ) { + + private roleService: RoleService, + ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ id: ps.channelId, @@ -71,7 +74,8 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - if (channel.userId !== me.id) { + const iAmModerator = await this.roleService.isModerator(me); + if (channel.userId !== me.id && !iAmModerator) { throw new ApiError(meta.errors.accessDenied); } diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index f3f9c3477..b9d8dce47 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -106,6 +106,10 @@ export default class extends Endpoint { noteId: note.id, clipId: clip.id, }); + + await this.clipsRepository.update(clip.id, { + lastClippedAt: new Date(), + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index c095de702..a770dc986 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -67,7 +67,7 @@ export default class extends Endpoint { description: ps.description, }).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0])); - return await this.clipEntityService.pack(clip); + return await this.clipEntityService.pack(clip, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts new file mode 100644 index 000000000..6addf743a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['clip'], + + requireCredential: true, + + kind: 'write:clip-favorite', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + }, + + alreadyFavorited: { + message: 'The clip has already been favorited.', + code: 'ALREADY_FAVORITED', + id: '92658936-c625-4273-8326-2d790129256e', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + if ((clip.userId !== me.id) && !clip.isPublic) { + throw new ApiError(meta.errors.noSuchClip); + } + + const exist = await this.clipFavoritesRepository.findOneBy({ + clipId: clip.id, + userId: me.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + await this.clipFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + clipId: clip.id, + userId: me.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts index 63ca06936..3b8deab70 100644 --- a/packages/backend/src/server/api/endpoints/clips/list.ts +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -42,7 +42,7 @@ export default class extends Endpoint { userId: me.id, }); - return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts new file mode 100644 index 000000000..fc727e93b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ClipFavoritesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; + +export const meta = { + tags: ['account', 'clip'], + + requireCredential: true, + + kind: 'read:clip-favorite', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Clip', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + + private clipEntityService: ClipEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.clipFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.clip', 'clip'); + + const favorites = await query + .getMany(); + + return this.clipEntityService.packMany(favorites.map(x => x.clip!), me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts index e6d3f4f1f..99d630a9b 100644 --- a/packages/backend/src/server/api/endpoints/clips/show.ts +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -58,7 +58,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchClip); } - return await this.clipEntityService.pack(clip); + return await this.clipEntityService.pack(clip, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts new file mode 100644 index 000000000..244843d50 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts @@ -0,0 +1,65 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['clip'], + + requireCredential: true, + + kind: 'write:clip-favorite', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '2603966e-b865-426c-94a7-af4a01241dc1', + }, + + notFavorited: { + message: 'You have not favorited the clip.', + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const exist = await this.clipFavoritesRepository.findOneBy({ + clipId: clip.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + await this.clipFavoritesRepository.delete(exist.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index 597b67c44..a103c3f7d 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -64,7 +64,7 @@ export default class extends Endpoint { isPublic: ps.isPublic, }); - return await this.clipEntityService.pack(clip.id); + return await this.clipEntityService.pack(clip.id, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index f6fad50fd..460930777 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -31,6 +31,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, + sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size'] }, }, required: [], } as const; @@ -63,6 +64,15 @@ export default class extends Endpoint { } } + switch (ps.sort) { + case '+createdAt': query.orderBy('file.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('file.createdAt', 'ASC'); break; + case '+name': query.orderBy('file.name', 'DESC'); break; + case '-name': query.orderBy('file.name', 'ASC'); break; + case '+size': query.orderBy('file.size', 'DESC'); break; + case '-size': query.orderBy('file.size', 'ASC'); break; + } + const files = await query.take(ps.limit).getMany(); return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); diff --git a/packages/backend/src/server/api/endpoints/emoji.ts b/packages/backend/src/server/api/endpoints/emoji.ts new file mode 100644 index 000000000..681d3e649 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji.ts @@ -0,0 +1,56 @@ +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { EmojisRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: false, + allowGet: true, + cacheSec: 3600, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'EmojiDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const emoji = await this.emojisRepository.findOneOrFail({ + where: { + name: ps.name, + host: IsNull(), + }, + }); + + return this.emojiEntityService.packDetailed(emoji); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 325b75835..0711fe4a5 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -23,24 +23,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - properties: { - name: { - type: 'string', - optional: false, nullable: false, - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - }, + ref: 'EmojiSimple', }, }, }, diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 60b24e958..061c6eb5b 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -76,9 +76,9 @@ export default class extends Endpoint { if (typeof ps.blocked === 'boolean') { const meta = await this.metaService.fetch(true); if (ps.blocked) { - query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere(meta.blockedHosts.length === 0 ? '1=0' : 'instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); } else { - query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); } } diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 6beef5ab8..a3e3e02a1 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -3,6 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '../error.js'; export const meta = { tags: ['account'], @@ -14,6 +15,15 @@ export const meta = { optional: false, nullable: false, ref: 'MeDetailed', }, + + errors: { + userIsDeleted: { + message: 'User is deleted.', + code: 'USER_IS_DELETED', + id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a', + kind: 'permission', + }, + } } as const; export const paramDef = { @@ -41,13 +51,17 @@ export default class extends Endpoint { const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 - const userProfile = await this.userProfilesRepository.findOneOrFail({ + const userProfile = await this.userProfilesRepository.findOne({ where: { userId: user.id, }, relations: ['user'], }); + if (userProfile == null) { + throw new ApiError(meta.errors.userIsDeleted); + } + if (!userProfile.loggedInDates.includes(today)) { this.userProfilesRepository.update({ userId: user.id }, { loggedInDates: [...userProfile.loggedInDates, today], diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index cdb314a87..37974ce2a 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -276,7 +276,7 @@ export default class extends Endpoint { uri: this.config.url, description: instance.description, langs: instance.langs, - tosUrl: instance.ToSUrl, + tosUrl: instance.termsOfServiceUrl, repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, @@ -315,8 +315,6 @@ export default class extends Endpoint { mediaProxy: this.config.mediaProxy, ...(ps.detail ? { - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, requireSetup: (await this.usersRepository.countBy({ host: IsNull(), diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts index d5caec6e1..0a5542f49 100644 --- a/packages/backend/src/server/api/endpoints/notes/clips.ts +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -4,8 +4,8 @@ import type { ClipNotesRepository, ClipsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['clips', 'notes'], @@ -67,7 +67,7 @@ export default class extends Endpoint { isPublic: true, }); - return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 786ad103b..69fafcb9c 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -97,6 +97,7 @@ export const paramDef = { } }, cw: { type: 'string', nullable: true, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false }, @@ -110,7 +111,7 @@ export const paramDef = { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, - nullable: false + nullable: false, }, fileIds: { type: 'array', @@ -280,6 +281,7 @@ export default class extends Endpoint { renote, cw: ps.cw, localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, visibleUsers, channel, diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index cf939f663..6bf17b222 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -71,7 +71,7 @@ export default class extends Endpoint { let notes = await query .orderBy('note.score', 'DESC') - .take(50) + .take(100) .getMany(); notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 5d0cdc3fc..9118d3393 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -89,6 +89,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 2819abb12..8a7ec65ab 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -8,6 +8,7 @@ import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -69,6 +70,7 @@ export default class extends Endpoint { private metaService: MetaService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me.id); @@ -83,7 +85,7 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .andWhere(new Brackets(qb => { qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); @@ -107,6 +109,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 18ed6d4e2..8c1c07a9f 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -8,6 +8,7 @@ import { MetaService } from '@/core/MetaService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -65,6 +66,7 @@ export default class extends Endpoint { private metaService: MetaService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -75,7 +77,7 @@ export default class extends Endpoint { //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('user.avatar', 'avatar') @@ -95,6 +97,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateMutedNoteQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index b9e06a783..2a44dc537 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -8,7 +8,6 @@ import { QueueService } from '@/core/QueueService.js'; import { PollService } from '@/core/PollService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; import { DI } from '@/di-symbols.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { ApiError } from '../../../error.js'; @@ -89,7 +88,6 @@ export default class extends Endpoint { private pollService: PollService, private apRendererService: ApRendererService, private globalEventService: GlobalEventService, - private createNotificationService: CreateNotificationService, private userBlockingService: UserBlockingService, ) { super(meta, paramDef, async (ps, me) => { @@ -161,7 +159,7 @@ export default class extends Endpoint { if (note.userHost != null) { const pollOwner = await this.usersRepository.findOneByOrFail({ id: note.userId }) as RemoteUser; - this.queueService.deliver(me, this.apRendererService.addContext(await this.apRendererService.renderVote(me, vote, note, poll, pollOwner)), pollOwner.inbox); + this.queueService.deliver(me, this.apRendererService.addContext(await this.apRendererService.renderVote(me, vote, note, poll, pollOwner)), pollOwner.inbox, false); } // リモートフォロワーにUpdate配信 diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index ef47a3004..5db5b6267 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -6,6 +6,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -23,6 +25,11 @@ export const meta = { }, errors: { + unavailable: { + message: 'Search of notes unavailable.', + code: 'UNAVAILABLE', + id: '0b44998d-77aa-4427-80d0-d2c9b8523011', + }, }, } as const; @@ -59,8 +66,14 @@ export default class extends Endpoint { private noteEntityService: NoteEntityService, private queryService: QueryService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me ? me.id : null); + if (!policies.canSearchNotes) { + throw new ApiError(meta.errors.unavailable); + } + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); if (ps.userId) { diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index e6de087c4..d9e72d260 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -6,6 +6,7 @@ import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['notes'], @@ -56,6 +57,7 @@ export default class extends Endpoint { private noteEntityService: NoteEntityService, private queryService: QueryService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const followees = await this.followingsRepository.createQueryBuilder('following') @@ -66,7 +68,7 @@ export default class extends Endpoint { //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('user.avatar', 'avatar') .leftJoinAndSelect('user.banner', 'banner') @@ -93,6 +95,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts index 2e63eee26..4102a924a 100644 --- a/packages/backend/src/server/api/endpoints/notifications/create.ts +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { tags: ['notifications'], @@ -27,10 +27,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, user, token) => { - this.createNotificationService.createNotification(user.id, 'app', { + this.notificationService.createNotification(user.id, 'app', { appAccessTokenId: token ? token.id : null, customBody: ps.body, customHeader: ps.header, diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts new file mode 100644 index 000000000..051a005b6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + limit: { + duration: ms('1hour'), + max: 20, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '5e0a5dff-1e94-4202-87ae-4d9c89eb2271', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '37285718-52f7-4aef-b7de-c38b8e8a8420', + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING', + id: 'ccfecbe4-1f1c-4fc2-8a3d-c3ffee61cb7b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // 自分自身 + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check if already muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await this.renoteMutingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as RenoteMuting); + + // publishUserEvent(user.id, 'mute', mutee); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts new file mode 100644 index 000000000..51a895fb7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -0,0 +1,87 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9b6728cf-638c-4aa1-bedb-e07d8101474d', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '619b1314-0850-4597-a242-e245f3da42af', + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '2e4ef874-8bf0-4b4b-b069-4598f6d05817', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // Check if the mutee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await this.renoteMutingsRepository.delete({ + id: exist.id, + }); + + // publishUserEvent(user.id, 'unmute', mutee); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts new file mode 100644 index 000000000..b2d7addb6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RenoteMutingEntityService } from '@/core/entities/RenoteMutingEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'read:mutes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'RenoteMuting', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private renoteMutingEntityService: RenoteMutingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere('muting.muterId = :meId', { meId: me.id }); + + const mutings = await query + .take(ps.limit) + .getMany(); + + return await this.renoteMutingEntityService.packMany(mutings, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts index e3fd0920c..c5aa93baa 100644 --- a/packages/backend/src/server/api/endpoints/users/clips.ts +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { .take(ps.limit) .getMany(); - return await this.clipEntityService.packMany(clips); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index ac9104bf9..3267c1884 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -50,6 +50,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, { @@ -91,6 +95,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, }, diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 29f24b045..ba432c273 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -48,6 +48,7 @@ export const meta = { message: 'No such user.', code: 'NO_SUCH_USER', id: '4362f8dc-731f-4ad8-a694-be5a88922a24', + httpStatusCode: 404, }, }, } as const; diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 347d5650a..34f452160 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -1,4 +1,4 @@ -type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number }; +type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: number }; export class ApiError extends Error { public message: string; diff --git a/packages/backend/src/server/api/openapi/OpenApiServerService.ts b/packages/backend/src/server/api/openapi/OpenApiServerService.ts new file mode 100644 index 000000000..e804ba276 --- /dev/null +++ b/packages/backend/src/server/api/openapi/OpenApiServerService.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { genOpenapiSpec } from './gen-spec.js'; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; + +const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); + +@Injectable() +export class OpenApiServerService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + @bindThis + public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.get('/api-doc', async (_request, reply) => { + reply.header('Cache-Control', 'public, max-age=86400'); + return await reply.sendFile('/redoc.html', staticAssets); + }); + fastify.get('/api.json', (_request, reply) => { + reply.header('Cache-Control', 'public, max-age=600'); + reply.send(genOpenapiSpec(this.config)); + }); + done(); + } +} diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts new file mode 100644 index 000000000..fa62480c0 --- /dev/null +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -0,0 +1,193 @@ +import type { Config } from '@/config.js'; +import endpoints from '../endpoints.js'; +import { errors as basicErrors } from './errors.js'; +import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; + +export function genOpenapiSpec(config: Config) { + const spec = { + openapi: '3.0.0', + + info: { + version: config.version, + title: 'Misskey API', + 'x-logo': { url: '/static-assets/api-doc.png' }, + }, + + externalDocs: { + description: 'Repository', + url: 'https://github.com/misskey-dev/misskey', + }, + + servers: [{ + url: config.apiUrl, + }], + + paths: {} as any, + + components: { + schemas: schemas, + + securitySchemes: { + ApiKeyAuth: { + type: 'apiKey', + in: 'body', + name: 'i', + }, + }, + }, + }; + + for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { + const errors = {} as any; + + if (endpoint.meta.errors) { + for (const e of Object.values(endpoint.meta.errors)) { + errors[e.code] = { + value: { + error: e, + }, + }; + } + } + + const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; + + let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n'; + desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; + if (endpoint.meta.kind) { + const kind = endpoint.meta.kind; + desc += ` / **Permission**: *${kind}*`; + } + + const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json'; + const schema = { ...endpoint.params }; + + if (endpoint.meta.requireFile) { + schema.properties = { + ...schema.properties, + file: { + type: 'string', + format: 'binary', + description: 'The file contents.', + }, + }; + schema.required = [...schema.required ?? [], 'file']; + } + + const info = { + operationId: endpoint.name, + summary: endpoint.name, + description: desc, + externalDocs: { + description: 'Source code', + url: `https://github.com/misskey-dev/misskey/blob/develop/packages/backend/src/server/api/endpoints/${endpoint.name}.ts`, + }, + ...(endpoint.meta.tags ? { + tags: [endpoint.meta.tags[0]], + } : {}), + ...(endpoint.meta.requireCredential ? { + security: [{ + ApiKeyAuth: [], + }], + } : {}), + requestBody: { + required: true, + content: { + [requestType]: { + schema, + }, + }, + }, + responses: { + ...(endpoint.meta.res ? { + '200': { + description: 'OK (with results)', + content: { + 'application/json': { + schema: resSchema, + }, + }, + }, + } : { + '204': { + description: 'OK (without any results)', + }, + }), + '400': { + description: 'Client error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: { ...errors, ...basicErrors['400'] }, + }, + }, + }, + '401': { + description: 'Authentication error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['401'], + }, + }, + }, + '403': { + description: 'Forbidden error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['403'], + }, + }, + }, + '418': { + description: 'I\'m Ai', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['418'], + }, + }, + }, + ...(endpoint.meta.limit ? { + '429': { + description: 'To many requests', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['429'], + }, + }, + }, + } : {}), + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['500'], + }, + }, + }, + }, + }; + + spec.paths['/' + endpoint.name] = { + post: info, + }; + } + + return spec; +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 796383f5e..0cef361ca 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -1,5 +1,5 @@ -import type { Schema } from '@/misc/schema.js'; -import { refs } from '@/misc/schema.js'; +import type { Schema } from '@/misc/json-schema.js'; +import { refs } from '@/misc/json-schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema) { const res: any = schema; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 3e67880b4..32935325a 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -27,6 +27,10 @@ export default abstract class Channel { return this.connection.muting; } + protected get renoteMuting() { + return this.connection.renoteMuting; + } + protected get blocking() { return this.connection.blocking; } diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 18604d94f..e2a42fbfe 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -39,6 +39,8 @@ class AntennaChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index f5ef1d110..12caa7f23 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; @@ -51,6 +51,8 @@ class ChannelChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index b8c0076ed..d79247cd6 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -68,6 +68,8 @@ class GlobalTimelineChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 00f8d8ecd..98dc858de 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; @@ -49,6 +49,8 @@ class HashtagChannel extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 04a9f2968..c623fef64 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; @@ -75,6 +75,8 @@ class HomeTimelineChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index ab52aabb3..f54767bc9 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -86,6 +86,8 @@ class HybridTimelineChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index d8532c477..eb0642900 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -65,6 +65,8 @@ class LocalTimelineChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 7254d0a6d..8a42e99a5 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -93,6 +93,8 @@ class UserListChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.send('note', note); } diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index d3056aca5..7c6eb9a20 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -1,9 +1,9 @@ import type { User } from '@/models/entities/User.js'; import type { Channel as ChannelModel } from '@/models/entities/Channel.js'; -import type { FollowingsRepository, MutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; +import type { FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import type { UserProfile } from '@/models/entities/UserProfile.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { GlobalEventService } from '@/core/GlobalEventService.js'; import type { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; @@ -22,6 +22,7 @@ export default class Connection { public userProfile?: UserProfile | null; public following: Set = new Set(); public muting: Set = new Set(); + public renoteMuting: Set = new Set(); public blocking: Set = new Set(); // "被"blocking public followingChannels: Set = new Set(); public token?: AccessToken; @@ -34,6 +35,7 @@ export default class Connection { constructor( private followingsRepository: FollowingsRepository, private mutingsRepository: MutingsRepository, + private renoteMutingsRepository: RenoteMutingsRepository, private blockingsRepository: BlockingsRepository, private channelFollowingsRepository: ChannelFollowingsRepository, private userProfilesRepository: UserProfilesRepository, @@ -66,6 +68,7 @@ export default class Connection { if (this.user) { this.updateFollowing(); this.updateMuting(); + this.updateRenoteMuting(); this.updateBlocking(); this.updateFollowingChannels(); this.updateUserProfile(); @@ -93,6 +96,7 @@ export default class Connection { this.muting.delete(data.body.id); break; + // TODO: renote mute events // TODO: block events case 'followChannel': @@ -342,6 +346,18 @@ export default class Connection { this.muting = new Set(mutings.map(x => x.muteeId)); } + @bindThis + private async updateRenoteMuting() { + const renoteMutings = await this.renoteMutingsRepository.find({ + where: { + muterId: this.user!.id, + }, + select: ['muteeId'], + }); + + this.renoteMuting = new Set(renoteMutings.map(x => x.muteeId)); + } + @bindThis private async updateBlocking() { // ここでいうBlockingは被Blockingの意 const blockings = await this.blockingsRepository.find({ diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index c45077305..b8f50e054 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -9,7 +9,7 @@ import type { UserList } from '@/models/entities/UserList.js'; import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; import type { Signin } from '@/models/entities/Signin.js'; import type { Page } from '@/models/entities/Page.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import type { Meta } from '@/models/entities/Meta.js'; import { Role, RoleAssignment } from '@/models'; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 98cdd3120..fb76f07e4 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -194,11 +194,6 @@ export class ClientServerService { //#region static assets - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - fastify.register(fastifyStatic, { root: staticAssets, prefix: '/static-assets/', diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 2ce7293a5..b3e193cd3 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { summaly } from 'summaly'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -9,6 +8,7 @@ import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { ApiError } from '@/server/api/error.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() @@ -19,9 +19,6 @@ export class UrlPreviewService { @Inject(DI.config) private config: Config, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - private metaService: MetaService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, @@ -43,23 +40,23 @@ export class UrlPreviewService { @bindThis public async handle( - request: FastifyRequest<{ Querystring: { url: string; lang: string; } }>, + request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>, reply: FastifyReply, - ) { + ): Promise { const url = request.query.url; if (typeof url !== 'string') { reply.code(400); return; } - + const lang = request.query.lang; if (Array.isArray(lang)) { reply.code(400); return; } - + const meta = await this.metaService.fetch(); - + this.logger.info(meta.summalyProxy ? `(Proxy) Getting preview of ${url}@${lang} ...` : `Getting preview of ${url}@${lang} ...`); @@ -81,26 +78,32 @@ export class UrlPreviewService { this.logger.succ(`Got preview of ${url}: ${summary.title}`); - if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { + if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { throw new Error('unsupported schema included'); } - if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { + if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { throw new Error('unsupported schema included'); } - + summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); - + // Cache 7days reply.header('Cache-Control', 'max-age=604800, immutable'); - + return summary; } catch (err) { this.logger.warn(`Failed to get preview of ${url}: ${err}`); - reply.code(200); + reply.code(422); reply.header('Cache-Control', 'max-age=86400, immutable'); - return {}; + return { + error: new ApiError({ + message: 'Failed to get preview', + code: 'URL_PREVIEW_FAILED', + id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', + }), + }; } } } diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 8d6897c46..a9a0dfd4e 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -36,7 +36,7 @@ html link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.2.0') + link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.10.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts new file mode 100644 index 000000000..0addb430c --- /dev/null +++ b/packages/backend/test/e2e/2fa.ts @@ -0,0 +1,439 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as crypto from 'node:crypto'; +import * as cbor from 'cbor'; +import * as OTPAuth from 'otpauth'; +import { loadConfig } from '../../src/config.js'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('2要素認証', () => { + let app: INestApplicationContext; + let alice: unknown; + + const config = loadConfig(); + const password = 'test'; + const username = 'alice'; + + // https://datatracker.ietf.org/doc/html/rfc8152 + // 各値の定義は上記規格に基づく。鍵ペアは適当に生成したやつ + const coseKtyEc2 = 2; + const coseKid = 'meriadoc.brandybuck@buckland.example'; + const coseAlgEs256 = -7; + const coseEc2CrvP256 = 1; + const coseEc2X = '4932eaacc657565705e4287e7870ce3aad55545d99d35a98a472dc52880cfc8f'; + const coseEc2Y = '5ca68303bf2c0433473e3d5cb8586bc2c8c43a4945a496fce8dbeda8b23ab0b1'; + + // private key only for testing + const pemToSign = '-----BEGIN EC PRIVATE KEY-----\n' + + 'MHcCAQEEIHqe/keuXyolbXzgLOu+YFJjDBGWVgXc3QCXfyqwDPf2oAoGCCqGSM49\n' + + 'AwEHoUQDQgAESTLqrMZXVlcF5Ch+eHDOOq1VVF2Z01qYpHLcUogM/I9cpoMDvywE\n' + + 'M0c+PVy4WGvCyMQ6SUWklvzo2+2osjqwsQ==\n' + + '-----END EC PRIVATE KEY-----\n'; + + const otpToken = (secret: string): string => { + return OTPAuth.TOTP.generate({ + secret: OTPAuth.Secret.fromBase32(secret), + digits: 6, + }); + }; + + const rpIdHash = (): Buffer => { + return crypto.createHash('sha256') + .update(Buffer.from(config.hostname, 'utf-8')) + .digest(); + }; + + const keyDoneParam = (param: { + keyName: string, + challengeId: string, + challenge: string, + credentialId: Buffer, + }): { + attestationObject: string, + challengeId: string, + clientDataJSON: string, + password: string, + name: string, + } => { + // A COSE encoded public key + const credentialPublicKey = cbor.encode(new Map([ + [-1, coseEc2CrvP256], + [-2, Buffer.from(coseEc2X, 'hex')], + [-3, Buffer.from(coseEc2Y, 'hex')], + [1, coseKtyEc2], + [2, coseKid], + [3, coseAlgEs256], + ])); + + // AuthenticatorAssertionResponse.authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + const credentialIdLength = Buffer.allocUnsafe(2); + credentialIdLength.writeUInt16BE(param.credentialId.length); + const authData = Buffer.concat([ + rpIdHash(), // rpIdHash(32) + Buffer.from([0x45]), // flags(1) + Buffer.from([0x00, 0x00, 0x00, 0x00]), // signCount(4) + Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // AAGUID(16) + credentialIdLength, + param.credentialId, + credentialPublicKey, + ]); + + return { + attestationObject: cbor.encode({ + fmt: 'none', + attStmt: {}, + authData, + }).toString('hex'), + challengeId: param.challengeId, + clientDataJSON: JSON.stringify({ + type: 'webauthn.create', + challenge: param.challenge, + origin: config.scheme + '://' + config.host, + androidPackageName: 'org.mozilla.firefox', + }), + password, + name: param.keyName, + }; + }; + + const signinParam = (): { + username: string, + password: string, + 'g-recaptcha-response'?: string | null, + 'hcaptcha-response'?: string | null, + } => { + return { + username, + password, + 'g-recaptcha-response': null, + 'hcaptcha-response': null, + }; + }; + + const signinWithSecurityKeyParam = (param: { + keyName: string, + challengeId: string, + challenge: string, + credentialId: Buffer, + }): { + authenticatorData: string, + credentialId: string, + challengeId: string, + clientDataJSON: string, + username: string, + password: string, + signature: string, + 'g-recaptcha-response'?: string | null, + 'hcaptcha-response'?: string | null, + } => { + // AuthenticatorAssertionResponse.authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + const authenticatorData = Buffer.concat([ + rpIdHash(), + Buffer.from([0x05]), // flags(1) + Buffer.from([0x00, 0x00, 0x00, 0x01]), // signCount(4) + ]); + const clientDataJSONBuffer = Buffer.from(JSON.stringify({ + type: 'webauthn.get', + challenge: param.challenge, + origin: config.scheme + '://' + config.host, + androidPackageName: 'org.mozilla.firefox', + })); + const hashedclientDataJSON = crypto.createHash('sha256') + .update(clientDataJSONBuffer) + .digest(); + const privateKey = crypto.createPrivateKey(pemToSign); + const signature = crypto.createSign('SHA256') + .update(Buffer.concat([authenticatorData, hashedclientDataJSON])) + .sign(privateKey); + return { + authenticatorData: authenticatorData.toString('hex'), + credentialId: param.credentialId.toString('base64'), + challengeId: param.challengeId, + clientDataJSON: clientDataJSONBuffer.toString('hex'), + username, + password, + signature: signature.toString('hex'), + 'g-recaptcha-response': null, + 'hcaptcha-response': null, + }; + }; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username, password }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + test('が設定でき、OTPでログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + assert.notEqual(registerResponse.body.qr, undefined); + assert.notEqual(registerResponse.body.url, undefined); + assert.notEqual(registerResponse.body.secret, undefined); + assert.strictEqual(registerResponse.body.label, username); + assert.strictEqual(registerResponse.body.issuer, config.host); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }, alice); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + token: otpToken(registerResponse.body.secret), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); + + test('が設定でき、セキュリティキーでログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + assert.notEqual(registerKeyResponse.body.challengeId, undefined); + assert.notEqual(registerKeyResponse.body.challenge, undefined); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex')); + assert.strictEqual(keyDoneResponse.body.name, keyName); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.securityKeys, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + }); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.i, undefined); + assert.notEqual(signinResponse.body.challengeId, undefined); + assert.notEqual(signinResponse.body.challenge, undefined); + assert.notEqual(signinResponse.body.securityKeys, undefined); + assert.strictEqual(signinResponse.body.securityKeys[0].id, credentialId.toString('hex')); + + const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({ + keyName, + challengeId: signinResponse.body.challengeId, + challenge: signinResponse.body.challenge, + credentialId, + })); + assert.strictEqual(signinResponse2.status, 200); + assert.notEqual(signinResponse2.body.i, undefined); + }); + + test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + const passwordLessResponse = await api('/i/2fa/password-less', { + value: true, + }, alice); + assert.strictEqual(passwordLessResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.usePasswordLessLogin, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + password: '', + }); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.i, undefined); + + const signinResponse2 = await api('/signin', { + ...signinWithSecurityKeyParam({ + keyName, + challengeId: signinResponse.body.challengeId, + challenge: signinResponse.body.challenge, + credentialId, + }), + password: '', + }); + assert.strictEqual(signinResponse2.status, 200); + assert.notEqual(signinResponse2.body.i, undefined); + }); + + test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + const renamedKey = 'other-key'; + const updateKeyResponse = await api('/i/2fa/update-key', { + name: renamedKey, + credentialId: credentialId.toString('hex'), + }, alice); + assert.strictEqual(updateKeyResponse.status, 200); + + const iResponse = await api('/i', { + }, alice); + assert.strictEqual(iResponse.status, 200); + const securityKeys = iResponse.body.securityKeysList.filter(s => s.id === credentialId.toString('hex')); + assert.strictEqual(securityKeys.length, 1); + assert.strictEqual(securityKeys[0].name, renamedKey); + assert.notEqual(securityKeys[0].lastUsed, undefined); + }); + + test('が設定でき、設定したセキュリティキーを削除できる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + // テストの実行順によっては複数残ってるので全部消す + const iResponse = await api('/i', { + }, alice); + assert.strictEqual(iResponse.status, 200); + for (const key of iResponse.body.securityKeysList) { + const removeKeyResponse = await api('/i/2fa/remove-key', { + password, + credentialId: key.id, + }, alice); + assert.strictEqual(removeKeyResponse.status, 200); + } + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.securityKeys, false); + + const signinResponse = await api('/signin', { + ...signinParam(), + token: otpToken(registerResponse.body.secret), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); + + test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + + const unregisterResponse = await api('/i/2fa/unregister', { + password, + }, alice); + assert.strictEqual(unregisterResponse.status, 204); + + const signinResponse = await api('/signin', { + ...signinParam(), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); +}); diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts index 4e162f42d..3af0d3518 100644 --- a/packages/backend/test/e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -5,14 +5,14 @@ import { signup, api, post, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('API visibility', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; beforeAll(async () => { - p = await startServer(); + app = await startServer(); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); describe('Note visibility', () => { diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts index 6ceccf66e..a46f336a7 100644 --- a/packages/backend/test/e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -5,20 +5,20 @@ import { signup, api, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('API', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let bob: any; let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); describe('General validation', () => { diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index 4e9030f85..57a46ab38 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -5,7 +5,7 @@ import { signup, api, post, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Block', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; // alice blocks bob let alice: any; @@ -13,14 +13,14 @@ describe('Block', () => { let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('Block作成', async () => { @@ -70,9 +70,9 @@ describe('Block', () => { // TODO: ユーザーリストから除外されるテスト test('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const res = await api('/notes/local-timeline', {}, bob); diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts new file mode 100644 index 000000000..f35aae9dc --- /dev/null +++ b/packages/backend/test/e2e/clips.ts @@ -0,0 +1,962 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { JTDDataType } from 'ajv/dist/jtd'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { paramDef as CreateParamDef } from '@/server/api/endpoints/clips/create.js'; +import { paramDef as UpdateParamDef } from '@/server/api/endpoints/clips/update.js'; +import { paramDef as DeleteParamDef } from '@/server/api/endpoints/clips/delete.js'; +import { paramDef as ShowParamDef } from '@/server/api/endpoints/clips/show.js'; +import { paramDef as FavoriteParamDef } from '@/server/api/endpoints/clips/favorite.js'; +import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unfavorite.js'; +import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js'; +import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js'; +import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js'; +import { + signup, + post, + startServer, + api, + successfulApiCall, + failedApiCall, + ApiRequest, + hiddenNote, +} from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('クリップ', () => { + type User = Packed<'User'>; + type Note = Packed<'Note'>; + type Clip = Packed<'Clip'>; + + let app: INestApplicationContext; + + let alice: User; + let bob: User; + let aliceNote: Note; + let aliceHomeNote: Note; + let aliceFollowersNote: Note; + let aliceSpecifiedNote: Note; + let bobNote: Note; + let bobHomeNote: Note; + let bobFollowersNote: Note; + let bobSpecifiedNote: Note; + + const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { + return selector(a).localeCompare(selector(b)); + }; + + type CreateParam = JTDDataType; + const defaultCreate = (): Partial => ({ + name: 'test', + }); + const create = async (parameters: Partial = {}, request: Partial = {}): Promise => { + const clip = await successfulApiCall({ + endpoint: '/clips/create', + parameters: { + ...defaultCreate(), + ...parameters, + }, + user: alice, + ...request, + }); + + // 入力が結果として入っていること + assert.deepStrictEqual(clip, { + ...clip, + ...defaultCreate(), + ...parameters, + }); + return clip; + }; + + const createMany = async (parameters: Partial, count = 10, user = alice): Promise => { + return await Promise.all([...Array(count)].map((_, i) => create({ + name: `test${i}`, + ...parameters, + }, { user }))); + }; + + type UpdateParam = JTDDataType; + const update = async (parameters: Partial, request: Partial = {}): Promise => { + const clip = await successfulApiCall({ + endpoint: '/clips/update', + parameters: { + name: 'updated', + ...parameters, + }, + user: alice, + ...request, + }); + + // 入力が結果として入っていること。clipIdはidになるので消しておく + delete (parameters as { clipId?: string }).clipId; + assert.deepStrictEqual(clip, { + ...clip, + ...parameters, + }); + return clip; + }; + + type DeleteParam = JTDDataType; + const deleteClip = async (parameters: DeleteParam, request: Partial = {}): Promise => { + return await successfulApiCall({ + endpoint: '/clips/delete', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type ShowParam = JTDDataType; + const show = async (parameters: ShowParam, request: Partial = {}): Promise => { + return await successfulApiCall({ + endpoint: '/clips/show', + parameters, + user: alice, + ...request, + }); + }; + + const list = async (request: Partial): Promise => { + return successfulApiCall({ + endpoint: '/clips/list', + parameters: {}, + user: alice, + ...request, + }); + }; + + const usersClips = async (request: Partial): Promise => { + return await successfulApiCall({ + endpoint: '/users/clips', + parameters: {}, + user: alice, + ...request, + }); + }; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + + // FIXME: misskey-jsのNoteはoutdatedなので直接変換できない + aliceNote = await post(alice, { text: 'test' }) as any; + aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any; + aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any; + aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any; + bobNote = await post(bob, { text: 'test' }) as any; + bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any; + bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any; + bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any; + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + // テスト間で影響し合わないように毎回全部消す。 + for (const user of [alice, bob]) { + const list = await api('/clips/list', { limit: 11 }, user); + for (const clip of list.body) { + await api('/clips/delete', { clipId: clip.id }, user); + } + } + }); + + test('の作成ができる', async () => { + const res = await create(); + // ISO 8601で日付が返ってくること + assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.lastClippedAt, null); + assert.strictEqual(res.name, 'test'); + assert.strictEqual(res.description, null); + assert.strictEqual(res.isPublic, false); + assert.strictEqual(res.favoritedCount, 0); + assert.strictEqual(res.isFavorited, false); + }); + + test('の作成はポリシーで定められた数以上はできない。', async () => { + // ポリシー + 1まで作れるという所がミソ + const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + for (let i = 0; i < clipLimit; i++) { + await create(); + } + + await failedApiCall({ + endpoint: '/clips/create', + parameters: defaultCreate(), + user: alice, + }, { + status: 400, + code: 'TOO_MANY_CLIPS', + id: '920f7c2d-6208-4b76-8082-e632020f5883', + }); + }); + + const createClipAllowedPattern = [ + { label: 'nameが最大長', parameters: { name: 'x'.repeat(100) } }, + { label: 'private', parameters: { isPublic: false } }, + { label: 'public', parameters: { isPublic: true } }, + { label: 'descriptionがnull', parameters: { description: null } }, + { label: 'descriptionが最大長', parameters: { description: 'a'.repeat(2048) } }, + ]; + test.each(createClipAllowedPattern)('の作成は$labelでもできる', async ({ parameters }) => await create(parameters)); + + const createClipDenyPattern = [ + { label: 'nameがnull', parameters: { name: null } }, + { label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } }, + { label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } }, + { label: 'descriptionがゼロ長', parameters: { description: '' } }, + { label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } }, + ]; + test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({ + endpoint: '/clips/create', + parameters: { + ...defaultCreate(), + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + })); + + test('の更新ができる', async () => { + const res = await update({ + clipId: (await create()).id, + name: 'updated', + description: 'new description', + isPublic: true, + }); + + // ISO 8601で日付が返ってくること + assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.lastClippedAt, null); + assert.strictEqual(res.name, 'updated'); + assert.strictEqual(res.description, 'new description'); + assert.strictEqual(res.isPublic, true); + assert.strictEqual(res.favoritedCount, 0); + assert.strictEqual(res.isFavorited, false); + }); + + test.each(createClipAllowedPattern)('の更新は$labelでもできる', async ({ parameters }) => await update({ + clipId: (await create()).id, + name: 'updated', + ...parameters, + })); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', + } }, + ...createClipDenyPattern as any, + ])('の更新は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/update', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + name: 'updated', + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('の削除ができる', async () => { + await deleteClip({ + clipId: (await create()).id, + }); + assert.deepStrictEqual(await list({}), []); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754', + } }, + ])('の削除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/delete', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('のID指定取得ができる', async () => { + const clip = await create(); + const res = await show({ clipId: clip.id }); + assert.deepStrictEqual(res, clip); + }); + + test('のID指定取得は他人のPrivateなクリップは取得できない', async () => { + const clip = await create({ isPublic: false }, { user: bob } ); + failedApiCall({ + endpoint: '/clips/show', + parameters: { clipId: clip.id }, + user: alice, + }, { + status: 400, + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', + }); + }); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', + } }, + ])('のID指定取得は$labelならできない', async ({ parameters, assetion }) => failedApiCall({ + endpoint: '/clips/show', + parameters: { + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('の一覧(clips/list)が取得できる(空)', async () => { + const res = await list({}); + assert.deepStrictEqual(res, []); + }); + + test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => { + const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clips = await createMany({}, clipLimit); + const res = await list({ + parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる + }); + + // 返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + clips.sort(compareBy(s => s.id)), + ); + }); + + test('の一覧が取得できる(空)', async () => { + const res = await usersClips({ + parameters: { + userId: alice.id, + }, + }); + assert.deepStrictEqual(res, []); + }); + + test.each([ + { label: '' }, + { label: '他人アカウントから', user: (): User => bob }, + ])('の一覧が$label取得できる', async () => { + const clips = await createMany({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + }, + }); + + // 返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + clips.sort(compareBy(s => s.id))); + + // 認証状態で見たときだけisFavoritedが入っている + for (const clip of res) { + assert.strictEqual(clip.isFavorited, false); + } + }); + + test.each([ + { label: '未認証', user: (): undefined => undefined }, + { label: '存在しないユーザーのもの', parameters: { userId: 'xxxxxxx' } }, + ])('の一覧は$labelでも取得できる', async ({ parameters, user }) => { + const clips = await createMany({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + limit: clips.length, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }); + + // 未認証で見たときはisFavoritedは入らない + for (const clip of res) { + assert.strictEqual('isFavorited' in clip, false); + } + }); + + test('の一覧はPrivateなクリップを含まない(自分のものであっても。)', async () => { + await create({ isPublic: false }); + const aliceClip = await create({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + limit: 2, + }, + }); + assert.deepStrictEqual(res, [aliceClip]); + }); + + test('の一覧はID指定で範囲選択ができる', async () => { + const clips = await createMany({ isPublic: true }, 7); + clips.sort(compareBy(s => s.id)); + const res = await usersClips({ + parameters: { + userId: alice.id, + sinceId: clips[1].id, + untilId: clips[5].id, + limit: 4, + }, + }); + + // Promise.allで返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + [clips[2], clips[3], clips[4]], // sinceIdとuntilId自体は結果に含まれない + clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id)); + }); + + test.each([ + { label: 'userId未指定', parameters: { userId: undefined } }, + { label: 'limitゼロ', parameters: { limit: 0 } }, + { label: 'limit最大+1', parameters: { limit: 101 } }, + ])('の一覧は$labelだと取得できない', async ({ parameters }) => failedApiCall({ + endpoint: '/users/clips', + parameters: { + userId: alice.id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + })); + + test.each([ + { label: '作成', endpoint: '/clips/create' }, + { label: '更新', endpoint: '/clips/update' }, + { label: '削除', endpoint: '/clips/delete' }, + { label: '取得', endpoint: '/clips/list' }, + { label: 'お気に入り設定', endpoint: '/clips/favorite' }, + { label: 'お気に入り解除', endpoint: '/clips/unfavorite' }, + { label: 'お気に入り取得', endpoint: '/clips/my-favorites' }, + { label: 'ノート追加', endpoint: '/clips/add-note' }, + { label: 'ノート削除', endpoint: '/clips/remove-note' }, + ])('の$labelは未認証ではできない', async ({ endpoint }) => await failedApiCall({ + endpoint: endpoint, + parameters: {}, + user: undefined, + }, { + status: 401, + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + })); + + describe('のお気に入り', () => { + let aliceClip: Clip; + + type FavoriteParam = JTDDataType; + const favorite = async (parameters: FavoriteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/favorite', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type UnfavoriteParam = JTDDataType; + const unfavorite = async (parameters: UnfavoriteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/unfavorite', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + const myFavorites = async (request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/my-favorites', + parameters: {}, + user: alice, + ...request, + }); + }; + + beforeEach(async () => { + aliceClip = await create(); + }); + + test('を設定できる。', async () => { + await favorite({ clipId: aliceClip.id }); + const clip = await show({ clipId: aliceClip.id }); + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + }); + + test('はPublicな他人のクリップに設定できる。', async () => { + const publicClip = await create({ isPublic: true }); + await favorite({ clipId: publicClip.id }, { user: bob }); + const clip = await show({ clipId: publicClip.id }, { user: bob }); + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + + // isFavoritedは見る人によって切り替わる。 + const clip2 = await show({ clipId: publicClip.id }); + assert.strictEqual(clip2.favoritedCount, 1); + assert.strictEqual(clip2.isFavorited, false); + }); + + test('は1つのクリップに対して複数人が設定できる。', async () => { + const publicClip = await create({ isPublic: true }); + await favorite({ clipId: publicClip.id }, { user: bob }); + await favorite({ clipId: publicClip.id }); + const clip = await show({ clipId: publicClip.id }, { user: bob }); + assert.strictEqual(clip.favoritedCount, 2); + assert.strictEqual(clip.isFavorited, true); + + const clip2 = await show({ clipId: publicClip.id }); + assert.strictEqual(clip2.favoritedCount, 2); + assert.strictEqual(clip2.isFavorited, true); + }); + + test('は11を超えて設定できる。', async () => { + const clips = [ + aliceClip, + ...await createMany({}, 10, alice), + ...await createMany({ isPublic: true }, 10, bob), + ]; + for (const clip of clips) { + await favorite({ clipId: clip.id }); + } + + // pagenationはない。全部一気にとれる。 + const favorited = await myFavorites(); + assert.strictEqual(favorited.length, clips.length); + for (const clip of favorited) { + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + } + }); + + test('は同じクリップに対して二回設定できない。', async () => { + await favorite({ clipId: aliceClip.id }); + await failedApiCall({ + endpoint: '/clips/favorite', + parameters: { + clipId: aliceClip.id, + }, + user: alice, + }, { + status: 400, + code: 'ALREADY_FAVORITED', + id: '92658936-c625-4273-8326-2d790129256e', + }); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + } }, + ])('の設定は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/favorite', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('を設定解除できる。', async () => { + await favorite({ clipId: aliceClip.id }); + await unfavorite({ clipId: aliceClip.id }); + const clip = await show({ clipId: aliceClip.id }); + assert.strictEqual(clip.favoritedCount, 0); + assert.strictEqual(clip.isFavorited, false); + assert.deepStrictEqual(await myFavorites(), []); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '2603966e-b865-426c-94a7-af4a01241dc1', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + } }, + { label: 'お気に入りしていないクリップ', assertion: { + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + } }, + ])('の設定解除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/unfavorite', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('を取得できる。', async () => { + await favorite({ clipId: aliceClip.id }); + const favorited = await myFavorites(); + assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]); + }); + + test('を取得したとき他人のお気に入りは含まない。', async () => { + await favorite({ clipId: aliceClip.id }); + const favorited = await myFavorites({ user: bob }); + assert.deepStrictEqual(favorited, []); + }); + }); + + describe('に紐づくノート', () => { + let aliceClip: Clip; + + const sampleNotes = (): Note[] => [ + aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, + bobNote, bobHomeNote, bobFollowersNote, bobSpecifiedNote, + ]; + + type AddNoteParam = JTDDataType; + const addNote = async (parameters: AddNoteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/add-note', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type RemoveNoteParam = JTDDataType; + const removeNote = async (parameters: RemoveNoteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/remove-note', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type NotesParam = JTDDataType; + const notes = async (parameters: Partial, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/notes', + parameters, + user: alice, + ...request, + }); + }; + + beforeEach(async () => { + aliceClip = await create(); + }); + + test('を追加できる。', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + const res = await show({ clipId: aliceClip.id }); + assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString()); + assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]); + + // 他人の非公開ノートも突っ込める + await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id }); + await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id }); + await addNote({ clipId: aliceClip.id, noteId: bobSpecifiedNote.id }); + }); + + test('として同じノートを二回紐づけることはできない', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: alice, + }, { + status: 400, + code: 'ALREADY_CLIPPED', + id: '734806c4-542c-463a-9311-15c512803965', + }); + }); + + // TODO: 17000msくらいかかる... + test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => { + const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1; + const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, { + text: `test ${i}`, + }) as unknown)) as Note[]; + await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id }))); + + await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: alice, + }, { + status: 400, + code: 'TOO_MANY_CLIP_NOTES', + id: 'f0dba960-ff73-4615-8df4-d6ac5d9dc118', + }); + }); + + test('は他人のクリップへ追加できない。', async () => await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + })); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + } }, + { label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_NOTE', + id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b', + } }, + { label: '他人のクリップ', user: (): object => bob, assetion: { + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + } }, + ])('の追加は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('を削除できる。', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []); + }); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる + } }, + { label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_NOTE', + id: 'aff017de-190e-434b-893e-33a9ff5049d8', // add-noteと異なる + } }, + { label: '他人のクリップ', user: (): object => bob, assetion: { + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる + } }, + ])('の削除は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ + endpoint: '/clips/remove-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('を取得できる。', async () => { + const noteList = sampleNotes(); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ clipId: aliceClip.id }); + + // 自分のノートは非公開でも入れられるし、見える + // 他人の非公開ノートは入れられるけど、除外される + const expects = [ + aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, + bobNote, bobHomeNote, + ]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test('を始端IDとlimitで取得できる。', async () => { + const noteList = sampleNotes(); + noteList.sort(compareBy(s => s.id)); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ + clipId: aliceClip.id, + sinceId: noteList[2].id, + limit: 3, + }); + + // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 + const expects = [noteList[3], noteList[4], noteList[5]]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test('をID範囲指定で取得できる。', async () => { + const noteList = sampleNotes(); + noteList.sort(compareBy(s => s.id)); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ + clipId: aliceClip.id, + sinceId: noteList[1].id, + untilId: noteList[4].id, + }); + + // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 + const expects = [noteList[2], noteList[3]]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test.todo('Remoteのノートもクリップできる。どうテストしよう?'); + + test('は他人のPublicなクリップからも取得できる。', async () => { + const bobClip = await create({ isPublic: true }, { user: bob } ); + await addNote({ clipId: bobClip.id, noteId: aliceNote.id }, { user: bob }); + const res = await notes({ clipId: bobClip.id }); + assert.deepStrictEqual(res, [aliceNote]); + }); + + test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => { + const publicClip = await create({ isPublic: true }); + await addNote({ clipId: publicClip.id, noteId: aliceNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceFollowersNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceSpecifiedNote.id }); + + const res = await notes({ clipId: publicClip.id }, { user: undefined }); + const expects = [ + aliceNote, aliceHomeNote, + // 認証なしだと非公開ノートは結果には含むけどhideされる。 + hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote), + ]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test.todo('ブロック、ミュートされたユーザーからの設定&取得etc.'); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'limitゼロ', parameters: { limit: 0 } }, + { label: 'limit最大+1', parameters: { limit: 101 } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + { label: '他人のPrivateなクリップから', user: (): object => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + { label: '未認証でPrivateなクリップから', user: (): undefined => undefined, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + ])('は$labelだと取得できない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/notes', + parameters: { + clipId: aliceClip.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + }); +}); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 42bdc5f24..afb72c84d 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -4,11 +4,11 @@ import * as assert from 'assert'; // node-fetch only supports it's own Blob yet // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; -import { startServer, signup, post, api, uploadFile } from '../utils.js'; +import { startServer, signup, post, api, uploadFile, simpleGet } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Endpoints', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let bob: any; @@ -16,7 +16,7 @@ describe('Endpoints', () => { let dave: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); @@ -24,7 +24,7 @@ describe('Endpoints', () => { }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); describe('signup', () => { @@ -162,14 +162,14 @@ describe('Endpoints', () => { const res = await api('/users/show', { userId: '000000000000000000000000', }); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); }); test('間違ったIDで怒られる', async () => { const res = await api('/users/show', { userId: 'kyoppie', }); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); }); }); @@ -206,7 +206,7 @@ describe('Endpoints', () => { describe('notes/reactions/create', () => { test('リアクションできる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: bobPost.id, @@ -224,7 +224,7 @@ describe('Endpoints', () => { }); test('自分の投稿にもリアクションできる', async () => { - const myPost = await post(alice); + const myPost = await post(alice, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: myPost.id, @@ -235,7 +235,7 @@ describe('Endpoints', () => { }); test('二重にリアクションすると上書きされる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); await api('/notes/reactions/create', { noteId: bobPost.id, @@ -439,6 +439,45 @@ describe('Endpoints', () => { assert.strictEqual(res.body.name, 'image.svg'); assert.strictEqual(res.body.type, 'image/svg+xml'); }); + + for (const type of ['webp', 'avif']) { + const mediaType = `image/${type}`; + + const getWebpublicType = async (user: any, fileId: string): Promise => { + // drive/files/create does not expose webpublicType directly, so get it by posting it + const res = await post(user, { + text: mediaType, + fileIds: [fileId], + }); + const apRes = await simpleGet(`notes/${res.id}`, 'application/activity+json'); + assert.strictEqual(apRes.status, 200); + assert.ok(Array.isArray(apRes.body.attachment)); + return apRes.body.attachment[0].mediaType; + }; + + test(`透明な${type}ファイルを作成できる`, async () => { + const path = `with-alpha.${type}`; + const res = await uploadFile(alice, { path }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, path); + assert.strictEqual(res.body.type, mediaType); + + const webpublicType = await getWebpublicType(alice, res.body.id); + assert.strictEqual(webpublicType, 'image/webp'); + }); + + test(`透明じゃない${type}ファイルを作成できる`, async () => { + const path = `without-alpha.${type}`; + const res = await uploadFile(alice, { path }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, path); + assert.strictEqual(res.body.type, mediaType); + + const webpublicType = await getWebpublicType(alice, res.body.id); + assert.strictEqual(webpublicType, 'image/webp'); + }); + } }); describe('drive/files/update', () => { @@ -802,4 +841,12 @@ describe('Endpoints', () => { assert.strictEqual(res.body[0].id, carolPost.id); }); }); + + describe('URL preview', () => { + test('Error from summaly becomes HTTP 422', async () => { + const res = await simpleGet('/url?url=https://e:xample.com'); + assert.strictEqual(res.status, 422); + assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED'); + }); + }); }); diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 6b3c79523..78ca8b43b 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -1,7 +1,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { startServer, signup, post, api, simpleGet } from '../utils.js'; +import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js'; +import type { SimpleGetResponse } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; // Request Accept @@ -13,181 +14,448 @@ const UNSPECIFIED = '*/*'; // Response Content-Type const AP = 'application/activity+json; charset=utf-8'; const HTML = 'text/html; charset=utf-8'; +const JSON_UTF8 = 'application/json; charset=utf-8'; -describe('Fetch resource', () => { - let p: INestApplicationContext; +describe('Webリソース', () => { + let app: INestApplicationContext; let alice: any; + let aliceUploadedFile: any; let alicesPost: any; + let alicePage: any; + let alicePlay: any; + let aliceClip: any; + let aliceGalleryPost: any; + let aliceChannel: any; + + type Request = { + path: string, + accept?: string, + cookie?: string, + }; + const ok = async (param: Request & { + type?: string, + }):Promise => { + const { path, accept, cookie, type } = param; + const res = await simpleGet(path, accept, cookie); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, type ?? HTML); + return res; + }; + + const notOk = async (param: Request & { + status?: number, + code?: string, + }): Promise => { + const { path, accept, cookie, status, code } = param; + const res = await simpleGet(path, accept, cookie); + assert.notStrictEqual(res.status, 200); + if (status != null) { + assert.strictEqual(res.status, status); + } + if (code != null) { + assert.strictEqual(res.body.error.code, code); + } + return res; + }; + + const notFound = async (param: Request): Promise => { + return await notOk({ + ...param, + status: 404, + }); + }; + + const metaTag = (res: SimpleGetResponse, key: string, superkey = 'name'): string => { + return res.body.window.document.querySelector('meta[' + superkey + '="' + key + '"]')?.content; + }; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); + aliceUploadedFile = await uploadFile(alice); alicesPost = await post(alice, { text: 'test', }); + alicePage = await page(alice, {}); + alicePlay = await play(alice, {}); + aliceClip = await clip(alice, {}); + aliceGalleryPost = await galleryPost(alice, { + fileIds: [aliceUploadedFile.body.id], + }); + aliceChannel = await channel(alice, {}); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); - describe('Common', () => { - test('meta', async () => { - const res = await api('/meta', { - }); + describe.each([ + { path: '/', type: HTML }, + { path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。" + // fastify-static gives charset=UTF-8 instead of utf-8 and that's okay + { path: '/api-doc', type: 'text/html; charset=UTF-8' }, + { path: '/api.json', type: JSON_UTF8 }, + { path: '/api-console', type: HTML }, + { path: '/_info_card_', type: HTML }, + { path: '/bios', type: HTML }, + { path: '/cli', type: HTML }, + { path: '/flush', type: HTML }, + { path: '/robots.txt', type: 'text/plain; charset=UTF-8' }, + { path: '/favicon.ico', type: 'image/vnd.microsoft.icon' }, + { path: '/opensearch.xml', type: 'application/opensearchdescription+xml' }, + { path: '/apple-touch-icon.png', type: 'image/png' }, + { path: '/twemoji/2764.svg', type: 'image/svg+xml' }, + { path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' }, + { path: '/twemoji-badge/2764.png', type: 'image/png' }, + { path: '/twemoji-badge/2764-fe0f-200d-1f525.png', type: 'image/png' }, + { path: '/fluent-emoji/2764.png', type: 'image/png' }, + { path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' }, + ])('$path', (p) => { + test('がGETできる。', async () => await ok({ ...p })); - assert.strictEqual(res.status, 200); - }); + // 注意: Webページが200で取得できても、実際のHTMLが正しく表示できるとは限らない + // 例えば、 /@xxx/pages/yyy に存在しないIDを渡した場合、HTTPレスポンスではエラーを区別できない + // こういったアサーションはフロントエンドE2EやAPI Endpointのテストで担保する。 + }); - test('GET root', async () => { - const res = await simpleGet('/'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); + describe.each([ + { path: '/twemoji/2764.png' }, + { path: '/twemoji/2764-fe0f-200d-1f525.png' }, + { path: '/twemoji-badge/2764.svg' }, + { path: '/twemoji-badge/2764-fe0f-200d-1f525.svg' }, + { path: '/fluent-emoji/2764.svg' }, + { path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' }, + ])('$path', ({ path }) => { + test('はGETできない。', async () => await notFound({ path })); + }); - test('GET docs', async () => { - const res = await simpleGet('/docs/ja-JP/about'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); + describe.each([ + { ext: 'rss', type: 'application/rss+xml; charset=utf-8' }, + { ext: 'atom', type: 'application/atom+xml; charset=utf-8' }, + { ext: 'json', type: 'application/json; charset=utf-8' }, + ])('/@:username.$ext', ({ ext, type }) => { + const path = (username: string): string => `/@${username}.${ext}`; - test('GET api-doc (廃止)', async () => { - const res = await simpleGet('/api-doc'); - assert.strictEqual(res.status, 404); - }); + test('がGETできる。', async () => await ok({ + path: path(alice.username), + type, + })); - test('GET api.json (廃止)', async () => { - const res = await simpleGet('/api.json'); - assert.strictEqual(res.status, 404); - }); + test('は存在しないユーザーはGETできない。', async () => await notOk({ + path: path('nonexisting'), + status: 404, + })); + }); - 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'); - }); + describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { + test('はGETできない。', async () => await notOk({ + path, + status: 404, + 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/vnd.microsoft.icon'); - }); + describe.each([{ path: '/queue' }])('$path', ({ path }) => { + test('はadminでなければGETできない。', async () => await notOk({ + path, + status: 500, // FIXME? 403ではない。 + })); + + test('はadminならGETできる。', async () => await ok({ + path, + cookie: cookie(alice), + })); + }); - test('GET apple-touch-icon.png', async () => { - const res = await simpleGet('/apple-touch-icon.png'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/png'); - }); - - test('GET twemoji svg', async () => { - const res = await simpleGet('/twemoji/2764.svg'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/svg+xml'); - }); - - test('GET twemoji svg with hyphen', async () => { - const res = await simpleGet('/twemoji/2764-fe0f-200d-1f525.svg'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/svg+xml'); - }); + describe.each([{ path: '/streaming' }])('$path', ({ path }) => { + test('はGETできない。', async () => await notOk({ + path, + status: 503, + })); }); describe('/@:username', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/@${alice.username}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); + const path = (username: string): string => `/@${username}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + accept, + type: HTML, + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + // TODO の検証 + }); + test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + type: HTML, + })); }); - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/@${alice.username}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Person'); + }); - test('Prefer HTML => HTML', async () => { - const res = await simpleGet(`/@${alice.username}`, PREFER_HTML); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ + path: path('xxxxxxxxxx'), + accept, + })); }); + }); - test('Unspecified => HTML', async () => { - const res = await simpleGet(`/@${alice.username}`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); + describe.each([ + // 実際のハンドルはフロントエンド(index.vue)で行われる + { sub: 'home' }, + { sub: 'notes' }, + { sub: 'activity' }, + { sub: 'achievements' }, + { sub: 'reactions' }, + { sub: 'clips' }, + { sub: 'pages' }, + { sub: 'gallery' }, + ])('/@:username/$sub', ({ sub }) => { + const path = (username: string): string => `/@${username}/${sub}`; + + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); }); }); + + describe('/@:user/pages/:page', () => { + const path = (username: string, pagename: string): string => `/@${username}/pages/${pagename}`; + + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username, alicePage.name), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:page-id'), alicePage.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('はGETできる。(存在しないIDでも。)', async () => await ok({ + path: path(alice.username, 'xxxxxxxxxx'), + })); + }); describe('/users/:id', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/users/${alice.id}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); + const path = (id: string): string => `/users/${id}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('は/@:usernameにリダイレクトする', async () => { + const res = await simpleGet(path(alice.id), accept); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.location, `/@${alice.username}`); + }); + + test('は存在しないユーザーはGETできない。', async () => await notFound({ + path: path('xxxxxxxx'), + })); }); - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/users/${alice.id}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.id), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Person'); + }); - test('Prefer HTML => Redirect to /@:username', async () => { - const res = await simpleGet(`/users/${alice.id}`, PREFER_HTML); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.location, `/@${alice.username}`); - }); - - test('Undecided => HTML', async () => { - const res = await simpleGet(`/users/${alice.id}`, UNSPECIFIED); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.location, `/@${alice.username}`); + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({ + path: path('xxxxxxxx'), + accept, + status: 404, + })); }); }); + + describe('/users/inbox', () => { + test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ + path: '/inbox', + })); + // test.todo('POSTできる?'); + }); + + describe('/users/:id/inbox', () => { + const path = (id: string): string => `/users/${id}/inbox`; + + test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ + path: path(alice.id), + })); + + // test.todo('POSTできる?'); + }); + + describe('/users/:id/outbox', () => { + const path = (id: string): string => `/users/${id}/outbox`; + + test('がGETできる。', async () => { + const res = await ok({ + path: path(alice.id), + type: AP, + }); + assert.strictEqual(res.body.type, 'OrderedCollection'); + }); + }); + describe('/notes/:id', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); + const path = (noteId: string): string => `/notes/${noteId}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alicesPost.id), + accept, + type: HTML, + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); }); - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alicesPost.id), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Note'); + }); - test('Prefer HTML => HTML', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_HTML); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - - test('Unspecified => HTML', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ + path: path('xxxxxxxxxx'), + accept, + })); }); }); + + describe('/play/:id', () => { + const path = (playid: string): string => `/play/${playid}`; - describe('Feeds', () => { - test('RSS', async () => { - const res = await simpleGet(`/@${alice.username}.rss`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/rss+xml; charset=utf-8'); + test('がGETできる。', async () => { + const res = await ok({ + path: path(alicePlay.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:flash-id'), alicePlay.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 }); - test('ATOM', async () => { - const res = await simpleGet(`/@${alice.username}.atom`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/atom+xml; charset=utf-8'); - }); + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/clips/:clip', () => { + const path = (clip: string): string => `/clips/${clip}`; - test('JSON', async () => { - const res = await simpleGet(`/@${alice.username}.json`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/json; charset=utf-8'); + test('がGETできる。', async () => { + const res = await ok({ + path: path(aliceClip.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:clip-id'), aliceClip.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/gallery/:post', () => { + const path = (post: string): string => `/gallery/${post}`; + + test('がGETできる。', async () => { + const res = await ok({ + path: path(aliceGalleryPost.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + + // FIXME: misskey:gallery-post-idみたいなmetaタグの設定がない + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/channels/:channel', () => { + const path = (channel: string): string => `/channels/${channel}`; + + test('はGETできる。', async () => { + const res = await ok({ + path: path(aliceChannel.id), + }); + + // FIXME: misskey関連のmetaタグの設定がない + // TODO ogタグの検証 + }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); }); }); diff --git a/packages/backend/test/e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts index d53919ca1..7b75005a3 100644 --- a/packages/backend/test/e2e/ff-visibility.ts +++ b/packages/backend/test/e2e/ff-visibility.ts @@ -5,19 +5,19 @@ import { signup, api, startServer, simpleGet } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('FF visibility', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let bob: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => { diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index 6654a290b..25bd532cf 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -5,7 +5,7 @@ import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Mute', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; // alice mutes carol let alice: any; @@ -13,14 +13,14 @@ describe('Mute', () => { let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('ミュート作成', async () => { @@ -76,9 +76,9 @@ describe('Mute', () => { describe('Timeline', () => { test('タイムラインにミュートしているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const res = await api('/notes/local-timeline', {}, alice); @@ -90,8 +90,8 @@ describe('Mute', () => { }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { - const aliceNote = await post(alice); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { renoteId: carolNote.id, }); @@ -108,7 +108,7 @@ describe('Mute', () => { describe('Notification', () => { test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => { - const aliceNote = await post(alice); + const aliceNote = await post(alice, { text: 'hi' }); await react(bob, aliceNote, 'like'); await react(carol, aliceNote, 'like'); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 1b5f9580d..e87045a8c 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -6,14 +6,14 @@ import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } fro import type { INestApplicationContext } from '@nestjs/common'; describe('Note', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let Notes: any; let alice: any; let bob: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); const connection = await initTestDb(true); Notes = connection.getRepository(Note); alice = await signup({ username: 'alice' }); @@ -21,7 +21,7 @@ describe('Note', () => { }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('投稿できる', async () => { @@ -136,6 +136,31 @@ describe('Note', () => { assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); }); + test('visibility: followersでrenoteできる', async () => { + const createRes = await api('/notes/create', { + text: 'test', + visibility: 'followers', + }, alice); + + assert.strictEqual(createRes.status, 200); + + const renoteId = createRes.body.createdNote.id; + const renoteRes = await api('/notes/create', { + visibility: 'followers', + renoteId, + }, alice); + + assert.strictEqual(renoteRes.status, 200); + assert.strictEqual(renoteRes.body.createdNote.renoteId, renoteId); + assert.strictEqual(renoteRes.body.createdNote.visibility, 'followers'); + + const deleteRes = await api('/notes/delete', { + noteId: renoteRes.body.createdNote.id, + }, alice); + + assert.strictEqual(deleteRes.status, 204); + }); + test('文字数ぎりぎりで怒られない', async () => { const post = { text: '!'.repeat(3000), diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts new file mode 100644 index 000000000..0f73b8d09 --- /dev/null +++ b/packages/backend/test/e2e/renote-mute.ts @@ -0,0 +1,85 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Renote Mute', () => { + let app: INestApplicationContext; + + // alice mutes carol + let alice: any; + let bob: any; + let carol: any; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + test('ミュート作成', async () => { + const res = await api('/renote-mute/create', { + userId: carol.id, + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('タイムラインにリノートミュートしているユーザーのリノートが含まれない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, false); + }); + + test('ストリームにリノートミュートしているユーザーの引用が流れる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id, text: 'kore' }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, true); + }); +}); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 23c431f2e..e1b690c30 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -6,7 +6,7 @@ import { connectStream, signup, api, post, startServer, initTestDb, waitFire } f import type { INestApplicationContext } from '@nestjs/common'; describe('Streaming', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let Followings: any; const follow = async (follower: any, followee: any) => { @@ -38,7 +38,7 @@ describe('Streaming', () => { let list: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); const connection = await initTestDb(true); Followings = connection.getRepository(Following); @@ -74,7 +74,7 @@ describe('Streaming', () => { }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); describe('Events', () => { diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 792436d88..2ae2eb67c 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -5,21 +5,21 @@ import { signup, api, post, connectStream, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Note thread mute', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let bob: any; let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => { diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index 690cba174..c11099e7b 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -5,7 +5,7 @@ import { signup, api, post, uploadUrl, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('users/notes', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let jpgNote: any; @@ -13,7 +13,7 @@ describe('users/notes', () => { let jpgPngNote: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); @@ -29,7 +29,7 @@ describe('users/notes', () => { }, 1000 * 60 * 2); afterAll(async() => { - await p.close(); + await app.close(); }); test('ファイルタイプ指定 (jpg)', async () => { diff --git a/packages/backend/test/resources/with-alpha.avif b/packages/backend/test/resources/with-alpha.avif new file mode 100644 index 000000000..05f494212 Binary files /dev/null and b/packages/backend/test/resources/with-alpha.avif differ diff --git a/packages/backend/test/resources/with-alpha.webp b/packages/backend/test/resources/with-alpha.webp new file mode 100644 index 000000000..d7b0d70b7 Binary files /dev/null and b/packages/backend/test/resources/with-alpha.webp differ diff --git a/packages/backend/test/resources/without-alpha.avif b/packages/backend/test/resources/without-alpha.avif new file mode 100644 index 000000000..9ea23608b Binary files /dev/null and b/packages/backend/test/resources/without-alpha.avif differ diff --git a/packages/backend/test/resources/without-alpha.webp b/packages/backend/test/resources/without-alpha.webp new file mode 100644 index 000000000..a51091efe Binary files /dev/null and b/packages/backend/test/resources/without-alpha.webp differ diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts new file mode 100644 index 000000000..0549800a6 --- /dev/null +++ b/packages/backend/test/unit/DriveService.ts @@ -0,0 +1,55 @@ +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DriveService } from '@/core/DriveService.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { S3Service } from '@/core/S3Service'; +import type { Meta } from '@/models'; +import type { DeleteObjectOutput } from 'aws-sdk/clients/s3'; +import type { AWSError } from 'aws-sdk/lib/error'; +import type { PromiseResult, Request } from 'aws-sdk/lib/request'; +import type { TestingModule } from '@nestjs/testing'; + +describe('DriveService', () => { + let app: TestingModule; + let driveService: DriveService; + + beforeEach(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [DriveService, S3Service], + }).compile(); + app.enableShutdownHooks(); + driveService = app.get(DriveService); + + const s3Service = app.get(S3Service); + const s3 = s3Service.getS3({} as Meta); + + // new S3() surprisingly does not return an instance of class S3. + // Let's use getPrototypeOf here to get a real prototype, since spying on S3.prototype doesn't work. + // TODO: Use `aws-sdk-client-mock` package when upgrading to AWS SDK v3. + jest.spyOn(Object.getPrototypeOf(s3), 'deleteObject').mockImplementation(() => { + // Roughly mock AWS request object + return { + async promise(): Promise> { + const err = new Error('mock') as AWSError; + err.code = 'NoSuchKey'; + throw err; + }, + } as Request; + }); + }); + + describe('Object storage', () => { + test('delete a file with no valid key', async () => { + try { + await driveService.deleteObjectStorageFile('lol no way'); + } catch (err: any) { + console.log(err.cause); + throw err; + } + }); + }); +}); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 3d0032507..146998937 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -7,15 +7,35 @@ import { jest } from '@jest/globals'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; +import type { IActor } from '@/core/activitypub/type.js'; import { MockResolver } from '../misc/mock-resolver.js'; +import { Note } from '@/models/index.js'; + +const host = 'https://host1.test'; + +function createRandomActor(): IActor & { id: string } { + const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; + const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + id: actorId, + type: 'Person', + preferredUsername, + inbox: `${actorId}/inbox`, + outbox: `${actorId}/outbox`, + }; +} describe('ActivityPub', () => { let noteService: ApNoteService; let personService: ApPersonService; + let rendererService: ApRendererService; let resolver: MockResolver; beforeEach(async () => { @@ -28,6 +48,7 @@ describe('ActivityPub', () => { noteService = app.get(ApNoteService); personService = app.get(ApPersonService); + rendererService = app.get(ApRendererService); resolver = new MockResolver(await app.resolve(LoggerService)); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error @@ -36,18 +57,7 @@ describe('ActivityPub', () => { }); describe('Parse minimum object', () => { - const host = 'https://host1.test'; - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; - const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; - - const actor = { - '@context': 'https://www.w3.org/ns/activitystreams', - id: actorId, - type: 'Person', - preferredUsername, - inbox: `${actorId}/inbox`, - outbox: `${actorId}/outbox`, - }; + const actor = createRandomActor(); const post = { '@context': 'https://www.w3.org/ns/activitystreams', @@ -80,29 +90,40 @@ describe('ActivityPub', () => { }); }); - describe('Truncate long name', () => { - const host = 'https://host1.test'; - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; - const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; + describe('Name field', () => { + test('Truncate long name', async () => { + const actor = { + ...createRandomActor(), + name: rndstr('0-9a-z', 129), + }; - const name = rndstr('0-9a-z', 129); - - const actor = { - '@context': 'https://www.w3.org/ns/activitystreams', - id: actorId, - type: 'Person', - preferredUsername, - name, - inbox: `${actorId}/inbox`, - outbox: `${actorId}/outbox`, - }; - - test('Actor', async () => { resolver._register(actor.id, actor); const user = await personService.createPerson(actor.id, resolver); - assert.deepStrictEqual(user.name, actor.name.substr(0, 128)); + assert.deepStrictEqual(user.name, actor.name.slice(0, 128)); + }); + + test('Normalize empty name', async () => { + const actor = { + ...createRandomActor(), + name: '', + }; + + resolver._register(actor.id, actor); + + const user = await personService.createPerson(actor.id, resolver); + + assert.strictEqual(user.name, null); + }); + }); + + describe('Renderer', () => { + test('Render an announce with visibility: followers', () => { + rendererService.renderAnnounce(null, { + createdAt: new Date(0), + visibility: 'followers', + } as Note); }); }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 8203e4935..4f501a872 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -1,8 +1,11 @@ +import * as assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import { isAbsolute, basename } from 'node:path'; +import { inspect } from 'node:util'; import WebSocket from 'ws'; import fetch, { Blob, File, RequestInit } from 'node-fetch'; import { DataSource } from 'typeorm'; +import { JSDOM } from 'jsdom'; import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; @@ -12,11 +15,45 @@ export { server as startServer } from '@/boot/common.js'; const config = loadConfig(); export const port = config.port; +export const cookie = (me: any): string => { + return `token=${me.token};`; +}; + export const api = async (endpoint: string, params: any, me?: any) => { const normalized = endpoint.replace(/^\//, ''); return await request(`api/${normalized}`, params, me); }; +export type ApiRequest = { + endpoint: string, + parameters: object, + user: object | undefined, +}; + +export const successfulApiCall = async (request: ApiRequest, assertion: { + status: number, +} = { status: 200 }): Promise => { + const { endpoint, parameters, user } = request; + const { status } = assertion; + const res = await api(endpoint, parameters, user); + assert.strictEqual(res.status, status, inspect(res.body)); + return res.body; +}; + +export const failedApiCall = async (request: ApiRequest, assertion: { + status: number, + code: string, + id: string +}): Promise => { + const { endpoint, parameters, user } = request; + const { status, code, id } = assertion; + const res = await api(endpoint, parameters, user); + assert.strictEqual(res.status, status, inspect(res.body)); + assert.strictEqual(res.body.error.code, code, inspect(res.body)); + assert.strictEqual(res.body.error.id, id, inspect(res.body)); + return res.body; +}; + const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const auth = me ? { i: me.token, @@ -57,15 +94,28 @@ export const signup = async (params?: any): Promise => { }; export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => { - const q = Object.assign({ - text: 'test', - }, params); + const q = params; const res = await api('notes/create', q, user); return res.body ? res.body.createdNote : null; }; +// 非公開ノートをAPI越しに見たときのノート NoteEntityService.ts +export const hiddenNote = (note: any): any => { + const temp = { + ...note, + fileIds: [], + files: [], + text: null, + cw: null, + isHidden: true, + }; + delete temp.visibleUserIds; + delete temp.poll; + return temp; +}; + export const react = async (user: any, note: any, reaction: string): Promise => { await api('notes/reactions/create', { noteId: note.id, @@ -73,6 +123,71 @@ export const react = async (user: any, note: any, reaction: string): Promise => { + const res = await api('pages/create', { + alignCenter: false, + content: [ + { + id: '2be9a64b-5ada-43a3-85f3-ec3429551ded', + text: 'Hello World!', + type: 'text', + }, + ], + eyeCatchingImageId: null, + font: 'sans-serif', + hideTitleWhenPinned: false, + name: '1678594845072', + script: '', + summary: null, + title: '', + variables: [], + ...page, + }, user); + return res.body; +}; + +export const play = async (user: any, play: any = {}): Promise => { + const res = await api('flash/create', { + permissions: [], + script: 'test', + summary: '', + title: 'test', + ...play, + }, user); + return res.body; +}; + +export const clip = async (user: any, clip: any = {}): Promise => { + const res = await api('clips/create', { + description: null, + isPublic: true, + name: 'test', + ...clip, + }, user); + return res.body; +}; + +export const galleryPost = async (user: any, channel: any = {}): Promise => { + const res = await api('gallery/posts/create', { + description: null, + fileIds: [], + isSensitive: false, + title: 'test', + ...channel, + }, user); + return res.body; +}; + +export const channel = async (user: any, channel: any = {}): Promise => { + const res = await api('channels/create', { + bannerId: null, + description: null, + name: 'test', + ...channel, + }, user); + return res.body; +}; + interface UploadOptions { /** Optional, absolute path or relative from ./resources/ */ path?: string | URL; @@ -198,17 +313,33 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond }); }; -export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status: number, body: any, type: string | null, location: string | null }> => { +export type SimpleGetResponse = { + status: number, + body: any | JSDOM | null, + type: string | null, + location: string | null +}; +export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise => { const res = await relativeFetch(path, { headers: { Accept: accept, + Cookie: cookie, }, redirect: 'manual', }); - const body = res.headers.get('content-type') === 'application/json; charset=utf-8' - ? await res.json() - : null; + const jsonTypes = [ + 'application/json; charset=utf-8', + 'application/activity+json; charset=utf-8', + ]; + const htmlTypes = [ + 'text/html; charset=utf-8', + ]; + + const body = + jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : + null; return { status: res.status, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e4c04f593..54404c8c5 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,6 +4,8 @@ "scripts": { "watch": "vite", "build": "vite build", + "test": "vitest --run", + "test-and-coverage": "vitest --run --coverage", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "lint": "pnpm typecheck && pnpm eslint" @@ -13,8 +15,8 @@ "@rollup/plugin-alias": "4.0.3", "@rollup/plugin-json": "6.0.0", "@rollup/pluginutils": "5.0.2", - "@syuilo/aiscript": "0.12.4", - "@tabler/icons-webfont": "2.2.0", + "@syuilo/aiscript": "0.13.1", + "@tabler/icons-webfont": "2.10.0", "@vitejs/plugin-vue": "4.0.0", "@vue/compiler-sfc": "3.2.47", "autobind-decorator": "2.4.0", @@ -46,7 +48,7 @@ "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.17.3", + "rollup": "3.19.0", "s-age": "1.1.2", "sanitize-html": "2.10.0", "sass": "1.58.3", @@ -54,10 +56,10 @@ "strict-event-emitter-types": "2.0.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.150.0", + "three": "0.150.1", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.2", + "tsc-alias": "1.8.3", "tsconfig-paths": "4.1.2", "twemoji-parser": "14.0.0", "typescript": "4.9.5", @@ -70,28 +72,34 @@ "vuedraggable": "next" }, "devDependencies": { + "@testing-library/vue": "^6.6.1", "@types/escape-regexp": "0.0.1", "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", "@types/matter-js": "0.18.2", - "@types/node": "18.14.1", + "@types/node": "18.15.0", "@types/punycode": "2.1.0", - "@types/sanitize-html": "2.8.0", + "@types/sanitize-html": "2.8.1", "@types/seedrandom": "3.0.5", "@types/throttle-debounce": "5.0.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "9.0.1", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.53.0", - "@typescript-eslint/parser": "5.53.0", + "@typescript-eslint/eslint-plugin": "5.54.1", + "@typescript-eslint/parser": "5.54.1", + "@vitest/coverage-c8": "^0.29.2", "@vue/runtime-core": "3.2.47", "cross-env": "7.0.3", "cypress": "12.7.0", "eslint": "8.35.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-vue": "9.9.0", - "start-server-and-test": "1.15.4", + "happy-dom": "8.9.0", + "start-server-and-test": "2.0.0", + "summaly": "github:misskey-dev/summaly", + "vitest": "^0.29.2", + "vitest-fetch-mock": "^0.2.2", "vue-eslint-parser": "9.1.0", "vue-tsc": "1.2.0" } diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 610212b6e..9b104391d 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -1,4 +1,4 @@ -import { defineAsyncComponent, reactive } from 'vue'; +import { defineAsyncComponent, reactive, ref } from 'vue'; import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; @@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; +import { MenuButton } from './types/menu'; // TODO: 他のタブと永続化されたstateを同期 @@ -26,11 +27,11 @@ export function incNotesCount() { } export async function signout() { + if (!$i) return; + waiting(); miLocalStorage.removeItem('account'); - await removeAccount($i.id); - const accounts = await getAccounts(); //#region Remove service worker registration @@ -76,15 +77,19 @@ export async function addAccount(id: Account['id'], token: Account['token']) { } } -export async function removeAccount(id: Account['id']) { +export async function removeAccount(idOrToken: Account['id']) { const accounts = await getAccounts(); - accounts.splice(accounts.findIndex(x => x.id === id), 1); + const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); + if (i !== -1) accounts.splice(i, 1); - if (accounts.length > 0) await set('accounts', accounts); - else await del('accounts'); + if (accounts.length > 0) { + await set('accounts', accounts); + } else { + await del('accounts'); + } } -function fetchAccount(token: string): Promise { +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { return new Promise((done, fail) => { // Fetch user window.fetch(`${apiUrl}/i`, { @@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise { 'Content-Type': 'application/json', }, }) - .then(res => res.json()) - .then(res => { - if (res.error) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - showSuspendedDialog().then(() => { - signout(); - }); - } else { - alert({ + .then(res => new Promise }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, }); } } else { - res.token = token; - done(res); + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); } - }) - .catch(fail); + + // rejectかつ理由がtrueの場合、削除対象であることを示す + fail(true); + } else { + (res as Account).token = token; + done(res as Account); + } + }) + .catch(fail); }); } -export function updateAccount(accountData) { +export function updateAccount(accountData: Partial) { + if (!$i) return; for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } miLocalStorage.setItem('account', JSON.stringify($i)); } -export function refreshAccount() { - return fetchAccount($i.token).then(updateAccount); +export async function refreshAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id) + .then(updateAccount, reason => { + if (reason === true) return signout(); + return; + }); } export async function login(token: Account['token'], redirect?: string) { - waiting(); + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, {}, 'closed'); if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token); + const me = await fetchAccount(token, undefined, true) + .catch(reason => { + if (reason === true) { + // 削除対象の場合 + removeAccount(token); + } + + showing.value = false; + throw reason; + }); miLocalStorage.setItem('account', JSON.stringify(me)); document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う await addAccount(me.id, token); @@ -155,6 +210,8 @@ export async function openAccountMenu(opts: { active?: misskey.entities.UserDetailed['id']; onChoose?: (account: misskey.entities.UserDetailed) => void; }, ev: MouseEvent) { + if (!$i) return; + function showSigninDialog() { popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { @@ -175,8 +232,9 @@ export async function openAccountMenu(opts: { async function switchAccount(account: misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); - const token = storedAccounts.find(x => x.id === account.id).token; - switchAccountWithToken(token); + const found = storedAccounts.find(x => x.id === account.id); + if (found == null) return; + switchAccountWithToken(found.token); } function switchAccountWithToken(token: string) { @@ -188,7 +246,7 @@ export async function openAccountMenu(opts: { function createItem(account: misskey.entities.UserDetailed) { return { - type: 'user', + type: 'user' as const, user: account, active: opts.active != null ? opts.active === account.id : false, action: () => { @@ -201,22 +259,29 @@ export async function openAccountMenu(opts: { }; } - const accountItemPromises = storedAccounts.map(a => new Promise(res => { + const accountItemPromises = storedAccounts.map(a => new Promise | MenuButton>(res => { accountsPromise.then(accounts => { const account = accounts.find(x => x.id === a.id); - if (account == null) return res(null); + if (account == null) return res({ + type: 'button' as const, + text: a.id, + action: () => { + switchAccountWithToken(a.token); + }, + }); + res(createItem(account)); }); })); if (opts.withExtraOperation) { popupMenu([...[{ - type: 'link', + type: 'link' as const, text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { - type: 'parent', + type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, children: [{ @@ -227,7 +292,7 @@ export async function openAccountMenu(opts: { action: () => { createAccount(); }, }], }, { - type: 'link', + type: 'link' as const, icon: 'ti ti-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c72cc2ab1..1875b507c 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -10,7 +10,8 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch } from 'vu import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; -type Captcha = { +// APIs provided by Captcha services +export type Captcha = { render(container: string | Node, options: { readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; }): string; @@ -32,7 +33,7 @@ declare global { const props = defineProps<{ provider: CaptchaProvider; - sitekey: string; + sitekey: string | null; // null will show error on request modelValue?: string | null; }>(); diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue new file mode 100644 index 000000000..c5fb71878 --- /dev/null +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 4525d3a00..d6303f967 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -1,7 +1,9 @@ diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index c768a086c..d36cc2d26 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -118,7 +118,7 @@ onMounted(() => { }); lightbox.init(); - + window.addEventListener('popstate', () => { if (lightbox.pswp && lightbox.pswp.isOpen === true) { lightbox.pswp.close(); @@ -239,5 +239,6 @@ const previewable = (file: misskey.entities.DriveFile): boolean => { max-height: 8em; overflow-y: auto; text-shadow: var(--bg) 0 0 10px, var(--bg) 0 0 3px, var(--bg) 0 0 3px; + white-space: pre-line; } diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 4529d61c2..852c72f6f 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -8,7 +8,7 @@ :duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened" >
-
+
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index bb1269562..af81051a5 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -103,7 +103,8 @@
- +