diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml new file mode 100644 index 000000000..6411d63bd --- /dev/null +++ b/.github/workflows/api-misskey-js.yml @@ -0,0 +1,36 @@ +name: API report (misskey.js) + +on: [push, pull_request] + +jobs: + report: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3.3.0 + + - run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v3.6.0 + with: + node-version: 18.x + cache: 'pnpm' + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Build + run: pnpm --filter misskey-js build + + - name: Check files + run: ls packages/misskey-js/built + + - name: API report + run: pnpm --filter misskey-js api-prod + + - name: Show report + if: always() + run: cat packages/misskey-js/temp/misskey-js.api.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d65076ebb..1c6615e17 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,6 +36,7 @@ jobs: - backend - frontend - sw + - misskey-js steps: - uses: actions/checkout@v3.3.0 with: @@ -61,6 +62,7 @@ jobs: matrix: workspace: - backend + - misskey-js steps: - uses: actions/checkout@v3.3.0 with: diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml new file mode 100644 index 000000000..eda28c5f7 --- /dev/null +++ b/.github/workflows/storybook.yml @@ -0,0 +1,56 @@ +name: Storybook + +on: + push: + branches: + - master + - develop + pull_request_target: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3.3.0 + with: + fetch-depth: 0 + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 7 + run_install: false + - name: Use Node.js 18.x + uses: actions/setup-node@v3.6.0 + with: + node-version: 18.x + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Build misskey-js + run: pnpm --filter misskey-js build + - name: Build storybook + run: pnpm --filter frontend build-storybook + env: + NODE_OPTIONS: "--max_old_space_size=7168" + - name: Publish to Chromatic + id: chromatic + uses: chromaui/action@v1 + with: + exitOnceUploaded: true + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + storybookBuildDir: storybook-static + workingDir: packages/frontend + - name: Compare on Chromatic + if: github.event_name == 'pull_request_target' + run: pnpm --filter frontend chromatic -d storybook-static --exit-once-uploaded --patch-build ${{ github.head_ref }}...${{ github.base_ref }} + env: + CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + - name: Upload Artifacts + uses: actions/upload-artifact@v3 + with: + name: storybook + path: packages/frontend/storybook-static diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml new file mode 100644 index 000000000..b15e704c7 --- /dev/null +++ b/.github/workflows/test-misskey-js.yml @@ -0,0 +1,52 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Test (misskey.js) + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + +jobs: + test: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - name: Checkout + uses: actions/checkout@v3.3.0 + + - run: corepack enable + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + + - name: Build + run: pnpm --filter misskey-js build + + - name: Test + run: pnpm --filter misskey-js test + env: + CI: true + + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/misskey-js/coverage/coverage-final.json diff --git a/.gitignore b/.gitignore index c413cd4da..fbe224550 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,8 @@ api-docs.json .DS_Store /files ormconfig.json +temp +/packages/frontend/src/**/*.stories.ts # blender backups *.blend1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e79bbbac3..00c90987d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,25 +1,129 @@ ## 13.x.x (unreleased) -### Improvements +### General +- チャンネルをお気に入りに登録できるように +- チャンネルにノートをピン留めできるように + +### Client +- 検索ページでURLを入力した際に照会したときと同等の挙動をするように +- ノートのリアクションを大きく表示するオプションを追加 +- オブジェクトストレージの設定画面を分かりやすく +- 「にゃああああああああああああああ!!!!!!!!!!!!」 (`isCat`) 有効時にアバターに表示される猫耳について挙動を変更 + - 「UIにぼかし効果を使用」 (`useBlurEffect`) で次の挙動が有効になります + - 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように + - 猫耳の色がアバター上部のピクセルから決定されます(無効化時はアバター全体の平均色) + - 左耳は上からおよそ 10%, 左からおよそ 20% の位置で決定します + - 右耳は上からおよそ 10%, 左からおよそ 80% の位置で決定します + - 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります + +### Server +- ノート作成時のパフォーマンスを向上 +- アンテナのタイムライン取得時のパフォーマンスを向上 +- チャンネルのタイムライン取得時のパフォーマンスを向上 +- 通知に関する全体的なパフォーマンスを向上 + +## 13.10.3 + +### Changes +- オブジェクトストレージのリージョン指定が必須になりました + - リージョンの指定の無いサービスは us-east-1 を設定してください + - 値が空の場合は設定ファイルまたは環境変数の使用を試みます + - e.g. ~/aws/config, AWS_REGION + +### General +- コンディショナルロールの条件に「投稿数が~以下」「投稿数が~以上」を追加 +- リアクション非対応AP実装からのLikeアクティビティの解釈を👍から♥に + +### Client +- クリップボタンをノートアクションに追加できるように +- センシティブワードの一覧にピン留めユーザーのIDが表示される問題を修正 + +### Server +- リモートユーザーのチャート生成を無効にするオプションを追加 +- リモートサーバーのチャート生成を無効にするオプションを追加 +- ドライブのチャートはローカルユーザーのみ生成するように +- 空のアンテナが作成できるのを修正 + +## 13.10.2 + +### Server +- 絵文字を編集すると保存できないことがある問題を修正 + +### Client +- ドライブファイルのメニューが正常に動作しない問題を修正 + +## 13.10.1 + +### Client +- Misskey PlayのPlayボタンを押した時にエラーが発生する問題を修正 + +## 13.10.0 + +### General - ユーザーごとにRenoteをミュートできるように - ノートごとに絵文字リアクションを受け取るか設定できるように -- enhance(client): DM作成時にメンションも含むように -- enhance(client): フォロー申請のボタンのデザインを改善 +- クリップをお気に入りに登録できるように +- ノート検索の利用可否をロールで制御可能に(デフォルトでオフ) +- ロールの並び順を設定可能に +- カスタム絵文字にライセンス情報を付与できるように +- 指定した文字列を含む投稿の公開範囲をホームにできるように +- 使われてないアンテナは自動停止されるように -### Bugfixes +### 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) @@ -257,8 +361,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 @@ -421,7 +525,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 @@ -649,7 +753,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) @@ -691,7 +795,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/CONTRIBUTING.md b/CONTRIBUTING.md index 887d17961..fece05d7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,6 +203,116 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。 これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。 +## Storybook + +Misskey uses [Storybook](https://storybook.js.org/) for UI development. + +### Setup & Run + +#### Universal + +##### Setup + +```bash +pnpm --filter misskey-js build +pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js) +``` + +##### Run + +```bash +node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev +``` + +#### macOS & Linux + +##### Setup + +```bash +pnpm --filter misskey-js build +``` + +##### Run + +```bash +pnpm --filter frontend storybook-dev +``` + +### Usage + +When you create a new component (in this example, `MyComponent.vue`), the story file (`MyComponent.stories.ts`) will be automatically generated by the `.storybook/generate.js` script. +You can override the default story by creating a impl story file (`MyComponent.stories.impl.ts`). + +```ts +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-duplicates */ +import { StoryObj } from '@storybook/vue3'; +import MyComponent from './MyComponent.vue'; +export const Default = { + render(args) { + return { + components: { + MyComponent, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + foo: 'bar', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; +``` + +If you want to opt-out from the automatic generation, create a `MyComponent.stories.impl.ts` file and add the following line to the file. + +```ts +import MyComponent from './MyComponent.vue'; +void MyComponent; +``` + +You can override the component meta by creating a meta story file (`MyComponent.stories.meta.ts`). + +```ts +export const argTypes = { + scale: { + control: { + type: 'range', + min: 1, + max: 4, + }, +}; +``` + +Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers. + +```ts +import { rest } from 'msw'; +export const handlers = [ + rest.post('/api/notes/timeline', (req, res, ctx) => { + return res( + ctx.json([]), + ); + }), +]; +``` + +Don't forget to re-run the `.storybook/generate.js` script after adding, editing, or removing the above files. + ## Notes ### How to resolve conflictions occurred at pnpm-lock.yaml? diff --git a/Dockerfile b/Dockerfile index fd0b5e1c9..8db7400c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ COPY --link ["scripts", "./scripts"] COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/frontend/package.json", "./packages/frontend/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] +COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 5254b20ef..c2910b90c 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -545,7 +545,6 @@ tokenRequested: "منح حق الوصول إلى الحساب" pluginTokenRequestedDescription: "ستتمكن الإضافة من استخدام هذه الأذونات." notificationType: "أنواع الإشعارات" edit: "التعديل" -useStarForReactionFallback: "استخدم ★ كبديل إذا كان التفاعل مجهولًا" emailServer: "خادم البريد الإلكتروني" emailConfigInfo: "يستخدم لتأكيد عنوان بريدك الإلكتروني ولإعادة تعيين كلمة المرور إن نسيتها." email: "البريد الإلكتروني " @@ -1275,3 +1274,7 @@ _deck: channel: "القنوات" mentions: "الإشارات" direct: "مباشرة" +_webhookSettings: + name: "الإسم" + active: "مفعّل" + diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 49b76b8ab..40af5a332 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -562,7 +562,6 @@ tokenRequested: "অ্যাকাউন্টে অ্যাক্সেস pluginTokenRequestedDescription: "এই প্লাগইনটি এখানে দেওয়া অনুমুতিসমূহ ব্যাবহার করবে" notificationType: "বিজ্ঞপ্তির ধরন" edit: "সম্পাদনা" -useStarForReactionFallback: "রিঅ্যাকশনের ইমোজি না জানলে ★ ব্যবহার করুন" emailServer: "ইমেইল সার্ভার" enableEmail: "ইমেইল বিতরণ চালু করুন" emailConfigInfo: "আপনার ইমেল ঠিকানা নিশ্চিত করতে এবং আপনার পাসওয়ার্ড পুনরায় সেট করতে ব্যবহৃত হয়" @@ -1354,3 +1353,7 @@ _deck: channel: "চ্যানেলগুলি" mentions: "উল্লেখসমূহ" direct: "ডাইরেক্ট নোটগুলি" +_webhookSettings: + name: "নাম" + active: "চালু" + diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 2b1168f78..bc9e66249 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -460,3 +460,4 @@ _deck: list: "Llistes" mentions: "Mencions" direct: "Publicacions directes" + diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 7f665895b..4b5919247 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -776,3 +776,7 @@ _deck: list: "Seznamy" channel: "Kanály" mentions: "Zmínění" +_webhookSettings: + name: "Jméno" + active: "Zapnuto" + diff --git a/locales/da-DK.yml b/locales/da-DK.yml index 08c15ed09..d1fbec9f6 100644 --- a/locales/da-DK.yml +++ b/locales/da-DK.yml @@ -1,2 +1,3 @@ --- _lang_: "Dansk" + diff --git a/locales/de-DE.yml b/locales/de-DE.yml index eae9389ac..aed515d2b 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" @@ -457,7 +460,7 @@ aboutX: "Über {x}" emojiStyle: "Emoji-Stil" native: "Nativ" disableDrawer: "Keine ausfahrbaren Menüs verwenden" -showNoteActionsOnlyHover: "Aktionen für Notizen nur bei Mouseover anzeigen" +showNoteActionsOnlyHover: "Notizmenü nur bei Mouseover anzeigen" noHistory: "Kein Verlauf gefunden" signinHistory: "Anmeldungsverlauf" enableAdvancedMfm: "Erweitertes MFM aktivieren" @@ -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" @@ -587,7 +594,6 @@ tokenRequested: "Zugriff zum Benutzerkonto gewähren" pluginTokenRequestedDescription: "Dieses Plugin wird die hier konfigurierten Berechtigungen verwenden können." notificationType: "Art der Benachrichtigung" edit: "Bearbeiten" -useStarForReactionFallback: "Verwende ★ falls das Reaktions-Emoji unbekannt ist" emailServer: "Email-Server" enableEmail: "Email-Versand aktivieren" emailConfigInfo: "Zur Email-Bestätigung bei Registrierung oder zum Zurücksetzen des Passworts verwendet" @@ -959,6 +965,24 @@ 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" +retryAllQueuesNow: "Sofort Warteschlangen erneut ausführen" +retryAllQueuesConfirmTitle: "Wirklich erneut versuchen?" +retryAllQueuesConfirmText: "Dies wird zu einer temporären Erhöhung der Serverlast führen." +enableChartsForRemoteUser: "Diagramme für Nutzer fremder Instanzen erstellen" +enableChartsForFederatedInstances: "Diagramme für fremde Instanzen erstellen" +showClipButtonInNoteFooter: "\"Clip\" zum Notizmenu hinzufügen" _achievements: earnedAt: "Freigeschaltet am" _types: @@ -1106,7 +1130,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 +1242,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 +1269,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" @@ -1252,6 +1279,8 @@ _role: followersMoreThanOrEq: "Hat X oder mehr Follower" followingLessThanOrEq: "Folgt X oder weniger Benutzern" followingMoreThanOrEq: "Folgt X oder mehr Benutzern" + notesLessThanOrEq: "Beitragszahl ist kleiner-gleich" + notesMoreThanOrEq: "Beitragszahl ist größer-gleich" and: "UND-Bedingung" or: "ODER-Bedingung" not: "NICHT-Bedingung" @@ -1844,3 +1873,24 @@ _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" +_webhookSettings: + createWebhook: "Webhook erstellen" + name: "Name" + secret: "Secret" + events: "Webhook-Ereignisse" + active: "Aktiviert" + _events: + follow: "Wenn du jemandem folgst" + followed: "Wenn dir jemand folgt" + note: "Wenn du eine Notiz schickst" + reply: "Wenn du eine Antwort erhältst" + renote: "Wenn du ein Renote erhältst" + reaction: "Wenn du eine Reaktion erhältst" + mention: "Wenn du erwähnt wirst" + diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 0721ba6e9..634e36c29 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -392,3 +392,6 @@ _deck: antenna: "Αντένες" list: "Λίστα" mentions: "Επισημάνσεις" +_webhookSettings: + name: "Όνομα" + diff --git a/locales/en-US.yml b/locales/en-US.yml index 53a30c741..6489919e8 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" @@ -587,7 +594,6 @@ tokenRequested: "Grant access to account" pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here." notificationType: "Notification type" edit: "Edit" -useStarForReactionFallback: "Use ★ as fallback if the reaction emoji is unknown" emailServer: "Email server" enableEmail: "Enable email distribution" emailConfigInfo: "Used to confirm your email during sign-up or if you forget your password" @@ -959,6 +965,24 @@ 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" +retryAllQueuesNow: "Retry running all queues" +retryAllQueuesConfirmTitle: "Really retry all?" +retryAllQueuesConfirmText: "This will temporarily increase the server load." +enableChartsForRemoteUser: "Generate remote user data charts" +enableChartsForFederatedInstances: "Generate remote instance data charts" +showClipButtonInNoteFooter: "Add \"Clip\" to note action menu" _achievements: earnedAt: "Unlocked at" _types: @@ -1218,6 +1242,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 +1269,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" @@ -1252,6 +1279,8 @@ _role: followersMoreThanOrEq: "Has X or more followers" followingLessThanOrEq: "Follows X or fewer accounts" followingMoreThanOrEq: "Follows X or more accounts" + notesLessThanOrEq: "Post count is less than/equal to" + notesMoreThanOrEq: "Post count is greater than/equal to" and: "AND-Condition" or: "OR-Condition" not: "NOT-Condition" @@ -1844,3 +1873,24 @@ _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" +_webhookSettings: + createWebhook: "Create Webhook" + name: "Name" + secret: "Secret" + events: "Webhook Events" + active: "Enabled" + _events: + follow: "When following a user" + followed: "When being followed" + note: "When posting a note" + reply: "When receiving a reply" + renote: "When renoted" + reaction: "When receiving a reaction" + mention: "When being mentioned" + diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 3ad2c21ff..70327c5ea 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" @@ -586,7 +594,6 @@ tokenRequested: "Permiso de acceso a la cuenta" pluginTokenRequestedDescription: "Este plugin podrá usar los permisos descritos aquí" notificationType: "Tipo de notificación" edit: "Editar" -useStarForReactionFallback: "En caso de que los emojis de reacciones no sean claros, usar en su lugar una estrella" emailServer: "Servidor de correo" enableEmail: "Activar el envío de correos electrónicos" emailConfigInfo: "Usar en caso de validación de correo electrónico y pedido de contraseña" @@ -955,6 +962,24 @@ 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" +notesSearchNotAvailable: "No se puede buscar una nota" +license: "Licencia" +unfavoriteConfirm: "¿Desea quitar de favoritos?" +myClips: "Mis clips" +drivecleaner: "Limpiador del Drive" +retryAllQueuesNow: "Reintentar inmediatamente todas las colas" +retryAllQueuesConfirmTitle: "Desea ¿reintentar inmediatamente todas las colas?" +retryAllQueuesConfirmText: "La carga del servidor está incrementándose temporalmente " _achievements: earnedAt: "Desbloqueado el" _types: @@ -1214,6 +1239,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 +1266,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 +1868,13 @@ _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." +_drivecleaner: + orderBySizeDesc: "Más grandes" + orderByCreatedAtAsc: "Más antiguos" +_webhookSettings: + name: "Nombre" + active: "Activado" + diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index f9b8939e8..11573e0ce 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -575,7 +575,6 @@ tokenRequested: "Autoriser l'accès au compte" pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies ici." notificationType: "Type de notifications" edit: "Editer" -useStarForReactionFallback: "Utiliser ★ comme alternative si l’émoji de réaction est inconnu" emailServer: "Serveur mail" enableEmail: "Activer la distribution de courriel" emailConfigInfo: "Utilisé pour confirmer votre adresse de courriel et la réinitialisation de votre mot de passe en cas d’oubli." @@ -1468,3 +1467,7 @@ _deck: channel: "Canaux" mentions: "Mentions" direct: "Direct" +_webhookSettings: + name: "Nom" + active: "Activé" + diff --git a/locales/hr-HR.yml b/locales/hr-HR.yml index ed97d539c..cd21505a4 100644 --- a/locales/hr-HR.yml +++ b/locales/hr-HR.yml @@ -1 +1,2 @@ --- + diff --git a/locales/ht-HT.yml b/locales/ht-HT.yml index ed97d539c..cd21505a4 100644 --- a/locales/ht-HT.yml +++ b/locales/ht-HT.yml @@ -1 +1,2 @@ --- + diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 5d74cf538..e5a057477 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -579,7 +579,6 @@ tokenRequested: "Berikan ijin akses ke akun" pluginTokenRequestedDescription: "Plugin ini dapat menggunakan setelan ijin disini." notificationType: "Jenis pemberitahuan" edit: "Sunting" -useStarForReactionFallback: "Gunakan ★ sebagai fallback jika reaksi emoji tidak diketahui" emailServer: "Peladen surel" enableEmail: "Nyalakan distribusi surel" emailConfigInfo: "Digunakan untuk mengonfirmasi surel kamu disaat mendaftar dan lupa kata sandi" @@ -1804,3 +1803,7 @@ _deck: channel: "Kanal" mentions: "Sebutan" direct: "Langsung" +_webhookSettings: + name: "Nama" + active: "Aktif" + diff --git a/locales/it-IT.yml b/locales/it-IT.yml index d5638aeb6..ddd1e5e90 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" @@ -558,8 +565,8 @@ enableInfiniteScroll: "Abilita scorrimento infinito" visibility: "Visibilità" poll: "Sondaggio" useCw: "Nascondere media" -enablePlayer: "Apri in lettore video" -disablePlayer: "Chiudi il lettore" +enablePlayer: "Visualizza" +disablePlayer: "Chiudi" expandTweet: "Espandi tweet" themeEditor: "Editor di temi" description: "Descrizione" @@ -587,7 +594,6 @@ tokenRequested: "Autorizza accesso al profilo" pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate qui." notificationType: "Tipo di notifiche" edit: "Modifica" -useStarForReactionFallback: "Se è sconosciuto l'emoji di reazione, usare la ★ come alternativa." emailServer: "Server email" enableEmail: "Abilita consegna email" emailConfigInfo: "Utilizzato per verificare il tuo indirizzo di posta elettronica e per reimpostare la tua password" @@ -959,6 +965,21 @@ 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" +drivecleaner: "Drive cleaner" +retryAllQueuesNow: "Ritenta di consumare tutte le code" +retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?" +retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente." _achievements: earnedAt: "Data di conseguimento" _types: @@ -1218,6 +1239,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 +1266,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 +1868,13 @@ _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" +_drivecleaner: + orderBySizeDesc: "Dal più grande al più piccolo" + orderByCreatedAtAsc: "Dal più vecchio al più recente" +_webhookSettings: + name: "Nome" + active: "Attivo" + diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 13f8bc02e..a4f1d802c 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: "インポートをリクエストしました。これには時間がかかる場合があります。" @@ -460,7 +460,7 @@ aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" disableDrawer: "メニューをドロワーで表示しない" -showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示する" +showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する" noHistory: "履歴はありません" signinHistory: "ログイン履歴" enableAdvancedMfm: "高度なMFMを有効にする" @@ -500,12 +500,13 @@ objectStoragePrefixDesc: "このprefixのディレクトリ下に格納されま objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "S3の場合は空、それ以外の場合は各サービスのendpointを指定してください。''または':'のように指定します。" objectStorageRegion: "Region" -objectStorageRegionDesc: "'xx-east-1'のようなregionを指定してください。使用サービスにregionの概念がない場合は、空または'us-east-1'にしてください。" +objectStorageRegionDesc: "'xx-east-1'のようなregionを指定してください。使用サービスにregionの概念がない場合は'us-east-1'にしてください。AWS設定ファイルまたは環境変数を参照する場合は空にしてください。" objectStorageUseSSL: "SSLを使用する" objectStorageUseSSLDesc: "API接続にhttpsを使用しない場合はオフにしてください" objectStorageUseProxy: "Proxyを利用する" objectStorageUseProxyDesc: "API接続にproxyを利用しない場合はオフにしてください" objectStorageSetPublicRead: "アップロード時に'public-read'を設定する" +s3ForcePathStyleDesc: "s3ForcePathStyleを有効にすると、バケット名をURLのホスト名ではなくパスの一部として指定することを強制します。セルフホストされたMinioなどの使用時に有効にする必要がある場合があります。" serverLogs: "サーバーログ" deleteAll: "全て削除" showFixedPostForm: "タイムライン上部に投稿フォームを表示する" @@ -594,7 +595,6 @@ tokenRequested: "アカウントへのアクセス許可" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" notificationType: "通知の種類" edit: "編集" -useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う" emailServer: "メールサーバー" enableEmail: "メール配信機能を有効化する" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" @@ -969,6 +969,23 @@ cannotBeChangedLater: "後から変更できません。" reactionAcceptance: "リアクションの受け入れ" likeOnly: "いいねのみ" likeOnlyForRemote: "リモートからはいいねのみ" +rolesAssignedToMe: "自分に割り当てられたロール" +resetPasswordConfirm: "パスワードリセットしますか?" +sensitiveWords: "センシティブワード" +sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" +notesSearchNotAvailable: "ノート検索は利用できません。" +license: "ライセンス" +unfavoriteConfirm: "お気に入り解除しますか?" +myClips: "自分のクリップ" +drivecleaner: "ドライブクリーナー" +retryAllQueuesNow: "すべてのキューを今すぐ再試行" +retryAllQueuesConfirmTitle: "今すぐ再試行しますか?" +retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" +enableChartsForRemoteUser: "リモートユーザーのチャートを生成" +enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" +showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" +largeNoteReactions: "ノートのリアクションを大きく表示" +noteIdOrUrl: "ノートIDまたはURL" _achievements: earnedAt: "獲得日時" @@ -1230,6 +1247,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして表示" descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。" + displayOrder: "表示順" + descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" priority: "優先度" @@ -1255,6 +1274,7 @@ _role: rateLimitFactor: "レートリミット" descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。" canHideAds: "広告の非表示" + canSearchNotes: "ノート検索の利用可否" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1264,6 +1284,8 @@ _role: followersMoreThanOrEq: "フォロワー数が~以上" followingLessThanOrEq: "フォロー数が~以下" followingMoreThanOrEq: "フォロー数が~以上" + notesLessThanOrEq: "投稿数が~以下" + notesMoreThanOrEq: "投稿数が~以上" and: "~かつ~" or: "~または~" not: "~ではない" @@ -1907,3 +1929,27 @@ _deck: _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}" + +_disabledTimeline: + title: "無効化されたタイムライン" + description: "現在のロールでは、このタイムラインを使用することはできません。" + +_drivecleaner: + orderBySizeDesc: "サイズが大きい順" + orderByCreatedAtAsc: "追加日が古い順" + +_webhookSettings: + createWebhook: "Webhookを作成" + name: "名前" + secret: "シークレット" + events: "Webhookを実行するタイミング" + active: "有効" + _events: + follow: "フォローしたとき" + followed: "フォローされたとき" + note: "ノートを投稿したとき" + reply: "返信されたとき" + renote: "Renoteされたとき" + reaction: "リアクションがあったとき" + mention: "メンションされたとき" + diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 081457c2f..5b1b312b7 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: "設定のバックアップ" @@ -587,7 +594,6 @@ tokenRequested: "アカウントへのアクセス許してやったらどうや pluginTokenRequestedDescription: "このプラグインはここで設定した権限を使えるようになるで。" notificationType: "通知の種類" edit: "編集" -useStarForReactionFallback: "リアクションがようわからん場合、★を使う" emailServer: "メールサーバー" enableEmail: "メール配信を受け取る" emailConfigInfo: "メールアドレスの確認とかパスワードリセットの時に使うで" @@ -600,12 +606,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 +630,7 @@ useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使 other: "その他" regenerateLoginToken: "ログイントークンを再生成" regenerateLoginTokenDescription: "ログインに使われる内部トークンをもっかい作るで。いつもならこれをやる必要はないで。もっかい作ると、全部のデバイスでログアウトされるで気ぃつけてなー。" -setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できるで。" +setMultipleBySeparatingWithSpace: "スペースで区切って何個でも設定できるで。" fileIdOrUrl: "ファイルIDかURL" behavior: "動作" sample: "サンプル" @@ -636,7 +642,7 @@ abuseReported: "無事内容が送信されたみたいやで。おおきに〜 reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートインスタンスに通報を転送するで" +forwardReport: "リモートサーバーに通報を転送するで" forwardReportIsAnonymous: "リモートインスタンスからはあんたの情報は見れへんくって、匿名のシステムアカウントとして表示されるで。" send: "送信" abuseMarkAsResolved: "対応したで" @@ -644,7 +650,7 @@ openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" editTheseSettingsMayBreakAccount: "このへんの設定をようわからんままイジるとアカウントが壊れて使えんくなるかも知れへんで?" -instanceTicker: "ノートのインスタンス情報" +instanceTicker: "ノートのサーバー情報" waitingFor: "{x}を待っとるで" random: "ランダム" system: "システム" @@ -655,7 +661,7 @@ createNew: "新しく作るで" optional: "任意" createNewClip: "新しいクリップを作るで" unclip: "クリップ解除するで" -confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外したる?" +confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外しよか?" public: "パブリック" i18nInfo: "Misskeyは有志によっていろんな言語に翻訳されとるで。{link}で翻訳に協力したってやー。" manageAccessTokens: "アクセストークンの管理" @@ -672,15 +678,15 @@ receivedReactionsCount: "リアクションされた数" pollVotesCount: "アンケートに投票した数" pollVotedCount: "アンケートに投票された数" yes: "ええで" -no: "あかんで" +no: "あかん" driveFilesCount: "ドライブのファイル数" driveUsage: "ドライブ使用量やで" noCrawle: "クローラーによるインデックスを拒否するで" -noCrawleDescription: "検索エンジンにあんたのユーザーページ、ノート、Pagesとかのコンテンツを登録(インデックス)せぇへんように頼むで。" +noCrawleDescription: "検索エンジンにあんたのユーザーページ、ノート、Pagesとかのコンテンツを登録(インデックス)せんように頼むで。邪魔すんねんやったら帰って〜。" lockedAccountInfo: "フォローを承認制にしとっても、ノートの公開範囲を「フォロワー」にせぇへん限り、誰でもあんたのノートを見れるで。" alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にするで" loadRawImages: "添付画像のサムネイルをオリジナル画質にするで" -disableShowingAnimatedImages: "アニメーション画像を再生しやへんで" +disableShowingAnimatedImages: "アニメーション画像を再生せんとくで" verificationEmailSent: "無事確認のメールを送れたで。メールに書いてあるリンクにアクセスして、設定を完了してなー。" notSet: "未設定" emailVerified: "メールアドレスは確認されたで" @@ -690,14 +696,14 @@ pageLikedCount: "Pageにええやんと思ってくれた数" contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使うで" clips: "クリップ" -experimentalFeatures: "実験的機能やで" +experimentalFeatures: "おためし機能やで" developer: "開発者やで" makeExplorable: "アカウントを見つけやすくするで" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。" showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示するで" duplicate: "複製" left: "左" -center: "中央" +center: "真ん中" wide: "広い" narrow: "狭い" reloadToApplySetting: "設定はページリロード後に反映されるで。今リロードしとくか?" @@ -708,7 +714,7 @@ onlineUsersCount: "{n}人が起きとるで" nUsers: "{n}ユーザー" nNotes: "{n}ノート" sendErrorReports: "エラーリポートを送る" -sendErrorReportsDescription: "オンにしたら、なんか変なことが起きたときにエラーの詳細がMisskeyに共有されて、ソフトウェアの品質向上に役立てられるんや。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれるで。" +sendErrorReportsDescription: "オンにしたら、変なことが起きたときにエラーの詳細がMisskeyに送られて、ソフトウェアの品質向上に使えるようになるで。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴なんかが含まれるで。" myTheme: "マイテーマ" backgroundColor: "背景" accentColor: "アクセント" @@ -877,7 +883,7 @@ isSystemAccount: "システムが自動で作成・管理しとるアカウン typeToConfirm: "この操作をやるんなら {x} と入力してなー" deleteAccount: "アカウント削除するで" document: "ドキュメント" -numberOfPageCache: "ページキャッシュ数やで" +numberOfPageCache: "ページ、どんだけキャッシュすんの?" numberOfPageCacheDescription: "増やすと使いやすくなる、負荷とメモリ使用量が増えてくで。一長一短やな。" logoutConfirm: "ログアウトしまっか?" lastActiveDate: "最後に使った日時" @@ -947,7 +953,7 @@ thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。" thisPostMayBeAnnoyingHome: "ホームに投稿" thisPostMayBeAnnoyingCancel: "やめとく" thisPostMayBeAnnoyingIgnore: "このまま投稿" -collapseRenotes: "見たことあるRenoteは省略やで" +collapseRenotes: "見たことあるRenoteは飛ばして表示するで" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ" copyErrorInfo: "エラー情報をコピー" @@ -959,6 +965,24 @@ invitationRequiredToRegister: "今このサーバー招待制になってもう emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ" postToTheChannel: "チャンネルに投稿" cannotBeChangedLater: "後からは変えられへんで。" +reactionAcceptance: "リアクションの受け入れ" +likeOnly: "いいねだけ" +likeOnlyForRemote: "リモートからはいいねだけな" +rolesAssignedToMe: "自分に割り当てられたロール" +resetPasswordConfirm: "パスワード作り直すんでええな?" +sensitiveWords: "けったいな単語" +sensitiveWordsDescription: "設定した単語が入っとるノートの公開範囲をホームにしたるわ。改行で区切ったら複数設定できるで。" +notesSearchNotAvailable: "ノート検索は使われへんで。" +license: "ライセンス" +unfavoriteConfirm: "ほんまに気に入らんの?" +myClips: "自分のクリップ" +drivecleaner: "ドライブキレイキレイ" +retryAllQueuesNow: "キューを全部もっかいやり直す" +retryAllQueuesConfirmTitle: "もっかいやってみるか?" +retryAllQueuesConfirmText: "一時的にサーバー重なるかもしれへんで。" +enableChartsForRemoteUser: "リモートユーザーのチャートを作る" +enableChartsForFederatedInstances: "リモートサーバーのチャートを作る" +showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" _achievements: earnedAt: "貰った日ぃ" _types: @@ -976,7 +1000,7 @@ _achievements: title: "ノートの生駒山" description: "ノートを500回投稿した" _notes1000: - title: "ノートの山" + title: "ノートの六甲山" description: "ノートを1,000回投稿した" _notes5000: title: "箕面の滝からノート" @@ -1218,6 +1242,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして見せる" descriptionOfAsBadge: "オンにすると、ユーザー名の横んとこにロールのアイコンが表示されるで。" + displayOrder: "表示順" + descriptionOfDisplayOrder: "数がでかいほど、UI上で先に表示されるで。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになるで。オフにすると管理者のみが行えるで。" priority: "優先度" @@ -1243,6 +1269,7 @@ _role: rateLimitFactor: "レートリミット" descriptionOfRateLimitFactor: "ちっちゃいほど制限が緩くなって、大きいほど制限されるで。" canHideAds: "広告を表示させへん" + canSearchNotes: "ノート検索を使わすかどうか" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1252,6 +1279,8 @@ _role: followersMoreThanOrEq: "フォロワー数が~以上" followingLessThanOrEq: "フォロー数が~以下" followingMoreThanOrEq: "フォロー数が~以上" + notesLessThanOrEq: "投稿数が~以下しかない" + notesMoreThanOrEq: "投稿を~以上しとる" and: "~かつ~" or: "~または~" not: "~ではない" @@ -1377,7 +1406,7 @@ _wordMute: mutedNotes: "ミュートされたノート" _instanceMute: instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。" - instanceMuteDescription2: "改行で区切って設定するで" + instanceMuteDescription2: "改行で区切って設定するんやで" title: "設定したインスタンスのノートを隠すで。" heading: "ミュートするインスタンス" _theme: @@ -1464,7 +1493,7 @@ _sfx: channel: "チャンネル通知" _ago: future: "未来" - justNow: "たった今" + justNow: "ついさっき" secondsAgo: "{n}秒前" minutesAgo: "{n}分前" hoursAgo: "{n}時間前" @@ -1587,7 +1616,7 @@ _weekday: saturday: "土曜日" _widgets: profile: "プロフィール" - instanceInfo: "インスタンス情報" + instanceInfo: "サーバー情報" memo: "付箋" notifications: "通知" timeline: "タイムライン" @@ -1690,7 +1719,7 @@ _charts: apRequest: "リクエスト" usersIncDec: "ユーザーの増減" usersTotal: "ユーザーの合計" - activeUsers: "アクティブユーザー数" + activeUsers: "いまおるユーザー数" notesIncDec: "ノートの増減" localNotesIncDec: "ローカルのノートの増減" remoteNotesIncDec: "リモートのノートの増減" @@ -1844,3 +1873,24 @@ _deck: _dialog: charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" +_disabledTimeline: + title: "使われへんタイムライン" + description: "あんたの今のロールやったら、このタイムラインは使われへんで。" +_drivecleaner: + orderBySizeDesc: "サイズのでかい順" + orderByCreatedAtAsc: "追加日の古い順" +_webhookSettings: + createWebhook: "Webhookをつくる" + name: "名前" + secret: "シークレット" + events: "Webhookを投げるタイミング" + active: "有効" + _events: + follow: "フォローしたとき~!" + followed: "フォローもらったとき~!" + note: "ノートを投稿したとき~!" + reply: "返信があるとき~!" + renote: "Renoteされるとき~!" + reaction: "リアクションがあるとき~!" + mention: "メンションがあるとき~!" + diff --git a/locales/jbo-EN.yml b/locales/jbo-EN.yml index ed97d539c..cd21505a4 100644 --- a/locales/jbo-EN.yml +++ b/locales/jbo-EN.yml @@ -1 +1,2 @@ --- + diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml index 18fd8f5a5..8b43041e4 100644 --- a/locales/kab-KAB.yml +++ b/locales/kab-KAB.yml @@ -103,3 +103,4 @@ _deck: _columns: notifications: "Ilɣuyen" list: "Tibdarin" + diff --git a/locales/kn-IN.yml b/locales/kn-IN.yml index ef66f3fbd..63a75302a 100644 --- a/locales/kn-IN.yml +++ b/locales/kn-IN.yml @@ -83,3 +83,4 @@ _deck: notifications: "ಅಧಿಸೂಚನೆಗಳು" tl: "ಸಮಯಸಾಲು" mentions: "ಹೆಸರಿಸಿದ" + diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 9115afe5a..31b4200c2 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: "항목 추가" @@ -586,7 +592,6 @@ tokenRequested: "계정 접근 허용" pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 됩니다." notificationType: "알림 유형" edit: "편집" -useStarForReactionFallback: "알 수 없는 리액션 이모지 대신 ★ 사용" emailServer: "메일 서버" enableEmail: "이메일 송신 기능 활성화" emailConfigInfo: "가입 시 메일 주소 확인이나 비밀번호 초기화 시에 사용합니다." @@ -955,6 +960,9 @@ exploreOtherServers: "다른 서버 둘러보기" letsLookAtTimeline: "타임라인 구경하기" disableFederationWarn: "연합이 비활성화됩니다. 비활성화해도 게시물이 비공개가 되지는 않습니다. 대부분의 경우 이 옵션을 활성화할 필요가 없습니다." invitationRequiredToRegister: "현재 이 서버는 비공개입니다. 회원가입을 하시려면 초대 코드가 필요합니다." +emailNotSupported: "이 서버에서는 메일 전송을 지원하지 않습니다" +postToTheChannel: "채널에 게시하기" +cannotBeChangedLater: "나중에 변경할 수 없습니다." _achievements: earnedAt: "달성 일시" _types: @@ -1840,3 +1848,7 @@ _deck: _dialog: charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {min}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" +_webhookSettings: + name: "이름" + active: "활성화" + diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 9c1a48c67..5736fa67a 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -368,3 +368,4 @@ _deck: list: "ລາຍການ" channel: "ຊ່ອງ" mentions: "ກ່າວເຖິງ" + diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 3d33b5227..efbb83c70 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -483,3 +483,6 @@ _deck: antenna: "Antennes" list: "Lijsten" mentions: "Vermeldingen" +_webhookSettings: + name: "Naam" + diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 83e189b9c..36a0a2e0e 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -1,2 +1,3 @@ --- _lang_: "Norsk Bokmål" + diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 1dc818d45..cc71e8777 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -129,6 +129,7 @@ unblockConfirm: "Czy na pewno chcesz odblokować to konto?" suspendConfirm: "Czy na pewno chcesz zawiesić to konto?" unsuspendConfirm: "Czy na pewno chcesz cofnąć zawieszenie tego konta?" selectList: "Wybierz listę" +selectChannel: "Wybierz kanał" selectAntenna: "Wybierz Antennę" selectWidget: "Wybierz widżet" editWidgets: "Edytuj widżety" @@ -149,6 +150,7 @@ flagAsCatDescription: "Przełącz tę opcję, aby konto było oznaczone jako kot flagShowTimelineReplies: "Pokazuj odpowiedzi na osi czasu" autoAcceptFollowed: "Automatycznie przyjmuj prośby o możliwość obserwacji od użytkowników, których obserwujesz" addAccount: "Dodaj konto" +reloadAccountsList: "Odśwież listę kont" loginFailed: "Nie udało się zalogować" showOnRemote: "Zobacz na zdalnej instancji" general: "Ogólne" @@ -159,6 +161,7 @@ searchWith: "Szukaj: {q}" youHaveNoLists: "Nie masz żadnej listy" followConfirm: "Czy na pewno chcesz zaobserwować {name}?" proxyAccount: "Konto proxy" +proxyAccountDescription: "Opis konta pełnomocniczego" host: "Host" selectUser: "Wybierz użytkownika" recipient: "Odbiorca" @@ -253,6 +256,7 @@ noMoreHistory: "Nie ma dalszej historii" startMessaging: "Rozpocznij czat" nUsersRead: "przeczytano przez {n}" agreeTo: "Wyrażam zgodę na {0}" +agreeBelow: "Zaakceptuj poniżej" tos: "Regulamin" start: "Rozpocznij" home: "Strona główna" @@ -385,13 +389,19 @@ about: "Informacje" aboutMisskey: "O Misskey" administrator: "Admin" token: "Token" +2fa: "Klucz 2FA " +totp: "Klucz aplikacji uwierzytelniającej (totp)" +totpDescription: "Opis klucza czasowego" moderator: "Moderator" moderation: "Moderacja" nUsersMentioned: "{n} wspomnianych użytkowników" +securityKeyAndPasskey: "Klucz bezpieczeństwa i klucze Passkey" securityKey: "Klucz bezpieczeństwa" lastUsed: "Ostatnio używane" +lastUsedAt: "Ostatnio używane w" unregister: "Cofnij rejestrację" passwordLessLogin: "Skonfiguruj logowanie bez użycia hasła" +passwordLessLoginDescription: "Opis logowania bez użycia hasła" resetPassword: "Zresetuj hasło" newPasswordIs: "Nowe hasło to „{password}”" reduceUiAnimation: "Ogranicz animacje w UI" @@ -518,11 +528,16 @@ disablePagesScript: "Wyłącz AiScript na Stronach" updateRemoteUser: "Aktualizuj zdalne dane o użytkowniku" deleteAllFiles: "Usuń wszystkie pliki" deleteAllFilesConfirm: "Czy na pewno chcesz usunąć wszystkie pliki?" +removeAllFollowing: "Przestań obserwować" removeAllFollowingDescription: "Przestań obserwować wszystkie konta z {host}. Wykonaj to, jeżeli instancja już nie istnieje." userSuspended: "To konto zostało zawieszone." userSilenced: "Ten użytkownik został wyciszony." yourAccountSuspendedTitle: "To konto jest zawieszone" yourAccountSuspendedDescription: "To konto zostało zawieszone z powodu złamania regulaminu serwera lub innych podobnych. Skontaktuj się z administratorem, jeśli chciałbyś poznać bardziej szczegółowy powód. Proszę nie zakładać nowego konta." +tokenRevoked: "Token odrzucony" +tokenRevokedDescription: "Opis odrzuconego tokena" +accountDeleted: "Konto usunięte" +accountDeletedDescription: "Opis konta usuniętego" menu: "Menu" divider: "Rozdzielacz" addItem: "Dodaj element" @@ -548,7 +563,9 @@ author: "Autor" leaveConfirm: "Są niezapisane zmiany. Czy chcesz je odrzucić?" manage: "Zarządzanie" plugins: "Wtyczki" +preferencesBackups: "Kopia zapasowa ustawień" deck: "Tablica" +undeck: "oddkouj" useBlurEffectForModal: "Używaj efektu rozmycia w modalach" useFullReactionPicker: "Używaj pełnowymiarowego wybornika reakcji" width: "Szerokość" @@ -564,7 +581,6 @@ tokenRequested: "Przydziel dostęp do konta" pluginTokenRequestedDescription: "Ta wtyczka będzie mogła korzystać z ustawionych tu uprawnień." notificationType: "Rodzaj powiadomień" edit: "Edytuj" -useStarForReactionFallback: "Użyj ★ jako zapasowego emoji, gdy emoji reakcji jest nieznane" emailServer: "Serwer poczty e-mail" enableEmail: "Włącz dostarczanie wiadomości e-mail" emailConfigInfo: "Wykorzystywany do potwierdzenia adresu e-mail w trakcie rejestracji, lub gdy zapomnisz hasła" @@ -816,6 +832,8 @@ tenMinutes: "10 minut" oneHour: "1 godzina" oneDay: "1 dzień" oneWeek: "1 tydzień" +oneMonth: "jeden miesiąc" +failedToFetchAccountInformation: "Nie udało się uzyskać informacji o koncie" file: "Pliki" recommended: "Zalecane" check: "Zweryfikuj" @@ -1358,3 +1376,7 @@ _deck: channel: "Kanały" mentions: "Wspomnienia" direct: "Bezpośredni" +_webhookSettings: + name: "Nazwa" + active: "Właczono" + diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 40b4aee7e..870ad5015 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -555,3 +555,6 @@ _deck: list: "Listas" mentions: "Menções" direct: "Notas diretas" +_webhookSettings: + name: "Nome" + diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 10cb085f3..89f8afac9 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -561,7 +561,6 @@ tokenRequested: "Acordă acces la cont" pluginTokenRequestedDescription: "Acest plugin va putea să folosească permisiunile setate aici." notificationType: "Tipul notificării" edit: "Editează" -useStarForReactionFallback: "Folosește ★ ca fallback dacă emoji-ul este necunoscut" emailServer: "Server email" enableEmail: "Activează distribuția de emailuri" emailConfigInfo: "Folosit pentru a confirma emailul tău în timpul logări dacă îți uiți parola" @@ -702,3 +701,6 @@ _deck: list: "Liste" channel: "Canale" mentions: "Mențiuni" +_webhookSettings: + name: "Nume" + diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 81ea01179..6a1756f8a 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -585,7 +585,6 @@ tokenRequested: "Открыть доступ к учётной записи" pluginTokenRequestedDescription: "Это расширение сможет пользоваться разрешениями, установленными здесь." notificationType: "Тип уведомления" edit: "Изменить" -useStarForReactionFallback: "Ставить ★ в качестве реакции вместо неизвестного эмодзи" emailServer: "Сервер электронной почты" enableEmail: "Включить обмен электронной почтой" emailConfigInfo: "Используется для подтверждения адреса электронной почты и сброса пароля." @@ -1837,3 +1836,7 @@ _deck: _dialog: charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}" charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}" +_webhookSettings: + name: "Название" + active: "Вкл." + diff --git a/locales/si-LK.yml b/locales/si-LK.yml index ed97d539c..cd21505a4 100644 --- a/locales/si-LK.yml +++ b/locales/si-LK.yml @@ -1 +1,2 @@ --- + diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index d4be5540b..ff6075b70 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -586,7 +586,6 @@ tokenRequested: "Povoliť prístup k účtu" pluginTokenRequestedDescription: "Tento plugin bude môcť používať oprávnenia nastavené tu." notificationType: "Typ oznámenia" edit: "Upraviť" -useStarForReactionFallback: "Použiť ★ keď emoji reakcie nie je známe" emailServer: "Email server" enableEmail: "Zapnúť email" emailConfigInfo: "Používa sa na overenie emaily pri registrácii alebo pri zabudnutí hesla" @@ -1475,3 +1474,7 @@ _deck: channel: "Kanály" mentions: "Zmienky" direct: "Priame poznámky" +_webhookSettings: + name: "Názov" + active: "Zapnuté" + diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 5e66df207..6ea5f77c2 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -442,3 +442,6 @@ _deck: antenna: "Antenner" list: "Listor" mentions: "Omnämningar" +_webhookSettings: + active: "Aktiverad" + diff --git a/locales/th-TH.yml b/locales/th-TH.yml index cf33e6642..a8b478439 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -544,6 +544,8 @@ userSuspended: "ผู้ใช้รายนี้ถูกระงับก userSilenced: "ผู้ใช้รายนี้กำลังถูกปิดกั้น" yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" +tokenRevoked: "โทเค็นไม่ถูกต้อง" +accountDeleted: "ลบบัญชีแล้ว" menu: "เมนู" divider: "ตัวแบ่ง" addItem: "เพิ่มรายการ" @@ -587,7 +589,6 @@ tokenRequested: "ให้สิทธิ์การเข้าถึงบั pluginTokenRequestedDescription: "ปลั๊กอินนี้จะสามารถใช้การอนุญาตที่ตั้งค่าไว้ที่นี่นะ" notificationType: "ประเภทการแจ้งเตือน" edit: "แก้ไข" -useStarForReactionFallback: "ใช้ ★ เป็นทางเลือกแทนถ้าหากไม่ทราบอิโมจิ" emailServer: "อีเมล์เซิร์ฟเวอร์" enableEmail: "เปิดใช้งานการกระจายอีเมล" emailConfigInfo: "ใช้เพื่อยืนยันอีเมลของคุณระหว่างการสมัครหรือถ้าหากคุณลืมรหัสผ่าน" @@ -959,6 +960,18 @@ invitationRequiredToRegister: "อินสแตนซ์นี้เป็น emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" postToTheChannel: "โพสต์ลงช่อง" cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" +likeOnly: "ที่ชอบเท่านั้น" +resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?" +sensitiveWords: "คำที่ละเอียดอ่อน" +sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ" +notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งานนะค่ะ" +license: "ใบอนุญาต" +unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?" +myClips: "คลิปของฉัน" +drivecleaner: "ทำความสะอาดไดรฟ์" +retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหมดอีกครั้ง" +retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?" +retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" _achievements: earnedAt: "ได้รับเมื่อ" _types: @@ -1218,6 +1231,8 @@ _role: iconUrl: "ไอคอน URL" asBadge: "แสดงเป็นตรา" descriptionOfAsBadge: "ไอคอนของบทบาทนี้จะปรากฏถัดจากชื่อผู้ใช้ของผู้ใช้งานด้วยบทบาทนี้ถ้าหากเปิดใช้งาน" + displayOrder: "ตำแหน่ง" + descriptionOfDisplayOrder: "ยิ่งตัวเลขสูง ตำแหน่ง UI ก็ยิ่งสูงขึ้นนะ" canEditMembersByModerator: "อนุญาตให้ผู้ดูแลแก้ไขสมาชิก" descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ ผู้ดูแลนอกเหนือจากผู้ดูแลระบบแล้ว จะสามารถกำหนดและยกเลิกการมอบหมายบทบาทนี้ให้กับผู้ใช้ได้ เมื่อปิด เฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถกำหนดผู้ใช้ได้นะ" priority: "ลำดับความสำคัญ" @@ -1243,6 +1258,7 @@ _role: rateLimitFactor: "ขีดจำกัดอัตรา" descriptionOfRateLimitFactor: "ขีดจํากัดอัตราที่ต่ำกว่ามีข้อจํากัดน้อยกว่าข้อจํากัดที่สูงกว่า" canHideAds: "ซ่อนโฆษณา" + canSearchNotes: "การใช้การค้นหาโน้ต" _condition: isLocal: "ผู้ใช้ภายใน" isRemote: "ผู้ใช้ระยะไกล" @@ -1844,3 +1860,13 @@ _deck: _dialog: charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}" charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}" +_disabledTimeline: + title: "ปิดใช้งานไทม์ไลน์" + description: "คุณไม่สามารถใช้ไทม์ไลน์นี้ภายใต้บทบาทปัจจุบันของคุณได้" +_drivecleaner: + orderBySizeDesc: "ขนาดไฟล์จากมากไปหาน้อย" + orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก" +_webhookSettings: + name: "ชื่อ" + active: "เปิดใช้งาน" + diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index 7bd8188a4..0f53dbafc 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -60,3 +60,4 @@ _deck: _columns: notifications: "Bildirim" tl: "Zaman çizelgesi" + diff --git a/locales/ug-CN.yml b/locales/ug-CN.yml index 65ef84125..5b825d7bf 100644 --- a/locales/ug-CN.yml +++ b/locales/ug-CN.yml @@ -2,3 +2,4 @@ _lang_: "ياپونچە" search: "ئىزدەش" searchByGoogle: "ئىزدەش" + diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 56e3f024a..7b2ee6d89 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -576,7 +576,6 @@ tokenRequested: "Надати доступ до акаунту" pluginTokenRequestedDescription: "Цей плагін зможе використовувати дозволи які тут вказані." notificationType: "Тип сповіщення" edit: "Редагувати" -useStarForReactionFallback: "Використовувати ★ як запасний варіант, якщо емодзі реакції невідомий" emailServer: "Email сервер" enableEmail: "Увімкнути функцію доставки пошти" emailConfigInfo: "Використовується для підтвердження електронної пошти підчас реєстрації, а також для відновлення паролю." @@ -1639,3 +1638,7 @@ _deck: channel: "Канали" mentions: "Згадки" direct: "Особисте" +_webhookSettings: + name: "Ім'я" + active: "Увімкнено" + diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index ce36de03d..f81445473 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -585,7 +585,6 @@ tokenRequested: "Cấp quyền truy cập vào tài khoản" pluginTokenRequestedDescription: "Plugin này sẽ có thể sử dụng các quyền được đặt ở đây." notificationType: "Loại thông báo" edit: "Sửa" -useStarForReactionFallback: "Dùng ★ nếu emoji biểu cảm không có" emailServer: "Email máy chủ" enableEmail: "Bật phân phối email" emailConfigInfo: "Được dùng để xác minh email của bạn lúc đăng ký hoặc nếu bạn quên mật khẩu của mình" @@ -1705,3 +1704,7 @@ _deck: _dialog: charactersExceeded: "Bạn nhắn quá giới hạn ký tự!! Hiện nay {current} / giới hạn {max}" charactersBelow: "Bạn nhắn quá ít tối thiểu ký tự!! Hiện nay {current} / Tối thiểu {min}" +_webhookSettings: + name: "Tên" + active: "Đã bật" + diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 7798582db..244528cc2 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: "添加项目" @@ -587,7 +594,6 @@ tokenRequested: "允许访问账户" pluginTokenRequestedDescription: "此插件将能够拥有此处设置的权限" notificationType: "通知类型" edit: "编辑" -useStarForReactionFallback: "如果回应的是未知表情符号,则使用★作为代替" emailServer: "邮件服务器" enableEmail: "启用发送邮件功能" emailConfigInfo: "用于确认电子邮件和密码重置" @@ -959,6 +965,24 @@ invitationRequiredToRegister: "此服务器目前只允许拥有邀请码的人 emailNotSupported: "此服务器不支持发送邮件" postToTheChannel: "发布到频道" cannotBeChangedLater: "之后不能再更改。" +reactionAcceptance: "接受表情回应" +likeOnly: "仅点赞" +likeOnlyForRemote: "远程仅点赞" +rolesAssignedToMe: "指派给自己的角色" +resetPasswordConfirm: "确定重置密码?" +sensitiveWords: "敏感词" +sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。" +notesSearchNotAvailable: "帖子检索不可用" +license: "许可信息" +unfavoriteConfirm: "确定要取消收藏吗?" +myClips: "我的便签" +drivecleaner: "网盘整理" +retryAllQueuesNow: "立刻重试所有队列" +retryAllQueuesConfirmTitle: "要再尝试一次吗?" +retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" +enableChartsForRemoteUser: "生成远程用户的图表" +enableChartsForFederatedInstances: "生成远程服务器的图表" +showClipButtonInNoteFooter: "在贴文下方显示便签按钮" _achievements: earnedAt: "达成时间" _types: @@ -1218,6 +1242,8 @@ _role: iconUrl: "图标URL" asBadge: "作为徽章显示" descriptionOfAsBadge: "开启后,用户名旁边将会出现角色图标。" + displayOrder: "显示顺序" + descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" canEditMembersByModerator: "允许监察者编辑成员" descriptionOfCanEditMembersByModerator: "如果选中,监察者和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" @@ -1243,6 +1269,7 @@ _role: rateLimitFactor: "速率限制" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" canHideAds: "可以隐藏广告" + canSearchNotes: "是否可以搜索帖子" _condition: isLocal: "是本地用户" isRemote: "是远程用户" @@ -1252,6 +1279,8 @@ _role: followersMoreThanOrEq: "关注者不少于" followingLessThanOrEq: "关注中不多于" followingMoreThanOrEq: "关注中不少于" + notesLessThanOrEq: "帖子数在~以下" + notesMoreThanOrEq: "帖子数在~以上" and: "符合以下全部条件" or: "符合以下任一条件" not: "不符合以下任何条件" @@ -1517,7 +1546,7 @@ _2fa: step4: "从现在开始,任何登录操作都将要求您提供动态口令。" securityKeyNotSupported: "您的浏览器不支持安全密钥。" registerTOTPBeforeKey: "要注册安全密钥或Passkey,请先设置验证器应用程序。" - securityKeyInfo: "您可以设置使用支持FIDO2的硬件安全密钥、设备上的指纹或PIN来保护您的登录过程。" + securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 码以及 Passkey 等。" chromePasskeyNotSupported: "目前不支持 Chrome 的Passkey。" registerSecurityKey: "注册安全密钥或Passkey" securityKeyName: "输入密钥名称" @@ -1844,3 +1873,24 @@ _deck: _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" +_disabledTimeline: + title: "时间线已禁用" + description: "您不能在当前角色使用时间线。" +_drivecleaner: + orderBySizeDesc: "按大小降序排列" + orderByCreatedAtAsc: "按添加日期降序排列" +_webhookSettings: + createWebhook: "创建 Webhook" + name: "名称" + secret: "密钥" + events: "何时运行Webhook" + active: "已启用" + _events: + follow: "关注时" + followed: "被关注时" + note: "发布贴文时" + reply: "收到回复时" + renote: "被转发时" + reaction: "被回应时" + mention: "被提及时" + diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 5c1512bd4..c0dcda583 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -15,7 +15,7 @@ gotIt: "知道了" cancel: "取消" noThankYou: "現在不要" enterUsername: "輸入使用者名稱" -renotedBy: "{user} 轉傳了" +renotedBy: "{user} 轉發了" noNotes: "無貼文。" noNotifications: "沒有通知" instance: "實例" @@ -99,9 +99,9 @@ followRequestPending: "追隨許可批准中" enterEmoji: "輸入表情符號" renote: "轉發" unrenote: "取消轉發" -renoted: "轉傳成功" +renoted: "轉發成功" cantRenote: "無法轉發此貼文。" -cantReRenote: "無法轉傳之前已經轉傳過的內容。" +cantReRenote: "無法轉發之前已經轉發過的內容。" quote: "引用" inChannelRenote: "在頻道內轉發" inChannelQuote: "在頻道內引用" @@ -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: "重置密碼" @@ -528,8 +531,8 @@ installedDate: "安裝時間" lastUsedDate: "最後上線日期" state: "狀態" sort: "排序" -ascendingOrder: "遞增" -descendingOrder: "遞減" +ascendingOrder: "昇冪" +descendingOrder: "降冪" scratchpad: "暫存記憶體" scratchpadDescription: "AiScript控制台為AiScript提供了實驗環境。您可以在此編寫、執行和確認代碼與Misskey互動的结果。" output: "輸出" @@ -544,6 +547,10 @@ userSuspended: "該使用者已被停用" userSilenced: "該用戶已被禁言。" yourAccountSuspendedTitle: "帳戶已被凍結" yourAccountSuspendedDescription: "由於違反了伺服器的服務條款或其他原因,該帳戶已被凍結。 您可以與管理員連繫以了解更多訊息。 請不要創建一個新的帳戶。" +tokenRevoked: "權杖無效" +tokenRevokedDescription: "登入權杖失效,請重新登入。" +accountDeleted: "帳戶已被刪除" +accountDeletedDescription: "這個帳戶已被刪除。" menu: "選單" divider: "分割線" addItem: "新增項目" @@ -587,7 +594,6 @@ tokenRequested: "允許存取帳戶" pluginTokenRequestedDescription: "此外掛將擁有在此設定的權限。" notificationType: "通知形式" edit: "編輯" -useStarForReactionFallback: "以★代替未知的表情符號" emailServer: "電郵伺服器" enableEmail: "啟用發送電郵功能" emailConfigInfo: "用於確認電郵地址及密碼重置" @@ -671,8 +677,8 @@ sentReactionsCount: "反應發送次數" receivedReactionsCount: "收到反應次數" pollVotesCount: "已統計的投票數" pollVotedCount: "已投票數" -yes: "確定" -no: "取消" +yes: "是" +no: "否" driveFilesCount: "雲端硬碟檔案數量" driveUsage: "雲端硬碟使用量" noCrawle: "拒絕搜尋引擎索引" @@ -872,10 +878,10 @@ recommended: "推薦" check: "檢查" driveCapOverrideLabel: "更改這個使用者的雲端硬碟容量上限" driveCapOverrideCaption: "如果指定0以下的值,就會被取消。" -requireAdminForView: "必須以管理員帳號登入才可以檢視。" -isSystemAccount: "由系統自動建立與管理的帳號。" +requireAdminForView: "必須以管理員帳戶登入才可以檢視。" +isSystemAccount: "由系統自動建立與管理的帳戶。" typeToConfirm: "要執行這項操作,請輸入 {x} " -deleteAccount: "刪除帳號" +deleteAccount: "刪除帳戶" document: "文件" numberOfPageCache: "快取頁面數" numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" @@ -915,7 +921,7 @@ sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通 windowMaximize: "最大化" windowRestore: "復原" caption: "標題" -loggedInAsBot: "以機器人帳號登入中" +loggedInAsBot: "以機器人帳戶登入中" tools: "工具" cannotLoad: "無法載入" numberOfProfileView: "個人檔案檢視次數" @@ -958,6 +964,25 @@ disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼 invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" emailNotSupported: "這個伺服器不支援寄送郵件" postToTheChannel: "發布到頻道" +cannotBeChangedLater: "之後不能變更。" +reactionAcceptance: "接受表情反應" +likeOnly: "僅限讚" +likeOnlyForRemote: "遠端僅限讚" +rolesAssignedToMe: "指派給自己的角色" +resetPasswordConfirm: "重設密碼?" +sensitiveWords: "敏感詞" +sensitiveWordsDescription: "將含有設定詞彙的貼文可見性設為發送至首頁。可以用換行來進行複數的設定。" +notesSearchNotAvailable: "無法使用搜尋貼文功能。" +license: "授權" +unfavoriteConfirm: "要取消收錄我的最愛嗎?" +myClips: "我的摘錄" +drivecleaner: "雲端硬碟清掃器" +retryAllQueuesNow: "立刻重試所有佇列" +retryAllQueuesConfirmTitle: "要現在重試嗎?" +retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" +enableChartsForRemoteUser: "生成遠端用戶的圖表" +enableChartsForFederatedInstances: "生成遠端伺服器的圖表" +showClipButtonInNoteFooter: "將摘錄添加至貼文" _achievements: earnedAt: "獲得日期" _types: @@ -1074,7 +1099,7 @@ _achievements: title: "有備而來" description: "設定了個人檔案" _markedAsCat: - title: "我是貓" + title: "吾輩乃貓是也" description: "已將帳戶設定為貓" flavor: "還沒有名字。" _following1: @@ -1217,6 +1242,8 @@ _role: iconUrl: "圖示的URL" asBadge: "顯示為徽章" descriptionOfAsBadge: "開啟的話,角色圖示會顯示在用戶名旁邊。" + displayOrder: "顯示順序" + descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" canEditMembersByModerator: "允許編輯審查員的成員" descriptionOfCanEditMembersByModerator: "如果開啟,管理員與審查員都可以為使用者指派/解除指派該角色。如果關閉,則只有管理員可以執行。" priority: "優先級" @@ -1242,6 +1269,7 @@ _role: rateLimitFactor: "速率限制" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" canHideAds: "不顯示廣告" + canSearchNotes: "可否搜尋貼文" _condition: isLocal: "本地使用者" isRemote: "遠端使用者" @@ -1251,6 +1279,8 @@ _role: followersMoreThanOrEq: "追隨者人數在~以上" followingLessThanOrEq: "追隨人數在~以下" followingMoreThanOrEq: "追隨人數在~以上" + notesLessThanOrEq: "發布數在~以下" + notesMoreThanOrEq: "發布數在~以上" and: "~和~" or: "~或~" not: "~否" @@ -1480,7 +1510,7 @@ _time: _tutorial: title: "Misskey使用方法" step1_1: "歡迎!" - step1_2: "此為「時間軸」頁面,它會按照時間順序顯示你「追隨」的人發出的「貼文」" + step1_2: "此為「時間軸」頁面,它會按照時間順序顯示你「追隨」的人發出的「貼文」。" step1_3: "由於你沒有發佈任何貼文,也沒有追隨任何人,所以你的時間軸目前是空的。" step2_1: "在發文或追隨其他人之前先讓我們設定一下個人資料吧。" step2_2: "提供一些關於自己的資訊來讓其他人更有追隨你的意願。" @@ -1713,7 +1743,7 @@ _instanceCharts: _timelines: home: "首頁" local: "本地" - social: "社群" + social: "社交" global: "公開" _play: new: "新增Play" @@ -1843,3 +1873,24 @@ _deck: _dialog: charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" +_disabledTimeline: + title: "停用的時間軸" + description: "目前的角色無法使用這個時間軸。" +_drivecleaner: + orderBySizeDesc: "檔案由大到小" + orderByCreatedAtAsc: "依照加入的日期順序" +_webhookSettings: + createWebhook: "建立 Webhook" + name: "名稱" + secret: "秘密" + events: "什麼時候運行Webhook" + active: "已啟用" + _events: + follow: "當你追隨時" + followed: "當被追隨時" + note: "當發布貼文時" + reply: "當收到回覆時" + renote: "當被轉發時" + reaction: "當獲得反應時" + mention: "當被提到時" + diff --git a/package.json b/package.json index d791e7821..8506d8aa2 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "13.9.2", + "version": "13.10.3", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@7.27.0", + "packageManager": "pnpm@8.1.0", "workspaces": [ "packages/frontend", "packages/backend", @@ -50,17 +50,17 @@ "gulp-replace": "1.1.4", "gulp-terser": "2.1.0", "js-yaml": "4.1.0", - "typescript": "4.9.5" + "typescript": "5.0.2" }, "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.57.0", + "@typescript-eslint/parser": "5.57.0", "cross-env": "7.0.3", - "cypress": "12.7.0", - "eslint": "8.35.0", - "start-server-and-test": "1.15.4" + "cypress": "12.9.0", + "eslint": "8.37.0", + "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/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/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/migration/1679639483253-enableChartsForRemoteUser.js b/packages/backend/migration/1679639483253-enableChartsForRemoteUser.js new file mode 100644 index 000000000..42faab746 --- /dev/null +++ b/packages/backend/migration/1679639483253-enableChartsForRemoteUser.js @@ -0,0 +1,11 @@ +export class enableChartsForRemoteUser1679639483253 { + name = 'enableChartsForRemoteUser1679639483253' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForRemoteUser" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForRemoteUser"`); + } +} diff --git a/packages/backend/migration/1679651580149-cleanup.js b/packages/backend/migration/1679651580149-cleanup.js new file mode 100644 index 000000000..1f00f3cc1 --- /dev/null +++ b/packages/backend/migration/1679651580149-cleanup.js @@ -0,0 +1,11 @@ +export class cleanup1679651580149 { + name = 'cleanup1679651580149' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "useStarForReactionFallback"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "useStarForReactionFallback" boolean NOT NULL DEFAULT false`); + } +} diff --git a/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js b/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js new file mode 100644 index 000000000..073333984 --- /dev/null +++ b/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js @@ -0,0 +1,11 @@ +export class enableChartsForFederatedInstances1679652081809 { + name = 'enableChartsForFederatedInstances1679652081809' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForFederatedInstances" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForFederatedInstances"`); + } +} diff --git a/packages/backend/migration/1680228513388-channelFavorite.js b/packages/backend/migration/1680228513388-channelFavorite.js new file mode 100644 index 000000000..afc676959 --- /dev/null +++ b/packages/backend/migration/1680228513388-channelFavorite.js @@ -0,0 +1,21 @@ +export class channelFavorite1680228513388 { + name = 'channelFavorite1680228513388' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "channel_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_59bddfd54d48689a298d41af00c" PRIMARY KEY ("id")); COMMENT ON COLUMN "channel_favorite"."createdAt" IS 'The created date of the ChannelFavorite.'`); + await queryRunner.query(`CREATE INDEX "IDX_735a5544f9249d412255f47f95" ON "channel_favorite" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_d3ca0db011b75ac2a940a2337d" ON "channel_favorite" ("channelId") `); + await queryRunner.query(`CREATE INDEX "IDX_8302bd27226605ece14842fb25" ON "channel_favorite" ("userId") `); + await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_8302bd27226605ece14842fb25a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_8302bd27226605ece14842fb25a"`); + await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8302bd27226605ece14842fb25"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d3ca0db011b75ac2a940a2337d"`); + await queryRunner.query(`DROP INDEX "public"."IDX_735a5544f9249d412255f47f95"`); + await queryRunner.query(`DROP TABLE "channel_favorite"`); + } +} diff --git a/packages/backend/migration/1680238118084-channelNotePining.js b/packages/backend/migration/1680238118084-channelNotePining.js new file mode 100644 index 000000000..126eae87e --- /dev/null +++ b/packages/backend/migration/1680238118084-channelNotePining.js @@ -0,0 +1,11 @@ +export class channelNotePining1680238118084 { + name = 'channelNotePining1680238118084' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "pinnedNoteIds" character varying(128) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "pinnedNoteIds"`); + } +} diff --git a/packages/backend/migration/1680491187535-cleanup.js b/packages/backend/migration/1680491187535-cleanup.js new file mode 100644 index 000000000..1e609ca06 --- /dev/null +++ b/packages/backend/migration/1680491187535-cleanup.js @@ -0,0 +1,10 @@ +export class cleanup1680491187535 { + name = 'cleanup1680491187535' + + async up(queryRunner) { + await queryRunner.query(`DROP TABLE "antenna_note" `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/migration/1680582195041-cleanup.js b/packages/backend/migration/1680582195041-cleanup.js new file mode 100644 index 000000000..c587e456a --- /dev/null +++ b/packages/backend/migration/1680582195041-cleanup.js @@ -0,0 +1,11 @@ +export class cleanup1680582195041 { + name = 'cleanup1680582195041' + + async up(queryRunner) { + await queryRunner.query(`DROP TABLE "notification" `); + } + + async down(queryRunner) { + + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 35e8dc5c6..4666563dc 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -23,43 +23,45 @@ }, "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.42", + "@swc/core-darwin-x64": "^1.3.42", + "@swc/core-linux-arm-gnueabihf": "^1.3.42", + "@swc/core-linux-arm64-gnu": "^1.3.42", + "@swc/core-linux-arm64-musl": "^1.3.42", + "@swc/core-linux-x64-gnu": "^1.3.42", + "@swc/core-linux-x64-musl": "^1.3.42", + "@swc/core-win32-arm64-msvc": "^1.3.42", + "@swc/core-win32-ia32-msvc": "^1.3.42", + "@swc/core-win32-x64-msvc": "^1.3.42", "@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", - "@discordapp/twemoji": "14.0.2", + "@aws-sdk/client-s3": "3.301.0", + "@aws-sdk/lib-storage": "3.301.0", + "@aws-sdk/node-http-handler": "3.296.0", + "@bull-board/api": "5.0.0", + "@bull-board/fastify": "5.0.0", + "@bull-board/ui": "5.0.0", + "@discordapp/twemoji": "14.1.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/cors": "8.2.1", + "@fastify/http-proxy": "9.0.0", + "@fastify/multipart": "7.5.0", "@fastify/static": "6.9.0", "@fastify/view": "7.4.1", - "@nestjs/common": "9.3.9", - "@nestjs/core": "9.3.9", - "@nestjs/testing": "9.3.9", + "@nestjs/common": "9.3.12", + "@nestjs/core": "9.3.12", + "@nestjs/testing": "9.3.12", "@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.42", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", "autwh": "0.1.0", - "aws-sdk": "2.1318.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", "bull": "4.10.4", @@ -74,35 +76,35 @@ "date-fns": "2.29.3", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", - "fastify": "4.13.0", + "fastify": "4.15.0", "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", "ip-cidr": "3.1.0", "is-svg": "4.3.2", "js-yaml": "4.1.0", - "jsdom": "21.1.0", + "jsdom": "21.1.1", "json5": "2.2.3", "jsonld": "8.1.1", - "jsrsasign": "10.6.1", + "jsrsasign": "10.7.0", "mfm-js": "0.23.3", "mime-types": "2.1.35", - "misskey-js": "0.0.15", + "misskey-js": "workspace:*", "ms": "3.0.0-canary.1", "nested-property": "4.0.0", - "node-fetch": "3.3.0", + "node-fetch": "3.3.1", "nodemailer": "6.9.1", "nsfwjs": "2.4.2", "oauth": "0.10.0", "os-utils": "0.0.14", - "otpauth": "^9.0.2", + "otpauth": "9.1.1", "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", @@ -123,33 +125,33 @@ "sanitize-html": "2.10.0", "seedrandom": "3.0.5", "semver": "7.3.8", - "sharp": "0.31.3", + "sharp": "0.32.0", "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", "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", - "tsconfig-paths": "4.1.2", + "tsc-alias": "1.8.5", + "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", "typeorm": "0.3.11", - "typescript": "4.9.5", + "typescript": "5.0.2", "ulid": "2.3.0", "unzipper": "0.10.11", "uuid": "9.0.0", "vary": "1.1.2", "web-push": "3.5.0", "websocket": "1.0.34", - "ws": "8.12.1", + "ws": "8.13.0", "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", + "@types/archiver": "5.3.2", "@types/bcryptjs": "2.4.2", "@types/bull": "4.10.0", "@types/cbor": "6.0.0", @@ -158,13 +160,13 @@ "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.21", "@types/ioredis": "4.28.10", - "@types/jest": "29.4.0", + "@types/jest": "29.5.0", "@types/js-yaml": "4.0.5", - "@types/jsdom": "21.1.0", + "@types/jsdom": "21.1.1", "@types/jsonld": "1.5.8", - "@types/jsrsasign": "10.5.5", + "@types/jsrsasign": "10.5.8", "@types/mime-types": "2.1.1", - "@types/node": "18.14.1", + "@types/node": "18.15.11", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.7", "@types/oauth": "0.9.1", @@ -176,7 +178,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.9.0", "@types/semver": "7.3.13", "@types/sharp": "0.31.1", "@types/sinonjs__fake-timers": "8.1.2", @@ -188,13 +190,14 @@ "@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.57.0", + "@typescript-eslint/parser": "5.57.0", + "aws-sdk-client-mock": "^2.1.1", "cross-env": "7.0.3", - "eslint": "8.35.0", + "eslint": "8.37.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..4bd3f39af 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -10,9 +10,9 @@ 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 type { MutingsRepository, NotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @@ -24,6 +24,9 @@ export class AntennaService implements OnApplicationShutdown { private antennas: Antenna[]; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, @@ -33,9 +36,6 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, @@ -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': @@ -90,54 +92,13 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise { - // 通知しない設定になっているか、自分自身の投稿なら既読にする - const read = !antenna.notify || (antenna.userId === noteUser.id); - - this.antennaNotesRepository.insert({ - id: this.idService.genId(), - antennaId: antenna.id, - noteId: note.id, - read: read, - }); - + this.redisClient.xadd( + `antennaTimeline:${antenna.id}`, + 'MAXLEN', '~', '200', + `${this.idService.parse(note.id).date.getTime()}-*`, + 'note', note.id); + this.globalEventService.publishAntennaStream(antenna.id, 'note', note); - - if (!read) { - const mutings = await this.mutingsRepository.find({ - where: { - muterId: antenna.userId, - }, - select: ['muteeId'], - }); - - // Copy - const _note: Note = { - ...note, - }; - - if (note.replyId != null) { - _note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId }); - } - if (note.renoteId != null) { - _note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); - } - - if (isUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { - return; - } - - // 2秒経っても既読にならなかったら通知 - setTimeout(async () => { - const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); - if (unread) { - this.globalEventService.publishMainStream(antenna.userId, 'unreadAntenna', antenna); - this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', { - antenna: { id: antenna.id, name: antenna.name }, - note: await this.noteEntityService.pack(note), - }); - } - }, 2000); - } } // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている @@ -217,7 +178,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/UserCacheService.ts b/packages/backend/src/core/CacheService.ts similarity index 65% rename from packages/backend/src/core/UserCacheService.ts rename to packages/backend/src/core/CacheService.ts index fc383d1c0..887baeb2c 100644 --- a/packages/backend/src/core/UserCacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import type { UsersRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; +import type { UserProfile, UsersRepository } from '@/models/index.js'; +import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { LocalUser, User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -10,13 +10,18 @@ import { StreamMessages } from '@/server/api/stream/types.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() -export class UserCacheService implements OnApplicationShutdown { - public userByIdCache: Cache; - public localUserByNativeTokenCache: Cache; - public localUserByIdCache: Cache; - public uriPersonCache: Cache; +export class CacheService implements OnApplicationShutdown { + public userByIdCache: MemoryKVCache; + public localUserByNativeTokenCache: MemoryKVCache; + public localUserByIdCache: MemoryKVCache; + public uriPersonCache: MemoryKVCache; + public userProfileCache: RedisKVCache; + public userMutingsCache: RedisKVCache; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, @@ -27,10 +32,12 @@ export class UserCacheService implements OnApplicationShutdown { ) { //this.onMessage = this.onMessage.bind(this); - this.userByIdCache = new Cache(Infinity); - this.localUserByNativeTokenCache = new Cache(Infinity); - this.localUserByIdCache = new Cache(Infinity); - this.uriPersonCache = new Cache(Infinity); + this.userByIdCache = new MemoryKVCache(Infinity); + this.localUserByNativeTokenCache = new MemoryKVCache(Infinity); + this.localUserByIdCache = new MemoryKVCache(Infinity); + this.uriPersonCache = new MemoryKVCache(Infinity); + this.userProfileCache = new RedisKVCache(this.redisClient, 'userProfile', 1000 * 60 * 60 * 24, 1000 * 60); + this.userMutingsCache = new RedisKVCache(this.redisClient, 'userMutings', 1000 * 60 * 60 * 24, 1000 * 60); this.redisSubscriber.on('message', this.onMessage); } @@ -52,7 +59,7 @@ export class UserCacheService implements OnApplicationShutdown { } } if (this.userEntityService.isLocalUser(user)) { - this.localUserByNativeTokenCache.set(user.token, user); + this.localUserByNativeTokenCache.set(user.token!, user); this.localUserByIdCache.set(user.id, user); } break; @@ -77,7 +84,7 @@ export class UserCacheService implements OnApplicationShutdown { } @bindThis - public findById(userId: User['id']) { + public findUserById(userId: User['id']) { return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 1fd2d1500..5c867e6cf 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'; @@ -39,7 +38,7 @@ import { S3Service } from './S3Service.js'; import { SignupService } from './SignupService.js'; import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; import { UserBlockingService } from './UserBlockingService.js'; -import { UserCacheService } from './UserCacheService.js'; +import { CacheService } from './CacheService.js'; import { UserFollowingService } from './UserFollowingService.js'; import { UserKeypairStoreService } from './UserKeypairStoreService.js'; import { UserListService } from './UserListService.js'; @@ -126,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 }; @@ -161,7 +159,7 @@ const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; -const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService }; +const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; @@ -250,7 +248,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AppLockService, AchievementService, CaptchaService, - CreateNotificationService, CreateSystemUserService, CustomEmojiService, DeleteAccountService, @@ -285,7 +282,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SignupService, TwoFactorAuthenticationService, UserBlockingService, - UserCacheService, + CacheService, UserFollowingService, UserKeypairStoreService, UserListService, @@ -368,7 +365,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AppLockService, $AchievementService, $CaptchaService, - $CreateNotificationService, $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, @@ -403,7 +399,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SignupService, $TwoFactorAuthenticationService, $UserBlockingService, - $UserCacheService, + $CacheService, $UserFollowingService, $UserKeypairStoreService, $UserListService, @@ -487,7 +483,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AppLockService, AchievementService, CaptchaService, - CreateNotificationService, CreateSystemUserService, CustomEmojiService, DeleteAccountService, @@ -522,7 +517,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SignupService, TwoFactorAuthenticationService, UserBlockingService, - UserCacheService, + CacheService, UserFollowingService, UserKeypairStoreService, UserListService, @@ -604,7 +599,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AppLockService, $AchievementService, $CaptchaService, - $CreateNotificationService, $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, @@ -639,7 +633,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SignupService, $TwoFactorAuthenticationService, $UserBlockingService, - $UserCacheService, + $CacheService, $UserFollowingService, $UserKeypairStoreService, $UserListService, @@ -714,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..1c3b60e5d 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -8,7 +8,7 @@ import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Emoji } from '@/models/entities/Emoji.js'; import type { EmojisRepository, Note } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import type { Config } from '@/config.js'; import { ReactionService } from '@/core/ReactionService.js'; @@ -16,7 +16,7 @@ import { query } from '@/misc/prelude/url.js'; @Injectable() export class CustomEmojiService { - private cache: Cache; + private cache: MemoryKVCache; constructor( @Inject(DI.config) @@ -34,7 +34,7 @@ export class CustomEmojiService { private globalEventService: GlobalEventService, private reactionService: ReactionService, ) { - this.cache = new Cache(1000 * 60 * 60 * 12); + this.cache = new MemoryKVCache(1000 * 60 * 60 * 12); } @bindThis @@ -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..c6258474e 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -2,7 +2,9 @@ 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 { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -33,8 +35,8 @@ 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'; type AddFileArgs = { /** User who wish to add file */ @@ -79,6 +81,7 @@ type UploadFromUrlArgs = { export class DriveService { private registerLogger: Logger; private downloaderLogger: Logger; + private deleteLogger: Logger; constructor( @Inject(DI.config) @@ -116,6 +119,7 @@ export class DriveService { const logger = new Logger('drive', 'blue'); this.registerLogger = logger.createSubLogger('register', 'yellow'); this.downloaderLogger = logger.createSubLogger('downloader'); + this.deleteLogger = logger.createSubLogger('delete'); } /*** @@ -274,8 +278,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 +288,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 +313,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 +329,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 +338,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); @@ -373,7 +370,7 @@ export class DriveService { Body: stream, ContentType: type, CacheControl: 'max-age=31536000, immutable', - } as S3.PutObjectRequest; + } as PutObjectCommandInput; if (filename) params.ContentDisposition = contentDisposition( 'inline', @@ -383,21 +380,16 @@ export class DriveService { ); if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - const s3 = this.s3Service.getS3(meta); - - const upload = s3.upload(params, { - partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, - }); - - await upload.promise() + await this.s3Service.upload(meta, params) .then( result => { - if (result) { + if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); - } else { - this.registerLogger.error(`Upload Result Empty: key = ${key}, filename = ${filename}`); + } else { // AbortMultipartUploadCommandOutput + this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); } - }, + }) + .catch( err => { this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); }, @@ -476,7 +468,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) { @@ -533,10 +525,10 @@ export class DriveService { }; const properties: { - width?: number; - height?: number; - orientation?: number; - } = {}; + width?: number; + height?: number; + orientation?: number; + } = {}; if (info.width) { properties['width'] = info.width; @@ -621,17 +613,20 @@ export class DriveService { if (user) { this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { - // Publish driveFileCreated event + // Publish driveFileCreated event this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile); this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile); }); } - // 統計を更新 this.driveChart.update(file, true); - this.perUserDriveChart.update(file, true); - if (file.userHost !== null) { - this.instanceChart.updateDrive(file, true); + if (file.userHost == null) { + // ローカルユーザーのみ + this.perUserDriveChart.update(file, true); + } else { + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateDrive(file, true); + } } return file; @@ -697,7 +692,7 @@ export class DriveService { @bindThis private async deletePostProcess(file: DriveFile, isExpired = false) { - // リモートファイル期限切れ削除後は直リンクにする + // リモートファイル期限切れ削除後は直リンクにする if (isExpired && file.userHost !== null && file.uri != null) { this.driveFilesRepository.update(file.id, { isLink: true, @@ -714,24 +709,37 @@ export class DriveService { this.driveFilesRepository.delete(file.id); } - // 統計を更新 this.driveChart.update(file, false); - this.perUserDriveChart.update(file, false); - if (file.userHost !== null) { - this.instanceChart.updateDrive(file, false); + if (file.userHost == null) { + // ローカルユーザーのみ + this.perUserDriveChart.update(file, false); + } else { + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateDrive(file, false); + } } } @bindThis public async deleteObjectStorageFile(key: string) { const meta = await this.metaService.fetch(); + try { + const param = { + Bucket: meta.objectStorageBucket, + Key: key, + } as DeleteObjectCommandInput; - const s3 = this.s3Service.getS3(meta); - - await s3.deleteObject({ - Bucket: meta.objectStorageBucket!, - Key: key, - }).promise(); + await this.s3Service.delete(meta, param); + } catch (err: any) { + if (err.name === 'NoSuchKey') { + this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error); + return; + } else { + throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { + cause: err, + }); + } + } } @bindThis @@ -749,7 +757,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/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index e83b037dd..2c6d3ac50 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { InstancesRepository } from '@/models/index.js'; import type { Instance } from '@/models/entities/Instance.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -9,7 +9,7 @@ import { bindThis } from '@/decorators.js'; @Injectable() export class FederatedInstanceService { - private cache: Cache; + private cache: MemoryKVCache; constructor( @Inject(DI.instancesRepository) @@ -18,7 +18,7 @@ export class FederatedInstanceService { private utilityService: UtilityService, private idService: IdService, ) { - this.cache = new Cache(1000 * 60 * 60); + this.cache = new MemoryKVCache(1000 * 60 * 60); } @bindThis 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/IdService.ts b/packages/backend/src/core/IdService.ts index 31c0819e5..94084ad84 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { genAid } from '@/misc/id/aid.js'; +import { genAid, parseAid } from '@/misc/id/aid.js'; import { genMeid } from '@/misc/id/meid.js'; import { genMeidg } from '@/misc/id/meidg.js'; import { genObjectId } from '@/misc/id/object-id.js'; @@ -32,4 +32,17 @@ export class IdService { default: throw new Error('unrecognized id generation method'); } } + + @bindThis + public parse(id: string): { date: Date; } { + switch (this.method) { + case 'aid': return parseAid(id); + // TODO + //case 'meid': + //case 'meidg': + //case 'ulid': + //case 'objectid': + default: throw new Error('unrecognized id generation method'); + } + } } 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/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts index ee9ae0733..898fb4ce8 100644 --- a/packages/backend/src/core/InstanceActorService.ts +++ b/packages/backend/src/core/InstanceActorService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import type { LocalUser } from '@/models/entities/User.js'; import type { UsersRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryCache } from '@/misc/cache.js'; import { DI } from '@/di-symbols.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; import { bindThis } from '@/decorators.js'; @@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const; @Injectable() export class InstanceActorService { - private cache: Cache; + private cache: MemoryCache; constructor( @Inject(DI.usersRepository) @@ -19,12 +19,12 @@ export class InstanceActorService { private createSystemUserService: CreateSystemUserService, ) { - this.cache = new Cache(Infinity); + this.cache = new MemoryCache(Infinity); } @bindThis public async getInstanceActor(): Promise { - const cached = this.cache.get(null); + const cached = this.cache.get(); if (cached) return cached; const user = await this.usersRepository.findOneBy({ @@ -33,11 +33,11 @@ export class InstanceActorService { }) as LocalUser | undefined; if (user) { - this.cache.set(null, user); + this.cache.set(user); return user; } else { const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as LocalUser; - this.cache.set(null, created); + this.cache.set(created); return created; } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8d8535ca5..83290b310 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,6 +1,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; +import Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; @@ -19,7 +20,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js import { checkWordMute } from '@/misc/check-word-mute.js'; import type { Channel } from '@/models/entities/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryCache } from '@/misc/cache.js'; import type { UserProfile } from '@/models/entities/UserProfile.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -30,7 +31,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,8 +45,9 @@ 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); +const mutedWordsCache = new MemoryCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -59,7 +61,7 @@ class NotificationManager { constructor( private mutingsRepository: MutingsRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, notifier: { id: User['id']; }, note: Note, ) { @@ -100,7 +102,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, }); @@ -149,6 +151,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -182,7 +187,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, @@ -192,11 +197,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: { @@ -230,7 +236,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'; } } @@ -317,6 +325,14 @@ export class NoteCreateService implements OnApplicationShutdown { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); + if (data.channel) { + this.redisClient.xadd( + `channelTimeline:${data.channel.id}`, + 'MAXLEN', '~', '1000', + `${this.idService.parse(note.id).date.getTime()}-*`, + 'note', note.id); + } + setImmediate('post created', { signal: this.#shutdownController.signal }).then( () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), () => { /* aborted, ignore this */ }, @@ -387,7 +403,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); @@ -410,7 +426,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'; @@ -431,15 +447,20 @@ export class NoteCreateService implements OnApplicationShutdown { createdAt: User['createdAt']; isBot: User['isBot']; }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - // 統計を更新 + const meta = await this.metaService.fetch(); + this.notesChart.update(note, true); - this.perUserNotesChart.update(user, note, true); + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserNotesChart.update(user, note, true); + } // Register host if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(i => { + this.federatedInstanceService.fetch(user.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - this.instanceChart.updateNote(i.host, note, true); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } }); } @@ -452,7 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.incNotesCountOfUser(user); // Word mute - mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({ + mutedWordsCache.fetch(() => this.userProfilesRepository.find({ where: { enableWordMute: true, }, @@ -554,7 +575,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/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 571b62552..dd878f7bb 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -16,6 +16,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class NoteDeleteService { @@ -39,6 +40,7 @@ export class NoteDeleteService { private federatedInstanceService: FederatedInstanceService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, + private metaService: MetaService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private instanceChart: InstanceChart, @@ -95,14 +97,19 @@ export class NoteDeleteService { } //#endregion - // 統計を更新 + const meta = await this.metaService.fetch(); + this.notesChart.update(note, false); - this.perUserNotesChart.update(user, note, false); + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserNotesChart.update(user, note, false); + } if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(i => { + this.federatedInstanceService.fetch(user.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - this.instanceChart.updateNote(i.host, note, false); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, false); + } }); } } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index d23fb8238..7c6808fbd 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -4,11 +4,11 @@ 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'; -import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository, AntennaNotesRepository } from '@/models/index.js'; +import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { NotificationService } from './NotificationService.js'; @@ -38,9 +38,6 @@ export class NoteReadService implements OnApplicationShutdown { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - private userEntityService: UserEntityService, private idService: IdService, private globalEventService: GlobalEventService, @@ -121,7 +118,6 @@ export class NoteReadService implements OnApplicationShutdown { const readMentions: (Note | Packed<'Note'>)[] = []; const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; const readChannelNotes: (Note | Packed<'Note'>)[] = []; - const readAntennaNotes: (Note | Packed<'Note'>)[] = []; for (const note of notes) { if (note.mentions && note.mentions.includes(userId)) { @@ -133,14 +129,6 @@ export class NoteReadService implements OnApplicationShutdown { if (note.channelId && followingChannels.has(note.channelId)) { readChannelNotes.push(note); } - - if (note.user != null) { // たぶんnullになることは無いはずだけど一応 - for (const antenna of myAntennas) { - if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) { - readAntennaNotes.push(note); - } - } - } } if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { @@ -181,39 +169,6 @@ export class NoteReadService implements OnApplicationShutdown { this.globalEventService.publishMainStream(userId, 'readAllChannels'); } }); - - this.notificationService.readNotificationByQuery(userId, { - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), - }); - } - - if (readAntennaNotes.length > 0) { - await this.antennaNotesRepository.update({ - antennaId: In(myAntennas.map(a => a.id)), - noteId: In(readAntennaNotes.map(n => n.id)), - }, { - read: true, - }); - - // TODO: まとめてクエリしたい - for (const antenna of myAntennas) { - const count = await this.antennaNotesRepository.countBy({ - antennaId: antenna.id, - read: false, - }); - - if (count === 0) { - this.globalEventService.publishMainStream(userId, 'readAntenna', antenna); - this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); - } - } - - this.userEntityService.getHasUnreadAntenna(userId).then(unread => { - if (!unread) { - this.globalEventService.publishMainStream(userId, 'readAllAntennas'); - this.pushNotificationService.pushNotification(userId, 'readAllAntennas', undefined); - } - }); } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 88173c230..9c179f931 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -1,70 +1,156 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import Redis from 'ioredis'; +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, UserProfile, 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'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() -export class NotificationService { - constructor( - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, +export class NotificationService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private notificationEntityService: NotificationEntityService, private userEntityService: UserEntityService, + private idService: IdService, private globalEventService: GlobalEventService, private pushNotificationService: PushNotificationService, + private cacheService: CacheService, ) { } @bindThis - public async readNotification( + public async readAllNotification( userId: User['id'], - notificationIds: Notification['id'][], ) { - if (notificationIds.length === 0) return; + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); + + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '-', + 'COUNT', 1); + const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - // Update documents - const result = await this.notificationsRepository.update({ - notifieeId: userId, - id: In(notificationIds), - isRead: false, - }, { - isRead: true, - }); + if (latestNotificationId == null) return; - if (result.affected === 0) return; + this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId); - if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId); - else return this.postReadNotifications(userId, notificationIds); - } - - @bindThis - public async readNotificationByQuery( - userId: User['id'], - query: Record, - ) { - const notificationIds = await this.notificationsRepository.findBy({ - ...query, - notifieeId: userId, - isRead: false, - }).then(notifications => notifications.map(notification => notification.id)); - - return this.readNotification(userId, notificationIds); + if (latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) { + return this.postReadAllNotifications(userId); + } } @bindThis private postReadAllNotifications(userId: User['id']) { this.globalEventService.publishMainStream(userId, 'readAllNotifications'); - return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); } @bindThis - private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { - return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds }); + public async createNotification( + notifieeId: User['id'], + type: Notification['type'], + data: Partial, + ): Promise { + const profile = await this.cacheService.userProfileCache.fetch(notifieeId, () => this.userProfilesRepository.findOneByOrFail({ userId: notifieeId })); + const isMuted = profile.mutingNotificationTypes.includes(type); + if (isMuted) return null; + + if (data.notifierId) { + if (notifieeId === data.notifierId) { + return null; + } + + const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId, () => this.mutingsRepository.findBy({ muterId: notifieeId }).then(xs => xs.map(x => x.muteeId))); + if (mutings.includes(data.notifierId)) { + return null; + } + } + + const notification = { + id: this.idService.genId(), + createdAt: new Date(), + type: type, + ...data, + } as Notification; + + this.redisClient.xadd( + `notificationTimeline:${notifieeId}`, + 'MAXLEN', '~', '300', + `${this.idService.parse(notification.id).date.getTime()}-*`, + 'data', JSON.stringify(notification)); + + const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); + + // Publish notification event + this.globalEventService.publishMainStream(notifieeId, 'notification', packed); + + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); + if (latestReadNotificationId && (latestReadNotificationId >= notification.id)) return; + + 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..69020f7e8 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'; @@ -15,10 +15,6 @@ type PushNotificationsTypes = { antenna: { id: string, name: string }; note: Packed<'Note'>; }; - 'readNotifications': { notificationIds: string[] }; - 'readAllNotifications': undefined; - 'readAntenna': { antennaId: string }; - 'readAllAntennas': undefined; }; // Reduce length because push message servers have character limits @@ -72,14 +68,6 @@ export class PushNotificationService { }); for (const subscription of subscriptions) { - // Continue if sendReadMessage is false - if ([ - 'readNotifications', - 'readAllNotifications', - 'readAntenna', - 'readAllAntennas', - ].includes(type) && !subscription.sendReadMessage) continue; - const pushSubscription = { endpoint: subscription.endpoint, keys: { 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 3e644018d..b3aea878d 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'; @@ -21,6 +21,8 @@ import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +const FALLBACK = '❤'; + const legacies: Record = { 'like': '👍', 'love': '❤', // ここに記述する場合は異体字セレクタを入れない @@ -79,7 +81,7 @@ export class ReactionService { private globalEventService: GlobalEventService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private perUserReactionsChart: PerUserReactionsChart, ) { } @@ -93,19 +95,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.'); } - + 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(), @@ -113,7 +115,7 @@ export class ReactionService { userId: user.id, reaction, }; - + // Create reaction try { await this.noteReactionsRepository.insert(record); @@ -123,7 +125,7 @@ export class ReactionService { noteId: note.id, userId: user.id, }); - + if (exists.reaction !== reaction) { // 別のリアクションがすでにされていたら置き換える await this.delete(user, note); @@ -136,7 +138,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() @@ -146,12 +148,16 @@ export class ReactionService { }) .where('id = :id', { id: note.id }) .execute(); - - this.perUserReactionsChart.update(user, note); - + + const meta = await this.metaService.fetch(); + + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserReactionsChart.update(user, note); + } + // カスタム絵文字リアクションだったら絵文字情報も送る const decodedReaction = this.decodeReaction(reaction); - + const emoji = await this.emojisRepository.findOne({ where: { name: decodedReaction.name, @@ -159,7 +165,7 @@ export class ReactionService { }, select: ['name', 'host', 'originalUrl', 'publicUrl'], }); - + this.globalEventService.publishNoteStream(note.id, 'reacted', { reaction: decodedReaction.reaction, emoji: emoji != null ? { @@ -169,16 +175,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)); @@ -187,7 +193,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') { @@ -196,7 +202,7 @@ export class ReactionService { dm.addDirectRecipe(u as RemoteUser); } } - + dm.execute(); } //#endregion @@ -209,18 +215,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() @@ -229,14 +235,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)); @@ -250,12 +256,6 @@ export class ReactionService { } //#endregion } - - @bindThis - public async getFallbackReaction(): Promise { - const meta = await this.metaService.fetch(); - return meta.useStarForReactionFallback ? '⭐' : '👍'; - } @bindThis public convertLegacyReactions(reactions: Record) { @@ -290,7 +290,7 @@ export class ReactionService { @bindThis public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise { - if (reaction == null) return await this.getFallbackReaction(); + if (reaction == null) return FALLBACK; reacterHost = this.utilityService.toPunyNullable(reacterHost); @@ -300,7 +300,7 @@ export class ReactionService { // Unicode絵文字 const match = emojiRegex.exec(reaction); if (match) { - // 合字を含む1つの絵文字 + // 合字を含む1つの絵文字 const unicode = match[0]; // 異体字セレクタ除去 @@ -318,7 +318,7 @@ export class ReactionService { if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; } - return await this.getFallbackReaction(); + return FALLBACK; } @bindThis diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 2e07825e9..4df7fb3bf 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -3,7 +3,7 @@ import { IsNull } from 'typeorm'; import type { LocalUser, User } from '@/models/entities/User.js'; import type { RelaysRepository, UsersRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryCache } from '@/misc/cache.js'; import type { Relay } from '@/models/entities/Relay.js'; import { QueueService } from '@/core/QueueService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; @@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const; @Injectable() export class RelayService { - private relaysCache: Cache; + private relaysCache: MemoryCache; constructor( @Inject(DI.usersRepository) @@ -30,7 +30,7 @@ export class RelayService { private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, ) { - this.relaysCache = new Cache(1000 * 60 * 10); + this.relaysCache = new MemoryCache(1000 * 60 * 10); } @bindThis @@ -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); } @@ -109,7 +109,7 @@ export class RelayService { public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise { if (activity == null) return; - const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({ + const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ status: 'accepted', })); if (relays.length === 0) return; @@ -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..52e6292a1 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { In } from 'typeorm'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache, MemoryCache } from '@/misc/cache.js'; import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/entities/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @@ -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, @@ -55,8 +57,8 @@ export const DEFAULT_POLICIES: RolePolicies = { @Injectable() export class RoleService implements OnApplicationShutdown { - private rolesCache: Cache; - private roleAssignmentByUserIdCache: Cache; + private rolesCache: MemoryCache; + private roleAssignmentByUserIdCache: MemoryKVCache; public static AlreadyAssignedError = class extends Error {}; public static NotAssignedError = class extends Error {}; @@ -75,15 +77,15 @@ export class RoleService implements OnApplicationShutdown { private roleAssignmentsRepository: RoleAssignmentsRepository, private metaService: MetaService, - private userCacheService: UserCacheService, + private cacheService: CacheService, private userEntityService: UserEntityService, private globalEventService: GlobalEventService, private idService: IdService, ) { //this.onMessage = this.onMessage.bind(this); - this.rolesCache = new Cache(Infinity); - this.roleAssignmentByUserIdCache = new Cache(Infinity); + this.rolesCache = new MemoryCache(Infinity); + this.roleAssignmentByUserIdCache = new MemoryKVCache(Infinity); this.redisSubscriber.on('message', this.onMessage); } @@ -96,7 +98,7 @@ export class RoleService implements OnApplicationShutdown { const { type, body } = obj.message as StreamMessages['internal']['payload']; switch (type) { case 'roleCreated': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { cached.push({ ...body, @@ -108,7 +110,7 @@ export class RoleService implements OnApplicationShutdown { break; } case 'roleUpdated': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { const i = cached.findIndex(x => x.id === body.id); if (i > -1) { @@ -123,9 +125,9 @@ export class RoleService implements OnApplicationShutdown { break; } case 'roleDeleted': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { - this.rolesCache.set(null, cached.filter(x => x.id !== body.id)); + this.rolesCache.set(cached.filter(x => x.id !== body.id)); } break; } @@ -190,6 +192,12 @@ export class RoleService implements OnApplicationShutdown { case 'followingMoreThanOrEq': { return user.followingCount >= value.value; } + case 'notesLessThanOrEq': { + return user.notesCount <= value.value; + } + case 'notesMoreThanOrEq': { + return user.notesCount >= value.value; + } default: return false; } @@ -206,9 +214,9 @@ export class RoleService implements OnApplicationShutdown { // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); - const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; + const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); return [...assignedRoles, ...matchedCondRoles]; } @@ -223,11 +231,11 @@ export class RoleService implements OnApplicationShutdown { // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); if (badgeCondRoles.length > 0) { - const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; + const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula)); return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; } else { @@ -264,6 +272,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)), @@ -292,7 +301,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getModeratorIds(includeAdmins = true): Promise { - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)), @@ -312,7 +321,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getAdministratorIds(): Promise { - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const administratorRoles = roles.filter(r => r.isAdministrator); const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(administratorRoles.map(r => r.id)), diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index 0ce69aaa7..01ce12ffd 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -1,11 +1,16 @@ import { URL } from 'node:url'; +import * as http from 'node:http'; +import * as https from 'node:https'; import { Inject, Injectable } from '@nestjs/common'; -import S3 from 'aws-sdk/clients/s3.js'; +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; +import { NodeHttpHandler, NodeHttpHandlerOptions } from '@aws-sdk/node-http-handler'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Meta } from '@/models/entities/Meta.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3'; @Injectable() export class S3Service { @@ -18,23 +23,47 @@ export class S3Service { } @bindThis - public getS3(meta: Meta) { - const u = meta.objectStorageEndpoint != null - ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` - : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; - - return new S3({ - endpoint: meta.objectStorageEndpoint ?? undefined, - accessKeyId: meta.objectStorageAccessKey!, - secretAccessKey: meta.objectStorageSecretKey!, - region: meta.objectStorageRegion ?? undefined, - sslEnabled: meta.objectStorageUseSSL, - s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted - ? false - : meta.objectStorageS3ForcePathStyle, - httpOptions: { - agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy), - }, + public getS3Client(meta: Meta): S3Client { + const u = meta.objectStorageEndpoint + ? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}` + : `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent + + const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy); + const handlerOption: NodeHttpHandlerOptions = {}; + if (meta.objectStorageUseSSL) { + handlerOption.httpsAgent = agent as https.Agent; + } else { + handlerOption.httpAgent = agent as http.Agent; + } + + return new S3Client({ + endpoint: meta.objectStorageEndpoint ? u : undefined, + credentials: (meta.objectStorageAccessKey !== null && meta.objectStorageSecretKey !== null) ? { + accessKeyId: meta.objectStorageAccessKey, + secretAccessKey: meta.objectStorageSecretKey, + } : undefined, + region: meta.objectStorageRegion ? meta.objectStorageRegion : undefined, // 空文字列もundefinedにするため ?? は使わない + tls: meta.objectStorageUseSSL, + forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted + requestHandler: new NodeHttpHandler(handlerOption), }); } + + @bindThis + public async upload(meta: Meta, input: PutObjectCommandInput) { + const client = this.getS3Client(meta); + return new Upload({ + client, + params: input, + partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com') + ? 500 * 1024 * 1024 + : 8 * 1024 * 1024, + }).done(); + } + + @bindThis + public delete(meta: Meta, input: DeleteObjectCommandInput) { + const client = this.getS3Client(meta); + return client.send(new DeleteObjectCommand(input)); + } } 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..040b6de2e 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -15,7 +15,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { WebhookService } from '@/core/WebhookService.js'; import { bindThis } from '@/decorators.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @Injectable() @@ -23,7 +23,7 @@ export class UserBlockingService implements OnApplicationShutdown { private logger: Logger; // キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ - private blockingsByUserIdCache: Cache; + private blockingsByUserIdCache: MemoryKVCache; constructor( @Inject(DI.redisSubscriber) @@ -58,7 +58,7 @@ export class UserBlockingService implements OnApplicationShutdown { ) { this.logger = this.loggerService.getLogger('user-block'); - this.blockingsByUserIdCache = new Cache(Infinity); + this.blockingsByUserIdCache = new MemoryKVCache(Infinity); this.redisSubscriber.on('message', this.onMessage); } @@ -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..b51b553c7 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -6,17 +6,18 @@ 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'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { bindThis } from '@/decorators.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { MetaService } from '@/core/MetaService.js'; import Logger from '../logger.js'; const logger = new Logger('following/create'); @@ -57,7 +58,8 @@ export class UserFollowingService { private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, - private createNotificationService: CreateNotificationService, + private metaService: MetaService, + private notificationService: NotificationService, private federatedInstanceService: FederatedInstanceService, private webhookService: WebhookService, private apRendererService: ApRendererService, @@ -82,7 +84,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 +133,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 +147,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,51 +171,55 @@ 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 => { + this.federatedInstanceService.fetch(follower.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - this.instanceChart.updateFollowing(i.host, true); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, true); + } }); } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(i => { + this.federatedInstanceService.fetch(followee.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - this.instanceChart.updateFollowers(i.host, true); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, true); + } }); } //#endregion - + this.perUserFollowingChart.update(follower, followee, true); - + // Publish follow event if (this.userEntityService.isLocalUser(follower)) { this.userEntityService.pack(followee.id, follower, { @@ -221,7 +227,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 +236,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 +249,9 @@ export class UserFollowingService { }); } }); - + // 通知を作成 - this.createNotificationService.createNotification(followee.id, 'follow', { + this.notificationService.createNotification(followee.id, 'follow', { notifierId: follower.id, }); } @@ -265,16 +271,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 +288,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,47 +297,51 @@ 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 => { + this.federatedInstanceService.fetch(follower.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - this.instanceChart.updateFollowing(i.host, false); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, false); + } }); } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(i => { + this.federatedInstanceService.fetch(followee.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - this.instanceChart.updateFollowers(i.host, false); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } }); } //#endregion - + this.perUserFollowingChart.update(follower, followee, false); } @@ -346,23 +356,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 +381,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 +414,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 +450,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 +476,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 +567,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/UserKeypairStoreService.ts b/packages/backend/src/core/UserKeypairStoreService.ts index 1d3cc87c8..872a0335e 100644 --- a/packages/backend/src/core/UserKeypairStoreService.ts +++ b/packages/backend/src/core/UserKeypairStoreService.ts @@ -1,20 +1,20 @@ import { Inject, Injectable } from '@nestjs/common'; import type { User } from '@/models/entities/User.js'; import type { UserKeypairsRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { UserKeypair } from '@/models/entities/UserKeypair.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @Injectable() export class UserKeypairStoreService { - private cache: Cache; + private cache: MemoryKVCache; constructor( @Inject(DI.userKeypairsRepository) private userKeypairsRepository: UserKeypairsRepository, ) { - this.cache = new Cache(Infinity); + this.cache = new MemoryKVCache(Infinity); } @bindThis 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/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index eccfeb0e7..5869905db 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -37,7 +37,7 @@ export class VideoProcessingService { }); }); - return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 280); + return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 422); } finally { cleanup(); } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index d0a4ad7a7..4b032be89 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -3,9 +3,9 @@ import escapeRegexp from 'escape-regexp'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { UserPublickey } from '@/models/entities/UserPublickey.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { RemoteUser, User } from '@/models/entities/User.js'; @@ -31,8 +31,8 @@ export type UriParseResult = { @Injectable() export class ApDbResolverService { - private publicKeyCache: Cache; - private publicKeyByUserIdCache: Cache; + private publicKeyCache: MemoryKVCache; + private publicKeyByUserIdCache: MemoryKVCache; constructor( @Inject(DI.config) @@ -47,11 +47,11 @@ export class ApDbResolverService { @Inject(DI.userPublickeysRepository) private userPublickeysRepository: UserPublickeysRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, private apPersonService: ApPersonService, ) { - this.publicKeyCache = new Cache(Infinity); - this.publicKeyByUserIdCache = new Cache(Infinity); + this.publicKeyCache = new MemoryKVCache(Infinity); + this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); } @bindThis @@ -107,11 +107,11 @@ export class ApDbResolverService { if (parsed.local) { if (parsed.type !== 'users') return null; - return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ + return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ id: parsed.id, }).then(x => x ?? undefined)) ?? null; } else { - return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ + return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ uri: parsed.uri, })); } @@ -138,7 +138,7 @@ export class ApDbResolverService { if (key == null) return null; return { - user: await this.userCacheService.findById(key.userId) as RemoteUser, + user: await this.cacheService.findUserById(key.userId) as RemoteUser, key, }; } 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..67e907c27 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -8,7 +8,7 @@ import type { Config } from '@/config.js'; import type { RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; import { truncate } from '@/misc/truncate.js'; -import type { UserCacheService } from '@/core/UserCacheService.js'; +import type { CacheService } from '@/core/CacheService.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import type Logger from '@/logger.js'; @@ -30,6 +30,7 @@ import { StatusError } from '@/misc/status-error.js'; import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -50,9 +51,10 @@ export class ApPersonService implements OnModuleInit { private userEntityService: UserEntityService; private idService: IdService; private globalEventService: GlobalEventService; + private metaService: MetaService; private federatedInstanceService: FederatedInstanceService; private fetchInstanceMetadataService: FetchInstanceMetadataService; - private userCacheService: UserCacheService; + private cacheService: CacheService; private apResolverService: ApResolverService; private apNoteService: ApNoteService; private apImageService: ApImageService; @@ -92,9 +94,10 @@ export class ApPersonService implements OnModuleInit { //private userEntityService: UserEntityService, //private idService: IdService, //private globalEventService: GlobalEventService, + //private metaService: MetaService, //private federatedInstanceService: FederatedInstanceService, //private fetchInstanceMetadataService: FetchInstanceMetadataService, - //private userCacheService: UserCacheService, + //private cacheService: CacheService, //private apResolverService: ApResolverService, //private apNoteService: ApNoteService, //private apImageService: ApImageService, @@ -112,9 +115,10 @@ export class ApPersonService implements OnModuleInit { this.userEntityService = this.moduleRef.get('UserEntityService'); this.idService = this.moduleRef.get('IdService'); this.globalEventService = this.moduleRef.get('GlobalEventService'); + this.metaService = this.moduleRef.get('MetaService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); - this.userCacheService = this.moduleRef.get('UserCacheService'); + this.cacheService = this.moduleRef.get('CacheService'); this.apResolverService = this.moduleRef.get('ApResolverService'); this.apNoteService = this.moduleRef.get('ApNoteService'); this.apImageService = this.moduleRef.get('ApImageService'); @@ -164,6 +168,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)) { @@ -200,14 +207,14 @@ export class ApPersonService implements OnModuleInit { public async fetchPerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - const cached = this.userCacheService.uriPersonCache.get(uri); + const cached = this.cacheService.uriPersonCache.get(uri); if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ if (uri.startsWith(this.config.url + '/')) { const id = uri.split('/').pop(); const u = await this.usersRepository.findOneBy({ id }); - if (u) this.userCacheService.uriPersonCache.set(uri, u); + if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } @@ -215,7 +222,7 @@ export class ApPersonService implements OnModuleInit { const exist = await this.usersRepository.findOneBy({ uri }); if (exist) { - this.userCacheService.uriPersonCache.set(uri, exist); + this.cacheService.uriPersonCache.set(uri, exist); return exist; } //#endregion @@ -324,10 +331,12 @@ export class ApPersonService implements OnModuleInit { } // Register host - this.federatedInstanceService.fetch(host).then(i => { + this.federatedInstanceService.fetch(host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); - this.instanceChart.newUser(i.host); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.newUser(i.host); + } }); this.usersChart.update(user!, true); 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..328511f5d 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 { AntennasRepository } from '@/models/index.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Antenna } from '@/models/entities/Antenna.js'; import { bindThis } from '@/decorators.js'; @@ -10,9 +10,6 @@ export class AntennaEntityService { constructor( @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, ) { } @@ -22,8 +19,6 @@ export class AntennaEntityService { ): Promise> { const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); - const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null; - return { id: antenna.id, createdAt: antenna.createdAt.toISOString(), @@ -37,7 +32,8 @@ export class AntennaEntityService { notify: antenna.notify, withReplies: antenna.withReplies, withFile: antenna.withFile, - hasUnreadNote, + isActive: antenna.isActive, + hasUnreadNote: false, // TODO }; } } 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..72e9b2554 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -1,13 +1,14 @@ 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 { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/index.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 { UserEntityService } from './UserEntityService.js'; -import { DriveFileEntityService } from './DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { NoteEntityService } from './NoteEntityService.js'; +import { In } from 'typeorm'; @Injectable() export class ChannelEntityService { @@ -18,13 +19,19 @@ export class ChannelEntityService { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, ) { } @@ -33,6 +40,7 @@ export class ChannelEntityService { public async pack( src: Channel['id'] | Channel, me?: { id: User['id'] } | null | undefined, + detailed?: boolean, ): Promise> { const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); const meId = me ? me.id : null; @@ -46,6 +54,17 @@ export class ChannelEntityService { followeeId: channel.id, }) : null; + const favorite = meId ? await this.channelFavoritesRepository.findOneBy({ + userId: meId, + channelId: channel.id, + }) : null; + + const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ + where: { + id: In(channel.pinnedNoteIds), + }, + }) : []; + return { id: channel.id, createdAt: channel.createdAt.toISOString(), @@ -54,13 +73,19 @@ export class ChannelEntityService { description: channel.description, userId: channel.userId, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + pinnedNoteIds: channel.pinnedNoteIds, usersCount: channel.usersCount, notesCount: channel.notesCount, ...(me ? { isFollowing: following != null, + isFavorited: favorite != null, hasUnreadNote, } : {}), + + ...(detailed ? { + pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), + } : {}), }; } } 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 b750606f1..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'; @@ -104,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 67850ad9a..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'; 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..6b9a9d332 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -1,11 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; +import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; 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'; @@ -25,8 +26,11 @@ export class NotificationEntityService implements OnModuleInit { constructor( private moduleRef: ModuleRef, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, @@ -48,30 +52,40 @@ export class NotificationEntityService implements OnModuleInit { @bindThis public async pack( - src: Notification['id'] | Notification, + src: Notification, + meId: User['id'], + // eslint-disable-next-line @typescript-eslint/ban-types options: { - _hint_?: { - packedNotes: Map>; - }; + + }, + hint?: { + packedNotes: Map>; + packedUsers: Map>; }, ): Promise> { - const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); + const notification = src; const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( - options._hint_?.packedNotes != null - ? options._hint_.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + hint?.packedNotes != null + ? hint.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.noteId!, { id: meId }, { detail: true, }) ) : undefined; + const userIfNeed = notification.notifierId != null ? ( + hint?.packedUsers != null + ? hint.packedUsers.get(notification.notifierId) + : this.userEntityService.pack(notification.notifierId!, { id: meId }, { + detail: false, + }) + ) : undefined; return await awaitAll({ id: notification.id, - createdAt: notification.createdAt.toISOString(), + createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, - isRead: notification.isRead, userId: notification.notifierId, - user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, + ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { reaction: notification.reaction, @@ -87,33 +101,36 @@ export class NotificationEntityService implements OnModuleInit { }); } - /** - * @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId - */ @bindThis public async packMany( notifications: Notification[], meId: User['id'], ) { if (notifications.length === 0) return []; - - for (const notification of notifications) { - if (meId !== notification.notifieeId) { - // because we call note packMany with meId, all notifieeId should be same as meId - throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION'); - } - } - const notes = notifications.map(x => x.note).filter(isNotNull); + const noteIds = notifications.map(x => x.noteId).filter(isNotNull); + const notes = noteIds.length > 0 ? await this.notesRepository.find({ + where: { id: In(noteIds) }, + relations: ['user', 'user.avatar', 'user.banner', 'reply', 'reply.user', 'reply.user.avatar', 'reply.user.banner', 'renote', 'renote.user', 'renote.user.avatar', 'renote.user.banner'], + }) : []; const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { detail: true, }); const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - return await Promise.all(notifications.map(x => this.pack(x, { - _hint_: { - packedNotes, - }, + const userIds = notifications.map(x => x.notifierId).filter(isNotNull); + const users = userIds.length > 0 ? await this.usersRepository.find({ + where: { id: In(userIds) }, + relations: ['avatar', 'banner'], + }) : []; + const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { + detail: false, + }); + const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); + + return await Promise.all(notifications.map(x => this.pack(x, meId, {}, { + packedNotes, + packedUsers, }))); } } 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 index 66ee7305a..f8871e049 100644 --- a/packages/backend/src/core/entities/RenoteMutingEntityService.ts +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -2,7 +2,7 @@ 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/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 { RenoteMuting } from '@/models/entities/RenoteMuting.js'; 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 e7aa885f3..e8474c7e0 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,18 +1,19 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, Not } from 'typeorm'; +import Redis from 'ioredis'; 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'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } 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, RenoteMutingsRepository } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -52,7 +53,7 @@ export class UserEntityService implements OnModuleInit { private customEmojiService: CustomEmojiService; private antennaService: AntennaService; private roleService: RoleService; - private userInstanceCache: Cache; + private userInstanceCache: MemoryKVCache; constructor( private moduleRef: ModuleRef, @@ -60,6 +61,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -90,9 +94,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, @@ -108,9 +109,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, @@ -121,7 +119,7 @@ export class UserEntityService implements OnModuleInit { //private antennaService: AntennaService, //private roleService: RoleService, ) { - this.userInstanceCache = new Cache(1000 * 60 * 60 * 3); + this.userInstanceCache = new MemoryKVCache(1000 * 60 * 60 * 3); } onModuleInit() { @@ -223,6 +221,7 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getHasUnreadAntenna(userId: User['id']): Promise { + /* const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ @@ -231,6 +230,8 @@ export class UserEntityService implements OnModuleInit { }) : null; return unread != null; + */ + return false; // TODO } @bindThis @@ -247,21 +248,16 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getHasUnreadNotification(userId: User['id']): Promise { - const mute = await this.mutingsRepository.findBy({ - muterId: userId, - }); - const mutedUserIds = mute.map(m => m.muteeId); + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); + + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '-', + 'COUNT', 1); + const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - const count = await this.notificationsRepository.count({ - where: { - notifieeId: userId, - ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), - isRead: false, - }, - take: 1, - }); - - return count > 0; + return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId); } @bindThis @@ -390,9 +386,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 ? { @@ -429,7 +426,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, @@ -437,6 +434,7 @@ export class UserEntityService implements OnModuleInit { description: role.description, isModerator: role.isModerator, isAdministrator: role.isAdministrator, + displayOrder: role.displayOrder, }))), } : {}), 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 187f930ac..56ce755a1 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -33,7 +33,6 @@ export const DI = { emojisRepository: Symbol('emojisRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), - notificationsRepository: Symbol('notificationsRepository'), metasRepository: Symbol('metasRepository'), mutingsRepository: Symbol('mutingsRepository'), renoteMutingsRepository: Symbol('renoteMutingsRepository'), @@ -52,15 +51,15 @@ export const DI = { moderationLogsRepository: Symbol('moderationLogsRepository'), clipsRepository: Symbol('clipsRepository'), clipNotesRepository: Symbol('clipNotesRepository'), + clipFavoritesRepository: Symbol('clipFavoritesRepository'), antennasRepository: Symbol('antennasRepository'), - antennaNotesRepository: Symbol('antennaNotesRepository'), promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), relaysRepository: Symbol('relaysRepository'), mutedNotesRepository: Symbol('mutedNotesRepository'), channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), - channelNotePiningsRepository: Symbol('channelNotePiningsRepository'), + channelFavoritesRepository: Symbol('channelFavoritesRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), adsRepository: Symbol('adsRepository'), diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 43a71a2b5..870dfd237 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -1,18 +1,103 @@ +import Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; +// redis通すとDateのインスタンスはstringに変換されるので +type Serialized = { + [K in keyof T]: + T[K] extends Date + ? string + : T[K] extends (Date | null) + ? (string | null) + : T[K] extends Record + ? Serialized + : T[K]; +}; + +export class RedisKVCache { + private redisClient: Redis.Redis; + private name: string; + private lifetime: number; + private memoryCache: MemoryKVCache; + + constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], lifetime: RedisKVCache['lifetime'], memoryCacheLifetime: number) { + this.redisClient = redisClient; + this.name = name; + this.lifetime = lifetime; + this.memoryCache = new MemoryKVCache(memoryCacheLifetime); + } + + @bindThis + public async set(key: string, value: T): Promise { + this.memoryCache.set(key, value); + if (this.lifetime === Infinity) { + await this.redisClient.set( + `kvcache:${this.name}:${key}`, + JSON.stringify(value), + ); + } else { + await this.redisClient.set( + `kvcache:${this.name}:${key}`, + JSON.stringify(value), + 'ex', Math.round(this.lifetime / 1000), + ); + } + } + + @bindThis + public async get(key: string): Promise | T | undefined> { + const memoryCached = this.memoryCache.get(key); + if (memoryCached !== undefined) return memoryCached; + + const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); + if (cached == null) return undefined; + return JSON.parse(cached); + } + + @bindThis + public async delete(key: string): Promise { + this.memoryCache.delete(key); + await this.redisClient.del(`kvcache:${this.name}:${key}`); + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + @bindThis + public async fetch(key: string, fetcher: () => Promise, validator?: (cachedValue: Serialized | T) => boolean): Promise | T> { + const cachedValue = await this.get(key); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + this.set(key, value); + return value; + } +} + // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? -export class Cache { - public cache: Map; +export class MemoryKVCache { + public cache: Map; private lifetime: number; - constructor(lifetime: Cache['lifetime']) { + constructor(lifetime: MemoryKVCache['lifetime']) { this.cache = new Map(); this.lifetime = lifetime; } @bindThis - public set(key: string | null, value: T): void { + public set(key: string, value: T): void { this.cache.set(key, { date: Date.now(), value, @@ -20,7 +105,7 @@ export class Cache { } @bindThis - public get(key: string | null): T | undefined { + public get(key: string): T | undefined { const cached = this.cache.get(key); if (cached == null) return undefined; if ((Date.now() - cached.date) > this.lifetime) { @@ -31,7 +116,7 @@ export class Cache { } @bindThis - public delete(key: string | null) { + public delete(key: string) { this.cache.delete(key); } @@ -40,7 +125,7 @@ export class Cache { * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします */ @bindThis - public async fetch(key: string | null, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetch(key: string, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -65,7 +150,7 @@ export class Cache { * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします */ @bindThis - public async fetchMaybe(key: string | null, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetchMaybe(key: string, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -87,3 +172,88 @@ export class Cache { return value; } } + +export class MemoryCache { + private cachedAt: number | null = null; + private value: T | undefined; + private lifetime: number; + + constructor(lifetime: MemoryCache['lifetime']) { + this.lifetime = lifetime; + } + + @bindThis + public set(value: T): void { + this.cachedAt = Date.now(); + this.value = value; + } + + @bindThis + public get(): T | undefined { + if (this.cachedAt == null) return undefined; + if ((Date.now() - this.cachedAt) > this.lifetime) { + this.value = undefined; + this.cachedAt = null; + return undefined; + } + return this.value; + } + + @bindThis + public delete() { + this.value = undefined; + this.cachedAt = null; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + @bindThis + public async fetch(fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + this.set(value); + return value; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + @bindThis + public async fetchMaybe(fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + if (value !== undefined) { + this.set(value); + } + return value; + } +} 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/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 19c8546f9..93a9929aa 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -23,3 +23,8 @@ export function genAid(date: Date): string { counter++; return getTime(t) + getNoise(); } + +export function parseAid(id: string): { date: Date; } { + const time = parseInt(id.slice(0, 8), 36) + TIME2000; + return { date: new Date(time) }; +} 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 80% rename from packages/backend/src/misc/schema.ts rename to packages/backend/src/misc/json-schema.ts index 9b8af6958..e748f93a2 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -6,29 +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 { packedRenoteMutingSchema } from '@/models/schema/renote-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, diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index d29b07b02..7be7b8190 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, 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 } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, 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, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -172,12 +172,6 @@ const $driveFoldersRepository: Provider = { inject: [DI.db], }; -const $notificationsRepository: Provider = { - provide: DI.notificationsRepository, - useFactory: (db: DataSource) => db.getRepository(Notification), - inject: [DI.db], -}; - const $metasRepository: Provider = { provide: DI.metasRepository, useFactory: (db: DataSource) => db.getRepository(Meta), @@ -286,15 +280,15 @@ const $clipNotesRepository: Provider = { inject: [DI.db], }; -const $antennasRepository: Provider = { - provide: DI.antennasRepository, - useFactory: (db: DataSource) => db.getRepository(Antenna), +const $clipFavoritesRepository: Provider = { + provide: DI.clipFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ClipFavorite), inject: [DI.db], }; -const $antennaNotesRepository: Provider = { - provide: DI.antennaNotesRepository, - useFactory: (db: DataSource) => db.getRepository(AntennaNote), +const $antennasRepository: Provider = { + provide: DI.antennasRepository, + useFactory: (db: DataSource) => db.getRepository(Antenna), inject: [DI.db], }; @@ -334,9 +328,9 @@ const $channelFollowingsRepository: Provider = { inject: [DI.db], }; -const $channelNotePiningsRepository: Provider = { - provide: DI.channelNotePiningsRepository, - useFactory: (db: DataSource) => db.getRepository(ChannelNotePining), +const $channelFavoritesRepository: Provider = { + provide: DI.channelFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ChannelFavorite), inject: [DI.db], }; @@ -426,7 +420,6 @@ const $roleAssignmentsRepository: Provider = { $emojisRepository, $driveFilesRepository, $driveFoldersRepository, - $notificationsRepository, $metasRepository, $mutingsRepository, $renoteMutingsRepository, @@ -445,15 +438,15 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, - $antennaNotesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, - $channelNotePiningsRepository, + $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, @@ -493,7 +486,6 @@ const $roleAssignmentsRepository: Provider = { $emojisRepository, $driveFilesRepository, $driveFoldersRepository, - $notificationsRepository, $metasRepository, $mutingsRepository, $renoteMutingsRepository, @@ -512,15 +504,15 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, - $antennaNotesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, - $channelNotePiningsRepository, + $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, 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/AntennaNote.ts b/packages/backend/src/models/entities/AntennaNote.ts deleted file mode 100644 index 5524a8936..000000000 --- a/packages/backend/src/models/entities/AntennaNote.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { id } from '../id.js'; -import { Note } from './Note.js'; -import { Antenna } from './Antenna.js'; - -@Entity() -@Index(['noteId', 'antennaId'], { unique: true }) -export class AntennaNote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The note ID.', - }) - public noteId: Note['id']; - - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; - - @Index() - @Column({ - ...id(), - comment: 'The antenna ID.', - }) - public antennaId: Antenna['id']; - - @ManyToOne(type => Antenna, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public antenna: Antenna | null; - - @Index() - @Column('boolean', { - default: false, - }) - public read: boolean; -} diff --git a/packages/backend/src/models/entities/Channel.ts b/packages/backend/src/models/entities/Channel.ts index a6e32d54f..2d346fdf9 100644 --- a/packages/backend/src/models/entities/Channel.ts +++ b/packages/backend/src/models/entities/Channel.ts @@ -59,6 +59,11 @@ export class Channel { @JoinColumn() public banner: DriveFile | null; + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public pinnedNoteIds: string[]; + @Index() @Column('integer', { default: 0, diff --git a/packages/backend/src/models/entities/ChannelNotePining.ts b/packages/backend/src/models/entities/ChannelFavorite.ts similarity index 59% rename from packages/backend/src/models/entities/ChannelNotePining.ts rename to packages/backend/src/models/entities/ChannelFavorite.ts index ab5796626..cfb2c892c 100644 --- a/packages/backend/src/models/entities/ChannelNotePining.ts +++ b/packages/backend/src/models/entities/ChannelFavorite.ts @@ -1,21 +1,24 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { id } from '../id.js'; -import { Note } from './Note.js'; +import { User } from './User.js'; import { Channel } from './Channel.js'; @Entity() -@Index(['channelId', 'noteId'], { unique: true }) -export class ChannelNotePining { +@Index(['userId', 'channelId'], { unique: true }) +export class ChannelFavorite { @PrimaryColumn(id()) public id: string; + @Index() @Column('timestamp with time zone', { - comment: 'The created date of the ChannelNotePining.', + comment: 'The created date of the ChannelFavorite.', }) public createdAt: Date; @Index() - @Column(id()) + @Column({ + ...id(), + }) public channelId: Channel['id']; @ManyToOne(type => Channel, { @@ -24,12 +27,15 @@ export class ChannelNotePining { @JoinColumn() public channel: Channel | null; - @Column(id()) - public noteId: Note['id']; + @Index() + @Column({ + ...id(), + }) + public userId: User['id']; - @ManyToOne(type => Note, { + @ManyToOne(type => User, { onDelete: 'CASCADE', }) @JoinColumn() - public note: Note | null; + public user: User | null; } 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..2e4f90b57 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; @@ -42,82 +42,69 @@ export class Meta { }) public disableRegistration: boolean; - @Column('boolean', { - default: false, - }) - 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 +137,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 +154,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 +171,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 +205,7 @@ export class Meta { public enableSensitiveMediaDetectionForVideos: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public summalyProxy: string | null; @@ -229,7 +216,7 @@ export class Meta { public enableEmail: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public email: string | null; @@ -240,7 +227,7 @@ export class Meta { public smtpSecure: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpHost: string | null; @@ -251,13 +238,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 +255,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 +278,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 +315,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; @@ -404,6 +391,16 @@ export class Meta { }) public enableActiveEmailValidation: boolean; + @Column('boolean', { + default: true, + }) + public enableChartsForRemoteUser: boolean; + + @Column('boolean', { + default: true, + }) + public enableChartsForFederatedInstances: boolean; + @Column('jsonb', { default: { }, }) diff --git a/packages/backend/src/models/entities/Notification.ts b/packages/backend/src/models/entities/Notification.ts index 51117efba..aa6f99712 100644 --- a/packages/backend/src/models/entities/Notification.ts +++ b/packages/backend/src/models/entities/Notification.ts @@ -1,54 +1,19 @@ -import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm'; -import { notificationTypes, obsoleteNotificationTypes } from '@/types.js'; -import { id } from '../id.js'; +import { notificationTypes } from '@/types.js'; import { User } from './User.js'; import { Note } from './Note.js'; import { FollowRequest } from './FollowRequest.js'; import { AccessToken } from './AccessToken.js'; -@Entity() -export class Notification { - @PrimaryColumn(id()) - public id: string; +export type Notification = { + id: string; - @Index() - @Column('timestamp with time zone', { - comment: 'The created date of the Notification.', - }) - public createdAt: Date; - - /** - * 通知の受信者 - */ - @Index() - @Column({ - ...id(), - comment: 'The ID of recipient user of the Notification.', - }) - public notifieeId: User['id']; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public notifiee: User | null; + // RedisのためDateではなくstring + createdAt: string; /** * 通知の送信者(initiator) */ - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The ID of sender user of the Notification.', - }) - public notifierId: User['id'] | null; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public notifier: User | null; + notifierId: User['id'] | null; /** * 通知の種類。 @@ -64,104 +29,37 @@ export class Notification { * achievementEarned - 実績を獲得 * app - アプリ通知 */ - @Index() - @Column('enum', { - enum: [ - ...notificationTypes, - ...obsoleteNotificationTypes, - ], - comment: 'The type of the Notification.', - }) - public type: typeof notificationTypes[number]; + type: typeof notificationTypes[number]; - /** - * 通知が読まれたかどうか - */ - @Index() - @Column('boolean', { - default: false, - comment: 'Whether the Notification is read.', - }) - public isRead: boolean; + noteId: Note['id'] | null; - @Column({ - ...id(), - nullable: true, - }) - public noteId: Note['id'] | null; + followRequestId: FollowRequest['id'] | null; - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; + reaction: string | null; - @Column({ - ...id(), - nullable: true, - }) - public followRequestId: FollowRequest['id'] | null; + choice: number | null; - @ManyToOne(type => FollowRequest, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public followRequest: FollowRequest | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public reaction: string | null; - - @Column('integer', { - nullable: true, - }) - public choice: number | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public achievement: string | null; + achievement: string | null; /** * アプリ通知のbody */ - @Column('varchar', { - length: 2048, nullable: true, - }) - public customBody: string | null; + customBody: string | null; /** * アプリ通知のheader * (省略時はアプリ名で表示されることを期待) */ - @Column('varchar', { - length: 256, nullable: true, - }) - public customHeader: string | null; + customHeader: string | null; /** * アプリ通知のicon(URL) * (省略時はアプリアイコンで表示されることを期待) */ - @Column('varchar', { - length: 1024, nullable: true, - }) - public customIcon: string | null; + customIcon: string | null; /** * アプリ通知のアプリ(のトークン) */ - @Index() - @Column({ - ...id(), - nullable: true, - }) - public appAccessTokenId: AccessToken['id'] | null; - - @ManyToOne(type => AccessToken, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public appAccessToken: AccessToken | null; + appAccessTokenId: AccessToken['id'] | 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..eca9bcf27 100644 --- a/packages/backend/src/models/entities/Role.ts +++ b/packages/backend/src/models/entities/Role.ts @@ -54,6 +54,16 @@ type CondFormulaValueFollowingMoreThanOrEq = { value: number; }; +type CondFormulaValueNotesLessThanOrEq = { + type: 'notesLessThanOrEq'; + value: number; +}; + +type CondFormulaValueNotesMoreThanOrEq = { + type: 'notesMoreThanOrEq'; + value: number; +}; + export type RoleCondFormulaValue = CondFormulaValueAnd | CondFormulaValueOr | @@ -65,7 +75,9 @@ export type RoleCondFormulaValue = CondFormulaValueFollowersLessThanOrEq | CondFormulaValueFollowersMoreThanOrEq | CondFormulaValueFollowingLessThanOrEq | - CondFormulaValueFollowingMoreThanOrEq; + CondFormulaValueFollowingMoreThanOrEq | + CondFormulaValueNotesLessThanOrEq | + CondFormulaValueNotesMoreThanOrEq; @Entity() export class Role { @@ -144,6 +156,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 4acb958b0..48d6e15f2 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -4,15 +4,15 @@ import { Ad } from '@/models/entities/Ad.js'; import { Announcement } from '@/models/entities/Announcement.js'; import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; import { Antenna } from '@/models/entities/Antenna.js'; -import { AntennaNote } from '@/models/entities/AntennaNote.js'; import { App } from '@/models/entities/App.js'; import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { ChannelFavorite } from '@/models/entities/ChannelFavorite.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'; @@ -32,7 +32,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js'; -import { Notification } from '@/models/entities/Notification.js'; import { Page } from '@/models/entities/Page.js'; import { PageLike } from '@/models/entities/PageLike.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; @@ -72,15 +71,15 @@ export { Announcement, AnnouncementRead, Antenna, - AntennaNote, App, AttestationChallenge, AuthSession, Blocking, ChannelFollowing, - ChannelNotePining, + ChannelFavorite, Clip, ClipNote, + ClipFavorite, DriveFile, DriveFolder, Emoji, @@ -100,7 +99,6 @@ export { NoteReaction, NoteThreadMuting, NoteUnread, - Notification, Page, PageLike, PasswordResetRequest, @@ -139,15 +137,15 @@ export type AdsRepository = Repository; export type AnnouncementsRepository = Repository; export type AnnouncementReadsRepository = Repository; export type AntennasRepository = Repository; -export type AntennaNotesRepository = Repository; export type AppsRepository = Repository; export type AttestationChallengesRepository = Repository; export type AuthSessionsRepository = Repository; export type BlockingsRepository = Repository; export type ChannelFollowingsRepository = Repository; -export type ChannelNotePiningsRepository = Repository; +export type ChannelFavoritesRepository = 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; @@ -167,7 +165,6 @@ export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; export type NoteThreadMutingsRepository = Repository; export type NoteUnreadsRepository = Repository; -export type NotificationsRepository = Repository; export type PagesRepository = Repository; export type PageLikesRepository = Repository; export type PasswordResetRequestsRepository = 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 81% rename from packages/backend/src/models/schema/channel.ts rename to packages/backend/src/models/json-schema/channel.ts index 7f4f2a48b..745b39a6b 100644 --- a/packages/backend/src/models/schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -42,10 +42,22 @@ export const packedChannelSchema = { type: 'boolean', optional: true, nullable: false, }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, userId: { type: 'string', nullable: true, optional: false, format: 'id', }, + pinnedNoteIds: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + format: 'id', + }, + }, }, } as const; 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 100% rename from packages/backend/src/models/schema/note.ts rename to packages/backend/src/models/json-schema/note.ts diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts similarity index 93% rename from packages/backend/src/models/schema/notification.ts rename to packages/backend/src/models/json-schema/notification.ts index d3f2405cd..e88ca61ba 100644 --- a/packages/backend/src/models/schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -14,10 +14,6 @@ export const packedNotificationSchema = { optional: false, nullable: false, format: 'date-time', }, - isRead: { - type: 'boolean', - optional: false, nullable: false, - }, type: { type: 'string', optional: false, nullable: false, 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/schema/renote-muting.ts b/packages/backend/src/models/json-schema/renote-muting.ts similarity index 100% rename from packages/backend/src/models/schema/renote-muting.ts rename to packages/backend/src/models/json-schema/renote-muting.ts 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 99% rename from packages/backend/src/models/schema/user.ts rename to packages/backend/src/models/json-schema/user.ts index 8c61ee1f5..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: { diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 741985f3a..efeca46b4 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -12,15 +12,15 @@ import { Ad } from '@/models/entities/Ad.js'; import { Announcement } from '@/models/entities/Announcement.js'; import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; import { Antenna } from '@/models/entities/Antenna.js'; -import { AntennaNote } from '@/models/entities/AntennaNote.js'; import { App } from '@/models/entities/App.js'; import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { ChannelFavorite } from '@/models/entities/ChannelFavorite.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'; @@ -40,7 +40,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js'; -import { Notification } from '@/models/entities/Notification.js'; import { Page } from '@/models/entities/Page.js'; import { PageLike } from '@/models/entities/PageLike.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; @@ -155,7 +154,6 @@ export const entities = [ DriveFolder, Poll, PollVote, - Notification, Emoji, Hashtag, SwSubscription, @@ -165,15 +163,15 @@ export const entities = [ ModerationLog, Clip, ClipNote, + ClipFavorite, Antenna, - AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, - ChannelNotePining, + ChannelFavorite, RegistryItem, Ad, PasswordResetRequest, 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..1936e8df2 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 { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; @@ -20,14 +20,11 @@ export class CleanProcessorService { @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.mutedNotesRepository) private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, @Inject(DI.roleAssignmentsRepository) private roleAssignmentsRepository: RoleAssignmentsRepository, @@ -46,8 +43,9 @@ export class CleanProcessorService { createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), }); - this.notificationsRepository.delete({ - createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), + this.mutedNotesRepository.delete({ + id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + reason: 'word', }); this.mutedNotesRepository.delete({ @@ -55,8 +53,11 @@ export class CleanProcessorService { reason: 'word', }); - this.antennaNotesRepository.delete({ - id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + // 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..a9af22ad0 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -7,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryCache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; @@ -22,7 +22,7 @@ import type { DeliverJobData } from '../types.js'; @Injectable() export class DeliverProcessorService { private logger: Logger; - private suspendedHostsCache: Cache; + private suspendedHostsCache: MemoryCache; private latest: string | null; constructor( @@ -46,7 +46,7 @@ export class DeliverProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); - this.suspendedHostsCache = new Cache(1000 * 60 * 60); + this.suspendedHostsCache = new MemoryCache(1000 * 60 * 60); } @bindThis @@ -60,14 +60,14 @@ export class DeliverProcessorService { } // isSuspendedなら中断 - let suspendedHosts = this.suspendedHostsCache.get(null); + let suspendedHosts = this.suspendedHostsCache.get(); if (suspendedHosts == null) { suspendedHosts = await this.instancesRepository.find({ where: { isSuspended: true, }, }); - this.suspendedHostsCache.set(null, suspendedHosts); + this.suspendedHostsCache.set(suspendedHosts); } if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { return 'skip (suspended)'; @@ -88,10 +88,12 @@ export class DeliverProcessorService { } this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - - this.instanceChart.requestSent(i.host, true); this.apRequestChart.deliverSucc(); this.federationChart.deliverd(i.host, true); + + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestSent(i.host, true); + } }); return 'Success'; @@ -107,14 +109,29 @@ export class DeliverProcessorService { }); } - this.instanceChart.requestSent(i.host, false); this.apRequestChart.deliverFail(); this.federationChart.deliverd(i.host, false); + + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestSent(i.host, false); + } }); 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/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 4ecf8daf7..ed96e9a52 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -102,6 +102,7 @@ export class ImportCustomEmojisProcessorService { host: null, aliases: emojiInfo.aliases, driveFile, + license: emojiInfo.license, }); } diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 33d6f4eaf..ed7f38d01 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -64,7 +64,7 @@ export class InboxProcessorService { const activity = job.data.activity; //#region Log - const info = Object.assign({}, activity) as any; + const info = Object.assign({}, activity); delete info['@context']; this.logger.debug(JSON.stringify(info, null, 2)); //#endregion @@ -184,9 +184,12 @@ export class InboxProcessorService { this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - this.instanceChart.requestReceived(i.host); this.apRequestChart.inbox(); this.federationChart.inbox(i.host); + + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestReceived(i.host); + } }); // アクティビティを処理 diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 1214c9eb9..5d650c686 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -12,6 +12,8 @@ export type DeliverJobData = { content: unknown; /** inbox URL to deliver */ to: string; + /** whether it is sharedInbox */ + isSharedInbox: boolean; }; export type InboxJobData = { diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 835657b62..794fa76d9 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -2,7 +2,6 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; -import fastifyStatic from '@fastify/static'; import rename from 'rename'; import type { Config } from '@/config.js'; import type { DriveFile, DriveFilesRepository } from '@/models/index.js'; @@ -60,11 +59,6 @@ export class FileServerService { done(); }); - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - fastify.get('/files/app-default.jpg', (request, reply) => { 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..66c1faaac 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -4,7 +4,7 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryCache } from '@/misc/cache.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import NotesChart from '@/core/chart/charts/notes.js'; @@ -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, @@ -118,17 +118,17 @@ export class NodeinfoServerService { }; }; - const cache = new Cache>>(1000 * 60 * 10); + const cache = new MemoryCache>>(1000 * 60 * 10); fastify.get(nodeinfo2_1path, async (request, reply) => { - const base = await cache.fetch(null, () => nodeinfo2()); + const base = await cache.fetch(() => nodeinfo2()); reply.header('Cache-Control', 'public, max-age=600'); return { version: '2.1', ...base }; }); fastify.get(nodeinfo2_0path, async (request, reply) => { - const base = await cache.fetch(null, () => nodeinfo2()); + const base = await cache.fetch(() => nodeinfo2()); delete (base as any).software.repository; 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/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/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 87438c348..6548c475b 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -3,9 +3,9 @@ import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js'; import type { LocalUser } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { App } from '@/models/entities/App.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import isNativeToken from '@/misc/is-native-token.js'; import { bindThis } from '@/decorators.js'; @@ -18,7 +18,7 @@ export class AuthenticationError extends Error { @Injectable() export class AuthenticateService { - private appCache: Cache; + private appCache: MemoryKVCache; constructor( @Inject(DI.usersRepository) @@ -30,9 +30,9 @@ export class AuthenticateService { @Inject(DI.appsRepository) private appsRepository: AppsRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, ) { - this.appCache = new Cache(Infinity); + this.appCache = new MemoryKVCache(Infinity); } @bindThis @@ -42,7 +42,7 @@ export class AuthenticateService { } if (isNativeToken(token)) { - const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token, + const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); if (user == null) { @@ -67,7 +67,7 @@ export class AuthenticateService { lastUsedAt: new Date(), }); - const user = await this.userCacheService.localUserByIdCache.fetch(accessToken.userId, + const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId, () => this.usersRepository.findOneBy({ id: accessToken.userId, }) as Promise); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 272464959..cab247741 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'; @@ -94,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js'; import * as ep___channels_timeline from './endpoints/channels/timeline.js'; import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; import * as ep___channels_update from './endpoints/channels/update.js'; +import * as ep___channels_favorite from './endpoints/channels/favorite.js'; +import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; +import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -114,6 +118,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,6 +227,7 @@ 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'; @@ -260,7 +268,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -366,6 +373,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 }; @@ -418,6 +426,9 @@ const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___c const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; +const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; +const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; +const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; @@ -438,6 +449,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 }; @@ -544,6 +558,7 @@ 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 }; @@ -584,7 +599,6 @@ const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep__ const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; -const $notifications_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.default }; const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; @@ -694,6 +708,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, @@ -746,6 +761,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_timeline, $channels_unfollow, $channels_update, + $channels_favorite, + $channels_unfavorite, + $channels_myFavorites, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -766,6 +784,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, @@ -872,6 +893,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_delete, $meta, $emojis, + $emoji, $miauth_genToken, $mute_create, $mute_delete, @@ -912,7 +934,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_userListTimeline, $notifications_create, $notifications_markAllAsRead, - $notifications_read, $pagePush, $pages_create, $pages_delete, @@ -1016,6 +1037,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, @@ -1068,6 +1090,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_timeline, $channels_unfollow, $channels_update, + $channels_favorite, + $channels_unfavorite, + $channels_myFavorites, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -1088,6 +1113,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, @@ -1194,6 +1222,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_delete, $meta, $emojis, + $emoji, $miauth_genToken, $mute_create, $mute_delete, @@ -1234,7 +1263,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_userListTimeline, $notifications_create, $notifications_markAllAsRead, - $notifications_read, $pagePush, $pages_create, $pages_delete, 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 1f01865e0..e33c2349c 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'; @@ -94,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js'; import * as ep___channels_timeline from './endpoints/channels/timeline.js'; import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; import * as ep___channels_update from './endpoints/channels/update.js'; +import * as ep___channels_favorite from './endpoints/channels/favorite.js'; +import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; +import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -114,6 +118,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,6 +227,7 @@ 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'; @@ -260,7 +268,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -364,6 +371,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], @@ -416,6 +424,9 @@ const eps = [ ['channels/timeline', ep___channels_timeline], ['channels/unfollow', ep___channels_unfollow], ['channels/update', ep___channels_update], + ['channels/favorite', ep___channels_favorite], + ['channels/unfavorite', ep___channels_unfavorite], + ['channels/my-favorites', ep___channels_myFavorites], ['charts/active-users', ep___charts_activeUsers], ['charts/ap-request', ep___charts_apRequest], ['charts/drive', ep___charts_drive], @@ -436,6 +447,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], @@ -542,6 +556,7 @@ 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], @@ -582,7 +597,6 @@ const eps = [ ['notes/user-list-timeline', ep___notes_userListTimeline], ['notifications/create', ep___notifications_create], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], - ['notifications/read', ep___notifications_read], ['page-push', ep___pagePush], ['pages/create', ep___pages_create], ['pages/delete', ep___pages_delete], 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..bc0475e05 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { EmojisRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; @@ -19,6 +19,11 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', }, + sameNameEmojiExists: { + message: 'Emoji that have same name already exists.', + code: 'SAME_NAME_EMOJI_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 sameNameEmoji = await this.emojisRepository.findOneBy({ name: ps.name, host: IsNull() }); if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - + if (sameNameEmoji != null && sameNameEmoji.id !== ps.id) throw new ApiError(meta.errors.sameNameEmojiExists); 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..fc318a621 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, @@ -231,6 +239,14 @@ export const meta = { type: 'boolean', optional: true, nullable: false, }, + enableChartsForRemoteUser: { + type: 'boolean', + optional: false, nullable: false, + }, + enableChartsForFederatedInstances: { + type: 'boolean', + optional: false, nullable: false, + }, policies: { type: 'object', optional: false, nullable: false, @@ -266,7 +282,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 +306,11 @@ 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, @@ -330,6 +344,8 @@ export default class extends Endpoint { deeplIsPro: instance.deeplIsPro, enableIpLogging: instance.enableIpLogging, enableActiveEmailValidation: instance.enableActiveEmailValidation, + enableChartsForRemoteUser: instance.enableChartsForRemoteUser, + enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, policies: { ...DEFAULT_POLICIES, ...instance.policies }, }; }); 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/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 3ad6c7c48..770b61850 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, FollowingsRepository, NotificationsRepository } from '@/models/index.js'; +import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -36,9 +36,6 @@ export default class extends Endpoint { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - private userEntityService: UserEntityService, private userFollowingService: UserFollowingService, private userSuspendService: UserSuspendService, @@ -73,7 +70,6 @@ export default class extends Endpoint { (async () => { await this.userSuspendService.doPostSuspend(user).catch(e => {}); await this.unFollowAll(user).catch(e => {}); - await this.readAllNotify(user).catch(e => {}); })(); }); } @@ -96,14 +92,4 @@ export default class extends Endpoint { await this.userFollowingService.unfollow(follower, followee, true); } } - - @bindThis - private async readAllNotify(notifier: User) { - await this.notificationsRepository.update({ - notifierId: notifier.id, - isRead: false, - }, { - isRead: true, - }); - } } 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..11de29bf8 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -17,7 +17,6 @@ export const paramDef = { type: 'object', properties: { disableRegistration: { type: 'boolean', nullable: true }, - useStarForReactionFallback: { type: 'boolean', nullable: true }, pinnedUsers: { type: 'array', nullable: true, items: { type: 'string', } }, @@ -27,6 +26,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 +58,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', } }, @@ -94,6 +92,8 @@ export const paramDef = { objectStorageS3ForcePathStyle: { type: 'boolean' }, enableIpLogging: { type: 'boolean' }, enableActiveEmailValidation: { type: 'boolean' }, + enableChartsForRemoteUser: { type: 'boolean' }, + enableChartsForFederatedInstances: { type: 'boolean' }, }, required: [], } as const; @@ -115,10 +115,6 @@ export default class extends Endpoint { set.disableRegistration = ps.disableRegistration; } - if (typeof ps.useStarForReactionFallback === 'boolean') { - set.useStarForReactionFallback = ps.useStarForReactionFallback; - } - if (Array.isArray(ps.pinnedUsers)) { set.pinnedUsers = ps.pinnedUsers.filter(Boolean); } @@ -131,6 +127,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 +247,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 +296,7 @@ export default class extends Endpoint { } if (ps.tosUrl !== undefined) { - set.ToSUrl = ps.tosUrl; + set.termsOfServiceUrl = ps.tosUrl; } if (ps.repositoryUrl !== undefined) { @@ -387,6 +379,14 @@ export default class extends Endpoint { set.enableActiveEmailValidation = ps.enableActiveEmailValidation; } + if (ps.enableChartsForRemoteUser !== undefined) { + set.enableChartsForRemoteUser = ps.enableChartsForRemoteUser; + } + + if (ps.enableChartsForFederatedInstances !== undefined) { + set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; + } + await this.metaService.update(set); this.moderationLogService.insertModerationLog(me, 'updateMeta'); }); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index bc5d249ae..b7ce3363a 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) || ps.keywords[0].every(x => x === '')) { + 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..364f9d9c0 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NotesRepository, AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; +import type { NotesRepository, AntennasRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -50,15 +52,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - + private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, private noteReadService: NoteReadService, @@ -73,9 +76,24 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchAntenna); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .innerJoin(this.antennaNotesRepository.metadata.targetName, 'antennaNote', 'antennaNote.noteId = note.id') + const noteIdsRes = await this.redisClient.xrevrange( + `antennaTimeline:${antenna.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('user.avatar', 'avatar') .leftJoinAndSelect('user.banner', 'banner') @@ -86,21 +104,23 @@ export default class extends Endpoint { .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id }); + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); - const notes = await query - .take(ps.limit) - .getMany(); + const notes = await query.getMany(); + notes.sort((a, b) => a.id > b.id ? -1 : 1); if (notes.length > 0) { 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/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts new file mode 100644 index 000000000..f52b45ccf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '4938f5f3-6167-4c04-9149-6607b7542861', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + channelId: channel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/my-favorites.ts b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts new file mode 100644 index 000000000..60525ed06 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['channels', 'account'], + + requireCredential: true, + + kind: 'read:channels', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} 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.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.channelFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.channel', 'channel'); + + const favorites = await query + .getMany(); + + return await Promise.all(favorites.map(x => this.channelEntityService.pack(x.channel!, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts index 8718615db..070d14631 100644 --- a/packages/backend/src/server/api/endpoints/channels/show.ts +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - return await this.channelEntityService.pack(channel, me); + return await this.channelEntityService.pack(channel, me, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index cdaa40013..eef343d13 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { ChannelsRepository, NotesRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -48,12 +50,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, + private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, private activeUsersChart: ActiveUsersChart, @@ -67,9 +73,25 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } + const noteIdsRes = await this.redisClient.xrevrange( + `channelTimeline:${channel.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.channelId = :channelId', { channelId: channel.id }) + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('user.avatar', 'avatar') .leftJoinAndSelect('user.banner', 'banner') @@ -90,7 +112,8 @@ export default class extends Endpoint { } //#endregion - const timeline = await query.take(ps.limit).getMany(); + const timeline = await query.getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); if (me) this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts new file mode 100644 index 000000000..0c3f6c485 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '353c68dd-131a-476c-aa99-88a345e83668', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFavoritesRepository.delete({ + userId: me.id, + channelId: channel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index a86cc2565..084b3f919 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -3,8 +3,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; 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'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['channels'], @@ -47,6 +47,12 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + pinnedNoteIds: { + type: 'array', + items: { + type: 'string', format: 'misskey:id', + }, + }, }, required: ['channelId'], } as const; @@ -64,7 +70,7 @@ export default class extends Endpoint { private channelEntityService: ChannelEntityService, private roleService: RoleService, - ) { + ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ id: ps.channelId, @@ -97,6 +103,7 @@ export default class extends Endpoint { await this.channelsRepository.update(channel.id, { ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), ...(banner ? { bannerId: banner.id } : {}), }); 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/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index e3897d38b..f27b4e86d 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,6 +1,7 @@ -import { Brackets } from 'typeorm'; +import { Brackets, In } from 'typeorm'; +import Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js'; +import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; @@ -8,6 +9,8 @@ import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { Notification } from '@/models/entities/Notification.js'; export const meta = { tags: ['account', 'notifications'], @@ -38,8 +41,6 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - following: { type: 'boolean', default: false }, - unreadOnly: { type: 'boolean', default: false }, markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { @@ -56,21 +57,22 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, private queryService: QueryService, @@ -89,85 +91,39 @@ export default class extends Endpoint { const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + const notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: me.id }); - - const suspendedQuery = this.usersRepository.createQueryBuilder('users') - .select('users.id') - .where('users.isSuspended = TRUE'); - - const query = this.queryService.makePaginationQuery(this.notificationsRepository.createQueryBuilder('notification'), ps.sinceId, ps.untilId) - .andWhere('notification.notifieeId = :meId', { meId: me.id }) - .leftJoinAndSelect('notification.notifier', 'notifier') - .leftJoinAndSelect('notification.note', 'note') - .leftJoinAndSelect('notifier.avatar', 'notifierAvatar') - .leftJoinAndSelect('notifier.banner', 'notifierBanner') - .leftJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - - // muted users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - query.setParameters(mutingQuery.getParameters()); - - // muted instances - query.andWhere(new Brackets(qb => { qb - .andWhere('notifier.host IS NULL') - .orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`); - })); - query.setParameters(mutingInstanceQuery.getParameters()); - - // suspended users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - - if (ps.following) { - query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: me.id }); - query.setParameters(followingQuery.getParameters()); + if (notificationsRes.length === 0) { + return []; } + let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[]; + if (includeTypes && includeTypes.length > 0) { - query.andWhere('notification.type IN (:...includeTypes)', { includeTypes }); + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); } else if (excludeTypes && excludeTypes.length > 0) { - query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes }); + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); } - if (ps.unreadOnly) { - query.andWhere('notification.isRead = false'); + if (notifications.length === 0) { + return []; } - const notifications = await query.take(ps.limit).getMany(); - // Mark all as read - if (notifications.length > 0 && ps.markAsRead) { - this.notificationService.readNotification(me.id, notifications.map(x => x.id)); + if (ps.markAsRead) { + this.notificationService.readAllNotification(me.id); } - const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + const noteIds = notifications + .filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId!); - if (notes.length > 0) { + if (noteIds.length > 0) { + const notes = await this.notesRepository.findBy({ id: In(noteIds) }); this.noteReadService.read(me.id, notes); } diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index f942f43cc..786e64374 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -34,7 +34,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const freshUser = await this.usersRepository.findOneByOrFail({ id: me.id }); - const oldToken = freshUser.token; + const oldToken = freshUser.token!; const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b1eaab390..46b16e9dc 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -18,6 +18,7 @@ import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -152,6 +153,7 @@ export default class extends Endpoint { private accountUpdateService: AccountUpdateService, private hashtagService: HashtagService, private roleService: RoleService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }); @@ -276,9 +278,13 @@ export default class extends Endpoint { includeSecrets: isSecure, }); + const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + this.cacheService.userProfileCache.set(user.id, updatedProfile); + // Publish meUpdated event this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); - this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', await this.userProfilesRepository.findOneByOrFail({ userId: user.id })); + this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', updatedProfile); // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 if (user.isLocked && ps.isLocked === false) { 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/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index 9099eea52..fd062e1ca 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -7,6 +7,7 @@ import type { Muting } from '@/models/entities/Muting.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -65,6 +66,7 @@ export default class extends Endpoint { private globalEventService: GlobalEventService, private getterService: GetterService, private idService: IdService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const muter = me; @@ -103,6 +105,7 @@ export default class extends Endpoint { muteeId: mutee.id, } as Muting); + this.cacheService.userMutingsCache.delete(muter.id); this.globalEventService.publishUserEvent(me.id, 'mute', mutee); }); } 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/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 3802ae540..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)'); 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 381001695..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') 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/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index d0036f0fb..93517ab10 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -59,7 +59,7 @@ export default class extends Endpoint { this.noteThreadMutingsRepository.count({ where: { userId: me.id, - threadId: note.threadId || note.id, + threadId: note.threadId ?? note.id, }, take: 1, }), diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 5ce436ee1..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') 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/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts index 09134cf48..9ba607918 100644 --- a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -1,9 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { NotificationsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { PushNotificationService } from '@/core/PushNotificationService.js'; import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { tags: ['notifications', 'account'], @@ -23,24 +21,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - - private globalEventService: GlobalEventService, - private pushNotificationService: PushNotificationService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { - // Update documents - await this.notificationsRepository.update({ - notifieeId: me.id, - isRead: false, - }, { - isRead: true, - }); - - // 全ての通知を読みましたよというイベントを発行 - this.globalEventService.publishMainStream(me.id, 'readAllNotifications'); - this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined); + this.notificationService.readAllNotification(me.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts deleted file mode 100644 index 6262c47fd..000000000 --- a/packages/backend/src/server/api/endpoints/notifications/read.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NotificationService } from '@/core/NotificationService.js'; - -export const meta = { - tags: ['notifications', 'account'], - - requireCredential: true, - - kind: 'write:notifications', - - description: 'Mark a notification as read.', - - errors: { - noSuchNotification: { - message: 'No such notification.', - code: 'NO_SUCH_NOTIFICATION', - id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e', - }, - }, -} as const; - -export const paramDef = { - oneOf: [ - { - type: 'object', - properties: { - notificationId: { type: 'string', format: 'misskey:id' }, - }, - required: ['notificationId'], - }, - { - type: 'object', - properties: { - notificationIds: { - type: 'array', - items: { type: 'string', format: 'misskey:id' }, - maxItems: 100, - }, - }, - required: ['notificationIds'], - }, - ], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - private notificationService: NotificationService, - ) { - super(meta, paramDef, async (ps, me) => { - if ('notificationId' in ps) return this.notificationService.readNotification(me.id, [ps.notificationId]); - return this.notificationService.readNotification(me.id, ps.notificationIds); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index 051a005b6..b28526961 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -92,8 +92,6 @@ export default class extends Endpoint { muterId: muter.id, muteeId: mutee.id, } as RenoteMuting); - - // publishUserEvent(user.id, 'mute', mutee); }); } } 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/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/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/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 1234738ce..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'; 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 ab439a171..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'; diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 63a2dd5b3..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'; 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 678fbe12d..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'; 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 e33a28049..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'; 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 341c4e32c..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'; 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 e7899245b..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'; diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 0a4fd8393..f1f8bfd3a 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -3,7 +3,7 @@ import type { Channel as ChannelModel } from '@/models/entities/Channel.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'; @@ -195,8 +195,7 @@ export default class Connection { @bindThis private onReadNotification(payload: any) { - if (!payload.id) return; - this.notificationService.readNotification(this.user!.id, [payload.id]); + this.notificationService.readAllNotification(this.user!.id); } /** diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index c45077305..1e6e51e76 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'; @@ -19,7 +19,7 @@ import type { EventEmitter } from 'events'; //#region Stream type-body definitions export interface InternalStreamTypes { userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; }; - userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; }; + userTokenRegenerated: { id: User['id']; oldToken: string; newToken: string; }; remoteUserUpdated: { id: User['id']; }; follow: { followerId: User['id']; followeeId: User['id']; }; unfollow: { followerId: User['id']; followeeId: User['id']; }; 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..d772ac318 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.12.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 5fee2b93a..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 () => { 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 c13009373..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); }); }); @@ -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 5811d6baf..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 () => { 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 index 32c8ebe2c..0f73b8d09 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -5,7 +5,7 @@ import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Renote Mute', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; // alice mutes carol let alice: any; @@ -13,14 +13,14 @@ describe('Renote 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 () => { 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..406566557 --- /dev/null +++ b/packages/backend/test/unit/DriveService.ts @@ -0,0 +1,56 @@ +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { DeleteObjectCommandOutput, DeleteObjectCommand, NoSuchKey, InvalidObjectState, S3Client } from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DriveService } from '@/core/DriveService.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import type { TestingModule } from '@nestjs/testing'; + +describe('DriveService', () => { + let app: TestingModule; + let driveService: DriveService; + const s3Mock = mockClient(S3Client); + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [DriveService], + }).compile(); + app.enableShutdownHooks(); + driveService = app.get(DriveService); + }); + + beforeEach(async () => { + s3Mock.reset(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Object storage', () => { + test('delete a file', async () => { + s3Mock.on(DeleteObjectCommand) + .resolves({} as DeleteObjectCommandOutput); + + await driveService.deleteObjectStorageFile('peace of the world'); + }); + + test('delete a file then unexpected error', async () => { + s3Mock.on(DeleteObjectCommand) + .rejects(new InvalidObjectState({ $metadata: {}, message: '' })); + + await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrowError(Error); + }); + + test('delete a file with no valid key', async () => { + // Some S3 implementations returns 404 Not Found on deleting with a non-existent key + s3Mock.on(DeleteObjectCommand) + .rejects(new NoSuchKey({ $metadata: {}, message: 'allowed error.' })); + + await driveService.deleteObjectStorageFile('lol no way'); + }); + }); +}); diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts index 6a20a1e08..38db081ac 100644 --- a/packages/backend/test/unit/ReactionService.ts +++ b/packages/backend/test/unit/ReactionService.ts @@ -74,19 +74,19 @@ describe('ReactionService', () => { }); test('fallback - undefined', async () => { - assert.strictEqual(await reactionService.toDbReaction(undefined), '👍'); + assert.strictEqual(await reactionService.toDbReaction(undefined), '❤'); }); test('fallback - null', async () => { - assert.strictEqual(await reactionService.toDbReaction(null), '👍'); + assert.strictEqual(await reactionService.toDbReaction(null), '❤'); }); test('fallback - empty', async () => { - assert.strictEqual(await reactionService.toDbReaction(''), '👍'); + assert.strictEqual(await reactionService.toDbReaction(''), '❤'); }); test('fallback - unknown', async () => { - assert.strictEqual(await reactionService.toDbReaction('unknown'), '👍'); + assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤'); }); }); }); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 6fe04274e..907f1f2ed 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -11,7 +11,7 @@ import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { genAid } from '@/misc/id/aid.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { sleep } from '../utils.js'; @@ -65,7 +65,7 @@ describe('RoleService', () => { ], providers: [ RoleService, - UserCacheService, + CacheService, IdService, GlobalEventService, ], diff --git a/packages/backend/test/unit/S3Service.ts b/packages/backend/test/unit/S3Service.ts new file mode 100644 index 000000000..1dfa22afd --- /dev/null +++ b/packages/backend/test/unit/S3Service.ts @@ -0,0 +1,77 @@ +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { S3Service } from '@/core/S3Service'; +import { Meta } from '@/models'; +import type { TestingModule } from '@nestjs/testing'; + +describe('S3Service', () => { + let app: TestingModule; + let s3Service: S3Service; + const s3Mock = mockClient(S3Client); + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [S3Service], + }).compile(); + app.enableShutdownHooks(); + s3Service = app.get(S3Service); + }); + + beforeEach(async () => { + s3Mock.reset(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('upload', () => { + test('upload a file', async () => { + s3Mock.on(PutObjectCommand).resolves({}); + + await s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x', + }); + }); + + test('upload a large file', async () => { + s3Mock.on(CreateMultipartUploadCommand).resolves({ UploadId: '1' }); + s3Mock.on(UploadPartCommand).resolves({ ETag: '1' }); + s3Mock.on(CompleteMultipartUploadCommand).resolves({ Bucket: 'fake', Key: 'fake' }); + + await s3Service.upload({} as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ + }); + }); + + test('upload a file error', async () => { + s3Mock.on(PutObjectCommand).rejects({ name: 'Fake Error' }); + + await expect(s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x', + })).rejects.toThrowError(Error); + }); + + test('upload a large file error', async () => { + s3Mock.on(UploadPartCommand).rejects(); + + await expect(s3Service.upload({} as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ + })).rejects.toThrowError(Error); + }); + }); +}); 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 37e5ae10d..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, @@ -64,6 +101,21 @@ export const post = async (user: any, params?: misskey.Endpoints['notes/create'] 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, @@ -71,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; @@ -196,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/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 000000000..1aa0ac14e --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1 @@ +/storybook-static diff --git a/packages/frontend/.storybook/.gitignore b/packages/frontend/.storybook/.gitignore new file mode 100644 index 000000000..649b36b84 --- /dev/null +++ b/packages/frontend/.storybook/.gitignore @@ -0,0 +1,9 @@ +# (cd path/to/frontend; pnpm tsc -p .storybook) +# (cd path/to/frontend; node .storybook/generate.js) +/generate.js +# (cd path/to/frontend; node .storybook/preload-locale.js) +/preload-locale.js +/locale.ts +# (cd path/to/frontend; node .storybook/preload-theme.js) +/preload-theme.js +/themes.ts diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts new file mode 100644 index 000000000..b620cf68a --- /dev/null +++ b/packages/frontend/.storybook/fakes.ts @@ -0,0 +1,54 @@ +import type { entities } from 'misskey-js' + +export const userDetailed = { + id: 'someuserid', + username: 'miskist', + host: 'misskey-hub.net', + name: 'Misskey User', + onlineStatus: 'unknown', + avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', + avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', + emojis: [], + bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', + bannerColor: '#000000', + bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + birthday: '2014-06-20', + createdAt: '2016-12-28T22:49:51.000Z', + description: 'I am a cool user!', + ffVisibility: 'public', + fields: [ + { + name: 'Website', + value: 'https://misskey-hub.net', + }, + ], + followersCount: 1024, + followingCount: 16, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isAdmin: false, + isBlocked: false, + isBlocking: false, + isBot: false, + isCat: false, + isFollowed: false, + isFollowing: false, + isLocked: false, + isModerator: false, + isMuted: false, + isSilenced: false, + isSuspended: false, + lang: 'en', + location: 'Fediverse', + notesCount: 65536, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPage: null, + pinnedPageId: null, + publicReactions: false, + securityKeys: false, + twoFactorEnabled: false, + updatedAt: null, + uri: null, + url: null, +} satisfies entities.UserDetailed diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx new file mode 100644 index 000000000..f0865fcc2 --- /dev/null +++ b/packages/frontend/.storybook/generate.tsx @@ -0,0 +1,406 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { basename, dirname } from 'node:path/posix'; +import { GENERATOR, type State, generate } from 'astring'; +import type * as estree from 'estree'; +import glob from 'fast-glob'; +import { format } from 'prettier'; + +interface SatisfiesExpression extends estree.BaseExpression { + type: 'SatisfiesExpression'; + expression: estree.Expression; + reference: estree.Identifier; +} + +const generator = { + ...GENERATOR, + SatisfiesExpression(node: SatisfiesExpression, state: State) { + switch (node.expression.type) { + case 'ArrowFunctionExpression': { + state.write('('); + this[node.expression.type](node.expression, state); + state.write(')'); + break; + } + default: { + // @ts-ignore + this[node.expression.type](node.expression, state); + break; + } + } + state.write(' satisfies ', node as unknown as estree.Expression); + this[node.reference.type](node.reference, state); + }, +}; + +type SplitCamel< + T extends string, + YC extends string = '', + YN extends readonly string[] = [] +> = T extends `${infer XH}${infer XR}` + ? XR extends '' + ? [...YN, Uncapitalize<`${YC}${XH}`>] + : XH extends Uppercase + ? SplitCamel, [...YN, YC]> + : SplitCamel + : YN; + +// @ts-ignore +type SplitKebab = T extends `${infer XH}-${infer XR}` + ? [XH, ...SplitKebab] + : [T]; + +type ToKebab = T extends readonly [ + infer XO extends string +] + ? XO + : T extends readonly [ + infer XH extends string, + ...infer XR extends readonly string[] + ] + ? `${XH}${XR extends readonly string[] ? `-${ToKebab}` : ''}` + : ''; + +// @ts-ignore +type ToPascal = T extends readonly [ + infer XH extends string, + ...infer XR extends readonly string[] +] + ? `${Capitalize}${ToPascal}` + : ''; + +function h( + component: T['type'], + props: Omit +): T { + const type = component.replace(/(?:^|-)([a-z])/g, (_, c) => c.toUpperCase()); + return Object.assign(props || {}, { type }) as T; +} + +declare global { + namespace JSX { + type Element = estree.Node; + type ElementClass = never; + type ElementAttributesProperty = never; + type ElementChildrenAttribute = never; + type IntrinsicAttributes = never; + type IntrinsicClassAttributes = never; + type IntrinsicElements = { + [T in keyof typeof generator as ToKebab>>]: { + [K in keyof Omit< + Parameters<(typeof generator)[T]>[0], + 'type' + >]?: Parameters<(typeof generator)[T]>[0][K]; + }; + }; + } +} + +function toStories(component: string): string { + const msw = `${component.slice(0, -'.vue'.length)}.msw`; + const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`; + const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`; + const hasMsw = existsSync(`${msw}.ts`); + const hasImplStories = existsSync(`${implStories}.ts`); + const hasMetaStories = existsSync(`${metaStories}.ts`); + const base = basename(component); + const dir = dirname(component); + const literal = + as estree.Literal; + const identifier = + as estree.Identifier; + const parameters = ( + as estree.Identifier} + value={ as estree.Literal} + kind={'init' as const} + /> as estree.Property, + ...(hasMsw + ? [ + as estree.Identifier} + value={ as estree.Identifier} + kind={'init' as const} + shorthand + /> as estree.Property, + ] + : []), + ]} + /> + ) as estree.ObjectExpression; + const program = ( + as estree.Literal} + specifiers={[ + as estree.Identifier} + imported={ as estree.Identifier} + /> as estree.ImportSpecifier, + ...(hasImplStories + ? [] + : [ + as estree.Identifier} + imported={ as estree.Identifier} + /> as estree.ImportSpecifier, + ]), + ]} + /> as estree.ImportDeclaration, + ...(hasMsw + ? [ + as estree.Literal} + specifiers={[ + as estree.Identifier} + /> as estree.ImportNamespaceSpecifier, + ]} + /> as estree.ImportDeclaration, + ] + : []), + ...(hasImplStories + ? [] + : [ + as estree.Literal} + specifiers={[ + as estree.ImportDefaultSpecifier, + ]} + /> as estree.ImportDeclaration, + ]), + ...(hasMetaStories + ? [ + as estree.Literal} + specifiers={[ + as estree.Identifier} + /> as estree.ImportNamespaceSpecifier, + ]} + /> as estree.ImportDeclaration, + ] + : []), + as estree.Identifier} + init={ + as estree.Identifier} + value={literal} + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={identifier} + kind={'init' as const} + /> as estree.Property, + ...(hasMetaStories + ? [ + as estree.Identifier} + /> as estree.SpreadElement, + ] + : []) + ]} + /> as estree.ObjectExpression + } + reference={`} /> as estree.Identifier} + /> as estree.Expression + } + /> as estree.VariableDeclarator, + ]} + /> as estree.VariableDeclaration, + ...(hasImplStories + ? [] + : [ + as estree.Identifier} + init={ + as estree.Identifier} + value={ + as estree.Identifier, + ]} + body={ + as estree.Identifier} + value={ + as estree.Property, + ]} + /> as estree.ObjectExpression + } + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={ + as estree.Identifier} + value={ as estree.Identifier} + kind={'init' as const} + shorthand + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={ + as estree.Identifier} + value={ + as estree.ThisExpression} + property={ as estree.Identifier} + /> as estree.MemberExpression + } + /> as estree.SpreadElement, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={`} /> as estree.Literal} + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={parameters} + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + reference={`} /> as estree.Identifier} + /> as estree.Expression + } + /> as estree.VariableDeclarator, + ]} + /> as estree.VariableDeclaration + } + /> as estree.ExportNamedDeclaration, + ]), + ) as estree.Identifier} + /> as estree.ExportDefaultDeclaration, + ]} + /> + ) as estree.Program; + return format( + '/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' + + '/* eslint-disable import/no-default-export */\n' + + generate(program, { generator }) + + (hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''), + { + parser: 'babel-ts', + singleQuote: true, + useTabs: true, + } + ); +} + +// glob('src/{components,pages,ui,widgets}/**/*.vue').then( +glob('src/components/global/**/*.vue').then( + (components) => + Promise.all( + components.map((component) => { + const stories = component.replace(/\.vue$/, '.stories.ts'); + return writeFile(stories, toStories(component)); + }) + ) +); diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts new file mode 100644 index 000000000..1e57c97b6 --- /dev/null +++ b/packages/frontend/.storybook/main.ts @@ -0,0 +1,35 @@ +import { resolve } from 'node:path'; +import type { StorybookConfig } from '@storybook/vue3-vite'; +import { mergeConfig } from 'vite'; +const config = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-links', + '@storybook/addon-storysource', + resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'), + ], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: { + autodocs: 'tag', + }, + core: { + disableTelemetry: true, + }, + async viteFinal(config, options) { + return mergeConfig(config, { + build: { + target: [ + 'chrome108', + 'firefox109', + 'safari16', + ], + }, + }); + }, +} satisfies StorybookConfig; +export default config; diff --git a/packages/frontend/.storybook/manager.ts b/packages/frontend/.storybook/manager.ts new file mode 100644 index 000000000..5653deee8 --- /dev/null +++ b/packages/frontend/.storybook/manager.ts @@ -0,0 +1,12 @@ +import { addons } from '@storybook/manager-api'; +import { create } from '@storybook/theming/create'; + +addons.setConfig({ + theme: create({ + base: 'dark', + brandTitle: 'Misskey Storybook', + brandUrl: 'https://misskey-hub.net', + brandImage: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAABgCAYAAAEobTsDAAAACXBIWXMAACxKAAAsSgF3enRNAAA4fklEQVR42uydaZBU1RXHX/frjWkG2ZR9HXDYkaCAEI0awSX5lA/G0i/5lLXKUoEwgAYjoIPgCMNioUAYUEipJKYs1mFREWXpASpEU1gMZaosNRY1KUsQFPDknMw7cOs/78573TSmwXerfvX63X057/S9r8+97RCRE9FMyVYs6oxS6gx2CSZu3Ltyz8QkrFQbUSwcs+FLD42npYd+QF4HJBfsSpIwb/d4enbvbeIfU4gdfg5RYH3Yimne33zzzY1YXmtpzp49O7EYnZFYtH8ILTlYyfSlp+qz9PT2LFWtT0hg6r5pgx/XynjSQ3I10UprxfEe/c2wL774oqN3m9Myzp8//wKWgXkY6ShE+aZrUn/fzlh9tIwW7etBixs6M+X02F8yNH29QzV7xghm410vo4Ty7bffbvf8XA0XP71XP72in9lYLYNJsGT8WvwgrXTUr4z4rnLmzJlJmDfeSxzs3BadseJIioTaXBsmTjNedmjGOoelpILmbqsgiaOV9DJJMim5CuqHiP+ePXuu03C9wueEYHbyxx9/fL3kr2ga6SCNB/mkWFLuVj9vMJJYlsTRthidHzM7w11+0CVBe3Lm+ubOmPW39jTr9fak/kbG6dOnT8/Uz0eOHBnM14wgfuovnDt37jW990vrkTI7eePGjV0ljuan8Xw+Z7jjbparpNE6aNiXX375O/O+qanpHu3cFp2hsGvDJIz7jJcowZQxcR0NrYwW7KUta4U2JupP7Ay/jHaKmT+mhTRB5WYVqIe2LXnhMc1Lu9s7QwvQwtt6ZE00vK6urgd5Tv3NzgAy2BHr16/vjg1uhbaKpazCO0MfE8voWSpmqThWUKVCsUtHWR5kFewIlIySnQBF0/GoM0pzOh7r0ieeNQMrRjqD5dq1X7aDhJdqI4reGUsP3cLrkjE8FR9xXu51XdJrUKxbDa9Lnn57HIm/z7S60Vg/OAFQHsoay2kKKkPTXHJnLNo3kqfgQ7kzKnjG2Y5kXSL0GRrv++iqsX+Vr1SoJK4XdJEkrhHWAtV6r1frOsW+/pEpeBXUwbr2wDBbOt/OkHUJwx3SlelAszZ2oGkvOdSzMlYp65Jn3xlDUMmEonN+nv6+KlfBb93gc98IU2LFFTRvLU/j4hrGrwwemOmWcjGPWIu1ia5Lat7Jnq/NJWnqKodkOt6jMj5I1yZaOK5L9N6Pr7766n5zTSLrDQ07ceLEWCN9wkTzV8w8zDiCLhI1XlCdNA8yp+LmdNxYl6SZRNVLzinpjFTGaSfrEgEWaSnbOgGuaduVG3Fc73ExZjQs09raRNciWLauV9DPLE8lxnehxpQZ93G593otyyRh9gnrEibPdQmL8k7LrLBl/oouvLBMOzgLxrIS2hmXOhXP4FTctjYxK0ee40Y9DtPkwHWJOImf79rE0iHaGW5enSHoY4IdUtDaxL5wSlmkowjrkpaLNFMySnYCFBEtTSIiwYi4UvD9Dp27ZczBpYd+xEvYCcw4ncQ7SocuTueaN+OkcHhi4f4fn15y8G5e5d1Ftz/Y+2fWn7osrtjvB8ysLwf6Ux+1dI1Xw7sOvwYnWBhYIG5kbuCBHs4Mpqlru2/lsNS8HRlC+gyN9ete2WbErPqRNGvbSH2FH0NInP0nyXoVkCsBW1tUWIpVjj5Mxax7wYKx5lhbEmreHUCLG/qxYPTma3fmOvrjpnY0Z1szj6xyaMoah3oOig2s3X8bKb+sGb4SKhMXjM50/ZAwfnczz4yP74/0d2h5beIXbn623YeNwwveTsY94tJF13T06NHO0B6skxLDMqUcWx0MwYhj3U+dOnWTcd8U0H51ORDuar0PFIxVH2ToAu9naNGBTiwU1zBZqs2lGZemskDMWO/QdKYnv/p55s1hJMzb1YzZOebA21636BsS+TlZP9uchGMc7sBnxE8x4/JPzGP0M4L5obCiH6RPIma5CtobmO/fWMAbsG0mJ0+evAv8Q/eNJR9XAeGJKVbBePFwkgzEs/yhFxNv1OYcks9Va5vfBerP8z0q3UGzN3Uj4cmNzRhrYtdnsFKIhpn3X3/99QyM99lnn42TKz8tv7XlYcsP49vjtLRfMIG0aQTLYkOQ34BlTFLD2Cxhh372e4UnpgJooqCmDJrGjGurg9hfmG2B8XBROHwnVfwmfMTzB2IkzN8e+5ckRJOFx1gghJkvO+KRmfgL95GZr6ZIcBNOW3zCjA5FJ1piheQBtOGGPkjg8I0XOk0v71X1XpFyNB4L3cpPP/30J5onvCNNCzaB0nB5t2qrA2IL88tjw4YN3aCdodpvvlqFuBmxmfGzgZF+Mt7yxS/LCy6cU5hvBwWUcBQEBd7WhaEsCGJ37NixCWY6MAvBt4FCOgCoO5CnXU3INmbDgu3Ctql2VMEQLqdgKHHUGH6mJyYBgtEWCOiQcPY7SoBgpACbYCNl+RBS6LPh+8UuFOEE4/JrDRQOJQ1YhKQoZJV8BQLnF0qQkF9Wwv9Sh23CdiUEFYrot5KI6JV4RCQYEZFgRESCEREJRkQJCwa7+IBRZSP56trCr+vllKnBtOfnDhzd8UYJK9XGRhQsGLrN+XZq3lIwnsraxTugUJi2GFVr4rvYL7m4YRLHv5tqc5PITzjAUv27sGEQN+1y2mPY7EquSnuMPkOS3dVAR20yZOBVKBbsTJ7XfScK+2cXvHsr1ey7nebvuYU0vmDbK4/+GFYkwXCKSYg659TvUttillMSgtF3WLqCNx/JwQiyAYkZylRKTVMPPZ/a5GOoI2FtHt86nJ6ov4Fmbh56RrQOdiTYISDaActL1TAnbFvEX2woilxOrJgUpjGGpSpeONKDag8ME8stZiDTbKwzd5tsyrrInC1l7FdGIjTpMre8dv9dH6L1VtCBEhZDk5hgU9N6mERr4Xgv5yeQ4dDSSgfUL08ABSBuQ8uxDDa2ETer1Qd8VTViezBPdBimG9rCaYzh8Qq14Ko9UEFygsjihp5MV6Yzzd560YJrSp1Dk1c7JMJgWHDJfcLSmVbrLbFG8rE4atJ7DQ9r+WSzxML4WJ4ONsZBdM8XGt4oKOyKDqjdCs1+j2VjPGyLaeGFgsU2Hfeh4AcKhu5YrN3XmWpz/zPpYzoy19DC/Vl6YnMHmrK62Xpr2tpm4x3ZyajwfVoLs5+60hJTcGzWVNKgICHD9DrBDRknbjera2nJpYMvDuwbrCfEsLYboAMcrh3BfipwGo5xVEPYjuZRWhEMt8I07at5txMLRwc5asc7ZSZJj650qEpOmvmzI1tcJfN2hmmf3GdQKEAwkog8dRKuZmhB5mtBJnGmX1izPikXj+nJx7RPT8yRATfzlbraBtVunmive1DfBJkXKiD0McUyx3ArdFuvwlpCbT154GPnTNM++SyCIdt9jS2/GdseZb+fsMFWMglmaIGYal398LMMmt7b4ki5NntLICnmelJ3RC3HUFgkHWgUBrY8e2779u1d8jU3xHCMg+1AwQg07esz1K3ws/mU86imvhTfJp9nrGspGKbNJwoGSG0KkY7AML+4x48fr0RbSUxj+ywuIL4Khg6CVUAhbdoEy7LvO29ptwrpwf7Tam6YDlMHFDDUIoGCkS6LZWXfu/J8zj2pg+td0zNbCka5ntclSBzUFtoxOri6V138VN1C48TvP2g3aX6W9Gh/aabH/LxzvbDzMnqvxrQWA1oFhS2tdfr888/vNfPBc8iwTn55eF8pG8x7PUtMkX7RuiveHOKgn32p2R+opdFIxyoYkkiMgBVZcUC4O3Wl8281Br62V6xSCp/5mhgCNyOForbQioQ1nvUzdjX3/H/00UfjMdzvgDYTMhzaYUL6tCBC3JoxMIHztM4DUC4Ickv/1gyepc1+YSoc6uRerbdEQDRukKFzaAsu8WRSXiEZy+vthFZCzxDwCst6VzesMXCwQXB48jWFI3ZhzOFQKMLbfOLABNereHai9nah5vjeWonLqSTEzsxTnqrvykpcvx4iK/ECBaOYVuKYB/m4AIvqSxYMKCvwyS+QtkgpC0a0fSDaPtC6YFi0hlVzFP/rxC4cIbVFykK62AJS/C0RwUKBc4xoX0m0ryTaVxIRERHZAkdEREQKIyIiolQp2YpFRERECiMiIuIK5tI2YhVlM5c9ztWy0y8i4mohnwc8Ub1j3D+WHb6Dlh66jbmV+SF17ZfsFmZvu8Tp1t/ptfCtGJnMft05rMfEeiT5mPrzSw/dQ8sO30tynVw3bg37uwUojzuZHNldPbNc4pXqAPkwmmlilpdo/fKlP1PN5GBcppVofb/XOGEedCZlKAlmPHMzM5a5iRmtlrJxv/SxmJN69s0EtYb+Ri1/I8//bUG1uYnMJFp0YCI9t/8O4m30Z82DOGxYNnU2MfWIuX0MN4CW6oBBVV8p0TpaMTYQo2vUcbnCzpXob1bzaifMALt9h8uRCBNQSTCjmJHef5nI1sSBn+iWAY/k7DdSn8zfmaYg5L9OOH72yR2j6am3xlL12zdT9e7xJP/1OnfXGLrvD/2WeXnHUAD9QGELAwqy+kfLo3CEHJfl5DnexVeB4Qhsyehoi/f/apfsa44UBmwdkaMyljTcREsaRjOjWDmMZEYww5ghzCDmemYA05+q1nXcPaUus7d6exlV7wiH/P+xWsbxUJT/dHKXxffP6bH5hnvaPSCKBGcw+QiapAuiQGWjAqPfijk91iIM3kzmFWOmUy9KKyiNbvYPqQDrjbyrzHD9Vsd0XppGLBe+/ZdbHuDij4s9r5wQplzpL509FjQWGDecWx4wPlXm+MCYWsvVesEYW+ssilnj6HL28iqMoamKtY3lpDz33gBeKlTS4oYLSkLP2mF6sX8Phrc2by6nOVvLee97CzgsSw+vcOjhlQxfew9xRWGk+o+8ZujihjsJOKezixDEBRTMfPAGkozBibc2E5H4ODA6OBZBN11O8vMUTzWEVec7c/LJ4xXJ23tgcua3tS0f9Zd0mJfXNzlsf9B44NE12L+FENDP8MBAn17iWEgcwZQV9VOkj4PGXuLZznmypcU8sB7iQPmRhEs88feToaIqDDlap+7DLJms/KCcFu7tS7W5PqaSYLow1zKdmA4c3p6e2NSentzSnmZvbWby6hhNrZOTVppPXfl987/sVcpMYuHeCeQDK6kJpLOMMIIJneLmi7FDeDvkmwujiCQdxAutyOSYHYgD6a1hjZYwy0MbXIbUpZU2vlqAUnYFPR3H5ox/Q3SBOJ65BUcEITFQLEUbCz1qKWwfQLvyjodK2vJlNy/MDBnPT0UuUWG4FX/6ZxvyY8E7XWnR/i6sGFRJdGTaM+2YtkwZh2UYOY0nQZNXsYJY69D0dRf/eVEUh/z7IpdVvmD3KPJj/tuj9MWoazbMJpB45FA+6D8t6l98+ikSEdZ8lQ4KmPFPkW5YWlOE+eTJ/wP386B8JI7Wu4CHxbWQQPDkIp4NvUg+DrZPtlA88remYRRsMcYCj+ASV+i4IVIPjZtnHlifuI2wy+4C32G4FSvfT5ONZawUntvbnmoPtFASTJJxqWpdTA73kxmFKAk5y01AhdGueucgMnl6x0VAYVgE1KowkiFJ6NFZqGx0IPN18k2MDwYfuDNQp4w2d/DgwWtbO4sNHzTwDwTjo7/kWYSHJWEhGZYA5Y/j5SrS70H1K2QsCul3KtAFj729PuEVWPA7o7wVxoq/p6gVJGLHmvcyPNu4oCSYGOOQhE2rY+XAykJP7DL/wldO7urhKYy5W3uTyZwtF1CFkWhNUVg6N+lDCtCDJJ+BmUFSOXHixFjI87Igfz1snkZme3hsD5akt+UN7bTng+cWKoUrKazjfOn3IPToPU0XUqnoLNH+sBc+Fqn/snc+rU0EYRjfNUk3m9RSCmJVsEgQ23pQEfTgP1QQBC+ClyLoB5DSc7EFPXjTi9AiBQ8VpXqIHuo38OTFinjsd8gt4KXrvMJbpi+ZvJvJWLLpE/hRmp1/2Xnn2ZlJ9llCOrN18lNU+k3BrwzRnrJEEQxVOHJuepYaa1vlrAtsKJXY9+Pb7y2+l2IhBaN0hgTj+dcjmc2zzV0UwXAruhaQdsdrAd1LufYAlu9J/0m9Hr1+qseaHcyJ4yKduxwhGPy+FpwVBdl+bmPihq0c9XNuDygyXe0iniM5+yJhRP2JfE66fUwiPUplPHTKIwQyYfTzoAuYY3yUmBCCQZmrb4zhq4vrDw7N8bcYju+tK5fuxo+WPpDl416e0hLlXbTrf7X8pZ794/Nenqyk36QDoI28crCXZq8v9v50wc+El8+iJ09PQh6Tnpz0THXpNU51UuDRXzk1lvU7jok0+kv6f8r80p9UC05FKGwSMrqV7eGBS3R53n+iQWmlV6wLs+cx3akvqB/pr7TI5D6UiM3IH4Qrj7Tc5PooTrT6lLa4BGxE4FrmScFgdMGQsAFwtR5NvNiMf61+jzLDzp3HMQ2GmhQLl2hQ2qv34wUjEjuGbP51vDWSRuNUtjXgq9OXo1uLH+M/i5/ibGEt/l1JojEWC21m0S1QFao5SW1MBz3kIOegZ19aDTNVnjVXm7eUh4PNXB1fNpvNY1o7fNor293JX9ZVjuvKmR/3eadBw+eABy6dBxJXOq6hDyodpS9SDUpnfwbKq+WhNPRZWTyo/o2NjeP5rWr9+03MOBhNOFgwdKQho1VoHKgMmaYs/eOUpUhFoAaqgodnb1BSHyjoKHi18q3fDjTz1OshIL7nP/Wkag9Yj/y1/cXfg9K/v9wzjjyCMdAWfXIqFFowAghFPTC1ftne3r7SwYV/2fzu4h5BYmIfo/+1+pUA9KZfobCvzHIZE0IYAvZjPTR6f+nCoQkGA8H4/4IxqhA0QFzp19fXT7Tb7Vf2epqM9Vut1nyP9aSCgRAMnk2xaJh9kaVQV/x9ZjQPEAzPm5cC7mFUuzHAghEkIHsVjAB7GkGXJAVYKohywsaLr1BAMJSZRr9r6gIsTbyEwyMAg25+FlBAguIbF6GFYmj2MLSlicdMI8i3JgUI1LpO+BlF3q9VQ8z8hoSaRojN6LybnUMjGACAAvlhAAAABAMAAMEAAEAwAAAQDABAkRjYhgEAIBgAgAIzsA0DAEAwAAAFZmAbBgAYAsEwr5jBk9sBOFjkTsg3ek2eKo03LqQzlhlv3KPbVrlxLmocnYoO800undKcvjhx/uTM2CSlgXAAUCDBoEE8NZueXf15O1vZumm4YbiW/WXvzIKjOM4AvDu7O7urAySbw7KEBJIJt4wPQGDHBocAhlRsUqmC4IeEB8AkfsAHGBD3fR8CY05zGhwuGwfCseJGBowk4wBJ4Yp4igJFqOUBqniIw5//X22L5md7ujUWIAFd9ZXXM709PfN3f/3PLDATvuxwogZvVPeP/8LzzaKjXpDJbOlpKcSD+AZNaDfuk+/ehGVn+8ZYWt4HQin+ZCGWGrIVnEsE+biuBsfhnCqQ9Drav5oyFImwt7ivQHLraH8fa4xkEQx7gsvOvoGS6I68hryKdEO6wpTd7cuENJxkMXWXdX7BEQsS4fN77Ko3rDXIKyr9JUqiN7bdJyaLJWW9YfGZnsD+oWHtLQ+4KPQmrDp+KxQTYH1+Wzh7kbJRTJ7cooKnXghDrPoTv3rhSDyrQF6Jy6IA6Yy8DINnZC6VpcFlMWSOtWr+ET+oGLXedwDrhcbt6lKy8PQbMUEUlfYCksci/LzgVHdolBVqQPJyKYyIgkQlWocHZvRREIYq2yMUIllRh4UhZ0aeRx2TyRcQWUWVLLoiXZBOyEvIC8jzUPDr1P7ieYMsm25vWb+ddzgAOrBu0pRI5/NzSn4O80++DgtO94iJYh5+nn3iFWjWLiWT2lMJgsPfLO4Evdk8kTQYdWHApstvE4c6Oqg0camQ3zyuqkdvLwep0BvYnWL4sM6rvgv8fgjDTpBVIC8iHfG2IR/pgLSDjDx/rpAG4svI9ebOPRQEE7B+cpf+jX8/7XAnmHmsAGYd74Z0hRlHu8DUQy/R/lBtCUP3Hf69JylxzTG5xmyfVhpPhFFvhJEwq4jLoj3SFmmN/AyC4aoXG9n439kHQ2AKZRjE6K/bXp1wIB8mRTrCxMjzMGF/B8jvmfYW9QOxeOAMhWHpUA3oJ7igFuNS1+NR1/sneFDCsBB7aXlnWFr2MvIiSuGurAJpg7RCWiJ5SAsYMMaeO7M4CWYdNEZ+XV9ym9dT+g6Ylrm7f2HGajvsTZff2v4AhLFC+u4w3XGofel5SIRub1gd0++WIrMMUm+qV6FrG19j+FT8XCqQKLKVtknHHgZYxDa+qtN+p/Nk+90IY7ZJXHS3kdRfw+NWIFtrIxainrroBUIPc/nxDM4hIsWej9cofTYZo8jQ+yqMojMvwqdnW8Dy77Px7ewdoKi0HSwpq84qkOeQXKQ5kg1LSrNg2r5kmBFJhpnFagq3BWDcDps+C2EEkFCPQTmDF57sdX7c9lcPP/VsOEvOLgwGh0VohGG6qkXFNsUAVBQ2kF0MODGROfzXA0WdKKhLlK2MEacVkyaZm34KNBmDpcd95sHkyK6b+1iAYaGxpxCFsggh6GJPcnA47jAhVVOh1aowVl9oDBsrUmOs+6EBLDrVGqVxd1ZRLYuyLORZWHiqCUzdlwrTD6SiOO7l/TUeGBFn5EafEIa9+Ntf/GdJWU+Q6TW4+UCRYTwwYfBBrR4wpRRAMTC5NHSyoP7R9+m7XEKmwuD7Fe2PlqVhIgwmi4p4G7OAlRoKI8qF7Abqj5G0uARrIRYUV6pH8J+AZVTi4kKhTIb1JarJzobxPlOsuOT4GKW6XE73RRgb/pkCMp+UNYXFp/Mwk8iNyyIHaYaTOxPJQJoijWHusXSURgOYth85cIexW234YK0HPtqArPfA+5956GBJPd7J+kPRt90hESLLMB2YUhDHkGxqgtMqiG/ZKhb78G1bnRSDOar6PguWxZEHIR5rG9/PhKGYRGpR4vaV7A3ixYna101qGoRsRbNM4NdXPlecOL0M4mMR+OrHp/k5KPCyerUQC3cZk+6a0niSrulKvl8XFyYkpUxNZP+ThLH+h2TgLCjB25MzOSiG6qwCeQZpgjRCnkLSYXqkIUzZlwZT91cxZW8D+BAFMWqjB0Z/7oGPN6E01sWEkTzvWNcLi069AonIaZ/aDOv4H7YwVAE1qscnvP67szXCMJYRF4uJMHQZAG/DEB8hpOpQovgqxAE8NjWdrJS68zruY+FeGCRYXT2+IJkdS11H9TyNssT7Kox1F5MgEQtPZqE0MqGotDqrQJ6OyyINaYCkwOS9DWHS3nSYvC8dPliDstiAstjsgbFbqqQxcn1MGKnzjnf614KSzpCIFvmp+QphqAakLAx/TWCB8QmoLbZdiTwpHUTicyszWo3d9o1gk10lS5+LWzeBT4FfBgf1KtAUfEfswESv8sNbg3dFHSEXXR9rJxbq66WCZSpGbXJhmvTXRCokSCYMJa6EsfYfYUjEiu+TURpNURp3ZRVIQyQVSUaZhJEgEoBRm7wkh5gkxmyJC2PzHWHMOdqxct7xFyARzfOTSRiBhyMM88HBBzMPHEvDyy5evNioNoRBK3J9FAaH4sWyDx5Hn2l/MR1/jq22li4W91sYdNvhJjY89nVaGJ/9PQQqFn6TDotOP41CuCurQJJwWwixET/MiFjw0Vq6BYnL4ot7hTH7UPvKOUfag8zsw1U075D0PAlDJwoHYQRM4MJgIjEuqjbKy8sb8/3yAKHV1ERmN2/e7K2WnLkU6ZiadtxNFr0gAjp49iHiI0MrNuuDsURJEO5jYX7dKVbgsuhizzGJCQlHJRWNOMyEseZCEJyYfyINf25tiGK4J6tAfJiBWNXPLcbQrYhCGDOLW1XOOtQaZGYerCKnffiREIZpKk4T4XEXRlwa26UUfaziXFiqbn4bYB4L98IQ2aZJ4cetl8JYfc4GJ5aXB68vOJmCYqjOKoQsEC+MWOH996j1KAvMLgo3I1vibMZtn6NI1lUJY/r+3MoZkTyQmX4gBgojJIShS339BBtkAY7q5bQomDmq77JJFqgtrl271oWOlWDAlPG60tPvPmw7W4kVaM6Ft+NudVULAgfr8JKSkiZ0zU2R+npJcS5R3g9aKDR9cxELdd+oiHHEoVjx2Jihjz1HFxONMLTiMBPG32xwollrK6/g7cDgRaeD4hYEsRAPvDbQGtK0hTd39EbMKIQsFMKYuje7cvr+bJCZtq+KnHbB+ywM/cTj+2of8z7wQUMDuq4Lg08uU0Aqqkku9l+9erWlSd/cxMKtMKh/j5UwVp0NgBPZbawcmvAtOlpdFp/x/YhZBWYbnv+1KvD2oO0ZeVb2WBRDoUYYk3dnVE79awbITNlTRXZbWycMP6EQhunAjEoPyQ7y/XJ6bNIeBTZRG5hS/s7pe5cuXWoljsPbUG2nNsU++r6uX6rzVE8Qvdg02EJqrP9BJ/bs2fOMqq8yTIDqyWUeiyCBzzlai7ai0eibvH/sGgQ5vH98PPL64pj450y68u2sH7YbgfFnKnzB/anC8CL2yu/84ABOZisL64XiJAnEtoznrEyRXTgJY+LXjSon724MMpP+EgOPEch3LwxzWTgJobi4uKlpuzS4RV31NjXmwtBPdkW9ByoMfv2omAhDjsuVK1cKVOck30rSMwllv8xjERTUljAUxwuq2rx161ZhvRTG8jLfjyvKfaCC5MAekPHPSU7CGLnO81+skzJ+Z9qlibvSIMZXd9Osja8VE4bq4RpPuS+pAkQXXqx6cqFVTTWAsf51US++CoQ4N27c+JP0IK5cbMd0ua/Ur+u0TTdJxDaTwYsSGaQbwGIfE0ZINwFMbxU4GjFHpfZDHIqdqj+G57ZK3s9jb9IPOd58H8H6F1Jx7ty5Njz2pu3xfTSO5O2a+NkM/kzFT3BhCNwII9B7sDV8eakFKvi/VcH+VqkPCRVu8sC4zQyUxVjcXtDPO4Sk8qvh9szxXyZDjJ13I6RkLAxXRT8oeVAp+LQSUBBJFPT/TkFn+ylTmUuDl8Cn6avZgN+uEwaHtcGL+LMAf3xYwuDHkMVKfSFM48JJIBhbl73wWFAcCX4dMYPZwWNJ8Hpy/2mfqq7YrzoejaV6JwxpwoeLSrzRT894gdMkx9tc/NzpJJ3GzbytEwnjw5Wey9Q+EkSSCrfbEGPbHQbPCOwUUtLdirDf8E1LVGQLBoQJPvF5of2iLocmB2gKPpia57QC0YplsprxUl5e3pb6wISRsG98QDJsnsVpCMpwMfDC2g6ZQJkhX8V11CAWYRW8Pr+2Mij6dwz+dux74rY+0XF27NiRwduVxge/bkGGeEbGhGEoDgAwyjKQpH5DvSOXnfbcRmDyTm+pHfY0jHfCZyCdoB3ypL1X5C0vRFEgt1/9jXcEtSs9jbeR5CHzvfvH/NkLyO1ub3vfFXVMnl04rWwaQoaEZWgwUXDEIMVVao0IqA5c6cfJE4c+04ASA6A20A1y6gMfeAKdMBjGwuC3UbR6CwGL6yCvsG6EwRYALZpYGMWSzkF89/Lly/14HS4OcTx2zkmMsAa3cQsQ7oWh/7UkgITYidiIz/A1A754/TB7KOqv7tD/2buflyjCOI7js6NTuzteOpWlFz0YFBQUHiozrfwR0tFTkBkJUYfo1KFAiMgOkVAudMsu3YrqECFJ1qFYLD116tgt8Q8IdHoe2Y3pgXHGx8fledb3wOuyu+58Z+fZzzzPM7PO/+sJK/KVxxpSAiOhK6wdEIUExRop6EuvM6ossmFnWa9GgOh+/gUd6pyH9udVe2FGRYXW/kq6nMBYYKg3GVImNf3N/r2QSwiXQHmdT2CsT375ZQ8nrU55NIx9sYr1EhjxIQSBsTWBYe8/GxXFKYwGhoGgCA0rblZ8/JpUp7jK8qDyupT1pweGDv2gSJ8XMhEMBvdjqEs/QNKDIy0wqgiMOg4MOYZWZ/bFWH5IEuPjO+rzcr2uBkZ87K9OUBIYBIZLgdGUwnQDCePEkORllGHZwHoKcTYEhpwcTDo1a2KIUGNNWRAYBIbRwFAtLS1dED2M2fjwQ/xc++H09PQ++bzLgaGeZZDbWS6XD8h6CQwCQ2HstGp+PRYHhpEGqREYmzrNanrS04HJSOV9zLYX3aAgMAgMAoPAIDCyBkbWoYnuEMWByU+t4NBogEYv4HIwQIzSbRemg6KeT6sSGAQGgUFg6E1+agxNjFwq7kBDDdOZH4Jk/fGZiaFinSimMHJBXdYLtggMAoPAsBuBAQAAIFlbGAAAcJe1hQEAAHdZWxgAAHCXtYUBAAB3WVsYAABwl7WFAQAAd1lbGAAAcJe1hQEAAHdZWxgAAHCXtYUBAAB31eJ+AL6UdL+1Gt2XwFdsqIas22LrTgYAwOkORuwg3JAPc8Xz11qu35/pnJ9a6F0tLfZEUws90ZPv3avjrw997h/dfSnY6QWxA3XOdA078l7Qf9Ebuf3C+zj5yVsRojVz3srdV97XoSve1Xzo5eM1KKqdicajg81d42+6308tDP4pLZ6LKlYezJ4u9422DTcG/r9tobMBANjuTHcsGls7Ch2TX7p+lxZ7I0F2KoRTQrdwUugSTgjHhWPRxIfDP5rbgpbYndlyujVUOwN72732e29zPx/N+VEWE+9yv5rbvNZqDTHB8K39Nx5/6xMdo35hQNQ8qBhYe1y+5uazzud+Q64WHY2n0dYsy5X3PmNrg3XMEWFZUJd5YZelNW8XY8JMpL/MC2OWbhtQHx2M2IE9ODuy53LpL3tn09NEEAbgfu7WxWoCHLxZKYnQSgVaKMVaFUu8q5FfYLyZKCEaI4kikdpSisjFxJM3CV68aARbq4hCSZP+gJ692mhM7Ude37fuJhvDst2ykAX2TZ5sM1+dw0zmmZlNVl4qkAAygPiRfqQPxl62v2FstdMEi7A4K5UbljMcGn1hej/z0QyNELpqHBF/Fnjk/smpxNoQPM2EYW5jmCQCufSPLP/ENMqjMon1Ibi36E9TfbUkg+qLAYAF2N1YKBQKzfo1UANsHUsa7fO+Qjx3qtXqDdihwLbviv9Lny+KZS8vmhdejfZTRyGqnVw0HTVy89nQHwVSgfh4enl64PLt1pv1LND/XWEwV26Zx+IpK2yH6aT1N2c3HBa+vz+VHszHVs9C/Os5mFm7UBOI2fWLMJshwvS7lpbAvPi38zC9GoLoShAcHns7CRdiVl0wNo+8IAFKKZfLwwAQQfJypxulUsmn1YGsUSIgEfop0e4gGuNbxfNKpXJNbg5RGbnTQ5IYXTAUEYZNQqN91VGIWqcXjKOLbatPKgSx8ApSgXQjp2E+60G6YC7j+tkZsPWLFmmTxLsRVnfQNBBLMr9iKQbU4LjbeALbbSKuP+t4/Tjth8inADxZOQPRL0FA4eAJ0ZPSanmRz4NAZSeWfd8tjJGrVzCUCoHM7kkVZBbGDal6+g5OkjByh9B3Z/KoOV9ICqTGcaNSTlBdakNKWMRlD/J8qWeDowvG/kVNwXBuQyqQU4gbcSGdSAdMvnXk7M2mVn6xtiBm/mm1txhaxhfZXDTJgoqIBYNjbKYjo69cuQdL3TDxoRceJX0wmeoTQ2mY54WHyz0w/s7z45jT1oZ1WeGqZy8KhgC1CxJRLBadf9s79+AqqjOA3937fgRCQEIgCXkQSAIkVRGw0vqY1rbaTv9prdr+YztTrbY+8AFEFASBAPIIOlMBeQgIFkWtNoAkiA8imATGgm1FScY/yOhYe5mpIuO09ev3wS4uN3v23HPP3svuzZ6Z3wzcu3cfZ/f7zu887s1ATJgezm6wUmFIwHYbZXw1KyY9wfAEY6Aj9WF9egQJVUwgwZiCYjAZuUxjEiIkFcg4ZCxSozEGfrti2HpFPTNtEfEHfdFfLwqtW7w3ClkABUOt0KZIIvpUSXFVuP7WNZX7Z7XWQtPOOnhwZz0QTURrHdy1dcwHtVcUXEPbImHG6AUn4KQFQ7UJxQiJBGvKRO8Beng4lVzEC02LGKYYe2j/Tq0PZ8AXDKeer9PJW8EYPSFU/fihSfB496WwqvsS5GLkW/j/RlwI2aAxEWFKBaFLBVKNVCGVSAV+bjQ07y2EBbtjsHBPDBa1naW53V5G158RjIhxsacmGnFkEFKIDNEo1F5LIFGzkQu3Cwanl7FdcnSkB9IvSenEbWwMxEsb1UMG96sn09EfhuytBrHSnCqCZtuIXgPt0+QZaZO7n/bDOKduW+NFvkHtsfF6k7yOQO7jQn5xOivvOTXncGJGn7pLQnqlh7Zn5QTBuk0iP3efYIwPVbd0jocNxwphc2/BOZ7+cBCs6qqClQfroaWrDqWjDmWhFiGpQNhSgYxGypEypBQ/MwpaOkvg0VcLkAQs2HOWhW1izH0lAnc/5YO71n7DPesVmL8rBuXjUwQDqWosnNDcftUR+qqqCXh91/7ryhvLf0bbpgqGDUlDNSIhGFLgMdaASTl16tRl3IRr/yr+JAWcYMOcBOkiPn8PJkVUVASkgrtIl3FObZlcg2EdgnR9ysUHG4tnLqk/u7nEQtZXZ0mmkg6IC61BtbUkdVF3Ts7hxwzJPEgUPXdQLINkMbseBwtGoHrT8QSwWPveEFjeUQMr3xmLDXINjmwgh9KSCmQkUqIxAimGZR1DYd6uApi/m2QD2VMAC9qsoW3u2+yHu9ehUGzwwfSNGhtIMHxA0lFWp1bpoxHhmD++qG3akce7roE0wOu6+lRV4+Aao2Tki2CwEiMdmyMYejAkMw1a7dhJzjSNaILppvNKs9eT5IiCvGCIJ+XtvARL75s0OqvtEAzG+pzVep1aPAfdNk+5qTys1hIZpzXsEA6Z86Fn1e71UvQMOCsu7J8icUbO4cdMSiwm6RhCx2fHcE+m+6K6cIVgVExUq5/+MA6WfBCHlQdKYcWBKmjprIRV3ZUoCxVIWlKBDEcuQoYhQ5EiaN5XCI/sHAzzdg+G+a+eAUWiP3NejsN0FIt7USbuf9oHD2z2wYwtBP2bXjv7Xuk4ZSytpSDuWdf4Yss73wERlr897Z/BiBplfANGOmEyAnoWHSvbgEn5+uuv27k9SHYSUkVgNbgY8GMs6quHkchtGcHJ8H59X6IRWiw3AiV+D3m91hMnTgwVORe6X4xrW5OFBt1PHDt2bBhdJ2RWevCzz1GcaRLiF4BzPzjPMh/FQv5vdWpccDou3M87OecYYkZ65IzuE28UR3LBc5tI+3RBBYMEIh2efLcQG+JyHM0ow15/GY5mGKQC4UgFMgQpRAYjNAVDIxmDYW4rysauQpSN85nxTADuXe+D+zZ+IxYzt/pglsbMZ+h1ev+MYIyjNRXEsv1TT6x4eyqIsLxjKlQ2FkzU12MMZMGgQBIITpmElGRtz7L2TI9NCZVgJ2N5waB64523fOKVFwx+HYgLVLYEw8inn35aQ6Jm0/RAD8UfCYyIYBAM4ekRlLUiKxF1alzICoaTcw7BEgK7YpZkQbRtYe3LJYLhr974fgzSZf3fY9gYF8PyAyUoGiXQ0lUCq7pHIGlJBVKAJJA4EsPPxXAfUVj8RgIWtA+CRfsSsOi1MExfr49aaGLxzFmpaNp2llkIycaMzbQNCYZKglFALH3z0r5l+yeBCI+9NQkqGxINqd8mYSGaIC0EI5BtLBonvxn0XjZkiJJnutNElPScJmXY67iW3hOoN73B8Gej7uh4vGtgnY/dU27CwsAnkC7YCE3GofXb8NyWCIx2cGKR+3wkGSM5fg2ewHbzhNGJcaFDsQAmhfc5B+Qcy/1y6la6fjKZzsvGdFROBWPDP6IgyhNdg2FZx3BYefAiaOm8CFZ1DUNZSEsqNLGIIhEkjISQIBKAh15QYLo+arHJMGpBYkE8myIYW84JRq0mGIOW7GvoW/pGI4iw5PVGqJgYJ8GI5JNgfPHFFz8QCRpWT8KOpPTVV1/9QqTnl05jgdscoqQrMAwuJRgSn5GCrk1WMPTzyTfBSFdCsKe/ljf6QfHCEwx9NMUqrjiSsFggDmyJCycLxoXIORIxLFs/6sASjAn+6vV/i0AmrD0Sxd7/EFybUQQrO4dAS1chSoKJVBBsqUD8KCsqjlgYRi02p4xaEM+mJxjNe+v6Fu+rByuaX+sHCkas0SgYMkKRgWAEbSZAUFJlDD1OZgpJjotVw3D48OGL6BokV5GvZVwvE1bjIypydjaSDMHI6Boc0LAEBAnaCV7H74BRPvvssyky8k4NpmCDByQsIvWXq7iw+3l3es7hxr18/fhzJCuKCNn7FgkKxrr3wiBDy8E4rs0YhFMmBSgZCRSGOMKVCkRFFJj7sgL3rvPB/RvOjlrM3IICgXLRpMmFKVtpG22KZOP5grFwT03fovYasGJhW39GT4jmlWBQ8gAsIg0TnRPkqNACPEqUosH75Zdf3igxHJ6kevEEY2ALBkEiIRAfIvGSNLtmWu8BJoWkQ7b+7I8L+wXDDTnHE4wsCMZTR0IgAy4Uvbjucv/3lrwR//fKgzFo6YyiaFhKBeKDx/b7TlZMVK4aWaNc8sBGlIVNKA0oF02aXDxIbDNh6zeCMRMFgz47yiAYj+6u6Fu4pxKsWPBqf0aPjxgFI8ATCpEEwEgsTQIJMcQhaKSjo2M4azgYvz0wlnUcTDI/NBtupffcAl079VDpvAFLJnUPJoXqRuIzUlDjYdYQSpxPgENORYozupDs7e0dx48BcbAXvxdMisi9oZEAs4Ys9RrxtV6JadJcxkWIh1meoMLKR4Qbco5dMUyfkYkTOcGQFw5bBWPtX4MgQ1mtWon7KiSuvClw2/IDodMtnUGUjH5SQeDrvv/99C7/DP0zI6qUChQFlAu2WPAFw39OMOa1jup7dFcpWDF/Z3/Kx4fyQjD0nlmmwcIaNnaqUPCgXh2YFIlEw2lo7E+U1DgNBMGgRpDR8+zNoWAkRe8Pe5Eie6qSXruAIzghzD9LwaRkQzDcknM8wbBbMMb7q9e8GwAZyuvVMv0PjWnEkUTRSHVUw9Xq9dNu8N/QcLVy/dDSM9slUrctqVZLtZELTS7kBGPuK8V981qLwYpH/tIPvI6gjGAErOD3FuShXp5V7+STTz6Zms5+6LyykeDp+JS8jY0vnTPn+Pq89u0yx2YlDuHt+cdJmjQkz2ejl02v23bN8okzKAPz/rOvNywLPVOMuFyawX0Kmt17EibG6FMvrz5sjouwGWBSksnkj3h1R9uASRGof9mcE04HHK29PDXn4FRVbQb1ERJAWsBkp1tkBcO2v6ZaWusfufqwH2QYUakOpYY5teJSHwTG+5FhpcpQzugFVzBKqv3lJBjEwy8V9c19uQismPPnfqBgBBrcJhjUQFMDBlqxSswiMBaQJXmSwpcWfoKkY3MaRyFYDYmdQqLT3t5eTPXEqTvh8x5IgmF8BizqMZwpra2tI1giTq9nGItBmno0G6VgiYdAfUjHhYhg0XuZCobeeHORzzlhHqdPn36QlXM8wciNYASR6Jzn/W8+eUiFTJi5yd9K+0BCjF/AZF2kqksOEr1lgbIrU8H4TbPyumFkJHHnmkTHwy8VwHm8yOU/hcVKkeGHtvwyUyKMIe6cFmqAKJlmmIgjn3/++R2MJHySglfbd4QFvc8KcirUu2B9ls6ddWw6L4EeapKRlG+mbeR6dmzwdxmeslho1kv1QtefmrSp90zvm9UVQzAihOw1yCZOCaFgEaZ7BHrh12OEBb1P2+HXNg+DXtgjF2EJuOdMRUaQGHFBJZnScEZYUPxQHFnERSQdzPZBr6Xej6NHj9bp8UD3gJU3LnTOIRjf8LmOWx/iIzwhDkLTLQzBYJILwdCnScKhqK+geZf/6JNdKojw0J/UtxTVF+P/NVL+OfiDvvgdK5XO2SgNs7dyoKkUpGmLD25foXQHQr4CkhSNWEm1Mnb2jugpBM7xvDVTfhK42ShKsoLB6vnxi7xU6L0ISSI6OAf4GNhYKAHo++Y1DFoSs61QD1WXLlnBEOhFJYFTeI0GJemBJhgpopi1Qo2feHzwe86cEbswA8Fhf/nCaPi50DOZYQyeZOwzJznHEwxhwZCXDCRWXqdc3Lxbff+PnQpY8cgL6qERlUqNz8eQC4lzKB6tTLjzCeUISYQVtM3wcqXecA4h47QLEr/uVnVO03OB/yLQtN2cWxb6X4nEfMMNchGwY0qElVApwWhTGkkZiaB9UNAYGoyIzURT2bFjRwme/2zRRp+2p4RBn6f9ZAL1bDIcBaKe6Tp2kmDLjd7jpeMK9Owse1d0HdSgpfZE6Rj0GtUvfo2uPvX6P/744+vNrotVX/o14ELDHbRfwuoaRIa0ccj2Jv1e0H71IWxJwukOzdOzL9uYaqNgkWxB9cIQmYidyMYFPVf0vMjAGBFhjjp99NFH3+bvVz7n4NqXZdu2bRtJbYQJUTPo3Iwxg8/bL9M5T969oX1RPGYSb4T27Z9efXE35yvF8sIBAHZKhj5dEtYqK44UDK9QR1Y2KFMqG9RJw0rVEfQaEte2Caf+YbBsnMPQkWpJWa06GbmsCP/NOQd9PwGDaMSQxKBhvpLScb4pZbW+7xZX+sZo+0kYBCVoFKVsCAaHsBz2C0aeEXEYUREoyUJKoddEr1dGMLKL+PPuVEj8zaTGuI0Xr1knxiEqRe7jLZhCwPGCoZOyLiJgbBAZCzQDxpPK3jkQ/HNgYJSNoDEpGfeVejMkFnUGrXBAQhUO0DzD6QmPCfXCwKTYer25F5CwCC5ocCMEjRKZ9aad/Hy5lLjNxNjkPr5Ef/fI0YJhJPVArBMAAFecg3EfnBW2KuEJRt4mrGguoCHV1J6rNjUUy4TOzs7xZsPD2JD93hMMZwmG2UJcKrRWwRMMTzA8wchjsCgs8lQw7E5QcYcTcwI0f2uxwG+2YU44bsXJkyf/QGLB+AGiX8lfv7xg5JILLRS0LobWEdG6hdR5eRJI1n3X19OI4oIG2ZI8yCeS8WW/cIgLBhtPMDzB8ATDhYJBHD9+/Ao7vv3CWNQZJzzBSCHro1L8whi5inqC4cp84gmGhycYDhaMhAwuSCDxdMBh8x+jGLwgIxX4teNpWbieqBWeYJw/QkHCkO79olGqPJxCcBoJO/EEwxMMTzA8wXCdYDi4x+UJRn6veXDqc+kJhicY+QcWJV0c+jXViAyeYHDIcYLOen3kfookJIMLFnk6VSTcsijS8nxckF9yKhSeYLgMTzA8wfAEwxMMTzA8wfAEwxMMNwiG1JSJC77G5/YhWLcl7ASHrCbAAfjDW56AuIt8X8QpJBTe11Q9wfAEwxMMTzA8wfBwZrx5guEJhqsXfXKEQy7hukBABjpxEfJgCsTWP3bmgilFj/xaVJvDH6bjx1de/VT4QMQTDE8wPMHwBMMTDNfgCUYOBeP/FczIFptfb3AAAAAASUVORK5CYII=', + brandTarget: '_blank', + }), +}); diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts new file mode 100644 index 000000000..41c3c5c4d --- /dev/null +++ b/packages/frontend/.storybook/mocks.ts @@ -0,0 +1,16 @@ +import { type SharedOptions, rest } from 'msw'; + +export const onUnhandledRequest = ((req, print) => { + if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) { + return + } + print.warning() +}) satisfies SharedOptions['onUnhandledRequest']; + +export const commonHandlers = [ + rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value)); + }), +]; diff --git a/packages/frontend/.storybook/preload-locale.ts b/packages/frontend/.storybook/preload-locale.ts new file mode 100644 index 000000000..a54164742 --- /dev/null +++ b/packages/frontend/.storybook/preload-locale.ts @@ -0,0 +1,9 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as locales from '../../../locales'; + +writeFile( + resolve(__dirname, 'locale.ts'), + `export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`, + 'utf8', +) diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts new file mode 100644 index 000000000..1ff8f71ec --- /dev/null +++ b/packages/frontend/.storybook/preload-theme.ts @@ -0,0 +1,39 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as JSON5 from 'json5'; + +const keys = [ + '_dark', + '_light', + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-botanical', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', +] + +Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => { + writeFile( + resolve(__dirname, './themes.ts'), + `export default ${JSON.stringify( + Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])), + undefined, + 2, + )} as const;`, + 'utf8' + ); +}); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html new file mode 100644 index 000000000..01912da28 --- /dev/null +++ b/packages/frontend/.storybook/preview-head.html @@ -0,0 +1,4 @@ + + diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts new file mode 100644 index 000000000..b2974276a --- /dev/null +++ b/packages/frontend/.storybook/preview.ts @@ -0,0 +1,113 @@ +import { addons } from '@storybook/addons'; +import { FORCE_REMOUNT } from '@storybook/core-events'; +import { type Preview, setup } from '@storybook/vue3'; +import isChromatic from 'chromatic/isChromatic'; +import { initialize, mswDecorator } from 'msw-storybook-addon'; +import locale from './locale'; +import { commonHandlers, onUnhandledRequest } from './mocks'; +import themes from './themes'; +import '../src/style.scss'; + +const appInitialized = Symbol(); + +let moduleInitialized = false; +let unobserve = () => {}; +let misskeyOS = null; + +function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) { + unobserve(); + const theme = themes[document.documentElement.dataset.misskeyTheme]; + if (theme) { + applyTheme(themes[document.documentElement.dataset.misskeyTheme]); + } else if (isChromatic()) { + applyTheme(themes['l-light']); + } + const observer = new MutationObserver((entries) => { + for (const entry of entries) { + if (entry.attributeName === 'data-misskey-theme') { + const target = entry.target as HTMLElement; + const theme = themes[target.dataset.misskeyTheme]; + if (theme) { + applyTheme(themes[target.dataset.misskeyTheme]); + } else { + target.removeAttribute('style'); + } + } + } + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-misskey-theme'], + }); + unobserve = () => observer.disconnect(); +} + +initialize({ + onUnhandledRequest, +}); +localStorage.setItem("locale", JSON.stringify(locale)); +queueMicrotask(() => { + Promise.all([ + import('../src/components'), + import('../src/directives'), + import('../src/widgets'), + import('../src/scripts/theme'), + import('../src/store'), + import('../src/os'), + ]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => { + setup((app) => { + moduleInitialized = true; + if (app[appInitialized]) { + return; + } + app[appInitialized] = true; + loadTheme(applyTheme); + components(app); + directives(app); + widgets(app); + misskeyOS = os; + if (isChromatic()) { + defaultStore.set('animation', false); + } + }); + }); +}); + +const preview = { + decorators: [ + (Story, context) => { + const story = Story(); + if (!moduleInitialized) { + const channel = addons.getChannel(); + (globalThis.requestIdleCallback || setTimeout)(() => { + channel.emit(FORCE_REMOUNT, { storyId: context.id }); + }); + } + return story; + }, + mswDecorator, + (Story, context) => { + return { + setup() { + return { + context, + popups: misskeyOS.popups, + }; + }, + template: + '' + + '', + }; + }, + ], + parameters: { + controls: { + exclude: /^__/, + }, + msw: { + handlers: commonHandlers, + }, + }, +} satisfies Preview; + +export default preview; diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json new file mode 100644 index 000000000..01aa9db6e --- /dev/null +++ b/packages/frontend/.storybook/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "strict": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "checkJs": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "jsxFactory": "h" + }, + "files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"] +} diff --git a/packages/frontend/@types/vue.d.ts b/packages/frontend/@types/vue.d.ts deleted file mode 100644 index 9c9c34ccc..000000000 --- a/packages/frontend/@types/vue.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/// - -import type { $i } from '@/account'; -import type { defaultStore } from '@/store'; -import type { instance } from '@/instance'; -import type { i18n } from '@/i18n'; - -declare module 'vue' { - interface ComponentCustomProperties { - $i: typeof $i; - $store: typeof defaultStore; - $instance: typeof instance; - $t: typeof i18n['t']; - $ts: typeof i18n['ts']; - } -} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index add5eabdd..2d96d5514 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,6 +4,9 @@ "scripts": { "watch": "vite", "build": "vite build", + "storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'", + "build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build", + "chromatic": "chromatic", "test": "vitest --run", "test-and-coverage": "vitest --run --coverage", "typecheck": "vue-tsc --noEmit", @@ -11,15 +14,14 @@ "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { - "@discordapp/twemoji": "14.0.2", + "@discordapp/twemoji": "14.1.2", "@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", - "@vitejs/plugin-vue": "4.0.0", + "@syuilo/aiscript": "0.13.1", + "@tabler/icons-webfont": "2.12.0", + "@vitejs/plugin-vue": "4.1.0", "@vue/compiler-sfc": "3.2.47", - "autobind-decorator": "2.4.0", "autosize": "5.0.2", "blurhash": "2.0.5", "broadcast-channel": "4.20.2", @@ -29,75 +31,108 @@ "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", - "chartjs-plugin-zoom": "2.0.0", + "chartjs-plugin-zoom": "2.0.1", "compare-versions": "5.0.1", "cropperjs": "2.0.0-beta.2", "date-fns": "2.29.3", "escape-regexp": "0.0.1", "eventemitter3": "5.0.0", - "gsap": "3.11.4", + "gsap": "3.11.5", "idb-keyval": "6.2.0", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", "json5": "2.2.3", "matter-js": "0.19.0", "mfm-js": "0.23.3", - "misskey-js": "0.0.15", - "photoswipe": "5.3.6", + "misskey-js": "workspace:*", + "photoswipe": "5.3.7", "prismjs": "1.29.0", "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.17.3", + "rollup": "3.20.2", "s-age": "1.1.2", "sanitize-html": "2.10.0", - "sass": "1.58.3", + "sass": "1.60.0", "seedrandom": "3.0.5", "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", - "tsconfig-paths": "4.1.2", + "tsc-alias": "1.8.5", + "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "4.9.5", + "typescript": "5.0.2", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vite": "4.1.4", + "vite": "4.2.1", "vue": "3.2.47", "vue-plyr": "7.0.0", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { + "@storybook/addon-essentials": "7.0.2", + "@storybook/addon-interactions": "7.0.2", + "@storybook/addon-links": "7.0.2", + "@storybook/addon-storysource": "7.0.2", + "@storybook/addons": "7.0.2", + "@storybook/blocks": "7.0.2", + "@storybook/core-events": "7.0.2", + "@storybook/jest": "0.1.0", + "@storybook/manager-api": "7.0.2", + "@storybook/preview-api": "7.0.2", + "@storybook/react": "7.0.2", + "@storybook/react-vite": "7.0.2", + "@storybook/testing-library": "0.0.14-next.1", + "@storybook/theming": "7.0.2", + "@storybook/types": "7.0.2", + "@storybook/vue3": "7.0.2", + "@storybook/vue3-vite": "7.0.2", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/vue": "^6.6.1", "@types/escape-regexp": "0.0.1", + "@types/estree": "^1.0.0", "@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.11", "@types/punycode": "2.1.0", - "@types/sanitize-html": "2.8.0", + "@types/sanitize-html": "2.9.0", "@types/seedrandom": "3.0.5", + "@types/testing-library__jest-dom": "^5.14.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", - "@vitest/coverage-c8": "^0.29.2", + "@typescript-eslint/eslint-plugin": "5.57.0", + "@typescript-eslint/parser": "5.57.0", + "@vitest/coverage-c8": "^0.29.8", "@vue/runtime-core": "3.2.47", + "astring": "^1.8.4", + "chokidar-cli": "^3.0.0", + "chromatic": "^6.17.2", "cross-env": "7.0.3", - "cypress": "12.7.0", - "eslint": "8.35.0", + "cypress": "12.9.0", + "eslint": "8.37.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.9.0", + "eslint-plugin-vue": "9.10.0", + "fast-glob": "^3.2.12", "happy-dom": "8.9.0", - "start-server-and-test": "1.15.4", - "vitest": "^0.29.2", + "msw": "^1.1.0", + "msw-storybook-addon": "^1.8.0", + "prettier": "^2.8.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "start-server-and-test": "2.0.0", + "storybook": "7.0.2", + "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", + "summaly": "github:misskey-dev/summaly", + "vitest": "^0.29.8", + "vitest-fetch-mock": "^0.2.2", "vue-eslint-parser": "9.1.0", "vue-tsc": "1.2.0" } diff --git a/packages/frontend/public/mockServiceWorker.js b/packages/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..e915a1eb0 --- /dev/null +++ b/packages/frontend/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.1.0). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts new file mode 100644 index 000000000..c95da64bb --- /dev/null +++ b/packages/frontend/src/cache.ts @@ -0,0 +1,6 @@ +import * as misskey from 'misskey-js'; +import { Cache } from '@/scripts/cache'; + +export const clipsCache = new Cache(Infinity); +export const rolesCache = new Cache(Infinity); +export const userListsCache = new Cache(Infinity); diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts new file mode 100644 index 000000000..05190aa26 --- /dev/null +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkAnalogClock from './MkAnalogClock.vue'; +export const Default = { + render(args) { + return { + components: { + MkAnalogClock, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + parameters: { + layout: 'fullscreen', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts new file mode 100644 index 000000000..e1c1c54d1 --- /dev/null +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +/* eslint-disable import/no-duplicates */ +import { StoryObj } from '@storybook/vue3'; +import MkButton from './MkButton.vue'; +export const Default = { + render(args) { + return { + components: { + MkButton, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: 'Text', + }; + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkCaptcha.stories.impl.ts b/packages/frontend/src/components/MkCaptcha.stories.impl.ts new file mode 100644 index 000000000..6ac437a27 --- /dev/null +++ b/packages/frontend/src/components/MkCaptcha.stories.impl.ts @@ -0,0 +1,2 @@ +import MkCaptcha from './MkCaptcha.vue'; +void MkCaptcha; 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/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 833fa9d38..1834224b8 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -14,10 +14,10 @@ @@ -35,6 +35,8 @@ diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 979c3eed2..e02a7af09 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -1,8 +1,8 @@ @@ -14,6 +14,7 @@ import { } from 'vue'; import tinycolor from 'tinycolor2'; import { host as localHost } from '@/config'; import { $i } from '@/account'; +import { defaultStore } from '@/store'; const props = defineProps<{ username: string; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 9e3022896..e513a65a3 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -1,5 +1,5 @@