diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 2921746295..d1534486d3 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 0e4f2f5a15..481c615587 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 137be487c0..a77f73706b 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 fa4a58c3a9..88e2aceaed 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 c22ba2c5d3..9a15962476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Client - Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正 +- Fix: MFMでルビの中のテキストがnyaizeされない問題を修正 ### Server - @@ -22,6 +23,8 @@ ### Client - Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加 +- Feat: データセーバーでコードハイライトの読み込みを削減できるように +- Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336 - Enhance: 絵文字のオートコンプリート機能強化 #12364 - Enhance: ユーザーのRawデータを表示するページが復活 - Enhance: リアクション選択時に音を鳴らせるように @@ -30,6 +33,11 @@ - Enhance: Shareページで投稿を完了すると、親ウィンドウ(親フレーム)にpostMessageするように - Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305 - Enhance: ノートプレビューに「内容を隠す」が反映されるように +- Enhance: データセーバーの適用範囲を個別で設定できるように + - 従来のデータセーバーの設定はリセットされます +- Enhance: タイムライン上のタブからリスト、アンテナ、チャンネルの管理ページにジャンプできるように +- Feat: センシティブと判断されたウェブサイトのサムネイルをぼかすように + - ウェブサイトをセンシティブと判断する仕組みが動いていないため、summalyProxyを使用しないと機能しません。 - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 - Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 - Enhance: 絵文字の詳細ページに記載される情報を追加 @@ -38,15 +46,25 @@ - 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 @@ -176,6 +194,7 @@ ### Client - Enhance: TLの返信表示オプションを記憶するように - Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく +- Feat: 絵文字ピッカーのカテゴリに「/」を入れることでフォルダ分け表示できるように ### Server - Enhance: タイムライン取得時のパフォーマンスを向上 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13e0656041..7f6c1f4f82 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/docker-compose.local-db.yml b/docker-compose.local-db.yml new file mode 100644 index 0000000000..16ba4b49e1 --- /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/locales/index.d.ts b/locales/index.d.ts index 20a7ad1a0c..bb797ed710 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -333,6 +333,7 @@ export interface Locale { "renameFolder": string; "deleteFolder": string; "Folder": string; + "folder": string; "addFile": string; "emptyDrive": string; "emptyFolder": string; @@ -459,7 +460,6 @@ export interface Locale { "notFound": string; "notFoundDescription": string; "uploadFolder": string; - "cacheClear": string; "markAsReadAllNotifications": string; "markAsReadAllUnreadNotes": string; "markAsReadAllTalkMessages": string; @@ -1210,6 +1210,8 @@ export interface Locale { "signupPendingError": string; "cwNotationRequired": string; "doReaction": string; + "code": string; + "reloadRequiredToApplySettings": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -2561,6 +2563,24 @@ export interface Locale { }; }; }; + "_dataSaver": { + "_media": { + "title": string; + "description": string; + }; + "_avatar": { + "title": string; + "description": string; + }; + "_urlPreview": { + "title": string; + "description": string; + }; + "_code": { + "title": string; + "description": string; + }; + }; "_schedulePost": { "list": string; "postDate": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 9482c743e8..4d2409921e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -330,6 +330,7 @@ createFolder: "フォルダーを作成" renameFolder: "フォルダー名を変更" deleteFolder: "フォルダーを削除" Folder: "フォルダー" +folder: "フォルダー" addFile: "ファイルを追加" emptyDrive: "ドライブは空です" emptyFolder: "フォルダーは空です" @@ -456,7 +457,6 @@ share: "共有" notFound: "見つかりません" notFoundDescription: "指定されたURLに該当するページはありませんでした。" uploadFolder: "既定アップロード先" -cacheClear: "キャッシュを削除" markAsReadAllNotifications: "すべての通知を既読にする" markAsReadAllUnreadNotes: "すべての投稿を既読にする" markAsReadAllTalkMessages: "すべてのチャットを既読にする" @@ -1207,6 +1207,8 @@ useGroupedNotifications: "通知をグルーピングして表示する" signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" doReaction: "リアクションする" +code: "コード" +reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -2458,3 +2460,17 @@ _schedulePost: willBePostedAtX: "{date}に投稿予約しました。" deleteAreYouSure: "予約投稿を削除しますか?" deleteAndEditConfirm: "予約投稿を削除して編集しますか?" + +_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 d362b3c52f..8e96a07cd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2023.12.0-beta.1-PrisMisskey.2", + "version": "2023.12.0-beta.3", "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:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", @@ -26,7 +27,7 @@ "check:connect": "cd packages/backend && pnpm check:connect", "migrateandstart": "pnpm migrate && pnpm start", "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", @@ -47,17 +48,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.12.0", - "@typescript-eslint/parser": "6.12.0", + "@typescript-eslint/eslint-plugin": "6.13.2", + "@typescript-eslint/parser": "6.13.2", "cross-env": "7.0.3", - "cypress": "13.6.0", - "eslint": "8.54.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/package.json b/packages/backend/package.json index b17ce904f6..85bdfbebf4 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,13 +61,13 @@ "dependencies": { "@aws-sdk/client-s3": "3.412.0", "@aws-sdk/lib-storage": "3.412.0", - "@bull-board/api": "5.9.2", - "@bull-board/fastify": "5.9.2", - "@bull-board/ui": "5.9.2", + "@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", @@ -80,7 +81,7 @@ "@sinonjs/fake-timers": "11.2.2", "@smithy/node-http-handler": "2.1.10", "@swc/cli": "0.1.63", - "@swc/core": "1.3.99", + "@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.14.2", + "bullmq": "4.15.2", "cacheable-lookup": "7.0.0", "cbor": "9.0.1", "chalk": "5.3.0", @@ -114,9 +115,9 @@ "ipaddr.js": "2.1.0", "is-svg": "5.0.0", "js-yaml": "4.1.0", - "jsdom": "23.0.0", + "jsdom": "23.0.1", "json5": "2.2.3", - "jsonld": "8.3.1", + "jsonld": "8.3.2", "jsrsasign": "10.9.0", "meilisearch": "0.36.0", "mfm-js": "0.23.3", @@ -124,7 +125,7 @@ "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", @@ -147,7 +148,7 @@ "ratelimiter": "3.4.1", "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.18", + "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", @@ -186,14 +187,14 @@ "@types/content-disposition": "0.5.8", "@types/fluent-ffmpeg": "2.1.24", "@types/http-link-header": "1.0.5", - "@types/jest": "29.5.10", + "@types/jest": "29.5.11", "@types/js-yaml": "4.0.9", "@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.10.0", + "@types/node": "20.10.4", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.14", "@types/oauth": "0.9.4", @@ -216,11 +217,11 @@ "@types/vary": "1.1.3", "@types/web-push": "3.6.3", "@types/ws": "8.5.10", - "@typescript-eslint/eslint-plugin": "6.12.0", - "@typescript-eslint/parser": "6.12.0", + "@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.54.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/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index bf6f0ef879..bc6d24b951 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'; @@ -195,6 +196,7 @@ const $SearchService: Provider = { provide: 'SearchService', useExisting: Search const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; 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 }; @@ -331,6 +333,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ClipService, FeaturedService, FanoutTimelineService, + FanoutTimelineEndpointService, ChannelFollowingService, RegistryApiService, ChartLoggerService, @@ -460,6 +463,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ClipService, $FeaturedService, $FanoutTimelineService, + $FanoutTimelineEndpointService, $ChannelFollowingService, $RegistryApiService, $ChartLoggerService, @@ -590,6 +594,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ClipService, FeaturedService, FanoutTimelineService, + FanoutTimelineEndpointService, ChannelFollowingService, RegistryApiService, FederationChart, @@ -718,6 +723,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ClipService, $FeaturedService, $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 0000000000..11027960f1 --- /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/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 6a1b0aa879..9b2678fbcd 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -9,6 +9,35 @@ 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 + | `userTimelineWithChannel:${string}` // only channel notes are included, replies are included + + // 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 FanoutTimelineService { constructor( @@ -20,7 +49,7 @@ export class FanoutTimelineService { } @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 +70,7 @@ export class FanoutTimelineService { } @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 +87,7 @@ export class FanoutTimelineService { } @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 +108,7 @@ export class FanoutTimelineService { } @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 507fc464ff..d970ffa43b 100644 --- a/packages/backend/src/core/FeaturedService.ts +++ b/packages/backend/src/core/FeaturedService.ts @@ -14,6 +14,8 @@ 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( @@ -24,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); } diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index c98b8ea6fc..43e72d2d7b 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 c36f89bd42..0e7c73b636 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -54,6 +54,7 @@ import { FeaturedService } from '@/core/FeaturedService.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'; @@ -859,7 +860,7 @@ 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; } @@ -877,7 +878,7 @@ 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; } @@ -895,11 +896,14 @@ export class NoteCreateService implements OnApplicationShutdown { } // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { + if (isReply(note)) { this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); if (note.visibility === 'public' && note.userHost == null) { 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.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); diff --git a/packages/backend/src/core/NotePiningService.ts b/packages/backend/src/core/NotePiningService.ts index 52abb4c2a1..74e53c5c46 100644 --- a/packages/backend/src/core/NotePiningService.ts +++ b/packages/backend/src/core/NotePiningService.ts @@ -77,7 +77,7 @@ export class NotePiningService { } as MiUserNotePining); // Deliver to remote followers - if (this.userEntityService.isLocalUser(user)) { + if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) { this.deliverPinnedChange(user.id, note.id, true); } } @@ -105,7 +105,7 @@ export class NotePiningService { }); // Deliver to remote followers - if (this.userEntityService.isLocalUser(user)) { + if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) { this.deliverPinnedChange(user.id, noteId, false); } } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index b6d2bcabc8..a46d68fd84 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 3062999c08..d600ffb9d9 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -304,8 +304,6 @@ export class UserFollowingService implements OnModuleInit { }); } }); - - this.fanoutTimelineService.purge(`homeTimeline:${follower.id}`); } // Publish followed event @@ -373,8 +371,6 @@ export class UserFollowingService implements OnModuleInit { }); } }); - - this.fanoutTimelineService.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 7aba140689..baaab67e48 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 1979cdda9c..05d5ca15db 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 e7b59f262b..de03f6793f 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 bed223225a..9f457f6f0a 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 366738de05..7646282edb 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 426a46970b..f2a55443ef 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 49bd9591c0..f5c3619fdb 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 b231058a95..35fe11849d 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 0000000000..964c2aa153 --- /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 80c1041c62..49f35b9b74 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -36,6 +36,8 @@ import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; 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, @@ -71,6 +73,9 @@ export const refs = { EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, Flash: packedFlashSchema, + Signin: packedSigninSchema, + RoleLite: packedRoleLiteSchema, + Role: packedRoleSchema, }; export type Packed = SchemaType; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts new file mode 100644 index 0000000000..dd2f32b14d --- /dev/null +++ b/packages/backend/src/models/json-schema/role.ts @@ -0,0 +1,157 @@ +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 packedRoleLiteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + 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, + }, + isModerator: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + isAdministrator: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + displayOrder: { + 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 0000000000..d27d2490c5 --- /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 b26e40ae57..526b989a86 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: { @@ -326,41 +338,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: { @@ -407,6 +385,7 @@ export const packedUserDetailedNotMeOnlySchema = { notify: { type: 'string', nullable: false, optional: true, + enum: ['normal', 'none'], }, withReplies: { type: 'boolean', @@ -562,6 +541,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', @@ -706,6 +698,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/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 2c9e2cf24f..78d2daa403 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -370,8 +370,9 @@ export class ActivityPubServerService { order: { id: 'DESC' }, }); - const pinnedNotes = await Promise.all(pinings.map(pining => - this.notesRepository.findOneByOrFail({ id: pining.noteId }))); + const pinnedNotes = (await Promise.all(pinings.map(pining => + this.notesRepository.findOneByOrFail({ id: pining.noteId })))) + .filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility)); const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 79b0a57f2b..4d94fcdd54 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 17c2a93525..bb41ab0e42 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -119,8 +119,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 bf77a367f5..5b41dfb514 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -6,14 +6,12 @@ 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'; -import {IsNull} from "typeorm"; export const meta = { tags: ['admin'], @@ -27,11 +25,11 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: 'e2785b66-dca3-4087-9cac-b93c541cc425', }, - duplicationEmojiAdd: { - message: 'This emoji is already added.', - code: 'DUPLICATION_EMOJI_ADD', - id: 'mattyaski_emoji_duplication_error', - } + duplicateName: { + message: 'Duplicate name.', + code: 'DUPLICATE_NAME', + id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975', + }, }, res: { @@ -62,63 +60,43 @@ 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); } - const duplicationEmoji = await this.emojisRepository.find({ - where: { - name: emoji.name, - host: IsNull() - }, - }); - - duplicationEmoji.forEach( - (_emoji) => { - if (_emoji.name === emoji.name) { - throw new ApiError(meta.errors.duplicationEmojiAdd); - } - } - ) - let driveFile: MiDriveFile; try { // 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, + }, 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/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 4a8625939d..ec39b1b8fe 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -327,6 +327,82 @@ export const meta = { type: 'number', 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, + }, DiscordWebhookUrl: { type: 'string', optional: false, nullable: true, 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 8451b1955f..fb53815333 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 3ed4b324dc..71b8e44e77 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 5f0accab6f..1ca952a3f8 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/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 7914b111cd..006228ceee 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 { FanoutTimelineService } from '@/core/FanoutTimelineService.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 fanoutTimelineService: FanoutTimelineService, + 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,62 +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.fanoutTimelineService.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 => { - return !isUserRelated(note, userIdsWhoMeMuting); - }); - - // 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 c8beefa9c7..e5a90715f5 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/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts index 139bede7bc..f82e3f9b28 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/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 2727e4f093..9dd4553152 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/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index f4bb290906..5484e78c07 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -9,9 +9,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { FeaturedService } from '@/core/FeaturedService.js'; -import {isUserRelated} from "@/misc/is-user-related.js"; -import {CacheService} from "@/core/CacheService.js"; - +import { isUserRelated } from '@/misc/is-user-related.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['notes'], @@ -67,11 +66,6 @@ export default class extends Endpoint { // eslint- this.globalNotesRankingCacheLastFetchedAt = Date.now(); } } - const [ - userIdsWhoMeMuting, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - ]) : [new Set()]; noteIds.sort((a, b) => a > b ? -1 : 1); if (ps.untilId) { @@ -83,6 +77,14 @@ export default class extends Endpoint { // eslint- return []; } + const [ + userIdsWhoMeMuting, + userIdsWhoBlockingMe, + ] = me ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]) : [new Set(), new Set()]; + const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') @@ -92,13 +94,17 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); - let notes = await query.getMany(); - notes.sort((a, b) => a.id > b.id ? -1 : 1); - notes = notes.filter(note => { - return !isUserRelated(note, userIdsWhoMeMuting); + const notes = (await query.getMany()).filter(note => { + if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; + + return true; }); + + notes.sort((a, b) => a.id > b.id ? -1 : 1); // TODO: ミュート等考慮 + return await this.noteEntityService.packMany(notes, me); }); } diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 372199844d..effcbaf2ee 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 { FanoutTimelineService } from '@/core/FanoutTimelineService.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 fanoutTimelineService: FanoutTimelineService, 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.fanoutTimelineService.getMulti([ - `homeTimelineWithFiles:${me.id}`, - 'localTimelineWithFiles', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); - } else if (ps.withReplies) { - const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.fanoutTimelineService.getMulti([ - `homeTimeline:${me.id}`, - 'localTimeline', - 'localTimelineWithReplies', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds])); - } else { - const [htlNoteIds, ltlNoteIds] = await this.fanoutTimelineService.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 7d8dec7b8f..3fd4dc83fb 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 { FanoutTimelineService } from '@/core/FanoutTimelineService.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 fanoutTimelineService: FanoutTimelineService, + 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.fanoutTimelineService.get('localTimelineWithFiles', untilId, sinceId); - } else { - const [nonReplyNoteIds, replyNoteIds] = await this.fanoutTimelineService.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 470abe0b14..790bcbe151 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 { FanoutTimelineService } from '@/core/FanoutTimelineService.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 fanoutTimelineService: FanoutTimelineService, + 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.fanoutTimelineService.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 1ac1d37f48..10d3a7a697 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 { FanoutTimelineService } from '@/core/FanoutTimelineService.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 fanoutTimelineService: FanoutTimelineService, + 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.fanoutTimelineService.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; }); } @@ -271,10 +235,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 d1de73ad32..dc2be8e11d 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/show.ts b/packages/backend/src/server/api/endpoints/roles/show.ts index 2afa0e7b7f..6bfe52bb1a 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/featured-notes.ts b/packages/backend/src/server/api/endpoints/users/featured-notes.ts index dec0b7a122..f84148d727 100644 --- a/packages/backend/src/server/api/endpoints/users/featured-notes.ts +++ b/packages/backend/src/server/api/endpoints/users/featured-notes.ts @@ -9,6 +9,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { FeaturedService } from '@/core/FeaturedService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; export const meta = { tags: ['notes'], @@ -46,6 +48,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private featuredService: FeaturedService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50); @@ -60,6 +63,14 @@ export default class extends Endpoint { // eslint- return []; } + const [ + userIdsWhoMeMuting, + userIdsWhoBlockingMe, + ] = me ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]) : [new Set(), new Set()]; + const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') @@ -69,10 +80,14 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); - const notes = await query.getMany(); - notes.sort((a, b) => a.id > b.id ? -1 : 1); + const notes = (await query.getMany()).filter(note => { + if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false; - // TODO: ミュート等考慮 + return true; + }); + + notes.sort((a, b) => a.id > b.id ? -1 : 1); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index a775b58f03..b32128a8aa 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 { FanoutTimelineService } from '@/core/FanoutTimelineService.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,121 +74,122 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private cacheService: CacheService, private idService: IdService, - private fanoutTimelineService: FanoutTimelineService, + 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] = await Promise.all([ - this.fanoutTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId), - ps.withReplies ? this.fanoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]), - ps.withChannelNotes ? this.fanoutTimelineService.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(`userTimelineWithReplies:${ps.userId}`); + if (ps.withChannelNotes) redisTimelines.push(`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 30bf6b8b3e..0e71510b48 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 2d8fec30b1..4180ccc56a 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 3aa0d69c0b..46b0709773 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 4b6628df6f..fe293e2b4d 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/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index f9f385e2b2..c4824f50ce 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 77038f0dfa..20f88dc078 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/lib/rollup-plugin-unwind-css-module-class-name.test.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts index a7b8cbb037..550e08d7f7 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) { @@ -181,7 +180,7 @@ import './photoswipe-!~{003}~.js'; const _hoisted_1 = createBaseVNode("i", { class: "ti ti-photo" }, null, -1); -const _sfc_main = defineComponent({ +const index_photos = defineComponent({ __name: "index.photos", props: { user: {} @@ -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) { @@ -263,7 +261,6 @@ const style0 = { const cssModules = { "$style": style0 }; -const index_photos = _sfc_main; export {index_photos as default}; `.slice(1)); }); diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts index 18c817e0f5..68cdc0bc78 100644 --- a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts +++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts @@ -13,13 +13,13 @@ function isFalsyIdentifier(identifier: estree.Identifier): boolean { return identifier.name === 'undefined' || identifier.name === 'NaN'; } -function normalizeClassWalker(tree: estree.Node): string | null { +function normalizeClassWalker(tree: estree.Node, stack: string | undefined): string | null { if (tree.type === 'Identifier') return isFalsyIdentifier(tree) ? '' : null; if (tree.type === 'Literal') return typeof tree.value === 'string' ? tree.value : ''; if (tree.type === 'BinaryExpression') { if (tree.operator !== '+') return null; - const left = normalizeClassWalker(tree.left); - const right = normalizeClassWalker(tree.right); + const left = normalizeClassWalker(tree.left, stack); + const right = normalizeClassWalker(tree.right, stack); if (left === null || right === null) return null; return `${left}${right}`; } @@ -33,15 +33,15 @@ function normalizeClassWalker(tree: estree.Node): string | null { if (tree.type === 'ArrayExpression') { const values = tree.elements.map((treeNode) => { if (treeNode === null) return ''; - if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument); - return normalizeClassWalker(treeNode); + if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument, stack); + return normalizeClassWalker(treeNode, stack); }); if (values.some((x) => x === null)) return null; return values.join(' '); } if (tree.type === 'ObjectExpression') { const values = tree.properties.map((treeNode) => { - if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument); + if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument, stack); let x = treeNode.value; let inveted = false; while (x.type === 'UnaryExpression' && x.operator === '!') { @@ -67,18 +67,26 @@ function normalizeClassWalker(tree: estree.Node): string | null { if (values.some((x) => x === null)) return null; return values.join(' '); } - console.error(`Unexpected node type: ${tree.type}`); + if ( + tree.type !== 'CallExpression' && + tree.type !== 'ChainExpression' && + tree.type !== 'ConditionalExpression' && + tree.type !== 'LogicalExpression' && + tree.type !== 'MemberExpression') { + console.error(stack ? `Unexpected node type: ${tree.type} (in ${stack})` : `Unexpected node type: ${tree.type}`); + } return null; } -export function normalizeClass(tree: estree.Node): string | null { - const walked = normalizeClassWalker(tree); +export function normalizeClass(tree: estree.Node, stack?: string): string | null { + const walked = normalizeClassWalker(tree, stack); return walked && walked.replace(/^\s+|\s+(?=\s)|\s+$/g, ''); } export function unwindCssModuleClassName(ast: estree.Node): void { (walk as typeof estreeWalker.walk)(ast, { enter(node, parent): void { + //#region if (parent?.type !== 'Program') return; if (node.type !== 'VariableDeclaration') return; if (node.declarations.length !== 1) return; @@ -102,6 +110,14 @@ export function unwindCssModuleClassName(ast: estree.Node): void { return true; }); if (!~__cssModulesIndex) return; + /* This region assumeed that the entered node looks like the following code. + * + * ```ts + * const SomeComponent = _export_sfc(_sfc_main, [["foo", bar], ["__cssModules", cssModules]]); + * ``` + */ + //#endregion + //#region const cssModuleForestName = ((node.declarations[0].init.arguments[1].elements[__cssModulesIndex] as estree.ArrayExpression).elements[1] as estree.Identifier).name; const cssModuleForestNode = parent.body.find((x) => { if (x.type !== 'VariableDeclaration') return false; @@ -117,6 +133,16 @@ export function unwindCssModuleClassName(ast: estree.Node): void { if (property.value.type !== 'Identifier') return []; return [[property.key.value as string, property.value.name as string]]; })); + /* This region collected a VariableDeclaration node in the module that looks like the following code. + * + * ```ts + * const cssModules = { + * "$style": style0, + * }; + * ``` + */ + //#endregion + //#region const sfcMain = parent.body.find((x) => { if (x.type !== 'VariableDeclaration') return false; if (x.declarations.length !== 1) return false; @@ -146,7 +172,22 @@ export function unwindCssModuleClassName(ast: estree.Node): void { if (ctx.type !== 'Identifier') return; if (ctx.name !== '_ctx') return; if (render.argument.body.type !== 'BlockStatement') return; + /* This region assumed that `sfcMain` looks like the following code. + * + * ```ts + * const _sfc_main = defineComponent({ + * setup(_props) { + * ... + * return (_ctx, _cache) => { + * ... + * }; + * }, + * }); + * ``` + */ + //#endregion for (const [key, value] of moduleForest) { + //#region const cssModuleTreeNode = parent.body.find((x) => { if (x.type !== 'VariableDeclaration') return false; if (x.declarations.length !== 1) return false; @@ -172,6 +213,19 @@ export function unwindCssModuleClassName(ast: estree.Node): void { if (actualValue.declarations[0].init?.type !== 'Literal') return []; return [[actualKey, actualValue.declarations[0].init.value as string]]; })); + /* This region collected VariableDeclaration nodes in the module that looks like the following code. + * + * ```ts + * const foo = "bar"; + * const baz = "qux"; + * const style0 = { + * foo: foo, + * baz: baz, + * }; + * ``` + */ + //#endregion + //#region (walk as typeof estreeWalker.walk)(render.argument.body, { enter(childNode) { if (childNode.type !== 'MemberExpression') return; @@ -189,6 +243,39 @@ export function unwindCssModuleClassName(ast: estree.Node): void { }); }, }); + /* This region inlined the reference identifier of the class name in the render function into the actual literal, as in the following code. + * + * ```ts + * const _sfc_main = defineComponent({ + * setup(_props) { + * ... + * return (_ctx, _cache) => { + * ... + * return openBlock(), createElementBlock("div", { + * class: normalizeClass(_ctx.$style.foo), + * }, null); + * }; + * }, + * }); + * ``` + * + * ↓ + * + * ```ts + * const _sfc_main = defineComponent({ + * setup(_props) { + * ... + * return (_ctx, _cache) => { + * ... + * return openBlock(), createElementBlock("div", { + * class: normalizeClass("bar"), + * }, null); + * }; + * }, + * }); + */ + //#endregion + //#region (walk as typeof estreeWalker.walk)(render.argument.body, { enter(childNode) { if (childNode.type !== 'MemberExpression') return; @@ -205,13 +292,47 @@ export function unwindCssModuleClassName(ast: estree.Node): void { }); }, }); + /* This region replaced the reference identifier of missing class names in the render function with `undefined`, as in the following code. + * + * ```ts + * const _sfc_main = defineComponent({ + * setup(_props) { + * ... + * return (_ctx, _cache) => { + * ... + * return openBlock(), createElementBlock("div", { + * class: normalizeClass(_ctx.$style.hoge), + * }, null); + * }; + * }, + * }); + * ``` + * + * ↓ + * + * ```ts + * const _sfc_main = defineComponent({ + * setup(_props) { + * ... + * return (_ctx, _cache) => { + * ... + * return openBlock(), createElementBlock("div", { + * class: normalizeClass(undefined), + * }, null); + * }; + * }, + * }); + * ``` + */ + //#endregion + //#region (walk as typeof estreeWalker.walk)(render.argument.body, { enter(childNode) { if (childNode.type !== 'CallExpression') return; if (childNode.callee.type !== 'Identifier') return; if (childNode.callee.name !== 'normalizeClass') return; if (childNode.arguments.length !== 1) return; - const normalized = normalizeClass(childNode.arguments[0]); + const normalized = normalizeClass(childNode.arguments[0], name); if (normalized === null) return; this.replace({ type: 'Literal', @@ -219,8 +340,60 @@ export function unwindCssModuleClassName(ast: estree.Node): void { }); }, }); + /* This region compiled the `normalizeClass` call into a pseudo-AOT compilation, as in the following code. + * + * ```ts + * const _sfc_main = defineComponent({ + * setup(_props) { + * ... + * return (_ctx, _cache) => { + * ... + * return openBlock(), createElementBlock("div", { + * class: normalizeClass("bar"), + * }, null); + * }; + * }, + * }); + * ``` + * + * ↓ + * + * ```ts + * const _sfc_main = defineComponent({ + * setup(_props) { + * ... + * return (_ctx, _cache) => { + * ... + * return openBlock(), createElementBlock("div", { + * class: "bar", + * }, null); + * }; + * }, + * }); + * ``` + */ + //#endregion } + //#region if (node.declarations[0].init.arguments[1].elements.length === 1) { + (walk as typeof estreeWalker.walk)(ast, { + enter(childNode) { + if (childNode.type !== 'Identifier') return; + if (childNode.name !== ident) return; + this.replace({ + type: 'Identifier', + name: node.declarations[0].id.name, + }); + }, + }); + this.remove(); + /* NOTE: The above logic is valid as long as the following two conditions are met. + * + * - the uniqueness of `ident` is kept throughout the module + * - `_export_sfc` is noop when the second argument is an empty array + * + * Otherwise, the below logic should be used instead. + this.replace({ type: 'VariableDeclaration', declarations: [{ @@ -236,6 +409,7 @@ export function unwindCssModuleClassName(ast: estree.Node): void { }], kind: 'const', }); + */ } else { this.replace({ type: 'VariableDeclaration', @@ -263,6 +437,35 @@ export function unwindCssModuleClassName(ast: estree.Node): void { kind: 'const', }); } + /* This region removed the `__cssModules` reference from the second argument of `_export_sfc`, as in the following code. + * + * ```ts + * const SomeComponent = _export_sfc(_sfc_main, [["foo", bar], ["__cssModules", cssModules]]); + * ``` + * + * ↓ + * + * ```ts + * const SomeComponent = _export_sfc(_sfc_main, [["foo", bar]]); + * ``` + * + * When the declaration becomes noop, it is removed as follows. + * + * ```ts + * const _sfc_main = defineComponent({ + * ... + * }); + * const SomeComponent = _export_sfc(_sfc_main, []); + * ``` + * + * ↓ + * + * ```ts + * const SomeComponent = defineComponent({ + * ... + * }); + */ + //#endregion }, }); } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index fab12df575..b78cfb2395 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,6 +4,7 @@ "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", @@ -21,11 +22,10 @@ "@rollup/plugin-alias": "5.1.0", "@rollup/plugin-json": "6.0.1", "@rollup/plugin-replace": "5.0.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", + "@vitejs/plugin-vue": "4.5.1", "@vue/compiler-sfc": "3.3.9", "astring": "1.8.6", "autosize": "6.0.1", @@ -34,19 +34,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.1.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", @@ -54,54 +54,54 @@ "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.6.0", + "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": "5.0.2", - "vue": "3.3.9", + "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", + "@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.5", "@types/micromatch": "4.0.6", - "@types/node": "20.10.0", + "@types/node": "20.10.4", "@types/punycode": "2.1.3", "@types/sanitize-html": "2.9.5", "@types/throttle-debounce": "5.0.2", @@ -109,33 +109,33 @@ "@types/uuid": "9.0.7", "@types/websocket": "1.0.10", "@types/ws": "8.5.10", - "@typescript-eslint/eslint-plugin": "6.12.0", - "@typescript-eslint/parser": "6.12.0", + "@typescript-eslint/eslint-plugin": "6.13.2", + "@typescript-eslint/parser": "6.13.2", "@vitest/coverage-v8": "0.34.6", - "@vue/runtime-core": "3.3.9", + "@vue/runtime-core": "3.3.11", "acorn": "8.11.2", "cross-env": "7.0.3", - "cypress": "13.6.0", - "eslint": "8.54.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 0000000000..2e95a03576 --- /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 6e216a78b4..60f2781fdf 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); + } } }); diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 71236e4c53..88e2f83895 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 2c7be319cb..ce7e134b70 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 fe7077bdbf..adb3c134ae 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 d321114cba..1a1b4323d9 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 diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 03788af21e..60f16f285f 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only -->