diff --git a/.config/docker_example.yml b/.config/docker_example.yml index db34a5036..e5f503aab 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -106,12 +106,16 @@ redis: # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── +# You can set scope to local (default value) or global +# (include notes from remote). + #meilisearch: # host: meilisearch # port: 7700 # apiKey: '' # ssl: true # index: '' +# scope: local # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── @@ -180,6 +184,9 @@ proxyRemoteFiles: true # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true +# For security reasons, uploading attachments from the intranet is prohibited, +# but exceptions can be made from the following settings. Default value is "undefined". +# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' #] diff --git a/.config/example.yml b/.config/example.yml index 7bf5ddf3b..6e3af6f5a 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -118,6 +118,9 @@ redis: # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── +# You can set scope to local (default value) or global +# (include notes from remote). + #meilisearch: # host: localhost # port: 7700 @@ -210,6 +213,9 @@ proxyRemoteFiles: true # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true +# For security reasons, uploading attachments from the intranet is prohibited, +# but exceptions can be made from the following settings. Default value is "undefined". +# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' #] diff --git a/.github/labeler.yml b/.github/labeler.yml index 137be487c..a77f73706 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,21 +1,34 @@ 'packages/backend': -- packages/backend/**/* +- any: + - changed-files: + - any-glob-to-any-file: ['packages/backend/**/*'] 'packages/backend:test': -- packages/backend/test/**/* +- any: + - changed-files: + - any-glob-to-any-file: ['packages/backend/test/**/*'] 'packages/frontend': -- packages/frontend/**/* +- any: + - changed-files: + - any-glob-to-any-file: ['packages/frontend/**/*'] 'packages/frontend:test': -- cypress/**/* +- any: + - changed-files: + - any-glob-to-any-file: ['cypress/**/*'] 'packages/sw': -- packages/sw/**/* +- any: + - changed-files: + - any-glob-to-any-file: ['packages/sw/**/*'] 'packages/misskey-js': -- packages/misskey-js/**/* +- any: + - changed-files: + - any-glob-to-any-file: ['packages/misskey-js/**/*'] 'packages/misskey-js:test': -- packages/misskey-js/test/**/* -- packages/misskey-js/test-d/**/* +- any: + - changed-files: + - any-glob-to-any-file: ['packages/misskey-js/test/**/*', 'packages/misskey-js/test-d/**/*'] diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index fa4a58c3a..88e2aceae 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -11,6 +11,6 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index e35a37e59..4d3133cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Client - Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正 +- Fix: MFMでルビの中のテキストがnyaizeされない問題を修正 ### Server - @@ -21,23 +22,54 @@ - Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正 ### Client +- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加 +- Feat: データセーバーでコードハイライトの読み込みを削減できるように +- Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336 - Enhance: 絵文字のオートコンプリート機能強化 #12364 - Enhance: ユーザーのRawデータを表示するページが復活 +- Enhance: リアクション選択時に音を鳴らせるように +- Enhance: サウンドにドライブのファイルを使用できるように +- Enhance: ナビゲーションバーに項目「キャッシュを削除」を追加 +- Enhance: Shareページで投稿を完了すると、親ウィンドウ(親フレーム)にpostMessageするように +- Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305 +- Enhance: ノートプレビューに「内容を隠す」が反映されるように +- Enhance: データセーバーの適用範囲を個別で設定できるように + - 従来のデータセーバーの設定はリセットされます +- Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように + - ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。 - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 - Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 +- Enhance: 絵文字の詳細ページに記載される情報を追加 - Fix: コードエディタが正しく表示されない問題を修正 - Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正 +- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正 +- Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305 +- Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470 +- Fix: 長い名前のチャンネルにおける投稿フォームの表示が崩れる問題を修正 +- Fix: セキュリティ向上のためAiScriptの`Mk:apiExternal`を無効化 ### Server - Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように +- Enhance: Meilisearchを有効にした検索で、ユーザーのミュートやブロックを考慮するように - Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303 - Fix: ロールタイムラインが保存されない問題を修正 - Fix: api.jsonの生成ロジックを改善 #12402 - Fix: 招待コードが使い回せる問題を修正 - Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正 +- Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正 +- Fix: リストタイムラインにてミュートが機能しないケースがある問題と、チャンネル投稿がストリーミングで流れてきてしまう問題を修正 #10443 +- Fix: 「みつける」のなかにミュートしたユーザが現れてしまう問題を修正 #12383 +- Fix: Social/Local/Home Timelineにてインスタンスミュートが効かない問題 +- Fix: ユーザのノート一覧にてインスタンスミュートが効かない問題 +- Fix: チャンネルのノート一覧にてインスタンスミュートが効かない問題 +- Fix: 「みつける」が年越し時に壊れる問題を修正 +- Fix: アカウントをブロックした際に、自身のユーザーのページでノートが相手に表示される問題を修正 ## 2023.11.1 +### Note +- 悪意のある第三者がリモートユーザーになりすました任意のアクティビティを受け取れてしまう問題を修正しました。詳しくは[GitHub security advisory](https://github.com/misskey-dev/misskey/security/advisories/GHSA-3f39-6537-3cgc)をご覧ください。 + ### General - Feat: 管理者がコントロールパネルからメールアドレスの照会を行えるようになりました - Enhance: ローカリゼーションの更新 @@ -51,6 +83,7 @@ - 例: `$[unixtime 1701356400]` - Enhance: プラグインでエラーが発生した場合のハンドリングを強化 - Enhance: 細かなUIのブラッシュアップ +- Enhance: サウンド設定に「サウンドを出力しない」と「Misskeyがアクティブな時のみサウンドを出力する」を追加 - Fix: 効果音が再生されるとデバイスで再生している動画や音声が停止する問題を修正 #12339 - Fix: デッキに表示されたチャンネルの表示先チャンネルを切り替えた際、即座に反映されない問題を修正 #12236 - Fix: プラグインでノートの表示を書き換えられない問題を修正 @@ -160,6 +193,7 @@ ### Client - Enhance: TLの返信表示オプションを記憶するように - Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく +- Feat: 絵文字ピッカーのカテゴリに「/」を入れることでフォルダ分け表示できるように ### Server - Enhance: タイムライン取得時のパフォーマンスを向上 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13e065604..7f6c1f4f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,6 +117,10 @@ command. - Server-side source files and automatically builds them if they are modified. Automatically start the server process(es). - Vite HMR (just the `vite` command) is available. The behavior may be different from production. - Service Worker is watched by esbuild. +- The front end can be viewed by accessing `http://localhost:5173`. +- The backend listens on the port configured with `port` in .config/default.yml. +If you have not changed it from the default, it will be "http://localhost:3000". +If "port" in .config/default.yml is set to something other than 3000, you need to change the proxy settings in packages/frontend/vite.config.local-dev.ts. ### Dev Container Instead of running `pnpm` locally, you can use Dev Container to set up your development environment. diff --git a/Dockerfile b/Dockerfile index ad885c3e4..95d370e64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,8 +73,8 @@ RUN apt-get update \ && corepack enable \ && groupadd -g "${GID}" misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ - && find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ - && find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \ + && find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ + && find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \ && apt-get clean \ && rm -rf /var/lib/apt/lists diff --git a/docker-compose.local-db.yml b/docker-compose.local-db.yml new file mode 100644 index 000000000..16ba4b49e --- /dev/null +++ b/docker-compose.local-db.yml @@ -0,0 +1,42 @@ +version: "3" + +# このconfigは、 dockerでMisskey本体を起動せず、 redisとpostgresql などだけを起動します + +services: + redis: + restart: always + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - ./redis:/data + healthcheck: + test: "redis-cli ping" + interval: 5s + retries: 20 + + db: + restart: always + image: postgres:15-alpine + ports: + - "5432:5432" + env_file: + - .config/docker.env + volumes: + - ./db:/var/lib/postgresql/data + healthcheck: + test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" + interval: 5s + retries: 20 + +# meilisearch: +# restart: always +# image: getmeili/meilisearch:v1.3.4 +# environment: +# - MEILI_NO_ANALYTICS=true +# - MEILI_ENV=production +# env_file: +# - .config/meilisearch.env +# volumes: +# - ./meili_data:/meili_data + diff --git a/docker-compose.yml.example b/docker-compose_example.yml similarity index 100% rename from docker-compose.yml.example rename to docker-compose_example.yml diff --git a/locales/generateDTS.js b/locales/generateDTS.js index 7af773f3b..d3afdd6e1 100644 --- a/locales/generateDTS.js +++ b/locales/generateDTS.js @@ -56,6 +56,18 @@ export default function generateDTS() { ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags, ), ), + ts.factory.createFunctionDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + undefined, + ts.factory.createIdentifier('build'), + undefined, + [], + ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier('Locale'), + undefined, + ), + undefined, + ), ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), ]; const printed = ts.createPrinter({ diff --git a/locales/index.d.ts b/locales/index.d.ts index 0e5c193d2..b013cdcb2 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -442,7 +442,6 @@ export interface Locale { "notFound": string; "notFoundDescription": string; "uploadFolder": string; - "cacheClear": string; "markAsReadAllNotifications": string; "markAsReadAllUnreadNotes": string; "markAsReadAllTalkMessages": string; @@ -549,6 +548,8 @@ export interface Locale { "popout": string; "volume": string; "masterVolume": string; + "notUseSound": string; + "useSoundOnlyWhenActive": string; "details": string; "chooseEmoji": string; "unableToProcess": string; @@ -1029,6 +1030,8 @@ export interface Locale { "sensitiveWords": string; "sensitiveWordsDescription": string; "sensitiveWordsDescription2": string; + "hiddenTags": string; + "hiddenTagsDescription": string; "notesSearchNotAvailable": string; "license": string; "unfavoriteConfirm": string; @@ -1172,6 +1175,8 @@ export interface Locale { "doReaction": string; "urlPreviewDenyList": string; "urlPreviewDenyListDescription": string; + "code": string; + "reloadRequiredToApplySettings": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -1952,6 +1957,15 @@ export interface Locale { "notification": string; "antenna": string; "channel": string; + "reaction": string; + }; + "_soundSettings": { + "driveFile": string; + "driveFileWarn": string; + "driveFileTypeWarn": string; + "driveFileTypeWarnDescription": string; + "driveFileDurationWarn": string; + "driveFileDurationWarnDescription": string; }; "_ago": { "future": string; @@ -2106,6 +2120,7 @@ export interface Locale { "chooseList": string; }; "clicker": string; + "birthdayFollowings": string; }; "_cw": { "hide": string; @@ -2518,8 +2533,27 @@ export interface Locale { }; }; }; + "_dataSaver": { + "_media": { + "title": string; + "description": string; + }; + "_avatar": { + "title": string; + "description": string; + }; + "_urlPreview": { + "title": string; + "description": string; + }; + "_code": { + "title": string; + "description": string; + }; + }; } declare const locales: { [lang: string]: Locale; }; +export function build(): Locale; export default locales; diff --git a/locales/index.js b/locales/index.js index 67a406d98..650e55233 100644 --- a/locales/index.js +++ b/locales/index.js @@ -51,33 +51,37 @@ const primaries = { // 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); -const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {}); +export function build() { + const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {}); -// 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す -const removeEmpty = (obj) => { - for (const [k, v] of Object.entries(obj)) { - if (v === '') { - delete obj[k]; - } else if (typeof v === 'object') { - removeEmpty(v); + // 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す + const removeEmpty = (obj) => { + for (const [k, v] of Object.entries(obj)) { + if (v === '') { + delete obj[k]; + } else if (typeof v === 'object') { + removeEmpty(v); + } } - } - return obj; -}; -removeEmpty(locales); + return obj; + }; + removeEmpty(locales); -export default Object.entries(locales) - .reduce((a, [k ,v]) => (a[k] = (() => { - const [lang] = k.split('-'); - switch (k) { - case 'ja-JP': return v; - case 'ja-KS': - case 'en-US': return merge(locales['ja-JP'], v); - default: return merge( - locales['ja-JP'], - locales['en-US'], - locales[`${lang}-${primaries[lang]}`] ?? {}, - v - ); - } - })(), a), {}); + return Object.entries(locales) + .reduce((a, [k, v]) => (a[k] = (() => { + const [lang] = k.split('-'); + switch (k) { + case 'ja-JP': return v; + case 'ja-KS': + case 'en-US': return merge(locales['ja-JP'], v); + default: return merge( + locales['ja-JP'], + locales['en-US'], + locales[`${lang}-${primaries[lang]}`] ?? {}, + v + ); + } + })(), a), {}); +} + +export default build(); diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 11f331485..c9f2969a9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -439,7 +439,6 @@ share: "共有" notFound: "見つかりません" notFoundDescription: "指定されたURLに該当するページはありませんでした。" uploadFolder: "既定アップロード先" -cacheClear: "キャッシュを削除" markAsReadAllNotifications: "すべての通知を既読にする" markAsReadAllUnreadNotes: "すべての投稿を既読にする" markAsReadAllTalkMessages: "すべてのチャットを既読にする" @@ -546,6 +545,8 @@ showInPage: "ページで表示" popout: "ポップアウト" volume: "音量" masterVolume: "マスター音量" +notUseSound: "サウンドを出力しない" +useSoundOnlyWhenActive: "Misskeyがアクティブな時のみサウンドを出力する" details: "詳細" chooseEmoji: "絵文字を選択" unableToProcess: "操作を完了できません" @@ -1026,6 +1027,8 @@ resetPasswordConfirm: "パスワードリセットしますか?" sensitiveWords: "センシティブワード" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" +hiddenTags: "非表示ハッシュタグ" +hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。" notesSearchNotAvailable: "ノート検索は利用できません。" license: "ライセンス" unfavoriteConfirm: "お気に入り解除しますか?" @@ -1169,6 +1172,8 @@ cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述 doReaction: "リアクションする" urlPreviewDenyList: "サムネイルの表示を制限するURL" urlPreviewDenyListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルがぼかされて表示されます。" +code: "コード" +reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -1857,6 +1862,15 @@ _sfx: notification: "通知" antenna: "アンテナ受信" channel: "チャンネル通知" + reaction: "リアクション選択時" + +_soundSettings: + driveFile: "ドライブの音声を使用" + driveFileWarn: "ドライブのファイルを選択してください" + driveFileTypeWarn: "このファイルは対応していません" + driveFileTypeWarnDescription: "音声ファイルを選択してください" + driveFileDurationWarn: "音声が長すぎます" + driveFileDurationWarnDescription: "長い音声を使用するとMisskeyの使用に支障をきたす可能性があります。それでも続行しますか?" _ago: future: "未来" @@ -2010,6 +2024,7 @@ _widgets: _userList: chooseList: "リストを選択" clicker: "クリッカー" + birthdayFollowings: "今日誕生日のユーザー" _cw: hide: "隠す" @@ -2404,3 +2419,17 @@ _externalResourceInstaller: _themeInstallFailed: title: "テーマのインストールに失敗しました" description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。" + +_dataSaver: + _media: + title: "メディアの読み込み" + description: "画像・動画が自動で読み込まれるのを防止します。隠れている画像・動画はタップすると読み込まれます。" + _avatar: + title: "アイコン画像" + description: "アイコン画像のアニメーションが停止します。アニメーション画像は通常の画像よりファイルサイズが大きいことがあるので、データ通信量をさらに削減できます。" + _urlPreview: + title: "URLプレビューのサムネイル" + description: "URLプレビューのサムネイル画像が読み込まれなくなります。" + _code: + title: "コードハイライト" + description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。" diff --git a/package.json b/package.json index cdb413460..69f948333 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2023.11.1-io.5", + "version": "2023.12.0-beta.3-io", "codename": "nasubi", "repository": { "type": "git", @@ -18,6 +18,7 @@ "build-assets": "node ./scripts/build-assets.mjs", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", + "build-misskey-js-with-types": "pnpm --filter backend build && pnpm --filter backend generate-api-json && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start:docker": "pnpm check:connect && cd packages/backend && exec node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", @@ -28,7 +29,7 @@ "migrateandstart": "pnpm migrate && pnpm start", "migrateandstart:docker": "pnpm migrate && exec pnpm start:docker", "watch": "pnpm dev", - "dev": "node ./scripts/dev.mjs", + "dev": "pnpm -r dev", "lint": "pnpm -r lint", "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:run": "pnpm cypress run", @@ -49,17 +50,18 @@ "execa": "8.0.1", "cssnano": "6.0.1", "js-yaml": "4.1.0", - "postcss": "8.4.31", + "postcss": "8.4.32", "terser": "5.24.0", - "typescript": "5.3.2" + "typescript": "5.3.3" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "6.11.0", - "@typescript-eslint/parser": "6.11.0", + "@typescript-eslint/eslint-plugin": "6.13.2", + "@typescript-eslint/parser": "6.13.2", "cross-env": "7.0.3", - "cypress": "13.5.1", - "eslint": "8.53.0", - "start-server-and-test": "2.0.3" + "cypress": "13.6.1", + "eslint": "8.55.0", + "start-server-and-test": "2.0.3", + "ncp": "2.0.0" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.4.0" diff --git a/packages/backend/migration/1700902349231-add-bday-index.js b/packages/backend/migration/1700902349231-add-bday-index.js new file mode 100644 index 000000000..251526fc2 --- /dev/null +++ b/packages/backend/migration/1700902349231-add-bday-index.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddBdayIndex1700902349231 { + name = 'AddBdayIndex1700902349231' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (SUBSTR("birthday", 6, 5))`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 18c7818dc..85bdfbebf 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -16,6 +16,7 @@ "watch:swc": "swc src -d built -D -w", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "node watch.mjs", + "dev": "node ./built/boot/entry.js", "typecheck": "tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", @@ -60,27 +61,27 @@ "dependencies": { "@aws-sdk/client-s3": "3.412.0", "@aws-sdk/lib-storage": "3.412.0", - "@bull-board/api": "5.9.1", - "@bull-board/fastify": "5.9.1", - "@bull-board/ui": "5.9.1", + "@bull-board/api": "5.10.2", + "@bull-board/fastify": "5.10.2", + "@bull-board/ui": "5.10.2", "@discordapp/twemoji": "14.1.2", - "@fastify/accepts": "4.2.0", + "@fastify/accepts": "4.3.0", "@fastify/cookie": "9.2.0", - "@fastify/cors": "8.4.1", + "@fastify/cors": "8.4.2", "@fastify/express": "2.3.0", "@fastify/http-proxy": "9.3.0", "@fastify/multipart": "8.0.0", "@fastify/static": "6.12.0", "@fastify/view": "8.2.0", - "@nestjs/common": "10.2.8", - "@nestjs/core": "10.2.8", - "@nestjs/testing": "10.2.8", + "@nestjs/common": "10.2.10", + "@nestjs/core": "10.2.10", + "@nestjs/testing": "10.2.10", "@peertube/http-signature": "1.7.0", "@simplewebauthn/server": "8.3.5", "@sinonjs/fake-timers": "11.2.2", - "@smithy/node-http-handler": "2.1.5", + "@smithy/node-http-handler": "2.1.10", "@swc/cli": "0.1.63", - "@swc/core": "1.3.96", + "@swc/core": "1.3.100", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "6.0.1", @@ -88,7 +89,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.2", - "bullmq": "4.13.3", + "bullmq": "4.15.2", "cacheable-lookup": "7.0.0", "cbor": "9.0.1", "chalk": "5.3.0", @@ -114,17 +115,17 @@ "ipaddr.js": "2.1.0", "is-svg": "5.0.0", "js-yaml": "4.1.0", - "jsdom": "22.1.0", + "jsdom": "23.0.1", "json5": "2.2.3", - "jsonld": "8.3.1", - "jsrsasign": "10.8.6", - "meilisearch": "0.35.0", + "jsonld": "8.3.2", + "jsrsasign": "10.9.0", + "meilisearch": "0.36.0", "mfm-js": "0.23.3", "microformats-parser": "1.5.2", "mime-types": "2.1.35", "misskey-js": "workspace:*", "ms": "3.0.0-canary.1", - "nanoid": "5.0.3", + "nanoid": "5.0.4", "nested-property": "4.0.0", "node-fetch": "3.3.2", "nodemailer": "6.9.7", @@ -133,7 +134,7 @@ "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.2.0", + "otpauth": "9.2.1", "parse5": "7.1.2", "pg": "8.11.3", "pkce-challenge": "4.0.1", @@ -145,9 +146,9 @@ "qrcode": "1.5.3", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.20.8", + "re2": "1.20.9", "redis-lock": "0.1.4", - "reflect-metadata": "0.1.13", + "reflect-metadata": "0.1.14", "rename": "1.0.4", "rss-parser": "3.13.0", "rxjs": "7.8.1", @@ -159,14 +160,14 @@ "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.21.17", + "systeminformation": "5.21.20", "tinycolor2": "1.6.0", "tmp": "0.2.1", "tsc-alias": "1.8.8", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", "typeorm": "0.3.17", - "typescript": "5.3.2", + "typescript": "5.3.3", "ulid": "2.3.0", "vary": "1.1.2", "web-push": "3.6.6", @@ -178,7 +179,7 @@ "@simplewebauthn/typescript-types": "8.3.4", "@swc/jest": "0.2.29", "@types/accepts": "1.3.7", - "@types/archiver": "6.0.1", + "@types/archiver": "6.0.2", "@types/bcryptjs": "2.4.6", "@types/body-parser": "1.19.5", "@types/cbor": "6.0.0", @@ -186,28 +187,28 @@ "@types/content-disposition": "0.5.8", "@types/fluent-ffmpeg": "2.1.24", "@types/http-link-header": "1.0.5", - "@types/jest": "29.5.8", + "@types/jest": "29.5.11", "@types/js-yaml": "4.0.9", - "@types/jsdom": "21.1.5", - "@types/jsonld": "1.5.12", + "@types/jsdom": "21.1.6", + "@types/jsonld": "1.5.13", "@types/jsrsasign": "10.5.12", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "20.9.1", + "@types/node": "20.10.4", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.14", "@types/oauth": "0.9.4", "@types/oauth2orize": "1.11.3", "@types/oauth2orize-pkce": "0.1.2", "@types/pg": "8.10.9", - "@types/pug": "2.0.9", - "@types/punycode": "2.1.2", + "@types/pug": "2.0.10", + "@types/punycode": "2.1.3", "@types/qrcode": "1.5.5", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", - "@types/sanitize-html": "2.9.4", - "@types/semver": "7.5.5", + "@types/sanitize-html": "2.9.5", + "@types/semver": "7.5.6", "@types/sharp": "0.32.0", "@types/simple-oauth2": "5.0.7", "@types/sinonjs__fake-timers": "8.1.5", @@ -215,12 +216,12 @@ "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.3", - "@types/ws": "8.5.9", - "@typescript-eslint/eslint-plugin": "6.11.0", - "@typescript-eslint/parser": "6.11.0", + "@types/ws": "8.5.10", + "@typescript-eslint/eslint-plugin": "6.13.2", + "@typescript-eslint/parser": "6.13.2", "aws-sdk-client-mock": "3.0.0", "cross-env": "7.0.3", - "eslint": "8.53.0", + "eslint": "8.55.0", "eslint-plugin-import": "2.29.0", "execa": "8.0.1", "jest": "29.7.0", diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index a6c934503..2783dd5b3 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -16,7 +16,7 @@ import type { AntennasRepository, UserListMembershipsRepository } from '@/models import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -39,7 +39,7 @@ export class AntennaService implements OnApplicationShutdown { private utilityService: UtilityService, private globalEventService: GlobalEventService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineService: FanoutTimelineService, ) { this.antennasFetched = false; this.antennas = []; @@ -97,7 +97,7 @@ export class AntennaService implements OnApplicationShutdown { const redisPipeline = this.redisForTimelines.pipeline(); for (const antenna of matchedAntennas) { - this.funoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline); + this.fanoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline); this.globalEventService.publishAntennaStream(antenna.id, 'note', note); } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index ef224abcc..00a50e82e 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -4,6 +4,7 @@ */ import { Module } from '@nestjs/common'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -62,7 +63,7 @@ import { FileInfoService } from './FileInfoService.js'; import { SearchService } from './SearchService.js'; import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; -import { FunoutTimelineService } from './FunoutTimelineService.js'; +import { FanoutTimelineService } from './FanoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js'; import { RegistryApiService } from './RegistryApiService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; @@ -195,7 +196,8 @@ const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: Fi const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; -const $FunoutTimelineService: Provider = { provide: 'FunoutTimelineService', useExisting: FunoutTimelineService }; +const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; +const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; @@ -332,7 +334,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SearchService, ClipService, FeaturedService, - FunoutTimelineService, + FanoutTimelineService, + FanoutTimelineEndpointService, ChannelFollowingService, RegistryApiService, ChartLoggerService, @@ -462,7 +465,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SearchService, $ClipService, $FeaturedService, - $FunoutTimelineService, + $FanoutTimelineService, + $FanoutTimelineEndpointService, $ChannelFollowingService, $RegistryApiService, $ChartLoggerService, @@ -593,7 +597,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SearchService, ClipService, FeaturedService, - FunoutTimelineService, + FanoutTimelineService, + FanoutTimelineEndpointService, ChannelFollowingService, RegistryApiService, FederationChart, @@ -722,7 +727,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SearchService, $ClipService, $FeaturedService, - $FunoutTimelineService, + $FanoutTimelineService, + $FanoutTimelineEndpointService, $ChannelFollowingService, $RegistryApiService, $FederationChart, diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts new file mode 100644 index 000000000..11027960f --- /dev/null +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiNote } from '@/models/Note.js'; +import { Packed } from '@/misc/json-schema.js'; +import type { NotesRepository } from '@/models/_.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { CacheService } from '@/core/CacheService.js'; +import { isReply } from '@/misc/is-reply.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; + +type TimelineOptions = { + untilId: string | null, + sinceId: string | null, + limit: number, + allowPartial: boolean, + me?: { id: MiUser['id'] } | undefined | null, + useDbFallback: boolean, + redisTimelines: FanoutTimelineName[], + noteFilter?: (note: MiNote) => boolean, + alwaysIncludeMyNotes?: boolean; + ignoreAuthorFromBlock?: boolean; + ignoreAuthorFromMute?: boolean; + excludeNoFiles?: boolean; + excludeReplies?: boolean; + excludePureRenotes: boolean; + dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise, +}; + +@Injectable() +export class FanoutTimelineEndpointService { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private noteEntityService: NoteEntityService, + private cacheService: CacheService, + private fanoutTimelineService: FanoutTimelineService, + ) { + } + + @bindThis + async timeline(ps: TimelineOptions): Promise[]> { + return await this.noteEntityService.packMany(await this.getMiNotes(ps), ps.me); + } + + @bindThis + private async getMiNotes(ps: TimelineOptions): Promise { + let noteIds: string[]; + let shouldFallbackToDb = false; + + // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える + if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); + + const shouldPrepend = ps.sinceId && !ps.untilId; + const idCompare: (a: string, b: string) => number = shouldPrepend ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1; + + const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); + + // TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい + const redisResultIds = Array.from(new Set(redisResult.flat(1))); + + redisResultIds.sort(idCompare); + noteIds = redisResultIds.slice(0, ps.limit); + + shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); + + if (!shouldFallbackToDb) { + let filter = ps.noteFilter ?? (_note => true); + + if (ps.alwaysIncludeMyNotes && ps.me) { + const me = ps.me; + const parentFilter = filter; + filter = (note) => note.userId === me.id || parentFilter(note); + } + + if (ps.excludeNoFiles) { + const parentFilter = filter; + filter = (note) => note.fileIds.length !== 0 && parentFilter(note); + } + + if (ps.excludeReplies) { + const parentFilter = filter; + filter = (note) => !isReply(note, ps.me?.id) && parentFilter(note); + } + + if (ps.excludePureRenotes) { + const parentFilter = filter; + filter = (note) => !isPureRenote(note) && parentFilter(note); + } + + if (ps.me) { + const me = ps.me; + const [ + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + userMutedInstances, + ] = await Promise.all([ + this.cacheService.userMutingsCache.fetch(ps.me.id), + this.cacheService.renoteMutingsCache.fetch(ps.me.id), + this.cacheService.userBlockedCache.fetch(ps.me.id), + this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)), + ]); + + const parentFilter = filter; + filter = (note) => { + if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; + if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; + if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false; + if (isInstanceMuted(note, userMutedInstances)) return false; + + return parentFilter(note); + }; + } + + const redisTimeline: MiNote[] = []; + let readFromRedis = 0; + let lastSuccessfulRate = 1; // rateをキャッシュする? + + while ((redisResultIds.length - readFromRedis) !== 0) { + const remainingToRead = ps.limit - redisTimeline.length; + + // DBからの取り直しを減らす初回と同じ割合以上で成功すると仮定するが、クエリの長さを考えて三倍まで + const countToGet = Math.ceil(remainingToRead * Math.min(1.1 / lastSuccessfulRate, 3)); + noteIds = redisResultIds.slice(readFromRedis, readFromRedis + countToGet); + + readFromRedis += noteIds.length; + + const gotFromDb = await this.getAndFilterFromDb(noteIds, filter, idCompare); + redisTimeline.push(...gotFromDb); + lastSuccessfulRate = gotFromDb.length / noteIds.length; + + if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) { + // 十分Redisからとれた + const result = redisTimeline.slice(0, ps.limit); + if (shouldPrepend) result.reverse(); + return result; + } + } + + // まだ足りない分はDBにフォールバック + const remainingToRead = ps.limit - redisTimeline.length; + let dbUntil: string | null; + let dbSince: string | null; + if (shouldPrepend) { + redisTimeline.reverse(); + dbUntil = ps.untilId; + dbSince = noteIds[noteIds.length - 1]; + } else { + dbUntil = noteIds[noteIds.length - 1]; + dbSince = ps.sinceId; + } + const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead); + return shouldPrepend ? [...gotFromDb, ...redisTimeline] : [...redisTimeline, ...gotFromDb]; + } + + return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); + } + + private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise { + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + const notes = (await query.getMany()).filter(noteFilter); + + notes.sort((a, b) => idCompare(a.id, b.id)); + + return notes; + } +} diff --git a/packages/backend/src/core/FunoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts similarity index 62% rename from packages/backend/src/core/FunoutTimelineService.ts rename to packages/backend/src/core/FanoutTimelineService.ts index c633c329e..fa329315c 100644 --- a/packages/backend/src/core/FunoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -9,8 +9,39 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +export type FanoutTimelineName = + // home timeline + | `homeTimeline:${string}` + | `homeTimelineWithFiles:${string}` // only notes with files are included + // local timeline + | `localTimeline` // replies are not included + | `localTimelineWithFiles` // only non-reply notes with files are included + | `localTimelineWithReplies` // only replies are included + | `localTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id. + + // antenna + | `antennaTimeline:${string}` + + // user timeline + | `userTimeline:${string}` // replies are not included + | `userTimelineWithFiles:${string}` // only non-reply notes with files are included + | `userTimelineWithReplies:${string}` // only replies are included + | `userTimelineWithRepliesWithFiles:${string}` + | `userTimelineWithChannel:${string}` // only channel notes are included, replies are included + | `userTimelineWithChannelWithFiles:${string}` + + // user list timelines + | `userListTimeline:${string}` + | `userListTimelineWithFiles:${string}` // only notes with files are included + + // channel timelines + | `channelTimeline:${string}` // replies are included + + // role timelines + | `roleTimeline:${string}` // any notes are included + @Injectable() -export class FunoutTimelineService { +export class FanoutTimelineService { constructor( @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, @@ -20,7 +51,7 @@ export class FunoutTimelineService { } @bindThis - public push(tl: string, id: string, maxlen: number, pipeline: Redis.ChainableCommander) { + public push(tl: FanoutTimelineName, id: string, maxlen: number, pipeline: Redis.ChainableCommander) { // リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、 // 3分以内に投稿されたものでない場合、Redisにある最古のIDより新しい場合のみ追加する if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) { @@ -41,7 +72,7 @@ export class FunoutTimelineService { } @bindThis - public get(name: string, untilId?: string | null, sinceId?: string | null) { + public get(name: FanoutTimelineName, untilId?: string | null, sinceId?: string | null) { if (untilId && sinceId) { return this.redisForTimelines.lrange('list:' + name, 0, -1) .then(ids => ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1)); @@ -58,7 +89,7 @@ export class FunoutTimelineService { } @bindThis - public getMulti(name: string[], untilId?: string | null, sinceId?: string | null): Promise { + public getMulti(name: FanoutTimelineName[], untilId?: string | null, sinceId?: string | null): Promise { const pipeline = this.redisForTimelines.pipeline(); for (const n of name) { pipeline.lrange('list:' + n, 0, -1); @@ -79,7 +110,7 @@ export class FunoutTimelineService { } @bindThis - public purge(name: string) { + public purge(name: FanoutTimelineName) { return this.redisForTimelines.del('list:' + name); } } diff --git a/packages/backend/src/core/FeaturedService.ts b/packages/backend/src/core/FeaturedService.ts index 078d2f522..8ff77e3dd 100644 --- a/packages/backend/src/core/FeaturedService.ts +++ b/packages/backend/src/core/FeaturedService.ts @@ -5,14 +5,17 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { MiNote, MiUser } from '@/models/_.js'; +import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと +export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと +const featuredEpoc = new Date('2023-01-01T00:00:00Z').getTime(); + @Injectable() export class FeaturedService { constructor( @@ -23,7 +26,7 @@ export class FeaturedService { @bindThis private getCurrentWindow(windowRange: number): number { - const passed = new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime(); + const passed = new Date().getTime() - featuredEpoc; return Math.floor(passed / windowRange); } @@ -79,6 +82,11 @@ export class FeaturedService { return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); } + @bindThis + public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise { + return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score); + } + @bindThis public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise { return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score); @@ -99,6 +107,11 @@ export class FeaturedService { return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold); } + @bindThis + public getGalleryPostsRanking(threshold: number): Promise { + return this.getRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, threshold); + } + @bindThis public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise { return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold); diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index c98b8ea6f..43e72d2d7 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -7,11 +7,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { genAid, parseAid } from '@/misc/id/aid.js'; -import { genAidx, parseAidx } from '@/misc/id/aidx.js'; -import { genMeid, parseMeid } from '@/misc/id/meid.js'; -import { genMeidg, parseMeidg } from '@/misc/id/meidg.js'; -import { genObjectId, parseObjectId } from '@/misc/id/object-id.js'; +import { genAid, isSafeAidT, parseAid } from '@/misc/id/aid.js'; +import { genAidx, isSafeAidxT, parseAidx } from '@/misc/id/aidx.js'; +import { genMeid, isSafeMeidT, parseMeid } from '@/misc/id/meid.js'; +import { genMeidg, isSafeMeidgT, parseMeidg } from '@/misc/id/meidg.js'; +import { genObjectId, isSafeObjectIdT, parseObjectId } from '@/misc/id/object-id.js'; import { bindThis } from '@/decorators.js'; import { parseUlid } from '@/misc/id/ulid.js'; @@ -26,6 +26,19 @@ export class IdService { this.method = config.id.toLowerCase(); } + @bindThis + public isSafeT(t: number): boolean { + switch (this.method) { + case 'aid': return isSafeAidT(t); + case 'aidx': return isSafeAidxT(t); + case 'meid': return isSafeMeidT(t); + case 'meidg': return isSafeMeidgT(t); + case 'ulid': return t > 0; + case 'objectid': return isSafeObjectIdT(t); + default: throw new Error('unrecognized id generation method'); + } + } + /** * 時間を元にIDを生成します(省略時は現在日時) * @param time 日時 diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 9c66d9ba3..49e7e559b 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -54,9 +54,10 @@ import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { isReply } from '@/misc/is-reply.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -194,7 +195,7 @@ export class NoteCreateService implements OnApplicationShutdown { private idService: IdService, private globalEventService: GlobalEventService, private queueService: QueueService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineService: FanoutTimelineService, private noteReadService: NoteReadService, private notificationService: NotificationService, private relayService: RelayService, @@ -876,22 +877,22 @@ export class NoteCreateService implements OnApplicationShutdown { ) continue; // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 - if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) { + if (isReply(note, userListMembership.userListUserId)) { if (!userListMembership.withReplies) continue; } - this.funoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); + this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.funoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); } } if (note.channelId) { - this.funoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); + this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); - this.funoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.funoutTimelineService.push(`userTimelineWithChannelWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userTimelineWithChannelWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); } const channelFollowings = await this.channelFollowingsRepository.find({ @@ -902,9 +903,9 @@ export class NoteCreateService implements OnApplicationShutdown { }); for (const channelFollowing of channelFollowings) { - this.funoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.funoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); } } } else { @@ -914,43 +915,46 @@ export class NoteCreateService implements OnApplicationShutdown { if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 - if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) { + if (isReply(note, following.followerId)) { if (!following.withReplies) continue; } - this.funoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.funoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); } } if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL - this.funoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.funoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); } } // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { - this.funoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + if (isReply(note)) { + this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.funoutTimelineService.push(`userTimelineWithRepliesWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userTimelineWithRepliesWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); } if (note.visibility === 'public' && note.userHost == null) { - this.funoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); + this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); + if (note.replyUserHost == null) { + this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); + } } } else { - this.funoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.funoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); } if (note.visibility === 'public' && note.userHost == null) { - this.funoutTimelineService.push('localTimeline', note.id, 1000, r); + this.fanoutTimelineService.push('localTimeline', note.id, 1000, r); if (note.fileIds.length > 0) { - this.funoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); + this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); } } } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index e76d6b1c6..2ab0931ee 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -21,7 +21,7 @@ import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import type { Packed } from '@/misc/json-schema.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { @@ -109,7 +109,7 @@ export class RoleService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private idService: IdService, private moderationLogService: ModerationLogService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineService: FanoutTimelineService, ) { //this.onMessage = this.onMessage.bind(this); @@ -496,7 +496,7 @@ export class RoleService implements OnApplicationShutdown { const redisPipeline = this.redisForTimelines.pipeline(); for (const role of roles) { - this.funoutTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline); + this.fanoutTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline); this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 934b57905..6d64c7ce7 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -12,6 +12,8 @@ import { MiNote } from '@/models/Note.js'; import { MiUser } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { CacheService } from '@/core/CacheService.js'; import { QueryService } from '@/core/QueryService.js'; import { IdService } from '@/core/IdService.js'; import type { Index, MeiliSearch } from 'meilisearch'; @@ -74,6 +76,7 @@ export class SearchService { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + private cacheService: CacheService, private queryService: QueryService, private idService: IdService, ) { @@ -187,8 +190,19 @@ export class SearchService { limit: pagination.limit, }); if (res.hits.length === 0) return []; - const notes = await this.notesRepository.findBy({ + const [ + userIdsWhoMeMuting, + userIdsWhoBlockingMe, + ] = me ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]) : [new Set(), new Set()]; + const notes = (await this.notesRepository.findBy({ id: In(res.hits.map(x => x.id)), + })).filter(note => { + if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; + return true; }); return notes.sort((a, b) => a.id > b.id ? -1 : 1); } else { diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index bd7f29802..d600ffb9d 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -29,7 +29,7 @@ import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { AccountMoveService } from '@/core/AccountMoveService.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import Logger from '../logger.js'; const logger = new Logger('following/create'); @@ -84,7 +84,7 @@ export class UserFollowingService implements OnModuleInit { private webhookService: WebhookService, private apRendererService: ApRendererService, private accountMoveService: AccountMoveService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineService: FanoutTimelineService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, ) { @@ -304,8 +304,6 @@ export class UserFollowingService implements OnModuleInit { }); } }); - - this.funoutTimelineService.purge(`homeTimeline:${follower.id}`); } // Publish followed event @@ -373,8 +371,6 @@ export class UserFollowingService implements OnModuleInit { }); } }); - - this.funoutTimelineService.purge(`homeTimeline:${follower.id}`); } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 098907b93..f496daa0e 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -306,9 +306,15 @@ export class ApInboxService { this.logger.info(`Creating the (Re)Note: ${uri}`); const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc); + const createdAt = activity.published ? new Date(activity.published) : null; + + if (createdAt && createdAt < this.idService.parse(renote.id).date) { + this.logger.warn('skip: malformed createdAt'); + return; + } await this.noteCreateService.create(actor, { - createdAt: activity.published ? new Date(activity.published) : null, + createdAt, renote, visibility: activityAudience.visibility, visibleUsers: activityAudience.visibleUsers, diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 1979cdda9..05d5ca15d 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -92,6 +92,10 @@ export class ApNoteService { return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } + if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) { + return new Error('invalid Note: published timestamp is malformed'); + } + return null; } diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index e7b59f262..de03f6793 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -34,3 +34,7 @@ export function parseAid(id: string): { date: Date; } { const time = parseInt(id.slice(0, 8), 36) + TIME2000; return { date: new Date(time) }; } + +export function isSafeAidT(t: number): boolean { + return t > TIME2000; +} diff --git a/packages/backend/src/misc/id/aidx.ts b/packages/backend/src/misc/id/aidx.ts index bed223225..9f457f6f0 100644 --- a/packages/backend/src/misc/id/aidx.ts +++ b/packages/backend/src/misc/id/aidx.ts @@ -41,3 +41,7 @@ export function parseAidx(id: string): { date: Date; } { const time = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000; return { date: new Date(time) }; } + +export function isSafeAidxT(t: number): boolean { + return t > TIME2000; +} diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts index 366738de0..7646282ed 100644 --- a/packages/backend/src/misc/id/meid.ts +++ b/packages/backend/src/misc/id/meid.ts @@ -38,3 +38,7 @@ export function parseMeid(id: string): { date: Date; } { date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000), }; } + +export function isSafeMeidT(t: number): boolean { + return t > 0; +} diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts index 426a46970..f2a55443e 100644 --- a/packages/backend/src/misc/id/meidg.ts +++ b/packages/backend/src/misc/id/meidg.ts @@ -38,3 +38,7 @@ export function parseMeidg(id: string): { date: Date; } { date: new Date(parseInt(id.slice(1, 12), 16)), }; } + +export function isSafeMeidgT(t: number): boolean { + return t > 0; +} diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts index 49bd9591c..f5c3619fd 100644 --- a/packages/backend/src/misc/id/object-id.ts +++ b/packages/backend/src/misc/id/object-id.ts @@ -38,3 +38,7 @@ export function parseObjectId(id: string): { date: Date; } { date: new Date(parseInt(id.slice(0, 8), 16) * 1000), }; } + +export function isSafeObjectIdT(t: number): boolean { + return t > 0; +} diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts index b231058a9..35fe11849 100644 --- a/packages/backend/src/misc/is-instance-muted.ts +++ b/packages/backend/src/misc/is-instance-muted.ts @@ -3,12 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MiNote } from '@/models/Note.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; - if (mutedInstances.has(note.reply?.user.host ?? '')) return true; - if (mutedInstances.has(note.renote?.user.host ?? '')) return true; +export function isInstanceMuted(note: Packed<'Note'> | MiNote, mutedInstances: Set): boolean { + if (mutedInstances.has(note.user?.host ?? '')) return true; + if (mutedInstances.has(note.reply?.user?.host ?? '')) return true; + if (mutedInstances.has(note.renote?.user?.host ?? '')) return true; return false; } diff --git a/packages/backend/src/misc/is-reply.ts b/packages/backend/src/misc/is-reply.ts new file mode 100644 index 000000000..964c2aa15 --- /dev/null +++ b/packages/backend/src/misc/is-reply.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { MiUser } from '@/models/User.js'; + +export function isReply(note: any, viewerId?: MiUser['id'] | undefined | null): boolean { + return note.replyId && note.replyUserId !== note.userId && note.replyUserId !== viewerId; +} diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 093168202..52ebb0787 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -36,9 +36,10 @@ import { packedPageLikeSchema, packedPageSchema } from '@/models/json-schema/pag import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; -import { packedRoleSchema } from '@/models/json-schema/role.js'; import { packedUserListMembershipSchema, packedUserListSchema } from '@/models/json-schema/user-list.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; +import { packedSigninSchema } from '@/models/json-schema/signin.js'; +import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -80,6 +81,8 @@ export const refs = { Flash: packedFlashSchema, FlashLike: packedFlashLikeSchema, + Signin: packedSigninSchema, + RoleLite: packedRoleLiteSchema, Role: packedRoleSchema, AbuseUserReport: packedAbuseUserReportSchema, ModerationLog: packedModerationLogSchema, diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 96763bae7..a3268fa94 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -29,6 +29,7 @@ export class MiUserProfile { }) public location: string | null; + @Index() @Column('char', { length: 10, nullable: true, comment: 'The birthday (YYYY-MM-DD) of the User.', diff --git a/packages/backend/src/models/json-schema/moderation-log.ts b/packages/backend/src/models/json-schema/moderation-log.ts index c3b472e30..e001a73ba 100644 --- a/packages/backend/src/models/json-schema/moderation-log.ts +++ b/packages/backend/src/models/json-schema/moderation-log.ts @@ -24,12 +24,6 @@ export const packedModerationLogSchema = { info: { type: 'object', optional: false, nullable: false, - patternProperties: { - '^': { - type: 'object', - nullable: false, optional: false, - }, - }, }, userId: { type: 'string', diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 392fa7e1c..aa749943f 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -186,6 +186,10 @@ export const packedNoteSchema = { optional: false, nullable: false, }, }, + clippedCount: { + type: 'number', + optional: true, nullable: false, + }, myReaction: { type: 'object', diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 07526942a..dd2f32b14 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -1,9 +1,30 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ +const rolePolicyValue = { + type: 'object', + properties: { + value: { + oneOf: [ + { + type: 'integer', + optional: false, nullable: false, + }, + { + type: 'boolean', + optional: false, nullable: false, + }, + ], + }, + priority: { + type: 'integer', + optional: false, nullable: false, + }, + useDefault: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; -export const packedRoleSchema = { +export const packedRoleLiteSchema = { type: 'object', properties: { id: { @@ -12,97 +33,125 @@ export const packedRoleSchema = { format: 'id', example: 'xxxxxxxxxx', }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, name: { type: 'string', optional: false, nullable: false, + example: 'New Role', + }, + color: { + type: 'string', + optional: false, nullable: true, + example: '#000000', + }, + iconUrl: { + type: 'string', + optional: false, nullable: true, }, description: { type: 'string', optional: false, nullable: false, }, - color: { - type: 'string', - optional: false, nullable: true, - }, - iconUrl: { - type: 'string', - format: 'url', - optional: false, nullable: true, - }, - target: { - type: 'string', - optional: false, nullable: false, - enum: ['manual', 'conditional'], - }, - condFormula: { - type: 'object', - optional: false, nullable: false, - }, - isPublic: { + isModerator: { type: 'boolean', optional: false, nullable: false, + example: false, }, isAdministrator: { type: 'boolean', optional: false, nullable: false, - }, - isModerator: { - type: 'boolean', - optional: false, nullable: false, - }, - isExplorable: { - type: 'boolean', - optional: false, nullable: false, - }, - asBadge: { - type: 'boolean', - optional: false, nullable: false, - }, - canEditMembersByModerator: { - type: 'boolean', - optional: false, nullable: false, + example: false, }, displayOrder: { - type: 'number', - optional: false, nullable: false, - }, - policies: { - type: 'object', - optional: false, nullable: false, - patternProperties: { - '^': { - type: 'object', - nullable: false, optional: false, - properties: { - useDefault: { - type: 'boolean', - nullable: false, optional: false, - }, - priority: { - type: 'number', - nullable: false, optional: false, - }, - value: { - type: 'object', - nullable: false, optional: false, - }, - }, - }, - }, - }, - usersCount: { - type: 'number', + type: 'integer', optional: false, nullable: false, + example: 0, }, }, } as const; + +export const packedRoleSchema = { + type: 'object', + allOf: [ + { + type: 'object', + ref: 'RoleLite', + }, + { + type: 'object', + properties: { + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + target: { + type: 'string', + optional: false, nullable: false, + enum: ['manual', 'conditional'], + }, + condFormula: { + type: 'object', + optional: false, nullable: false, + }, + isPublic: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + isExplorable: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + asBadge: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + canEditMembersByModerator: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + policies: { + type: 'object', + optional: false, nullable: false, + properties: { + pinLimit: rolePolicyValue, + canInvite: rolePolicyValue, + clipLimit: rolePolicyValue, + canHideAds: rolePolicyValue, + inviteLimit: rolePolicyValue, + antennaLimit: rolePolicyValue, + gtlAvailable: rolePolicyValue, + ltlAvailable: rolePolicyValue, + webhookLimit: rolePolicyValue, + canPublicNote: rolePolicyValue, + userListLimit: rolePolicyValue, + wordMuteLimit: rolePolicyValue, + alwaysMarkNsfw: rolePolicyValue, + canSearchNotes: rolePolicyValue, + driveCapacityMb: rolePolicyValue, + rateLimitFactor: rolePolicyValue, + inviteLimitCycle: rolePolicyValue, + noteEachClipsLimit: rolePolicyValue, + inviteExpirationTime: rolePolicyValue, + canManageCustomEmojis: rolePolicyValue, + userEachUserListsLimit: rolePolicyValue, + canManageAvatarDecorations: rolePolicyValue, + canUseTranslator: rolePolicyValue, + }, + }, + usersCount: { + type: 'integer', + optional: false, nullable: false, + }, + }, + }, + ], +} as const; diff --git a/packages/backend/src/models/json-schema/signin.ts b/packages/backend/src/models/json-schema/signin.ts new file mode 100644 index 000000000..d27d2490c --- /dev/null +++ b/packages/backend/src/models/json-schema/signin.ts @@ -0,0 +1,26 @@ +export const packedSigninSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + ip: { + type: 'string', + optional: false, nullable: false, + }, + headers: { + type: 'object', + optional: false, nullable: false, + }, + success: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index c63fac54b..0c4e7a124 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -3,6 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +const notificationRecieveConfig = { + type: 'object', + nullable: false, optional: true, + properties: { + type: { + type: 'string', + nullable: false, optional: false, + enum: ['all', 'following', 'follower', 'mutualFollow', 'list', 'never'], + }, + }, +} as const; + export const packedUserLiteSchema = { type: 'object', properties: { @@ -321,41 +333,7 @@ export const packedUserDetailedNotMeOnlySchema = { items: { type: 'object', nullable: false, optional: false, - properties: { - id: { - type: 'string', - nullable: false, optional: false, - format: 'id', - }, - name: { - type: 'string', - nullable: false, optional: false, - }, - color: { - type: 'string', - nullable: true, optional: false, - }, - iconUrl: { - type: 'string', - nullable: true, optional: false, - }, - description: { - type: 'string', - nullable: false, optional: false, - }, - isModerator: { - type: 'boolean', - nullable: false, optional: false, - }, - isAdministrator: { - type: 'boolean', - nullable: false, optional: false, - }, - displayOrder: { - type: 'number', - nullable: false, optional: false, - }, - }, + ref: 'RoleLite', }, }, memo: { @@ -402,6 +380,7 @@ export const packedUserDetailedNotMeOnlySchema = { notify: { type: 'string', nullable: false, optional: true, + enum: ['normal', 'none'], }, withReplies: { type: 'boolean', @@ -545,6 +524,19 @@ export const packedMeDetailedOnlySchema = { notificationRecieveConfig: { type: 'object', nullable: false, optional: false, + properties: { + app: notificationRecieveConfig, + quote: notificationRecieveConfig, + reply: notificationRecieveConfig, + follow: notificationRecieveConfig, + renote: notificationRecieveConfig, + mention: notificationRecieveConfig, + reaction: notificationRecieveConfig, + pollEnded: notificationRecieveConfig, + achievementEarned: notificationRecieveConfig, + receiveFollowRequest: notificationRecieveConfig, + followRequestAccepted: notificationRecieveConfig, + }, }, emailNotificationTypes: { type: 'array', @@ -689,6 +681,23 @@ export const packedMeDetailedOnlySchema = { items: { type: 'object', nullable: false, optional: false, + properties: { + id: { + type: 'string', + nullable: false, optional: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + name: { + type: 'string', + nullable: false, optional: false, + }, + lastUsed: { + type: 'string', + nullable: false, optional: false, + format: 'date-time', + }, + }, }, }, //#endregion diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 3c8eaaa7e..0ae84187f 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -96,6 +96,11 @@ export class NodeinfoServerService { metadata: { nodeName: meta.name, nodeDescription: meta.description, + nodeAdmins: [{ + name: meta.maintainerName, + email: meta.maintainerEmail, + }], + // deprecated maintainer: { name: meta.maintainerName, email: meta.maintainerEmail, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 8dbceb919..4c7998cd3 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -126,8 +126,8 @@ export class ServerService implements OnApplicationShutdown { return; } - const name = path.split('@')[0].replace('.webp', ''); - const host = path.split('@')[1]?.replace('.webp', ''); + const name = path.split('@')[0].replace(/\.webp$/i, ''); + const host = path.split('@')[1]?.replace(/\.webp$/i, ''); const emoji = await this.emojisRepository.findOneBy({ // `@.` is the spec of ReactionService.decodeReaction 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 a65e4e762..e7162141d 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -6,11 +6,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { EmojisRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { DI } from '@/di-symbols.js'; import { DriveService } from '@/core/DriveService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { ApiError } from '../../../error.js'; @@ -26,6 +25,11 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: 'e2785b66-dca3-4087-9cac-b93c541cc425', }, + duplicateName: { + message: 'Duplicate name.', + code: 'DUPLICATE_NAME', + id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975', + }, }, res: { @@ -56,15 +60,12 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - private emojiEntityService: EmojiEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me) => { const emoji = await this.emojisRepository.findOneBy({ id: ps.emojiId }); - if (emoji == null) { throw new ApiError(meta.errors.noSuchEmoji); } @@ -75,28 +76,28 @@ export default class extends Endpoint { // eslint- // Create file driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); } catch (e) { + // TODO: need to return Drive Error throw new ApiError(); } - const copied = await this.emojisRepository.insert({ - id: this.idService.gen(), - updatedAt: new Date(), + // Duplication Check + const isDuplicate = await this.customEmojiService.checkDuplicate(emoji.name); + if (isDuplicate) throw new ApiError(meta.errors.duplicateName); + + const addedEmoji = await this.customEmojiService.add({ + driveFile, name: emoji.name, + category: emoji.category, + aliases: emoji.aliases, host: null, - aliases: [], - originalUrl: driveFile.url, - publicUrl: driveFile.webpublicUrl ?? driveFile.url, - type: driveFile.webpublicType ?? driveFile.type, license: emoji.license, - }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + isSensitive: emoji.isSensitive, + localOnly: emoji.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, + roleIdsThatCanNotBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanNotBeUsedThisEmojiAsReaction, + }, me); - this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: await this.emojiEntityService.packDetailed(copied.id), - }); - - return { - id: copied.id, - }; + return this.emojiEntityService.packDetailed(addedEmoji); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts index 4a22fd482..c6ee45735 100644 --- a/packages/backend/src/server/api/endpoints/admin/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/invite/create.ts @@ -33,13 +33,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - properties: { - code: { - type: 'string', - optional: false, nullable: false, - example: 'GR6S02ERUA5VR', - }, - }, + ref: 'InviteCode', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/invite/list.ts b/packages/backend/src/server/api/endpoints/admin/invite/list.ts index f25d3fcb3..ff57940d4 100644 --- a/packages/backend/src/server/api/endpoints/admin/invite/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/invite/list.ts @@ -21,6 +21,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, + ref: 'InviteCode', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index cf52682f0..6a765aed1 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -335,6 +335,82 @@ export const meta = { optional: false, nullable: false, }, }, + backgroundImageUrl: { + type: 'string', + optional: false, nullable: true, + }, + deeplAuthKey: { + type: 'string', + optional: false, nullable: true, + }, + deeplIsPro: { + type: 'boolean', + optional: false, nullable: false, + }, + defaultDarkTheme: { + type: 'string', + optional: false, nullable: true, + }, + defaultLightTheme: { + type: 'string', + optional: false, nullable: true, + }, + description: { + type: 'string', + optional: false, nullable: true, + }, + disableRegistration: { + type: 'boolean', + optional: false, nullable: false, + }, + impressumUrl: { + type: 'string', + optional: false, nullable: true, + }, + maintainerEmail: { + type: 'string', + optional: false, nullable: true, + }, + maintainerName: { + type: 'string', + optional: false, nullable: true, + }, + name: { + type: 'string', + optional: false, nullable: true, + }, + objectStorageS3ForcePathStyle: { + type: 'boolean', + optional: false, nullable: false, + }, + privacyPolicyUrl: { + type: 'string', + optional: false, nullable: true, + }, + repositoryUrl: { + type: 'string', + optional: false, nullable: false, + }, + summalyProxy: { + type: 'string', + optional: false, nullable: true, + }, + themeColor: { + type: 'string', + optional: false, nullable: true, + }, + tosUrl: { + type: 'string', + optional: false, nullable: true, + }, + uri: { + type: 'string', + optional: false, nullable: false, + }, + version: { + type: 'string', + optional: false, nullable: false, + }, }, }, } as const; 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 8451b1955..fb5381533 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -13,6 +13,12 @@ export const meta = { requireCredential: true, requireAdmin: true, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'Role', + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/roles/list.ts b/packages/backend/src/server/api/endpoints/admin/roles/list.ts index 3ed4b324d..71b8e44e7 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/list.ts @@ -14,6 +14,16 @@ export const meta = { requireCredential: true, requireModerator: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Role', + }, + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/roles/show.ts b/packages/backend/src/server/api/endpoints/admin/roles/show.ts index 5f0accab6..1ca952a3f 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/show.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/show.ts @@ -23,6 +23,12 @@ export const meta = { id: '07dc7d34-c0d8-49b7-96c6-db3ce64ee0b3', }, }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'Role', + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 29e56b108..0bf2688b4 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -12,7 +12,7 @@ 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 { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ApiError } from '../../error.js'; @@ -71,7 +71,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, private noteReadService: NoteReadService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineService: FanoutTimelineService, private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { @@ -98,7 +98,7 @@ export default class extends Endpoint { // eslint- this.globalEventService.publishInternalEvent('antennaUpdated', antenna); } - let noteIds = await this.funoutTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId); + let noteIds = await this.fanoutTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId); noteIds = noteIds.slice(0, ps.limit); if (noteIds.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 4ca7325f3..006228cee 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -4,18 +4,17 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, MiNote, NotesRepository } from '@/models/_.js'; +import type { ChannelsRepository, NotesRepository } from '@/models/_.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 { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import { CacheService } from '@/core/CacheService.js'; import { MetaService } from '@/core/MetaService.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { MiLocalUser } from '@/models/User.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -51,6 +50,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, + allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default }, required: ['channelId'], } as const; @@ -58,9 +58,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -70,7 +67,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private cacheService: CacheService, private activeUsersChart: ActiveUsersChart, private metaService: MetaService, @@ -78,7 +75,6 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const isRangeSpecified = untilId != null && sinceId != null; const serverSettings = await this.metaService.fetch(); @@ -92,64 +88,48 @@ export default class extends Endpoint { // eslint- if (me) this.activeUsersChart.read(me); - if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) { - const [ - userIdsWhoMeMuting, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - ]) : [new Set()]; - - let noteIds = await this.funoutTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId); - noteIds = noteIds.slice(0, ps.limit); - - if (noteIds.length > 0) { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - let timeline = await query.getMany(); - - timeline = timeline.filter(note => { - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - - return true; - }); - - // TODO: フィルタで件数が減った場合の埋め合わせ処理 - - timeline.sort((a, b) => a.id > b.id ? -1 : 1); - - if (timeline.length > 0) { - return await this.noteEntityService.packMany(timeline, me); - } - } + if (!serverSettings.enableFanoutTimeline) { + return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me); } - //#region fallback to database - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.channelId = :channelId', { channelId: channel.id }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - //#endregion - - const timeline = await query.limit(ps.limit).getMany(); - - return await this.noteEntityService.packMany(timeline, me); - //#endregion + return await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + useDbFallback: true, + redisTimelines: [`channelTimeline:${channel.id}`], + excludePureRenotes: false, + dbFallback: async (untilId, sinceId, limit) => { + return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me); + }, + }); }); } + + private async getFromDb(ps: { + untilId: string | null, + sinceId: string | null, + limit: number, + channelId: string + }, me: MiLocalUser | null) { + //#region fallback to database + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.channelId = :channelId', { channelId: ps.channelId }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + return await query.limit(ps.limit).getMany(); + } } diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index c8beefa9c..e5a90715f 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -36,13 +36,32 @@ export const paramDef = { blocked: { type: 'boolean', nullable: true }, notResponding: { type: 'boolean', nullable: true }, suspended: { type: 'boolean', nullable: true }, - silenced: { type: "boolean", nullable: true }, + silenced: { type: 'boolean', nullable: true }, federating: { type: 'boolean', nullable: true }, subscribing: { type: 'boolean', nullable: true }, publishing: { type: 'boolean', nullable: true }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, offset: { type: 'integer', default: 0 }, - sort: { type: 'string' }, + sort: { + type: 'string', + nullable: true, + enum: [ + '+pubSub', + '-pubSub', + '+notes', + '-notes', + '+users', + '-users', + '+following', + '-following', + '+followers', + '-followers', + '+firstRetrievedAt', + '-firstRetrievedAt', + '+latestRequestReceivedAt', + '-latestRequestReceivedAt', + ], + }, }, required: [], } as const; @@ -103,18 +122,18 @@ export default class extends Endpoint { // eslint- } } - if (typeof ps.silenced === "boolean") { + if (typeof ps.silenced === 'boolean') { const meta = await this.metaService.fetch(true); if (ps.silenced) { if (meta.silencedHosts.length === 0) { return []; } - query.andWhere("instance.host IN (:...silences)", { + query.andWhere('instance.host IN (:...silences)', { silences: meta.silencedHosts, }); } else if (meta.silencedHosts.length > 0) { - query.andWhere("instance.host NOT IN (:...silences)", { + query.andWhere('instance.host NOT IN (:...silences)', { silences: meta.silencedHosts, }); } diff --git a/packages/backend/src/server/api/endpoints/gallery/featured.ts b/packages/backend/src/server/api/endpoints/gallery/featured.ts index cbab3a83a..cea423406 100644 --- a/packages/backend/src/server/api/endpoints/gallery/featured.ts +++ b/packages/backend/src/server/api/endpoints/gallery/featured.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { GalleryPostsRepository } from '@/models/_.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { DI } from '@/di-symbols.js'; +import { FeaturedService } from '@/core/FeaturedService.js'; export const meta = { tags: ['gallery'], @@ -27,25 +28,49 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + untilId: { type: 'string', format: 'misskey:id' }, + }, required: [], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export + private galleryPostsRankingCache: string[] = []; + private galleryPostsRankingCacheLastFetchedAt = 0; + constructor( @Inject(DI.galleryPostsRepository) private galleryPostsRepository: GalleryPostsRepository, private galleryPostEntityService: GalleryPostEntityService, + private featuredService: FeaturedService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.galleryPostsRepository.createQueryBuilder('post') - .andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) }) - .andWhere('post.likedCount > 0') - .orderBy('post.likedCount', 'DESC'); + let postIds: string[]; + if (this.galleryPostsRankingCacheLastFetchedAt !== 0 && (Date.now() - this.galleryPostsRankingCacheLastFetchedAt < 1000 * 60 * 30)) { + postIds = this.galleryPostsRankingCache; + } else { + postIds = await this.featuredService.getGalleryPostsRanking(100); + this.galleryPostsRankingCache = postIds; + this.galleryPostsRankingCacheLastFetchedAt = Date.now(); + } - const posts = await query.limit(10).getMany(); + postIds.sort((a, b) => a > b ? -1 : 1); + if (ps.untilId) { + postIds = postIds.filter(id => id < ps.untilId!); + } + postIds = postIds.slice(0, ps.limit); + + if (postIds.length === 0) { + return []; + } + + const query = this.galleryPostsRepository.createQueryBuilder('post') + .where('post.id IN (:...postIds)', { postIds: postIds }); + + const posts = await query.getMany(); return await this.galleryPostEntityService.packMany(posts, me); }); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index 165d3945b..f56285f8a 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/_.js'; +import { FeaturedService, GALLERY_POSTS_RANKING_WINDOW } from '@/core/FeaturedService.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -58,6 +59,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.galleryLikesRepository) private galleryLikesRepository: GalleryLikesRepository, + private featuredService: FeaturedService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { @@ -89,6 +91,11 @@ export default class extends Endpoint { // eslint- userId: me.id, }); + // ランキング更新 + if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { + await this.featuredService.updateGalleryPostsRanking(post.id, 1); + } + this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1); }); } diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts index faab1ece6..3f37f05d4 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -6,6 +6,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { GalleryPostsRepository, GalleryLikesRepository } from '@/models/_.js'; +import { FeaturedService, GALLERY_POSTS_RANKING_WINDOW } from '@/core/FeaturedService.js'; +import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -50,6 +52,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.galleryLikesRepository) private galleryLikesRepository: GalleryLikesRepository, + + private featuredService: FeaturedService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId }); @@ -69,6 +74,11 @@ export default class extends Endpoint { // eslint- // Delete like await this.galleryLikesRepository.delete(exist.id); + // ランキング更新 + if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { + await this.featuredService.updateGalleryPostsRanking(post.id, -1); + } + this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1); }); } diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts index 139bede7b..f82e3f9b2 100644 --- a/packages/backend/src/server/api/endpoints/i/signin-history.ts +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -12,8 +12,17 @@ import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, - secure: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Signin', + }, + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts index 94836283f..d82fa50e4 100644 --- a/packages/backend/src/server/api/endpoints/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/invite/create.ts @@ -31,13 +31,7 @@ export const meta = { res: { type: 'object', optional: false, nullable: false, - properties: { - code: { - type: 'string', - optional: false, nullable: false, - example: 'GR6S02ERUA5VR', - }, - }, + ref: 'InviteCode', }, } as const; diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts index 06139b680..2107516ce 100644 --- a/packages/backend/src/server/api/endpoints/invite/list.ts +++ b/packages/backend/src/server/api/endpoints/invite/list.ts @@ -9,7 +9,6 @@ import type { RegistrationTicketsRepository } from '@/models/_.js'; import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; export const meta = { tags: ['meta'], @@ -23,6 +22,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, + ref: 'InviteCode', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 2aed5d68b..6d9193ac7 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -250,6 +250,33 @@ export const meta = { }, }, }, + backgroundImageUrl: { + type: 'string', + optional: false, nullable: true, + }, + impressumUrl: { + type: 'string', + optional: false, nullable: true, + }, + logoImageUrl: { + type: 'string', + optional: false, nullable: true, + }, + privacyPolicyUrl: { + type: 'string', + optional: false, nullable: true, + }, + serverRules: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, + themeColor: { + type: 'string', + optional: false, nullable: true, + }, }, }, } as const; 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 408c2fa37..effcbaf2e 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,20 +5,20 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, FollowingsRepository, MiNote, ChannelFollowingsRepository } from '@/models/_.js'; +import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.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 { isUserRelated } from '@/misc/is-user-related.js'; import { CacheService } from '@/core/CacheService.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -42,6 +42,12 @@ export const meta = { code: 'STL_DISABLED', id: '620763f4-f621-4533-ab33-0577a1a3c342', }, + + bothWithRepliesAndWithFiles: { + message: 'Specifying both withReplies and withFiles is not supported', + code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', + id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f' + }, }, } as const; @@ -53,6 +59,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, + allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, @@ -77,10 +84,10 @@ export default class extends Endpoint { // eslint- private activeUsersChart: ActiveUsersChart, private idService: IdService, private cacheService: CacheService, - private funoutTimelineService: FunoutTimelineService, private queryService: QueryService, private userFollowingService: UserFollowingService, private metaService: MetaService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -91,10 +98,12 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.stlDisabled); } + if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); + const serverSettings = await this.metaService.fetch(); if (!serverSettings.enableFanoutTimeline) { - return await this.getFromDb({ + const timeline = await this.getFromDb({ untilId, sinceId, limit: ps.limit, @@ -104,103 +113,61 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withReplies: ps.withReplies, }, me); - } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]); - - let noteIds: string[]; - let shouldFallbackToDb = false; - - if (ps.withFiles) { - const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimelineWithFiles:${me.id}`, - 'localTimelineWithFiles', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); - } else if (ps.withReplies) { - const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimeline:${me.id}`, - 'localTimeline', - 'localTimelineWithReplies', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds])); - } else { - const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimeline:${me.id}`, - 'localTimeline', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); - shouldFallbackToDb = htlNoteIds.length === 0; - } - - noteIds.sort((a, b) => a > b ? -1 : 1); - noteIds = noteIds.slice(0, ps.limit); - - shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); - - let redisTimeline: MiNote[] = []; - - if (!shouldFallbackToDb) { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - redisTimeline = await query.getMany(); - - redisTimeline = redisTimeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } - - return true; - }); - - redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); - } - - if (redisTimeline.length > 0) { process.nextTick(() => { this.activeUsersChart.read(me); }); - return await this.noteEntityService.packMany(redisTimeline, me); - } else { - if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db - return await this.getFromDb({ - untilId, - sinceId, - limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withReplies: ps.withReplies, - }, me); - } else { - return []; - } + return await this.noteEntityService.packMany(timeline, me); } + + let timelineConfig: FanoutTimelineName[]; + + if (ps.withFiles) { + timelineConfig = [ + `homeTimelineWithFiles:${me.id}`, + 'localTimelineWithFiles', + ]; + } else if (ps.withReplies) { + timelineConfig = [ + `homeTimeline:${me.id}`, + 'localTimeline', + 'localTimelineWithReplies', + ]; + } else { + timelineConfig = [ + `homeTimeline:${me.id}`, + 'localTimeline', + ]; + } + + const redisTimeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + redisTimelines: timelineConfig, + useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + }, me), + }); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return redisTimeline; }); } @@ -301,12 +268,6 @@ export default class extends Endpoint { // eslint- } //#endregion - const timeline = await query.limit(ps.limit).getMany(); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return await this.noteEntityService.packMany(timeline, me); + return await query.limit(ps.limit).getMany(); } } 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 79baa6b28..3fd4dc83f 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { MiNote, NotesRepository } from '@/models/_.js'; +import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -13,11 +13,10 @@ import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -39,6 +38,12 @@ export const meta = { code: 'LTL_DISABLED', id: '45a6eb02-7695-4393-b023-dd3be9aaaefd', }, + + bothWithRepliesAndWithFiles: { + message: 'Specifying both withReplies and withFiles is not supported', + code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', + id: 'dd9c8400-1cb5-4eef-8a31-200c5f933793', + }, }, } as const; @@ -48,10 +53,10 @@ export const paramDef = { withFiles: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, withReplies: { type: 'boolean', default: false }, - excludeNsfw: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, }, @@ -69,7 +74,7 @@ export default class extends Endpoint { // eslint- private activeUsersChart: ActiveUsersChart, private idService: IdService, private cacheService: CacheService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, private metaService: MetaService, ) { @@ -82,98 +87,58 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.ltlDisabled); } + if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); + const serverSettings = await this.metaService.fetch(); if (!serverSettings.enableFanoutTimeline) { - return await this.getFromDb({ + const timeline = await this.getFromDb({ untilId, sinceId, limit: ps.limit, withFiles: ps.withFiles, withReplies: ps.withReplies, }, me); - } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set(), new Set()]; - - let noteIds: string[]; - - if (ps.withFiles) { - noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId); - } else { - const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([ - 'localTimeline', - 'localTimelineWithReplies', - ], untilId, sinceId); - noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds])); - noteIds.sort((a, b) => a > b ? -1 : 1); - } - - noteIds = noteIds.slice(0, ps.limit); - - let redisTimeline: MiNote[] = []; - - if (noteIds.length > 0) { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - redisTimeline = await query.getMany(); - - redisTimeline = redisTimeline.filter(note => { - if (me && (note.userId === me.id)) { - return true; - } - if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false; - if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } - - return true; - }); - - redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); - } - - if (redisTimeline.length > 0) { process.nextTick(() => { if (me) { this.activeUsersChart.read(me); } }); - return await this.noteEntityService.packMany(redisTimeline, me); - } else { - if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db - return await this.getFromDb({ - untilId, - sinceId, - limit: ps.limit, - withFiles: ps.withFiles, - withReplies: ps.withReplies, - }, me); - } else { - return []; - } + return await this.noteEntityService.packMany(timeline, me); } + + const timeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + redisTimelines: + ps.withFiles ? ['localTimelineWithFiles'] + : ps.withReplies ? ['localTimeline', 'localTimelineWithReplies'] + : me ? ['localTimeline', `localTimelineWithReplyTo:${me.id}`] + : ['localTimeline'], + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + }, me), + }); + + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return timeline; }); } @@ -214,14 +179,6 @@ export default class extends Endpoint { // eslint- })); } - const timeline = await query.limit(ps.limit).getMany(); - - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); - - return await this.noteEntityService.packMany(timeline, me); + return await query.limit(ps.limit).getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 8037d4862..790bcbe15 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { MiNote, NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; +import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -13,11 +13,10 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; import { MetaService } from '@/core/MetaService.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; export const meta = { tags: ['notes'], @@ -43,6 +42,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, + allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, @@ -65,7 +65,7 @@ export default class extends Endpoint { // eslint- private activeUsersChart: ActiveUsersChart, private idService: IdService, private cacheService: CacheService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private userFollowingService: UserFollowingService, private queryService: QueryService, private metaService: MetaService, @@ -77,7 +77,7 @@ export default class extends Endpoint { // eslint- const serverSettings = await this.metaService.fetch(); if (!serverSettings.enableFanoutTimeline) { - return await this.getFromDb({ + const timeline = await this.getFromDb({ untilId, sinceId, limit: ps.limit, @@ -87,81 +87,54 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withRenotes: ps.withRenotes, }, me); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); } const [ followings, - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, ] = await Promise.all([ this.cacheService.userFollowingsCache.fetch(me.id), - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), ]); - let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); - noteIds = noteIds.slice(0, ps.limit); - - let redisTimeline: MiNote[] = []; - - if (noteIds.length > 0) { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - redisTimeline = await query.getMany(); - - redisTimeline = redisTimeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } + const timeline = this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`], + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + noteFilter: note => { if (note.reply && note.reply.visibility === 'followers') { if (!Object.hasOwn(followings, note.reply.userId)) return false; } return true; - }); + }, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + }, me), + }); - redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); - } + process.nextTick(() => { + this.activeUsersChart.read(me); + }); - if (redisTimeline.length > 0) { - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return await this.noteEntityService.packMany(redisTimeline, me); - } else { - if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db - return await this.getFromDb({ - untilId, - sinceId, - limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withRenotes: ps.withRenotes, - }, me); - } else { - return []; - } - } + return timeline; }); } @@ -269,12 +242,6 @@ export default class extends Endpoint { // eslint- } //#endregion - const timeline = await query.limit(ps.limit).getMany(); - - process.nextTick(() => { - this.activeUsersChart.read(me); - }); - - return await this.noteEntityService.packMany(timeline, me); + return await query.limit(ps.limit).getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 562a15651..4f575e080 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -5,18 +5,17 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; -import type { MiNote, MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; import { MetaService } from '@/core/MetaService.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -52,6 +51,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, + allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, @@ -81,7 +81,7 @@ export default class extends Endpoint { // eslint- private activeUsersChart: ActiveUsersChart, private cacheService: CacheService, private idService: IdService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, private metaService: MetaService, ) { @@ -101,7 +101,7 @@ export default class extends Endpoint { // eslint- const serverSettings = await this.metaService.fetch(); if (!serverSettings.enableFanoutTimeline) { - return await this.getFromDb(list, { + const timeline = await this.getFromDb(list, { untilId, sinceId, limit: ps.limit, @@ -111,73 +111,37 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withRenotes: ps.withRenotes, }, me); - } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]); - - let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId); - noteIds = noteIds.slice(0, ps.limit); - - let redisTimeline: MiNote[] = []; - - if (noteIds.length > 0) { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - redisTimeline = await query.getMany(); - - redisTimeline = redisTimeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } - - return true; - }); - - redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); - } - - if (redisTimeline.length > 0) { this.activeUsersChart.read(me); - return await this.noteEntityService.packMany(redisTimeline, me); - } else { - if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db - return await this.getFromDb(list, { - untilId, - sinceId, - limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, - withFiles: ps.withFiles, - withRenotes: ps.withRenotes, - }, me); - } else { - return []; - } + + await this.noteEntityService.packMany(timeline, me); } + + const timeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`], + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, { + untilId, + sinceId, + limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + }, me), + }); + + this.activeUsersChart.read(me); + + return timeline; }); } @@ -270,10 +234,6 @@ export default class extends Endpoint { // eslint- } //#endregion - const timeline = await query.limit(ps.limit).getMany(); - - this.activeUsersChart.read(me); - - return await this.noteEntityService.packMany(timeline, me); + return await query.limit(ps.limit).getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/roles/list.ts b/packages/backend/src/server/api/endpoints/roles/list.ts index d1de73ad3..dc2be8e11 100644 --- a/packages/backend/src/server/api/endpoints/roles/list.ts +++ b/packages/backend/src/server/api/endpoints/roles/list.ts @@ -13,6 +13,16 @@ export const meta = { tags: ['role'], requireCredential: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Role', + }, + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index daa9affc2..7010df22c 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -11,7 +11,7 @@ import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -66,7 +66,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineService: FanoutTimelineService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -84,7 +84,7 @@ export default class extends Endpoint { // eslint- return []; } - let noteIds = await this.funoutTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId); + let noteIds = await this.fanoutTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId); noteIds = noteIds.slice(0, ps.limit); if (noteIds.length === 0) { diff --git a/packages/backend/src/server/api/endpoints/roles/show.ts b/packages/backend/src/server/api/endpoints/roles/show.ts index 2afa0e7b7..6bfe52bb1 100644 --- a/packages/backend/src/server/api/endpoints/roles/show.ts +++ b/packages/backend/src/server/api/endpoints/roles/show.ts @@ -22,6 +22,12 @@ export const meta = { id: 'de5502bf-009a-4639-86c1-fec349e46dcb', }, }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'Role', + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 03487275a..ead7ba8c4 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -42,6 +42,12 @@ export const meta = { code: 'FORBIDDEN', id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba', }, + + birthdayInvalid: { + message: 'Birthday date format is invalid.', + code: 'BIRTHDAY_DATE_FORMAT_INVALID', + id: 'a2b007b9-4782-4eba-abd3-93b05ed4130d', + }, }, } as const; @@ -59,6 +65,8 @@ export const paramDef = { nullable: true, description: 'The local host is represented with `null`.', }, + + birthday: { type: 'string', nullable: true }, }, anyOf: [ { required: ['userId'] }, @@ -117,6 +125,21 @@ export default class extends Endpoint { // eslint- .andWhere('following.followerId = :userId', { userId: user.id }) .innerJoinAndSelect('following.followee', 'followee'); + if (ps.birthday) { + try { + const d = new Date(ps.birthday); + d.setHours(0, 0, 0, 0); + const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`; + const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile'); + birthdayUserQuery.select('user_profile.userId') + .where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`); + + query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`); + } catch (err) { + throw new ApiError(meta.errors.birthdayInvalid); + } + } + const followings = await query .limit(ps.limit) .getMany(); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 415ae42d3..bad02c140 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -5,18 +5,18 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { MiNote, NotesRepository } from '@/models/_.js'; +import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import { QueryService } from '@/core/QueryService.js'; -import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { MetaService } from '@/core/MetaService.js'; -import { ApiError } from '../../error.js'; +import { MiLocalUser } from '@/models/User.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; +import { ApiError } from '@/server/api/error.js'; export const meta = { tags: ['users', 'notes'], @@ -37,6 +37,12 @@ export const meta = { code: 'NO_SUCH_USER', id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b', }, + + bothWithRepliesAndWithFiles: { + message: 'Specifying both withReplies and withFiles is not supported', + code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', + id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222', + }, }, } as const; @@ -52,8 +58,8 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, + allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default withFiles: { type: 'boolean', default: false }, - excludeNsfw: { type: 'boolean', default: false }, }, required: ['userId'], } as const; @@ -61,9 +67,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -71,127 +74,122 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private cacheService: CacheService, private idService: IdService, - private funoutTimelineService: FunoutTimelineService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const isRangeSpecified = untilId != null && sinceId != null; const isSelf = me && (me.id === ps.userId); const serverSettings = await this.metaService.fetch(); - if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) { - const [ - userIdsWhoMeMuting, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - ]) : [new Set()]; + if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = ps.withFiles - ? await Promise.all([ - this.funoutTimelineService.get(`userTimelineWithFiles:${ps.userId}`, untilId, sinceId), - ps.withReplies ? this.funoutTimelineService.get(`userTimelineWithRepliesWithFiles:${ps.userId}`, untilId, sinceId) : Promise.resolve([]), - ps.withChannelNotes ? this.funoutTimelineService.get(`userTimelineWithChannelWithFiles:${ps.userId}`, untilId, sinceId) : Promise.resolve([]), - ]) - : await Promise.all([ - this.funoutTimelineService.get(`userTimeline:${ps.userId}`, untilId, sinceId), - ps.withReplies ? this.funoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]), - ps.withChannelNotes ? this.funoutTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]), - ]); + if (!serverSettings.enableFanoutTimeline) { + const timeline = await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + userId: ps.userId, + withChannelNotes: ps.withChannelNotes, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + }, me); - let noteIds = Array.from(new Set([ - ...noteIdsRes, - ...repliesNoteIdsRes, - ...channelNoteIdsRes, - ])); - noteIds.sort((a, b) => a > b ? -1 : 1); - noteIds = noteIds.slice(0, ps.limit); - - if (noteIds.length > 0) { - const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId); - - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - let timeline = await query.getMany(); - - timeline = timeline.filter(note => { - if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false; - - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (ps.withRenotes === false) return false; - } - } - - if (note.channel?.isSensitive && !isSelf) return false; - if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; - if (note.visibility === 'followers' && !isFollowing && !isSelf) return false; - - return true; - }); - - // TODO: フィルタで件数が減った場合の埋め合わせ処理 - - timeline.sort((a, b) => a.id > b.id ? -1 : 1); - - if (timeline.length > 0) { - return await this.noteEntityService.packMany(timeline, me); - } - } + return await this.noteEntityService.packMany(timeline, me); } - //#region fallback to database - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.userId = :userId', { userId: ps.userId }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('note.channel', 'channel') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + const redisTimelines: FanoutTimelineName[] = [ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`]; - if (ps.withChannelNotes) { - if (!isSelf) query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('channel.isSensitive = false'); - })); - } else { - query.andWhere('note.channelId IS NULL'); - } + if (ps.withReplies) redisTimelines.push(ps.withFiles ? `userTimelineWithRepliesWithFiles:${ps.userId}` : `userTimelineWithReplies:${ps.userId}`); + if (ps.withChannelNotes) redisTimelines.push(ps.withFiles ? `userTimelineWithChannelWithFiles:${ps.userId}` : `userTimelineWithChannel:${ps.userId}`); - this.queryService.generateVisibilityQuery(query, me); - if (me) { - this.queryService.generateMutedUserQuery(query, me, { id: ps.userId }); - this.queryService.generateBlockedUserQuery(query, me); - } + const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } + const timeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + redisTimelines, + useDbFallback: true, + ignoreAuthorFromMute: true, + excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies + excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files + excludePureRenotes: !ps.withRenotes, + noteFilter: note => { + if (note.channel?.isSensitive && !isSelf) return false; + if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; + if (note.visibility === 'followers' && !isFollowing && !isSelf) return false; - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :userId', { userId: ps.userId }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + return true; + }, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + userId: ps.userId, + withChannelNotes: ps.withChannelNotes, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + }, me), + }); - const timeline = await query.limit(ps.limit).getMany(); - - return await this.noteEntityService.packMany(timeline, me); - //#endregion + return timeline; }); } + + private async getFromDb(ps: { + untilId: string | null, + sinceId: string | null, + limit: number, + userId: string, + withChannelNotes: boolean, + withFiles: boolean, + withRenotes: boolean, + }, me: MiLocalUser | null) { + const isSelf = me && (me.id === ps.userId); + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.userId = :userId', { userId: ps.userId }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('note.channel', 'channel') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (ps.withChannelNotes) { + if (!isSelf) query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('channel.isSensitive = false'); + })); + } else { + query.andWhere('note.channelId IS NULL'); + } + + this.queryService.generateVisibilityQuery(query, me); + if (me) { + this.queryService.generateMutedUserQuery(query, me, { id: ps.userId }); + this.queryService.generateBlockedUserQuery(query, me); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :userId', { userId: ps.userId }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + return await query.limit(ps.limit).getMany(); + } } diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index 30bf6b8b3..0e71510b4 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -43,7 +43,7 @@ export function genOpenapiSpec(config: Config) { // 書き換えたりするのでディープコピーしておく。そのまま編集するとメモリ上の値が汚れて次回以降の出力に影響する const copiedEndpoints = JSON.parse(JSON.stringify(endpoints)) as IEndpoint[]; - for (const endpoint of copiedEndpoints.filter(ep => !ep.meta.secure)) { + for (const endpoint of copiedEndpoints) { const errors = {} as any; if (endpoint.meta.errors) { @@ -59,6 +59,11 @@ export function genOpenapiSpec(config: Config) { const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n'; + + if (endpoint.meta.secure) { + desc += '**Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.\n'; + } + desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; if (endpoint.meta.kind) { const kind = endpoint.meta.kind; diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 2d8fec30b..4180ccc56 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -36,6 +36,7 @@ export default class Connection { public userIdsWhoMeMuting: Set = new Set(); public userIdsWhoBlockingMe: Set = new Set(); public userIdsWhoMeMutingRenotes: Set = new Set(); + public userMutedInstances: Set = new Set(); private fetchIntervalId: NodeJS.Timeout | null = null; constructor( @@ -69,6 +70,7 @@ export default class Connection { this.userIdsWhoMeMuting = userIdsWhoMeMuting; this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; + this.userMutedInstances = new Set(userProfile.mutedInstances); } @bindThis diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 3aa0d69c0..46b070977 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -41,6 +41,10 @@ export default abstract class Channel { return this.connection.userIdsWhoBlockingMe; } + protected get userMutedInstances() { + return this.connection.userMutedInstances; + } + protected get followingChannels() { return this.connection.followingChannels; } 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 4b6628df6..fe293e2b4 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -5,12 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; import { isUserRelated } from '@/misc/is-user-related.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'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import Channel from '../channel.js'; class UserListChannel extends Channel { @@ -80,6 +80,9 @@ class UserListChannel extends Channel { private async onNote(note: Packed<'Note'>) { const isMe = this.user!.id === note.userId; + // チャンネル投稿は無視する + if (note.channelId) return; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!Object.hasOwn(this.membershipsMap, note.userId)) return; @@ -115,6 +118,9 @@ class UserListChannel extends Channel { } } + // 流れてきたNoteがミュートしているインスタンスに関わるものだったら無視する + if (isInstanceMuted(note, this.userMutedInstances)) return; + this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 3ba26ad34..dd4304e6e 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -58,7 +58,7 @@ export class FeedService { const feed = new Feed({ id: author.link, title: `${author.name} (@${user.username}@${this.config.host})`, - updated: this.idService.parse(notes[0].id).date, + updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined, generator: 'Misskey', description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, link: author.link, diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 1cbfec3e5..251d66276 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -93,7 +93,7 @@ describe('Webリソース', () => { }); aliceChannel = await channel(alice, {}); - bob = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); }, 1000 * 60 * 2); afterAll(async () => { @@ -152,6 +152,11 @@ describe('Webリソース', () => { type, })); + test('がGETできる。(ノートが存在しない場合でも。)', async () => await ok({ + path: path(bob.username), + type, + })); + test('は存在しないユーザーはGETできない。', async () => await notOk({ path: path('nonexisting'), status: 404, diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index f9f385e2b..c4824f50c 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { MiFollowing } from '@/models/Following.js'; -import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js'; +import { signup, api, post, startServer, initTestDb, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; import type * as misskey from 'misskey-js'; @@ -34,12 +34,16 @@ describe('Streaming', () => { let ayano: misskey.entities.MeSignup; let kyoko: misskey.entities.MeSignup; let chitose: misskey.entities.MeSignup; + let kanako: misskey.entities.MeSignup; // Remote users let akari: misskey.entities.MeSignup; let chinatsu: misskey.entities.MeSignup; + let takumi: misskey.entities.MeSignup; let kyokoNote: any; + let kanakoNote: any; + let takumiNote: any; let list: any; beforeAll(async () => { @@ -50,11 +54,15 @@ describe('Streaming', () => { ayano = await signup({ username: 'ayano' }); kyoko = await signup({ username: 'kyoko' }); chitose = await signup({ username: 'chitose' }); + kanako = await signup({ username: 'kanako' }); akari = await signup({ username: 'akari', host: 'example.com' }); chinatsu = await signup({ username: 'chinatsu', host: 'example.com' }); + takumi = await signup({ username: 'takumi', host: 'example.com' }); kyokoNote = await post(kyoko, { text: 'foo' }); + kanakoNote = await post(kanako, { text: 'hoge' }); + takumiNote = await post(takumi, { text: 'piyo' }); // Follow: ayano => kyoko await api('following/create', { userId: kyoko.id }, ayano); @@ -62,6 +70,9 @@ describe('Streaming', () => { // Follow: ayano => akari await follow(ayano, akari); + // Mute: chitose => kanako + await api('mute/create', { userId: kanako.id }, chitose); + // List: chitose => ayano, kyoko list = await api('users/lists/create', { name: 'my list', @@ -76,6 +87,11 @@ describe('Streaming', () => { listId: list.id, userId: kyoko.id, }, chitose); + + await api('users/lists/push', { + listId: list.id, + userId: takumi.id, + }, chitose); }, 1000 * 60 * 2); afterAll(async () => { @@ -452,6 +468,96 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + + // #10443 + test('チャンネル投稿は流れない', async () => { + // リスインしている kyoko が 任意のチャンネルに投降した時の動きを見たい + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo', channelId: 'dummy' }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id, + { listId: list.id }, + ); + + assert.strictEqual(fired, false); + }); + + // #10443 + test('ミュートしているユーザへのリプライがリストTLに流れない', async () => { + // chitose が kanako をミュートしている状態で、リスインしている kyoko が kanako にリプライした時の動きを見たい + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id, + { listId: list.id }, + ); + + assert.strictEqual(fired, false); + }); + + // #10443 + test('ミュートしているユーザの投稿をリノートしたときリストTLに流れない', async () => { + // chitose が kanako をミュートしている状態で、リスインしている kyoko が kanako のノートをリノートした時の動きを見たい + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { renoteId: kanakoNote.id }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id, + { listId: list.id }, + ); + + assert.strictEqual(fired, false); + }); + + // #10443 + test('ミュートしているサーバのノートがリストTLに流れない', async () => { + await api('/i/update', { + mutedInstances: ['example.com'], + }, chitose); + + // chitose が example.com をミュートしている状態で、リスインしている takumi が ノートした時の動きを見たい + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo' }, takumi), + msg => msg.type === 'note' && msg.body.userId === kyoko.id, + { listId: list.id }, + ); + + assert.strictEqual(fired, false); + }); + + // #10443 + test('ミュートしているサーバのノートに対するリプライがリストTLに流れない', async () => { + await api('/i/update', { + mutedInstances: ['example.com'], + }, chitose); + + // chitose が example.com をミュートしている状態で、リスインしている kyoko が takumi のノートにリプライした時の動きを見たい + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo', replyId: takumiNote.id }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id, + { listId: list.id }, + ); + + assert.strictEqual(fired, false); + }); + + // #10443 + test('ミュートしているサーバのノートに対するリノートがリストTLに流れない', async () => { + await api('/i/update', { + mutedInstances: ['example.com'], + }, chitose); + + // chitose が example.com をミュートしている状態で、リスインしている kyoko が takumi のノートをリノートした時の動きを見たい + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { renoteId: takumiNote.id }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id, + { listId: list.id }, + ); + + assert.strictEqual(fired, false); + }); }); // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" diff --git a/packages/frontend/.eslintrc.cjs b/packages/frontend/.eslintrc.cjs index 77038f0df..20f88dc07 100644 --- a/packages/frontend/.eslintrc.cjs +++ b/packages/frontend/.eslintrc.cjs @@ -69,12 +69,6 @@ module.exports = { 'require': false, '__dirname': false, - // Vue - '$$': false, - '$ref': false, - '$shallowRef': false, - '$computed': false, - // Misskey '_DEV_': false, '_LANGS_': false, diff --git a/packages/frontend/assets/sounds/syuilo/bubble1.mp3 b/packages/frontend/assets/sounds/syuilo/bubble1.mp3 new file mode 100644 index 000000000..05b8ef8b1 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/bubble1.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/bubble2.mp3 b/packages/frontend/assets/sounds/syuilo/bubble2.mp3 new file mode 100644 index 000000000..8b4f8df6e Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/bubble2.mp3 differ diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts index a7b8cbb03..759f27039 100644 --- a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts +++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts @@ -89,7 +89,6 @@ const _sfc_main = /* @__PURE__ */ defineComponent({ api("users/notes", { userId: props.user.id, fileType: image, - excludeNsfw: defaultStore.state.nsfw !== "ignore", limit: 10 }).then((notes) => { for (const note of notes) { @@ -198,7 +197,6 @@ const _sfc_main = defineComponent({ api("users/notes", { userId: props.user.id, fileType: image, - excludeNsfw: defaultStore.state.nsfw !== "ignore", limit: 10 }).then(notes => { for (const note of notes) { diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 7fcc56249..5db740c94 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,12 +4,13 @@ "type": "module", "scripts": { "watch": "vite", + "dev": "vite --config vite.config.local-dev.ts", "build": "vite build", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", "build-storybook": "pnpm build-storybook-pre && storybook build", "chromatic": "chromatic", - "test": "vitest --run", + "test": "vitest --run --globals", "test-and-coverage": "vitest --run --coverage --globals", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", @@ -18,16 +19,15 @@ "dependencies": { "@discordapp/twemoji": "14.1.2", "@github/webauthn-json": "2.1.1", - "@rollup/plugin-alias": "5.0.1", + "@rollup/plugin-alias": "5.1.0", "@rollup/plugin-json": "6.0.1", "@rollup/plugin-replace": "5.0.5", "@rollup/plugin-typescript": "11.1.5", - "@rollup/pluginutils": "5.0.5", + "@rollup/pluginutils": "5.1.0", "@syuilo/aiscript": "0.16.0", "@tabler/icons-webfont": "2.37.0", - "@vitejs/plugin-vue": "4.5.0", - "@vue-macros/reactivity-transform": "0.4.0", - "@vue/compiler-sfc": "3.3.8", + "@vitejs/plugin-vue": "4.5.1", + "@vue/compiler-sfc": "3.3.9", "astring": "1.8.6", "autosize": "6.0.1", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.6", @@ -35,19 +35,19 @@ "browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3", "buraha": "0.0.1", "canvas-confetti": "1.6.1", - "chart.js": "4.4.0", + "chart.js": "4.4.1", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "9.0.0", + "chromatic": "10.1.0", "compare-versions": "6.1.0", "cropperjs": "2.0.0-beta.4", "date-fns": "2.30.0", "escape-regexp": "0.0.1", "estree-walker": "3.0.3", "eventemitter3": "5.0.1", - "gsap": "3.12.2", + "gsap": "3.12.3", "idb-keyval": "6.2.1", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", @@ -55,88 +55,88 @@ "matter-js": "0.19.0", "mfm-js": "0.23.3", "misskey-js": "workspace:*", - "photoswipe": "5.4.2", + "photoswipe": "5.4.3", "punycode": "2.3.1", "querystring": "0.2.1", - "rollup": "4.4.1", + "rollup": "4.7.0", "sanitize-html": "2.11.0", - "shiki": "^0.14.5", + "shiki": "0.14.6", "sass": "1.69.5", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.158.0", + "three": "0.159.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", "tsc-alias": "1.8.8", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "5.3.2", + "typescript": "5.3.3", "uuid": "9.0.1", "v-code-diff": "1.7.2", "vanilla-tilt": "1.8.1", - "vite": "4.5.0", - "vue": "3.3.8", + "vite": "5.0.7", + "vue": "3.3.11", "vuedraggable": "next" }, "devDependencies": { - "@storybook/addon-actions": "7.5.3", - "@storybook/addon-essentials": "7.5.3", - "@storybook/addon-interactions": "7.5.3", - "@storybook/addon-links": "7.5.3", - "@storybook/addon-storysource": "7.5.3", - "@storybook/addons": "7.5.3", - "@storybook/blocks": "7.5.3", - "@storybook/core-events": "7.5.3", + "@storybook/addon-actions": "7.6.4", + "@storybook/addon-essentials": "7.6.4", + "@storybook/addon-interactions": "7.6.4", + "@storybook/addon-links": "7.6.4", + "@storybook/addon-storysource": "7.6.4", + "@storybook/addons": "7.6.4", + "@storybook/blocks": "7.6.4", + "@storybook/core-events": "7.6.4", "@storybook/jest": "0.2.3", - "@storybook/manager-api": "7.5.3", - "@storybook/preview-api": "7.5.3", - "@storybook/react": "7.5.3", - "@storybook/react-vite": "7.5.3", + "@storybook/manager-api": "7.6.4", + "@storybook/preview-api": "7.6.4", + "@storybook/react": "7.6.4", + "@storybook/react-vite": "7.6.4", "@storybook/testing-library": "0.2.2", - "@storybook/theming": "7.5.3", - "@storybook/types": "7.5.3", - "@storybook/vue3": "7.5.3", - "@storybook/vue3-vite": "7.5.3", - "@testing-library/vue": "8.0.0", + "@storybook/theming": "7.6.4", + "@storybook/types": "7.6.4", + "@storybook/vue3": "7.6.4", + "@storybook/vue3-vite": "7.6.4", + "@testing-library/vue": "8.0.1", "@types/escape-regexp": "0.0.3", "@types/estree": "1.0.5", - "@types/matter-js": "0.19.4", - "@types/micromatch": "4.0.5", - "@types/node": "20.9.1", - "@types/punycode": "2.1.2", - "@types/sanitize-html": "2.9.4", + "@types/matter-js": "0.19.5", + "@types/micromatch": "4.0.6", + "@types/node": "20.10.4", + "@types/punycode": "2.1.3", + "@types/sanitize-html": "2.9.5", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/uuid": "9.0.7", - "@types/websocket": "1.0.9", - "@types/ws": "8.5.9", - "@typescript-eslint/eslint-plugin": "6.11.0", - "@typescript-eslint/parser": "6.11.0", + "@types/websocket": "1.0.10", + "@types/ws": "8.5.10", + "@typescript-eslint/eslint-plugin": "6.13.2", + "@typescript-eslint/parser": "6.13.2", "@vitest/coverage-v8": "0.34.6", - "@vue/runtime-core": "3.3.8", + "@vue/runtime-core": "3.3.11", "acorn": "8.11.2", "cross-env": "7.0.3", - "cypress": "13.5.1", - "eslint": "8.53.0", + "cypress": "13.6.1", + "eslint": "8.55.0", "eslint-plugin-import": "2.29.0", - "eslint-plugin-vue": "9.18.1", + "eslint-plugin-vue": "9.19.2", "fast-glob": "3.3.2", "happy-dom": "10.0.3", "micromatch": "4.0.5", "msw": "1.3.2", "msw-storybook-addon": "1.10.0", - "nodemon": "3.0.1", + "nodemon": "3.0.2", "prettier": "3.1.0", "react": "18.2.0", "react-dom": "18.2.0", "start-server-and-test": "2.0.3", - "storybook": "7.5.3", + "storybook": "7.6.4", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "summaly": "github:misskey-dev/summaly", "vite-plugin-turbosnap": "1.0.3", "vitest": "0.34.6", "vitest-fetch-mock": "0.2.2", "vue-eslint-parser": "9.3.2", - "vue-tsc": "1.8.22" + "vue-tsc": "1.8.25" } } diff --git a/packages/frontend/src/_dev_boot_.ts b/packages/frontend/src/_dev_boot_.ts new file mode 100644 index 000000000..2e95a0357 --- /dev/null +++ b/packages/frontend/src/_dev_boot_.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// devモードで起動される際(index.htmlを使うとき)はrouterが暴発してしまってうまく読み込めない。 +// よって、devモードとして起動されるときはビルド時に組み込む形としておく。 +// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない) +import '@tabler/icons-webfont/tabler-icons.scss'; + +import('@/_boot_.js'); diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 12bb56a87..60f2781fd 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -187,6 +187,12 @@ export async function common(createVue: () => App) { if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); defaultStore.set('themeInitial', false); + } else { + if (defaultStore.state.darkMode) { + applyTheme(darkTheme.value); + } else { + applyTheme(lightTheme.value); + } } }); @@ -202,16 +208,24 @@ export async function common(createVue: () => App) { } }, { immediate: true }); - if (defaultStore.state.keepScreenOn) { - if ('wakeLock' in navigator) { + // Keep screen on + const onVisibilityChange = () => document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { navigator.wakeLock.request('screen'); - - document.addEventListener('visibilitychange', async () => { - if (document.visibilityState === 'visible') { - navigator.wakeLock.request('screen'); - } - }); } + }); + if (defaultStore.state.keepScreenOn && 'wakeLock' in navigator) { + navigator.wakeLock.request('screen') + .then(onVisibilityChange) + .catch(() => { + // On WebKit-based browsers, user activation is required to send wake lock request + // https://webkit.org/blog/13862/the-user-activation-api/ + document.addEventListener( + 'click', + () => navigator.wakeLock.request('screen').then(onVisibilityChange), + { once: true }, + ); + }); } //#region Fetch user diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index c17393dd4..b2ab91fed 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -19,6 +19,7 @@ import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js import { mainRouter } from '@/router.js'; import { initializeSw } from '@/scripts/initialize-sw.js'; import { deckStore } from '@/ui/deck/deck-store.js'; +import { emojiPicker } from '@/scripts/emoji-picker.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( @@ -30,6 +31,7 @@ export async function mainBoot() { )); reactionPicker.init(); + emojiPicker.init(); if (isClientUpdated && $i) { popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index 2c7be319c..ce7e134b7 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -41,6 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index fe7077bdb..adb3c134a 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -74,7 +74,7 @@ const props = defineProps({ }, }); -let legendEl = $shallowRef>(); +const legendEl = shallowRef>(); const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const negate = arr => arr.map(x => -x); @@ -268,7 +268,7 @@ const render = () => { gradient, }, }, - plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl)] : [])], + plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl.value)] : [])], }); }; diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index d321114cb..1a1b4323d 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -13,29 +13,30 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -36,4 +46,27 @@ const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')) padding: .1em; border-radius: .3em; } + +.codePlaceholderRoot { + display: block; + width: 100%; + background: none; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + + box-sizing: border-box; + border-radius: 8px; + padding: 24px; + margin-top: 4px; + color: #D4D4D4; + background: #1E1E1E; +} + +.codePlaceholderContainer { + text-align: center; + font-size: 0.8em; +} diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index 6cca7fc35..b78252be8 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 81f393660..0a1ddd317 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index cd986e430..b0ae9d5cc 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index 0022531e5..a57e6c929 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -101,6 +101,8 @@ function close() { vertical-align: bottom; height: 100px; border-radius: 10px; + padding: 10px; + box-sizing: border-box; &:hover { color: var(--accent); diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index dd0b35249..8517eff40 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 122f8ad79..92b5388c3 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 9457bf385..951a0b281 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -53,6 +69,14 @@ const props = defineProps<{ min-width: 0; } +.cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + .header { margin-bottom: 2px; font-weight: bold; diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index a40dcaf00..868f64a4b 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only

- +

@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index cd1707a59..9fbcce228 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -5,6 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue index 71f8d3192..06269ec9a 100644 --- a/packages/frontend/src/pages/user-tag.vue +++ b/packages/frontend/src/pages/user-tag.vue @@ -25,7 +25,7 @@ const props = defineProps<{ tag: string; }>(); -const tagUsers = $computed(() => ({ +const tagUsers = computed(() => ({ endpoint: 'hashtags/users' as const, limit: 30, params: { diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue index bc6bf7716..bd1159cb3 100644 --- a/packages/frontend/src/pages/user/activity.following.vue +++ b/packages/frontend/src/pages/user/activity.following.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue index 52f520711..c5f51712f 100644 --- a/packages/frontend/src/pages/user/following.vue +++ b/packages/frontend/src/pages/user/following.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 2e5dd705d..e2b205a40 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -146,7 +146,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue index c531bda59..fe17de906 100644 --- a/packages/frontend/src/pages/user/index.activity.vue +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 50cc9a331..4725aedee 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index e1f2a0cbd..9c27eeec5 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -33,11 +33,12 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 0e6ce6178..61b86f993 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue index 2e6bb8b38..f7d262cc8 100644 --- a/packages/frontend/src/pages/welcome.vue +++ b/packages/frontend/src/pages/welcome.vue @@ -11,22 +11,22 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index d54368c93..0c52957ec 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue index 5bd6d7397..ef35d885f 100644 --- a/packages/frontend/src/ui/deck/widgets-column.vue +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue index 4f0945eb4..b819b6ca0 100644 --- a/packages/frontend/src/ui/zen.vue +++ b/packages/frontend/src/ui/zen.vue @@ -22,22 +22,22 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue index 53b6020ff..08037222d 100644 --- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue +++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue @@ -52,7 +52,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, const parser = new Parser(); const root = ref(); -const components: Ref[] = $ref([]); +const components = ref[]>([]); async function run() { const aiscript = new Interpreter({ @@ -60,7 +60,7 @@ async function run() { storageKey: 'widget', token: $i?.token, }), - ...registerAsUiLib(components, (_root) => { + ...registerAsUiLib(components.value, (_root) => { root.value = _root.value; }), }, { diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue new file mode 100644 index 000000000..7c4455516 --- /dev/null +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue @@ -0,0 +1,127 @@ + + + + + + + diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue index e4ea2c97d..ca115cfcf 100644 --- a/packages/frontend/src/widgets/WidgetClock.vue +++ b/packages/frontend/src/widgets/WidgetClock.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only