1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-13 22:24:47 +02:00

Compare commits

..

107 Commits

Author SHA1 Message Date
おさむのひと
ecd1d615ff fix(frontend): 未ログイン時のトップページがスクロール不可だったのを修正 (#17681)
* fix(frontend): 未ログイン時のトップページがスクロール不可だったのを修正

* fix CHANGELOG.md

* remove playwright file

* fix(frontend): 非ログイン時トップページを背景の上でもスクロールできるよう修正

.root を min-height から develop と同じ height + overflow: auto に戻して .root 自身を
スクロールコンテナとし、装飾レイヤ (.bg / .shape1 / .shape2) の pointer-events: none と
併せて、コンテンツ列以外 (背景) の上でもホイールスクロールが効くようにする。
CSS のみの対応で、TL のマーキーアニメーションやノート / フェデレーションのクリック挙動は
変更しない。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:21:18 +09:00
Genki.Takeuchi
53c398ce43 fix(frontend): loadRawImages有効時、リモート画像をタップしてからビューワーが開くまでに初回のみラグが発生する問題を修正 (#17703)
ビューワーを表示するトリガーをサムネイル画像又はオリジナル画像がロードされたときに修正
2026-07-13 19:58:05 +09:00
かっこかり
f74b427e67 fix(backend): configテンプレートのallowedPrivateNetworksをリスト記法に変更してエラーの発生を抑制 (#17701)
* fix(backend): configテンプレートのallowedPrivateNetworksをリスト記法に変更してエラーの発生を抑制

* fix
2026-07-12 17:15:13 +09:00
かっこかり
aab7903ab0 fix(backend): summalyの結果のキャッシュが後続処理によって書き換わる問題を修正
Follow-up of #17367
2026-07-12 16:54:57 +09:00
github-actions[bot]
6ceda0f004 Bump version to 2026.7.0-alpha.3 2026-07-12 03:12:51 +00:00
かっこかり
df5c491f4c enhance(backend): summalyの結果をキャッシュするように (#17367)
* enhance(backend): summalyの結果をキャッシュするように

* fix

* fix: unify memorykvcache

* fix

* refactor

* fix

* Update Changelog
2026-07-12 12:08:59 +09:00
syuilo
15c187ffa2 enhance(frontend): misskey-native lightbox (#17687)
* wip

* wip

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* wip

* wip

* wip

* wip

* wip

* Update MkImageGallery.vue

* Update MkImageGallery.vue

* wip

* wip

* Update MkMediaList.vue

* Update MkImageGallery.item.vue

* fix

* wip

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.item.vue

* refactor

* Update MkImageGallery.item.vue

* Update MkImageGallery.vue

* Delete animation.ts

* wip

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.vue

* Update MkImageGallery.vue

* Update MkImageGallery.item.vue

* fix

* Update MkImageGallery.item.vue

* add note

* lint

* Update MkMediaList.vue

* refactor

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* fix

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkVideoContol.vue

* wip

* Update MkMediaVideo.vue

* Update MkMediaVideo.vue

* wip

* Update MkImageGallery.vue

* Update MkVideoContol.vue

* Update MkVideoContol.vue

* Improve image viewer (#17696)

* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo

* fix(frontend/imagegallery): ボタンの当たり判定がまだ小さいのを修正 (#17697)

* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo

* fix

* Update Changelog

* indent

* fix

* fix

* refactor: rename componet

* fix

* fix

* fix

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2026-07-12 11:58:44 +09:00
おさむのひと
b4abbe432e fix: ensure color output for child processes in Windows environment (#17695) 2026-07-11 15:27:34 +09:00
おさむのひと
2335eb7b7f chore: pnpm dev時のプロセス管理をより綿密に行い、Windows環境でのプロセス残留を抑止 (#17690) 2026-07-10 21:51:01 +09:00
かっこかり
059fbaa92b fix(frontend/test): follow-up of #17682 of 2 (#17685)
* fix(frontend/test): follow-up of #17682 of 2

* fix
2026-07-08 22:37:19 +09:00
かっこかり
76ab562810 fix: follow-up of #17682 [ci skip] 2026-07-08 21:19:35 +09:00
かっこかり
9472e72678 enhance(frontend/test): frontend e2eをPlaywrightに移行 (#17682)
* enhance(frontend/test): frontend e2eをVitest Browser Modeに移行

* fix

* fix unit tests

* fix

* fix

* update playwright

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* perf

* Revert "perf"

This reverts commit e60e965c3c.

* fix
2026-07-08 20:58:02 +09:00
かっこかり
9440423a73 refactor(frontend): フロントエンドのfluent-emojisをemoji-assetsから取ってくるように (#17662)
refactor(frontend): フロントエンドのfluent-emojis使用箇所を `@misskey-dev/emoji-assets` のものに変更
2026-07-08 20:11:13 +09:00
かっこかり
b2867eb0d7 fix(frontend): ディレクティブのイベントリスナーがマウント解除後も残る問題を修正 (#17683)
* fix(frontend): ツールチップがマウント解除後もイベントリスナーが残る問題を修正

* more fix

* fix

* fix/refactor

* Update Changelog

* update comment

* refactor

* fix
2026-07-08 19:45:43 +09:00
syuilo
5590f8bad9 chore(backend): COOP/COEPヘッダーを送信するテスト用引数 2026-07-08 13:15:03 +09:00
おさむのひと
c15f93e48d fix: Sentry 使用環境下にて、外部送信リクエストへ sentry-trace / baggage ヘッダが既定で付与されないように (#17677)
* fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように

* fix
2026-07-08 09:46:44 +09:00
github-actions[bot]
03d224acd0 Bump version to 2026.7.0-alpha.2 2026-07-06 10:03:45 +00:00
かっこかり
c24ce9b55a deps: update dependencies (#17669)
* deps: update dependencies

* deps: update dependencies (major)

* migrate js-yaml

* migrate js-yaml

* run pnpm dedupe

* fix

* chore: reorganize dependencies

* perf: lazy-load id generators

* Revert "perf: lazy-load id generators"

This reverts commit 041f7c4206.
2026-07-06 18:55:06 +09:00
おさむのひと
7243bd34b9 fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 (#17671)
* fix: pgをexternalにしてSentryのspanが動くようにする

* fix changelog

* add comment
2026-07-06 18:54:04 +09:00
おさむのひと
d4dd02c744 feat: Sentryバックエンドの自動計装を個別で無効化出来る仕組みを追加する (#17673)
* feat: Sentryバックエンドの自動計装を個別で無効化出来る仕組みを追加する

* fix comment
2026-07-06 18:52:57 +09:00
syuilo
3817f00803 Revert "fix(dev): fix typo of report-api-diff.yml"
This reverts commit d1cac26cfb.
2026-07-05 14:11:05 +09:00
github-actions[bot]
f066c1c2ef Bump version to 2026.7.0-alpha.1 2026-07-05 05:06:33 +00:00
かっこかり
1d41cb3d29 perf(frontend): UNIX時計ウィジェットでrequestAnimationFrameを使用するように (#17665) 2026-07-05 13:08:44 +09:00
かっこかり
1858d56918 fix(frontend): ワードミュートの判定が誤っている問題を修正 (#17668) 2026-07-05 13:07:17 +09:00
かっこかり
97082efd99 Update CHANGELOG.md [ci skip] 2026-07-04 20:46:03 +09:00
Johann150
187610d516 fix(frontend): correctly detect isMe in mention (#17664) 2026-07-04 20:43:42 +09:00
かっこかり
00b2cb2076 fix(backend/test): streaming.ts e2e の検証不備を修正 (#17663)
fix(backend/test): streaming.ts e2e の検証不備を修正 (tiramiss-community/endolphin#113)

Co-authored-by: samunohito <46447427+samunohito@users.noreply.github.com>
2026-07-04 18:53:10 +09:00
おさむのひと
426f6748e5 fix: frontend-builderのutils.tsが型エラーを起こしていたのを修正 (#17649) 2026-07-04 17:44:42 +09:00
かっこかり
c423bf92a8 Update CHANGELOG.md 2026-07-04 16:47:31 +09:00
かっこかり
f59bf35615 feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように (#17635)
* URLプレビューのサムネイルを隠す機能を追加 (MisskeyIO#214)

* Update Changelog

* fix

* Update Changelog

* fix

* fix: もうセンシティブと判定されている場合はそれをそのまま通すように

* fix: unnecessary import

* fix lint

---------

Co-authored-by: CyberRex <hspwinx86@gmail.com>
2026-07-04 16:26:22 +09:00
おさむのひと
0f056c4955 refactor: Sentry呼び出し個所の抽象化 (#17661) 2026-07-04 16:06:24 +09:00
かっこかり
eb2c7ff6c6 fix(backend/test): follow-up of #17654 (waitForを他のe2eテストにも展開) (#17659)
test(backend): block.ts等のfanout timeline反映待ちレースを修正する (tiramiss-community/endolphin#112)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-04 11:21:23 +09:00
おさむのひと
9f614517c0 fix: tsconfig.jsonにskipLibCheck: trueを追加する (#17651)
* fix: tsconfig.jsonにskipLibCheck: trueを追加する

* skipLibCheckで抜けたチェックを自前でやる
2026-07-03 19:22:23 +09:00
github-actions[bot]
5432984af8 Bump version to 2026.7.0-alpha.0 2026-07-03 10:15:24 +00:00
かっこかり
c29a3d902b refactor(frontend): MkNote/MkNoteDetailedのロジックを統合 (#17636)
* refactor(frontend): MkNote/MkNoteDetailedのロジックを統合

* refactor

* fix

* fix: 差分を解消

* fix lint

* fix types

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-03 19:14:48 +09:00
かっこかり
721b1b06a0 fix(backend/test): backend e2e test の beforeAll でdispose前にschema dropが走るのを修正 (#17653)
fix(backend): setup.e2e.ts の beforeAll でdispose前にschema dropが走る順序バグを修正 (Phase 6) (tiramiss-community/endolphin#52)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 15:57:39 +09:00
かっこかり
97e54a1ee8 fix(frontend): APIキーを指定するいくつかのフォームでautocompleteが発動する問題を修正 (#17655) 2026-07-03 14:58:58 +09:00
かっこかり
96d6a09ebc fix(backend/test): e2e testの固定待ち時間をポーリングに変更 (#17654)
* test(backend): timelines.ts の固定250ms待ちをポーリング化する (tiramiss-community/endolphin#47)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>

* test(backend): move.ts のflaky/低速sleepをポーリング化する (tiramiss-community/endolphin#49)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>

---------

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 10:46:04 +09:00
かっこかり
1eedf04d9a fix(frontend): デバイスサイズをスマートフォンに固定している場合はページヘッダーのアイコンを常に表示するように (#17590)
* fix(frontend): デバイスサイズをスマートフォンに固定している場合はページヘッダーのアイコンを常に表示するように

* Update Changelog

* Update Changelog

* fix

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-02 22:36:57 +09:00
ihoronir
2d67380f2a fix(backend): 同名の新規ハッシュタグが複数同時に作成されたとき、DBで発生した競合を適切にハンドリング (#17640)
* Hashtag Timeline のテストを復活させる

* HashtagService.ts を修正

* fix indent

* refactor

* Update CHANGELOG.md

* fix review
2026-07-02 09:48:26 +09:00
おさむのひと
174fb434cc fix: frontend-embedで見つけた型エラーの修正 (#17650) 2026-07-02 09:24:50 +09:00
greymoth
7e29f04287 fix(chat): don't send message while the IME is composing (#17646)
Co-authored-by: greymoth <246701683+greymoth-jp@users.noreply.github.com>
2026-07-01 10:06:16 +09:00
renovate[bot]
bf88122140 chore(deps): update [github actions] update dependencies [ci skip] (#17645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 13:11:23 +09:00
かっこかり
4daa1ffe05 chore(deps): 未使用依存パッケージを削除 (#17641)
chore(deps): 未使用依存パッケージを削除 (tiramiss-community/endolphin#20)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-06-29 23:08:09 +09:00
syuilo
62f8589c05 Update CONTRIBUTING.md 2026-06-28 17:52:08 +09:00
かっこかり
6193c35f9f fix: stats API の型に reactionCount が定義されていない問題を修正 (#17634)
* fix: stats API の型に reactionCount が定義されていない問題を修正

* Update Changelog
2026-06-28 01:49:00 +09:00
かっこかり
1220f05903 Update CHANGELOG.md 2026-06-28 01:02:03 +09:00
かっこかり
7544ebf7a3 Update CHANGELOG.md 2026-06-28 00:54:52 +09:00
かっこかり
ffe65caf10 Update CHANGELOG.md 2026-06-28 00:25:42 +09:00
かっこかり
4f993cef1b enhance: Set default Node.js version to v26 (#17623)
* enhance: Set default Node.js version to v26

* fix

* Update Changelog

* fix types

* drop node v22

* update changelog

* fix test

* update

* fix test

* fix test

* Revert "drop node v22"

This reverts commit bb4e011628.

* fix changelog

* attempt to fix test

* attempt to fix test

* fix: update re2 that supports node 26

* attempt to fix test

* attempt to fix test

* run pnpm dedupe

* restore 2fa e2e

* refactor

* attempt to fix test

* attempt to fix test

* run pnpm dedupe

* attempt to fix test
2026-06-28 00:06:49 +09:00
かっこかり
ba3fb4aa14 Merge branch 'master' into develop 2026-06-27 23:40:06 +09:00
かっこかり
0137b1c406 fix: update compatible node version on master (#17632)
Update package.json
2026-06-27 23:39:29 +09:00
かっこかり
797dec7d0e Update CHANGELOG.md 2026-06-27 23:36:35 +09:00
github-actions[bot]
42ff280163 Bump version to 2026.6.1-alpha.1 2026-06-27 13:08:35 +00:00
かっこかり
554339aaa1 deps: update dependencies (#17631)
* deps: update deps

* Update Changelog

* update dependencies (major)

* fix: broken lockfile on changelog-checker

* update vite

* update minimum working node version to 22.22.0

* update minimum working node version to 22.22.2

* fix types

* refactor: avoid using default import

* Revert "refactor: avoid using default import"

This reverts commit 5784df2ff0.

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-06-27 22:04:08 +09:00
syuilo
529c4d4d0e chore(dev): tweak heap snapshot table 2026-06-27 20:21:17 +09:00
syuilo
cec23e5756 enhance(dev): improve heap snapshot analysis (#17628)
* Update measure-memory.mts

* Update utility.mts

* Update backend-memory-report.mts

* tweak
2026-06-27 18:59:46 +09:00
syuilo
e9715aa45a enhance(dev): make heap snapshot downloadable 2026-06-27 14:45:30 +09:00
syuilo
dbbc5fc8c0 chore(dev): clean up unnecessary step 2026-06-27 11:55:49 +09:00
syuilo
228beff6c8 chore(dev): tweak backend-memory-report 2026-06-27 11:54:05 +09:00
syuilo
1faeb9b324 refactor(dev): use actions/download-artifact 2026-06-27 11:53:36 +09:00
syuilo
d1cac26cfb fix(dev): fix typo of report-api-diff.yml 2026-06-27 11:49:41 +09:00
syuilo
a107caef6b chore(dev): clean up unnecessary logs 2026-06-27 11:48:36 +09:00
syuilo
8fc3403d13 chore(dev): clean up unnecessary logs 2026-06-27 11:45:24 +09:00
renovate[bot]
a7503c225d chore(deps): update pnpm to v11.8.0 [security] (#17625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-27 09:55:24 +09:00
syuilo
3cd772e5a6 Merge branch 'master' into develop 2026-06-26 20:36:34 +09:00
syuilo
8af257929d enhance(frontend): drop pref migration (#17379)
clean pref migration
2026-06-26 20:35:37 +09:00
syuilo
99a4eeb87d Workflowの追従 (#17622)
* [skip ci] Update CHANGELOG.md (prepend template)

* fix(dev): tweak frontend-bundle-report

* Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop

* enhance(dev): tweak Frontend Chunk Report

* fix(dev): tweak frontend-bundle-report-comment

* enhance(dev): tweak Frontend Chunk Report

* enhance(dev): tweak Frontend Chunk Report

* enhance(dev): improve backend memory usage comparison workflow (#17591)

* wip

* Update get-backend-memory.yml

* [ci skip] tweak table

* enhance(dev): tweak report-backend-memory

* refactor(dev): report-backend-memoryのmarkdown生成ロジックを分離

* enhance(dev): tweak report-backend-memory

* enhance(dev): tweak report-backend-memory

* enhance(dev): tweak report-backend-memory

* chore(dev): refactor

* refactor(dev): unify frontend-bundle-visualizer-report.mjs and frontend-js-size.mjs

* chore(dev): fix typo

* refactor(dev): refactor frontend-js-size.mjs

* chore(dev): tweak frontend-js-size.mjs

* chore(dev): tweak frontend-js-size.mjs

* chore(dev): tweak frontend-js-size.mjs

* chore(ci): simplify FFmpeg installation in workflows (#17612)

* chore(ci): simplify FFmpeg installation in workflows

* fix

* fix(dev): tweak frontend-js-size.mjs

* Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop

* enhance(dev): improve Frontend Bundle Report

* chore(dev): refactor frontend bundle report

* enhance(dev): improve backend memory usage report (#17608)

* enhance(dev): tweak report-backend-memory

* wip

* Update backend-memory-report.mjs

* Update backend-memory-report.mjs

* chore(dev): tweak frontend-js-size.mjs

* Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* (test) enhance(dev): improve backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak frontend-js-size

* chore(dev): tweak frontend-js-size

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak frontend-js-size

* Update frontend-bundle-report.yml

* [ci skip] chore(dev): tweak frontend-js-size

* refactor(dev): refactor of backend memory comparison workflow (#17619)

* refactor(dev): refactor of backend memory comparison workflow

* fix

* fix(dev): follow up of 1c4bcd9b32

* fix(dev): follow up of 1c4bcd9b32

* fix(dev): follow up of 1c4bcd9b32

* fix(dev): follow up of 1c4bcd9b32

* chore(dev): refactor workflow js

* refactor(dev): extract heap snapshot logic

将来的にフロントエンドでもheap snaphotを集計したくなった時などのため
あとpairedDeltaSummaryを共通化

* chore(dev): tweak backend-memory-report

* chore(dev): tweak some workflows

* chore(dev): tweak some workflows

* fix(dev): fix measure-memory

* clean up

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-06-26 19:41:17 +09:00
かっこかり
1068c6424f feat: コントロールパネルから二要素認証を解除できるように (#17614)
* enhance: コントロールパネルから二要素認証を解除できるように

* Update Changelog

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-06-26 19:33:25 +09:00
syuilo
9b32c6ffb8 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-06-26 17:52:57 +09:00
syuilo
038a2d0b81 refactor 2026-06-26 17:52:53 +09:00
misskey-release-bot[bot]
3c83952c48 Merge pull request #17546 from misskey-dev/develop
Release: 2026.6.0
2026-06-22 11:47:26 +00:00
misskey-release-bot[bot]
3ac6d287d6 Merge pull request #17458 from misskey-dev/develop
Release: 2026.5.4
2026-05-21 00:32:02 +00:00
misskey-release-bot[bot]
42a59b5d76 Merge pull request #17426 from misskey-dev/develop
Release: 2026.5.3
2026-05-18 01:44:55 +00:00
misskey-release-bot[bot]
138e66e618 Merge pull request #17397 from misskey-dev/develop
Release: 2026.5.2
2026-05-17 22:14:59 +00:00
misskey-release-bot[bot]
4188d68457 Merge pull request #17364 from misskey-dev/develop
Release: 2026.5.1
2026-05-06 10:44:22 +00:00
misskey-release-bot[bot]
6391a4e7e2 Merge pull request #17351 from misskey-dev/develop
Release: 2026.5.0
2026-05-02 03:30:56 +00:00
misskey-release-bot[bot]
41048638a2 Merge pull request #17232 from misskey-dev/develop
Release: 2026.3.2
2026-03-31 12:14:43 +00:00
misskey-release-bot[bot]
9c0e3e7937 Merge pull request #17230 from misskey-dev/develop
Release: 2026.3.1
2026-03-09 01:03:00 +00:00
misskey-release-bot[bot]
fe3dd8edb5 Merge pull request #17217 from misskey-dev/develop
Release: 2026.3.0
2026-03-05 10:56:50 +00:00
misskey-release-bot[bot]
0d46089f9a Merge pull request #16998 from misskey-dev/develop
Release: 2025.12.2
2025-12-22 05:30:45 +00:00
misskey-release-bot[bot]
7420c10a58 Merge pull request #16972 from misskey-dev/develop
Release: 2025.12.1
2025-12-14 07:27:09 +00:00
misskey-release-bot[bot]
e40c84f31d Merge pull request #16916 from misskey-dev/develop
Release: 2025.12.0
2025-12-06 12:22:58 +00:00
misskey-release-bot[bot]
994fc062cf Merge pull request #16840 from misskey-dev/develop
Release: 2025.11.1
2025-11-28 10:04:09 +00:00
misskey-release-bot[bot]
e7681f6c79 Merge pull request #16759 from misskey-dev/develop
Release: 2025.11.0
2025-11-16 08:23:46 +00:00
misskey-release-bot[bot]
19053339d9 Merge pull request #16709 from misskey-dev/develop
Release: 2025.10.2
2025-10-27 04:19:45 +00:00
misskey-release-bot[bot]
b4e16c83e2 Merge pull request #16629 from misskey-dev/develop
Release: 2025.10.1
2025-10-24 06:31:35 +00:00
misskey-release-bot[bot]
56cc89b521 Merge pull request #16591 from misskey-dev/develop
Release: 2025.10.0
2025-10-08 13:18:08 +00:00
misskey-release-bot[bot]
1eab314b17 Merge pull request #16521 from misskey-dev/develop
Release: 2025.9.0
2025-09-08 12:29:29 +00:00
misskey-release-bot[bot]
ec21336d45 Merge pull request #16335 from misskey-dev/develop
Release: 2025.8.0
2025-08-31 08:42:43 +00:00
misskey-release-bot[bot]
e86e9b46b3 Merge pull request #16244 from misskey-dev/develop
Release: 2025.7.0
2025-07-18 00:28:01 +00:00
misskey-release-bot[bot]
9b729b3d25 Merge pull request #16197 from misskey-dev/develop
Release: 2025.6.3
2025-06-16 11:13:26 +00:00
misskey-release-bot[bot]
3c973e21f2 Merge pull request #16195 from misskey-dev/develop
Release: 2025.6.2
2025-06-16 08:58:35 +00:00
misskey-release-bot[bot]
830e2f0a5b Merge pull request #16152 from misskey-dev/develop
Release: 2025.6.1
2025-06-16 02:33:18 +00:00
misskey-release-bot[bot]
1620477a1c Merge pull request #16134 from misskey-dev/develop
Release: 2025.6.0
2025-06-02 00:58:34 +00:00
misskey-release-bot[bot]
92b9a5218d Merge pull request #16005 from misskey-dev/develop
Release: 2025.5.1
2025-05-31 12:37:06 +00:00
misskey-release-bot[bot]
9ed0d5ccec Merge pull request #15933 from misskey-dev/develop
Release: 2025.5.0
2025-05-07 02:46:42 +00:00
misskey-release-bot[bot]
a6d1727205 Merge pull request #15842 from misskey-dev/develop
Release: 2025.4.1
2025-04-30 09:01:47 +00:00
misskey-release-bot[bot]
3c3982464f Merge pull request #15735 from misskey-dev/develop
Release: 2025.4.0
2025-04-09 02:17:31 +00:00
misskey-release-bot[bot]
bef73ff530 Merge pull request #15615 from misskey-dev/develop
Release: 2025.3.1
2025-03-09 03:29:58 +00:00
misskey-release-bot[bot]
4d31c0b1de Merge pull request #15585 from misskey-dev/develop
Release: 2025.3.0
2025-03-06 10:31:34 +00:00
misskey-release-bot[bot]
a5f28c21e4 Merge pull request #15507 from misskey-dev/develop
Release: 2025.2.1
2025-02-27 08:58:43 +00:00
misskey-release-bot[bot]
c93ead7474 Merge pull request #15378 from misskey-dev/develop
Release: 2025.2.0
2025-02-05 08:58:45 +00:00
misskey-release-bot[bot]
36880493cb Merge pull request #15279 from misskey-dev/develop
Release: 2025.1.0
2025-01-28 12:29:14 +00:00
misskey-release-bot[bot]
e8518de054 Merge pull request #14924 from misskey-dev/develop
Release: 2024.11.0
2024-11-22 09:15:34 +00:00
misskey-release-bot[bot]
b99e13e667 Merge pull request #14741 from misskey-dev/develop
Release: 2024.10.1
2024-10-15 04:53:46 +00:00
misskey-release-bot[bot]
2518cf36d0 Merge pull request #14675 from misskey-dev/develop
Release: 2024.10.0
2024-10-09 05:17:29 +00:00
295 changed files with 8774 additions and 9017 deletions

View File

@@ -35,7 +35,7 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、CLAUDE.md の肥大化 |
| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck |
| 4 | Memory Persistence | `.claude/skills/*/SKILL.md``references/` の同期状態を評価。プロジェクト側 `.claude/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Cypress 適用ガイド |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Playwright 適用ガイド |
| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし |

View File

@@ -21,7 +21,7 @@ note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック
`/quality-gate [scope]`
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Cypress) は CI 側で実行されるため、本コマンドには含めない。
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Playwright) は CI 側で実行されるため、本コマンドには含めない。
## Scope
@@ -120,4 +120,4 @@ Frontend ut: PASS (87/87)
- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。
- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。
- e2e / federation / Cypress は重いため除外し CI 側に委譲。
- e2e / federation / Playwright は重いため除外し CI 側に委譲。

View File

@@ -26,7 +26,7 @@ cp .github/misskey/test.yml .config/test.yml
補足:
- ルートの `pnpm start:test` (Cypress 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
- ルートの `pnpm start:test` (Playwright 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
- すでに `.config/test.yml` があれば各テストスクリプトの内部 `compile-config` で十分なので、追加で `pnpm --filter backend compile-config` を叩く必要はない
- `pnpm start:test` は backend e2e テスト (`pnpm --filter backend test:e2e`) の前提ではない (ポート競合の元になるため使わないこと)

View File

@@ -27,7 +27,7 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
- SCSS Modules / `--MI_THEME-*` `--MI-*` CSS 変数 / グローバル utility class (`_button` 等) → [references/knowledge/scss-modules.md](references/knowledge/scss-modules.md)
- `os.alert` / `os.confirm` / `os.popup` 等 UI ヘルパー (ブラウザ標準 `alert()` 直呼びは禁止) → [references/knowledge/os-api.md](references/knowledge/os-api.md)
- `*.stories.impl.ts` 併設規則 + 複数 story / argTypes / layout / action パターン → [references/knowledge/storybook.md](references/knowledge/storybook.md)
- frontend Vitest / Cypress E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
- frontend Vitest / Playwright E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
## 必ず最後に通る場所

View File

@@ -1,4 +1,4 @@
# Frontend テスト (Vitest / Cypress)
# Frontend テスト (Vitest / Playwright)
Misskey frontend のテスト構成。
@@ -13,11 +13,11 @@ pnpm --filter frontend test-and-coverage # カバレッジ付き
- 主な配置: `packages/frontend/test/*.test.ts` (例: `i18n.test.ts`, `theme.test.ts`, `is-birthday.test.ts`)
- ビルドツール周りなど対象コードと隣接させた方が分かりやすいテストは、コードと同じディレクトリに `*.test.ts` として置く (例: [packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts](../../../../../packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts))
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Cypress でカバー)
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Playwright でカバー)
## Cypress E2E
## Playwright E2E
Cypress**起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
Playwright**起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
```bash
# 1. テスト用 DB / Redis を起動 (テスト用ポート。開発用の compose.local-db.yml ではない)
@@ -29,15 +29,14 @@ cp .github/misskey/test.yml .config/test.yml
# 3. 全体ビルド
pnpm build
# 4. テストサーバー起動 + Cypress 実行 (いずれもルートから)
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Cypress run
pnpm cy:open # 対話的に開く (サーバーは別途 pnpm start:test で起動しておく)
# 4. テストサーバー起動 + Playwright 実行 (いずれもルートから)
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Playwright を実行
```
- 設定: ルート [cypress.config.ts](../../../../../cypress.config.ts)
- テスト本体は [cypress/](../../../../../cypress/) 配下
- 設定: ルート [packages/frontend/playwright.config.ts](../../../../../packages/frontend/playwright.config.ts) で、`packages/frontend/test/e2e/` 配下の `*.spec.ts` を対象にする
- テスト本体は [packages/frontend/test/e2e/](../../../../../packages/frontend/test/e2e/) 配下に配置する。
新規 frontend 機能の E2E は Cypress に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
新規 frontend 機能の E2E は Playwright に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
## Storybook (視覚確認 + Chromatic 視覚回帰)
@@ -55,6 +54,6 @@ pnpm --filter frontend build-storybook # 静的ビルド
frontend のテスト種別で DB / Redis の要否が違う:
- **Vitest (unit)** — DB 不要。ロジック / コンポーネント単体のテストで backend に繋がない (CI の `vitest` ジョブにも `services:` は無い)
- **Cypress (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Cypress E2E の手順を参照)
- **Playwright (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Playwright E2E の手順を参照)
開発用の `compose.local-db.yml` (db `5432` / redis `6379`) は **テストには使わない**。テスト用の `packages/backend/test/compose.yml` (`54312` / `56312`) とはポートが異なり、混同すると接続できない。

View File

@@ -233,9 +233,8 @@ proxyBypassHosts:
# 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'
#]
#allowedPrivateNetworks:
# - '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -303,8 +303,31 @@ id: 'aidx'
#sentryForBackend:
# enableNodeProfiling: true
# # Specify Sentry integration names to disable individual auto-instrumentation.
# # The names are integration .name values, not factory function names.
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
# # Postgres ... DB queries (when pg is externalized)
# # Redis ... ioredis commands
# # Fastify ... inbound HTTP routes
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
# # NodeFetch ... fetch/undici requests
# disabledIntegrations: ['Postgres']
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
# # To intentionally propagate distributed traces to trusted internal services,
# # list only those internal URL patterns here.
# # Avoid broad patterns that match remote servers unless you intentionally
# # want to restore Sentry's legacy behavior of propagating traces to all
# # outbound HTTP requests.
# #tracePropagationTargets: []
# # Internal allowlist example:
# #tracePropagationTargets:
# # - 'internal-service.example'
#sentryForFrontend:
# vueIntegration:
@@ -383,9 +406,8 @@ proxyBypassHosts:
# 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'
#]
#allowedPrivateNetworks:
# - '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -218,9 +218,8 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
allowedPrivateNetworks: [
'127.0.0.1/32'
]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -5,7 +5,7 @@
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "22.15.0"
"version": "22.23.1"
},
"ghcr.io/devcontainers-extra/features/pnpm:2": {
"version": "10.10.0"

View File

@@ -205,9 +205,8 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
allowedPrivateNetworks: [
'127.0.0.1/32'
]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -12,4 +12,4 @@ pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate
pnpm exec cypress install
pnpm --filter frontend exec playwright install --with-deps chromium

2
.github/labeler.yml vendored
View File

@@ -16,7 +16,7 @@
'packages/frontend:test':
- any:
- changed-files:
- any-glob-to-any-file: ['cypress/**/*']
- any-glob-to-any-file: ['packages/frontend/test/**/*']
'packages/sw':
- any:

View File

@@ -1 +1 @@
22.15.0
22.22.2

View File

@@ -23,7 +23,6 @@ const [repoDirArg, outputFileArg] = process.argv.slice(2);
const STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
const REQUEST_COUNT = util.readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0);
const MAX_TABLE_ITEMS = util.readIntegerEnv('MK_JS_FOOTPRINT_MAX_ITEMS', 20, 1);
const repoDir = resolve(repoDirArg);
const outputFile = resolve(outputFileArg);
@@ -389,7 +388,7 @@ function summarizeRecords(records, phase) {
stringLiteralKiB: bytesToKiB(totals.stringLiteralBytes),
},
packages: [...packages.values()].toSorted((a, b) => b.sourceBytes - a.sourceBytes),
modules: modules.toSorted((a, b) => b.sourceBytes - a.sourceBytes).slice(0, MAX_TABLE_ITEMS),
modules: modules.toSorted((a, b) => b.sourceBytes - a.sourceBytes),
};
}

View File

@@ -54,7 +54,7 @@ const metrics = [
function formatMemoryMb(valueKiB: number | null | undefined) {
if (valueKiB == null) return '-';
return `${util.formatNumber(valueKiB / 1024)} MB`;
return `${util.formatNumber(valueKiB / 1000)} MB`;
}
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
@@ -79,8 +79,8 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase:
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
function formatDeltaMemory(diffKiB: number) {
return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB);
function formatDeltaMemory(deltaKiB: number) {
return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold
}
for (const metric of metrics) {
@@ -91,7 +91,7 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase:
const headSpread = getSampleSpread(head, phase, metric);
const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
const percent = summary.median * 100 / baseValue;
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent).replaceAll('\\%', '\\\\%')}`;
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`);
}
@@ -144,7 +144,7 @@ function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
];
for (const graph of [
heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
//heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'),
]) {
if (graph == null) continue;
@@ -183,7 +183,7 @@ function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head
const headValue = getJsFootprintValue(head, 'afterRequest', key);
if (baseValue == null || headValue == null) continue;
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(formatter(headValue - baseValue), headValue - baseValue)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`);
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(headValue - baseValue, v => formatter(v))} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`);
}
return lines.join('\n');
@@ -278,7 +278,7 @@ function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, hea
];
for (const packageSummary of increases) {
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(util.formatBytes(packageSummary.sourceBytes - packageSummary.baseSourceBytes), packageSummary.sourceBytes - packageSummary.baseSourceBytes)} | ${util.formatColoredDelta(util.formatNumber(packageSummary.modules - packageSummary.baseModules), packageSummary.modules - packageSummary.baseModules)} |`);
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(packageSummary.sourceBytes - packageSummary.baseSourceBytes, v => util.formatBytes(v))} | ${util.formatColoredDelta(packageSummary.modules - packageSummary.baseModules, v => util.formatNumber(v))} |`);
}
return lines.join('\n');
@@ -374,6 +374,10 @@ if (heapSnapshotSection != null) {
lines.push('');
}
const artifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim();
lines.push(`[Download representative V8 heap snapshot (head)](${artifactUrl})`);
lines.push('');
const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint);
if (jsFootprintSection != null) {
lines.push(jsFootprintSection);
@@ -402,15 +406,15 @@ function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: type
const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null) return false;
const diff = headValue - baseValue;
if (diff <= 0) return false;
const delta = headValue - baseValue;
if (delta <= 0) return false;
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
if (baseSpread == null || headSpread == null) return true;
const combinedSpread = Math.hypot(baseSpread, headSpread);
return diff > combinedSpread * 3;
return delta > combinedSpread * 3;
}
const warningMetric = getWarningMetric(base, head);
@@ -420,6 +424,6 @@ if (warningMetric != null && warningDiffPercent != null && warningDiffPercent >
lines.push('');
}
lines.push(`[See workflow logs for details](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`);
//lines.push(`[See workflow logs for details](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`);
await writeFile(outputFile, `${lines.join('\n')}\n`);

View File

@@ -299,13 +299,13 @@ function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualize
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`,
`<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}</td>`),
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}</td>`),
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
@@ -357,16 +357,16 @@ function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, tot
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`);
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
}
}
return lines.join('\n');

View File

@@ -63,20 +63,20 @@ export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnap
const percent = summary.median * 100 / baseValue;
if (category === 'total') {
const deltaMedian = `${util.formatDeltaBytes(summary.median)}<br>${util.formatDeltaPercent(percent).replaceAll('\\%', '\\\\%')}`;
const deltaMedian = `${util.formatDeltaBytes(summary.median, 100000)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
const baseText = `${util.formatBytes(baseValue)} <br> ± ${util.formatBytes(baseSpread)}`;
const headText = `${util.formatBytes(headValue)} <br> ± ${util.formatBytes(headSpread)}`;
const metricText = `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min)} | ${util.formatDeltaBytes(summary.max)} |`);
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`);
lines.push('| | | | | | | |');
} else {
const deltaMedian = util.formatDeltaBytes(summary.median);
const deltaMedian = util.formatDeltaBytes(summary.median, 100000);
const baseText = util.formatBytes(baseValue);
const headText = util.formatBytes(headValue);
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
const headPercent = util.formatPercent((headValue * 100) / headTotal);
const metricText = `<details><summary>$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**</summary>${basePercent}${headPercent}</details>`;
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min)} | ${util.formatDeltaBytes(summary.max)} |`);
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`);
}
}

View File

@@ -4,7 +4,7 @@
*/
import { createRequire } from 'node:module';
import { writeFile } from 'node:fs/promises';
import { copyFile, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import * as util from './utility.mts';
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
@@ -39,6 +39,8 @@ export type MemoryReport = {
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
const HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots');
const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot');
async function resetState(repoDir: string) {
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
@@ -165,7 +167,7 @@ function summarizeSamples(samples: MemoryReport['samples']) {
return summary;
}
async function measureRepo(label: string, repoDir: string, round: number) {
async function measureRepo(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
process.stderr.write(`[${label}] Resetting database and Redis\n`);
await resetState(repoDir);
@@ -182,6 +184,7 @@ async function measureRepo(label: string, repoDir: string, round: number) {
MK_MEMORY_SAMPLE_COUNT: '1',
} as NodeJS.ProcessEnv;
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
if (options.heapSnapshotSavePath != null) measureEnv.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH = options.heapSnapshotSavePath;
const stdout = await util.run('node', ['packages/backend/scripts/measure-memory.mts'], {
cwd: repoDir,
@@ -194,6 +197,40 @@ async function measureRepo(label: string, repoDir: string, round: number) {
return sample;
}
function headHeapSnapshotPath(round: number) {
return join(HEAD_HEAP_SNAPSHOT_WORK_DIR, `round-${round}.heapsnapshot`);
}
function selectRepresentativeHeadHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const medianTotal = summary.afterGc.heapSnapshot?.categories?.total;
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
let selected: { round: number; distance: number } | null = null;
for (const sample of samples) {
const total = sample.phases.afterGc.heapSnapshot?.categories?.total;
if (total == null || !Number.isFinite(total)) continue;
const distance = Math.abs(total - medianTotal);
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
selected = {
round: sample.round,
distance,
};
}
}
return selected?.round ?? null;
}
async function saveRepresentativeHeadHeapSnapshot(samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const round = selectRepresentativeHeadHeapSnapshotRound(samples, summary);
if (round == null) return;
await copyFile(headHeapSnapshotPath(round), HEAD_HEAP_SNAPSHOT_OUTPUT_PATH);
process.stderr.write(`Selected head heap snapshot round ${round} for artifact\n`);
await rm(HEAD_HEAP_SNAPSHOT_WORK_DIR, { recursive: true, force: true });
}
async function main() {
const baseDir = resolve(baseDirArg);
const headDir = resolve(headDirArg);
@@ -203,6 +240,9 @@ async function main() {
const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const startedAt = new Date().toISOString();
await rm(HEAD_HEAP_SNAPSHOT_WORK_DIR, { recursive: true, force: true });
await rm(HEAD_HEAP_SNAPSHOT_OUTPUT_PATH, { force: true });
const reports = {
base: {
dir: baseDir,
@@ -225,8 +265,12 @@ async function main() {
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
for (const [orderIndex, label] of order.entries()) {
const sample = await measureRepo(label, reports[label].dir, round);
for (const label of order) {
const shouldSaveHeadHeapSnapshot = label === 'head';
const options = shouldSaveHeadHeapSnapshot
? { heapSnapshotSavePath: headHeapSnapshotPath(round) }
: {};
const sample = await measureRepo(label, reports[label].dir, round, options);
reports[label].samples.push({
...sample,
round,
@@ -234,6 +278,12 @@ async function main() {
}
}
const summaries = {
base: summarizeSamples(reports.base.samples),
head: summarizeSamples(reports.head.samples),
};
await saveRepresentativeHeadHeapSnapshot(reports.head.samples, summaries.head);
for (const label of ['base', 'head'] as const) {
const report = {
timestamp: new Date().toISOString(),
@@ -245,7 +295,7 @@ async function main() {
warmupRounds,
startedAt,
},
summary: summarizeSamples(reports[label].samples),
summary: summaries[label],
samples: reports[label].samples,
};

View File

@@ -94,11 +94,12 @@ export function escapeLatex(text: string) {
.replaceAll('%', '\\%');
}
export function formatColoredDelta(text: string, delta: number) {
if (delta === 0) return text;
const color = delta > 0 ? 'orange' : 'green';
export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) {
if (delta === 0) return text(0);
const sign = delta > 0 ? '+' : '-';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`;
const color = delta > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`;
}
const numberFormatter = new Intl.NumberFormat('en-US', {
@@ -114,8 +115,8 @@ export function formatBytes(value: number) {
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
while (size >= 1000 && unitIndex < units.length - 1) {
size /= 1000;
unitIndex += 1;
}
@@ -123,35 +124,34 @@ export function formatBytes(value: number) {
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
export function calcAndFormatDeltaNumber(before: number, after: number) {
export function calcAndFormatDeltaNumber(before: number, after: number, colorThreshold = 0) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatColoredDelta(formatNumber(Math.abs(delta)), delta);
return formatColoredDelta(delta, v => formatNumber(v), colorThreshold);
}
export function formatDeltaBytes(deltaBytes: number) {
return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes);
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
return formatColoredDelta(deltaBytes, v => formatBytes(v), colorThreshold);
}
export function calcAndFormatDeltaBytes(before: number, after: number) {
export function calcAndFormatDeltaBytes(before: number, after: number, colorThreshold = 0) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatDeltaBytes(delta);
return formatDeltaBytes(delta, colorThreshold);
}
export function formatPercent(value: number) {
return `${formatNumber(value)}%`;
}
export function formatDeltaPercent(deltaPercent: number) {
if (deltaPercent === 0) return '0%';
return formatColoredDelta(formatPercent(Math.abs(deltaPercent)), deltaPercent);
export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) {
return formatColoredDelta(deltaPercent, v => formatPercent(v), colorThreshold);
}
export function calcAndFormatDeltaPercent(before: number, after: number) {
export function calcAndFormatDeltaPercent(before: number, after: number, colorThreshold = 0) {
if (before == null || before === 0 || after == null || after === 0) return '-';
const delta = after - before;
return formatDeltaPercent(delta / before * 100);
return formatDeltaPercent(delta / before * 100, colorThreshold);
}
export function commandName(command: string) {

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Setup Node.js
uses: actions/setup-node@v6.4.0

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout head
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -18,7 +18,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
submodules: true
persist-credentials: false
@@ -66,7 +66,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
submodules: true
persist-credentials: false

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Check version
run: |
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Check
run: |
counter=0
@@ -44,7 +44,6 @@ jobs:
}
directories=(
"cypress/e2e"
"packages/backend/migration"
"packages/backend/src"
"packages/backend/test"

View File

@@ -10,7 +10,7 @@ jobs:
check_copyright_year:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
- run: |
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
echo "Please change copyright year!"

View File

@@ -27,9 +27,6 @@ jobs:
pr-ref: ${{ steps.get-ref.outputs.pr-ref }}
wait_time: ${{ steps.get-wait-time.outputs.wait_time }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Check allowed users
id: check-allowed-users
env:

View File

@@ -27,7 +27,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub

View File

@@ -32,7 +32,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Docker meta

View File

@@ -17,7 +17,7 @@ jobs:
DOCKLE_VERSION: 0.4.15
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
run: |

View File

@@ -41,7 +41,7 @@ jobs:
FRONTEND_JS_SIZE_LOCALE: ja-JP
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
@@ -49,7 +49,7 @@ jobs:
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
@@ -69,7 +69,7 @@ jobs:
- name: Setup pnpm
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
with:
package_json_file: after/package.json

View File

@@ -25,12 +25,12 @@ jobs:
ref: refs/pull/${{ github.event.number }}/merge
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
ref: ${{ matrix.ref }}
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -39,19 +39,19 @@ jobs:
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
ref: ${{ github.base_ref }}
path: base
submodules: true
- name: Checkout head
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
ref: refs/pull/${{ github.event.number }}/merge
path: head
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
with:
package_json_file: head/package.json
- name: Use Node.js
@@ -96,6 +96,13 @@ jobs:
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
MK_MEMORY_HEAP_SNAPSHOT: 1
run: node head/.github/scripts/measure-backend-memory-comparison.mts base head memory-base.json memory-head.json
- name: Upload head heap snapshot
uses: actions/upload-artifact@v7
with:
name: backend-memory-head-heap-snapshot
path: head-heap-snapshot.heapsnapshot
if-no-files-found: error
retention-days: 7
- name: Measure backend loaded JS footprint
run: |
node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json

View File

@@ -17,7 +17,9 @@ on:
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
- package.json
pull_request:
paths:
- packages/backend/**
@@ -31,17 +33,19 @@ on:
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
- package.json
jobs:
pnpm_install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
@@ -69,12 +73,12 @@ jobs:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
@@ -100,6 +104,25 @@ jobs:
- sw
- misskey-js
steps:
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- run: pnpm --filter "${{ matrix.workspace }}^..." run build
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
check-dts:
needs: [pnpm_install]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
@@ -111,5 +134,5 @@ jobs:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- run: pnpm --filter "${{ matrix.workspace }}^..." run build
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
- run: node --test scripts/check-dts.test.mjs
- run: pnpm check-dts

View File

@@ -16,12 +16,12 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: ".node-version"

View File

@@ -16,11 +16,11 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -11,44 +11,26 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
pull-requests: write
# api-artifact
steps:
- name: Download artifact
uses: actions/github-script@v9
- name: Download artifacts
uses: actions/download-artifact@v8
with:
script: |
const fs = require('fs');
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifacts = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name.startsWith("api-artifact-") || artifact.name == "api-artifact"
});
await Promise.all(matchArtifacts.map(async (artifact) => {
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
await fs.promises.writeFile(`${process.env.GITHUB_WORKSPACE}/${artifact.name}.zip`, Buffer.from(download.data));
}));
- name: Extract all artifacts
run: |
find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d artifacts ';'
ls -la
pattern: api-artifact-*
path: artifacts
merge-multiple: true
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Check artifacts
run: ls -lh artifacts/
- name: Load PR Number
id: load-pr-num
run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT"
- name: Output base
run: cat ./artifacts/api-base.json
- name: Output head
run: cat ./artifacts/api-head.json
- name: Arrange json files
run: |
jq '.' ./artifacts/api-base.json > ./api-base.json
@@ -57,8 +39,6 @@ jobs:
run: diff -u --label=base --label=head ./api-base.json ./api-head.json | cat > api.json.diff
- name: Get full diff
run: diff --label=base --label=head --new-line-format='+%L' --old-line-format='-%L' --unchanged-line-format=' %L' ./api-base.json ./api-head.json | cat > api-full.json.diff
- name: Echo full diff
run: cat ./api-full.json.diff
- name: Upload full diff to Artifact
uses: actions/upload-artifact@v7
with:

View File

@@ -17,48 +17,42 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Download artifact
uses: actions/github-script@v9
- name: Download artifacts
uses: actions/download-artifact@v8
with:
script: |
const fs = require('fs');
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifacts = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name.startsWith("memory-artifact-") || artifact.name == "memory-artifact"
});
await Promise.all(matchArtifacts.map(async (artifact) => {
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
await fs.promises.writeFile(`${process.env.GITHUB_WORKSPACE}/${artifact.name}.zip`, Buffer.from(download.data));
}));
- name: Extract all artifacts
run: |
find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d artifacts ';'
ls -la artifacts/
pattern: memory-artifact-*
path: artifacts
merge-multiple: true
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Check artifacts
run: ls -lh artifacts/
- name: Load PR Number
id: load-pr-num
run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT"
- name: Find head heap snapshot artifact
id: find-heap-snapshot-artifact
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const run_id = context.payload.workflow_run.id;
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id,
});
const artifact = artifacts.find(artifact => artifact.name === 'backend-memory-head-heap-snapshot');
if (artifact == null) return;
core.setOutput('url', `https://github.com/${owner}/${repo}/actions/runs/${run_id}/artifacts/${artifact.id}`);
- name: Output base
run: cat ./artifacts/memory-base.json
- name: Output head
run: cat ./artifacts/memory-head.json
- name: Output base JS footprint
run: cat ./artifacts/js-footprint-base.json
- name: Output head JS footprint
run: cat ./artifacts/js-footprint-head.json
- id: build-comment
name: Build memory comment
env:
MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD: ${{ steps.find-heap-snapshot-artifact.outputs.url }}
run: node .github/scripts/backend-memory-report.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json
- uses: thollander/actions-comment-pull-request@v3
with:

View File

@@ -22,12 +22,12 @@ jobs:
NODE_OPTIONS: "--max_old_space_size=7168"
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
if: github.event_name != 'pull_request_target'
with:
fetch-depth: 0
submodules: true
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
if: github.event_name == 'pull_request_target'
with:
fetch-depth: 0
@@ -37,7 +37,7 @@ jobs:
if: github.event_name == 'pull_request_target'
run: git checkout "$(git rev-list --parents -n1 HEAD | cut -d" " -f3)"
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -43,7 +43,7 @@ jobs:
ports:
- 56312:6379
meilisearch:
image: getmeili/meilisearch:v1.42.1
image: getmeili/meilisearch:v1.48.1
ports:
- 57712:7700
env:
@@ -51,11 +51,11 @@ jobs:
MEILI_ENV: development
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Install FFmpeg
run: |
sudo apt install -y ffmpeg
@@ -103,11 +103,11 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
@@ -147,11 +147,11 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Get current date
id: current-date
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT

View File

@@ -30,7 +30,7 @@ jobs:
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Install FFmpeg
run: |
sudo apt install -y ffmpeg

View File

@@ -28,11 +28,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
@@ -57,11 +57,6 @@ jobs:
name: E2E tests (frontend)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chrome]
services:
postgres:
image: postgres:18
@@ -76,17 +71,11 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
# https://github.com/cypress-io/cypress-docker-images/issues/150
#- name: Install mplayer for FireFox
# run: sudo apt install mplayer -y
# if: ${{ matrix.browser == 'firefox' }}
#- uses: browser-actions/setup-firefox@latest
# if: ${{ matrix.browser == 'firefox' }}
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
@@ -97,29 +86,14 @@ jobs:
run: cp .github/misskey/test.yml .config
- name: Build
run: pnpm build
# https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091
- name: ALSA Env
run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc
# XXX: This tries reinstalling Cypress if the binary is not cached
# Remove this when the cache issue is fixed
- name: Cypress install
run: pnpm exec cypress install
- name: Cypress run
uses: cypress-io/github-action@v7.1.9
- name: Playwright install
working-directory: packages/frontend
run: pnpm exec playwright install --with-deps chromium
- name: Test
run: pnpm start-server-and-test start:test http://localhost:61812 "pnpm --filter frontend test:e2e"
timeout-minutes: 15
with:
install: false
start: pnpm start:test
wait-on: 'http://localhost:61812'
headed: true
browser: ${{ matrix.browser }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: ${{ matrix.browser }}-cypress-screenshots
path: cypress/screenshots
- uses: actions/upload-artifact@v7
if: always()
with:
name: ${{ matrix.browser }}-cypress-videos
path: cypress/videos
name: playwright-e2e-artifacts
path: packages/frontend/test/e2e/artifacts

View File

@@ -22,10 +22,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Setup Node.js
uses: actions/setup-node@v6.4.0

View File

@@ -16,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -17,11 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

7
.gitignore vendored
View File

@@ -23,9 +23,8 @@ packages/sw/.yarn/cache
# pnpm
.pnpm-store
# Cypress
cypress/screenshots
cypress/videos
# Playwright
packages/frontend/test/e2e/artifacts
# Coverage
coverage
@@ -35,7 +34,7 @@ coverage
!/.config/example.yml
!/.config/docker_example.yml
!/.config/docker_example.env
!/.config/cypress-devcontainer.yml
!/.config/playwright-devcontainer.yml
docker-compose.yml
./compose.yml
.devcontainer/compose.yml

View File

@@ -1 +1 @@
22.18.0
26.4.0

View File

@@ -1,20 +1,51 @@
## 2026.6.1
## 2026.7.0
### Note
**今回のリリースではMisskeyの各種動作要件が変更されます。必ずアップグレード前にお使いの環境をご確認ください。**
- センシティブメディアの判定 (NSFW検出) が、本体に内蔵された nsfwjs による推論から、外部サービス [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) への HTTP 呼び出し方式に変更されました。
- これに伴い、本体から `nsfwjs` / `@tensorflow/tfjs` / `@tensorflow/tfjs-node` および同梱の NSFW 判定モデルが削除され、インストール要件 (ネイティブ ML スタック) が緩和されました。
- **センシティブ判定機能を利用しているサーバーは対応が必要です。** 別途 [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) サービスを立ち上げ、コントロールパネルの「モデレーション > センシティブなメディアの検出」で接続先 URL を設定してください。接続先が未設定の場合、センシティブ判定は行われません (すべて非センシティブ扱い)。
- 画像の正規化・動画フレームの抽出・しきい値判定・集約は引き続き本体側で行われ、外部サービスには正規化済み画像の推論のみを委譲します。
- Node.js v24, v26 をサポートしました。**Node.js v22 でも動作しますが、今後のリリースで v22 のサポートを終了する予定**ですので、Node.js のアップデートをご検討ください。
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
- Docker Image は Node.js 26.4.0-trixie に更新されています。
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
### General
-
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
### Client
-
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
- 操作性の改善
- ビューワーとMisskeyの各種機能との統合を強化
- パフォーマンスの向上
- Enhance: マウスホイールで拡大縮小できるように
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
- Enhance: URLプレビューの結果を内部でキャッシュするように
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
## 2026.6.0

View File

@@ -600,6 +600,90 @@ TypeScriptでjsonをimportすると、tscでコンパイルするときにその
コンポーネント自身がmarginを設定するのは問題の元となることはよく知られている
marginはそのコンポーネントを使う側が設定する
### 命名規則
本来それが略称であっても、通常それでひとつのワードとして用いられるものは、略称として扱わない。
#### 例: IP address
Good: `ipAddress` / `IpAddress`
Bad: `IPAddress`
#### 例: User ID
Good: `userId` / `UserId`
Bad: `userID` / `UserID`
#### 例: XMLなHTTPのRequest
Good: `xmlHttpRequest` / `XmlHttpRequest`
Bad: `XMLHttpRequest` / `XMLHTTPRequest`
### 関数化の基準
汎用性が低く(例えばそれを関数化したとしてもその呼び出しが元の場所一か所しか存在しない)、内容も短い処理(例えば10行以下)は、かえって読みにくくなるため、関数化しない。
また、関数化する場合でも、呼び出しがある特定のスコープに限られる場合は、そのスコープ内に閉じ込めた方が分かりやすく簡潔になる場合がある(ただし本来その処理に不要であっても、構造上親のスコープにある関係のない変数や引数にもアクセスできるようになるため、必ずしもそうすれば設計上綺麗になるというわけでもない。状況に応じて判断すべし)。
Bad:
``` ts
function withBrankets(x) {
return `(${x})`;
}
function formatPercent(x) {
return `${x}%`;
}
function formatValue(x) {
return withBrankets(formatPercent(x));
}
function showData(a, b) {
console.log(formatValue(a));
console.log(formatValue(b));
}
```
Good:
``` ts
function formatValue(x) {
return `(${x}%)`;
}
function showData(a, b) {
console.log(formatValue(a));
console.log(formatValue(b));
}
```
or
``` ts
function showData(a, b) {
function formatValue(x) {
return `(${x}%)`;
}
console.log(formatValue(a));
console.log(formatValue(b));
}
```
or
``` ts
function showData(a, b) {
console.log(`(${a}%)`);
console.log(`(${b}%)`);
}
```
## その他
### HTMLのクラス名で follow という単語は使わない
広告ブロッカーで誤ってブロックされる

View File

@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.23
ARG NODE_VERSION=22.22.2-bookworm
ARG NODE_VERSION=26.4.0-trixie
# build assets & compile TypeScript

View File

@@ -224,9 +224,8 @@ id: "aidx"
# Media Proxy
#mediaProxy: https://example.com/proxy
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:61812',
},
})

View File

@@ -1,265 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Before setup instance', () => {
beforeEach(() => {
cy.resetState();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('setup instance', () => {
cy.visitHome();
cy.intercept('POST', '/api/admin/accounts/create').as('signup');
cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked');
cy.get('[data-cy-admin-username] input').type('admin');
cy.get('[data-cy-admin-password] input').type('admin1234');
cy.get('[data-cy-admin-ok]').click();
// なぜか動かない
//cy.wait('@signup').should('have.property', 'response.statusCode');
cy.wait('@signup');
cy.intercept('POST', '/api/admin/update-meta').as('update-meta');
cy.get('[data-cy-next]').click();
cy.get('[data-cy-server-name] input').type('Testskey');
cy.get('[data-cy-server-setup-wizard-apply]').click();
cy.wait('@update-meta');
});
});
describe('After setup instance', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signup', () => {
cy.visitHome();
cy.intercept('POST', '/api/signup').as('signup');
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-invitation-code] input').type('test-invitation-code');
cy.get('[data-cy-signup-submit]').should('not.be.disabled');
cy.get('[data-cy-signup-submit]').click();
cy.wait('@signup');
});
it('signup with duplicated username', () => {
cy.registerUser('alice', 'alice1234');
cy.visitHome();
// ユーザー名が重複している場合の挙動確認
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
});
});
describe('After user signup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signin', () => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-username] input').type('alice{enter}');
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
cy.wait('@signin');
});
it('suspend', function() {
cy.request('POST', '/api/admin/suspend-user', {
i: this.admin.token,
userId: this.alice.id,
});
cy.visitHome();
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type('alice{enter}');
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi);
});
});
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).should('be.visible');
});
it('account setup wizard', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).click();
cy.get('[data-cy-user-setup-user-name] input').type('ありす');
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
// TODO: アイコン設定テスト
cy.get('[data-cy-user-setup-continue]').click();
// プライバシー設定
cy.get('[data-cy-user-setup-continue]').click();
// フォローはスキップ
cy.get('[data-cy-user-setup-continue]').click();
// プッシュ通知設定はスキップ
cy.get('[data-cy-user-setup-continue]').click();
cy.get('[data-cy-user-setup-continue]').click();
});
});
describe('After user setup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('note', () => {
cy.get('[data-cy-open-post-form]').should('be.visible');
cy.get('[data-cy-open-post-form]').click();
cy.get('[data-cy-post-form-text]').type('Hello, Misskey!');
cy.get('[data-cy-open-post-form-submit]').click();
cy.contains('Hello, Misskey!', { timeout: 15000 });
});
it('open note form with hotkey', () => {
// Wait until the page loads
cy.get('[data-cy-open-post-form]').should('be.visible');
// Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards.
cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" });
// See if the form is opened
cy.get('[data-cy-post-form-text]').should('be.visible');
// Close it
cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" });
// See if the form is closed
cy.get('[data-cy-post-form-text]').should('not.be.visible');
});
});
// TODO: 投稿フォームの公開範囲指定のテスト
// TODO: 投稿フォームのファイル添付のテスト
// TODO: 投稿フォームのハッシュタグ保持フィールドのテスト

View File

@@ -1,35 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Router transition', () => {
describe('Redirect', () => {
// サーバの初期化。ルートのテストに関しては各describeごとに1度だけ実行で十分だと思う使いまわした方が早い
before(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.wait(500);
cy.get('[data-cy-modal-dialog-ok]').click();
});
it('redirect to user profile', () => {
// テストのためだけに用意されたリダイレクト用ルートに飛ぶ
cy.visit('/redirect-test');
// プロフィールページのURLであることを確認する
cy.url().should('include', '/@alice')
});
});
});

View File

@@ -1,76 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* flaky
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
cy.viewport('macbook-16');
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('widget edit toggle is visible', () => {
cy.get('[data-cy-widget-edit]').should('be.visible');
});
it('widget select should be visible in edit mode', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select]').should('be.visible');
});
it('first widget should be removed', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click();
cy.get('[data-cy-customize-container]').should('have.length', 2);
});
function buildWidgetTest(widgetName) {
it(`${widgetName} widget should get added`, () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select] select').select(widgetName, { force: true });
cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true });
cy.get('[data-cy-widget-add]').click({ force: true });
cy.get(`[data-cy-mkw-${widgetName}]`).should('exist');
});
}
buildWidgetTest('memo');
buildWidgetTest('notifications');
buildWidgetTest('timeline');
buildWidgetTest('calendar');
buildWidgetTest('rss');
buildWidgetTest('trends');
buildWidgetTest('clock');
buildWidgetTest('activity');
buildWidgetTest('photos');
buildWidgetTest('digitalClock');
buildWidgetTest('federation');
buildWidgetTest('postForm');
buildWidgetTest('slideshow');
buildWidgetTest('serverMetric');
buildWidgetTest('onlineUsers');
buildWidgetTest('jobQueue');
buildWidgetTest('button');
buildWidgetTest('aiscript');
buildWidgetTest('aichan');
});
*/

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -1,67 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add('visitHome', () => {
cy.visit('/');
cy.get('button', { timeout: 30000 }).should('be.visible');
})
Cypress.Commands.add('resetState', () => {
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
/*
cy.window().then(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
*/
cy.request('POST', '/api/reset-db', {}).as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
});
Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
cy.request('POST', route, {
username: username,
password: password,
...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}),
}).its('body').as(username);
});
Cypress.Commands.add('login', (username, password) => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
cy.wait('@signin').as('signedIn');
});

View File

@@ -1,34 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.on('uncaught:exception', (err, runnable) => {
if ([
'The source image cannot be decoded',
// Chrome
'ResizeObserver loop limit exceeded',
// Firefox
'ResizeObserver loop completed with undelivered notifications',
].some(msg => err.message.includes(msg))) {
return false;
}
});

View File

@@ -1,19 +0,0 @@
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>;
registerUser(
username: string,
password: string,
isAdmin?: boolean
): Chainable<void>;
resetState(): Chainable<void>;
visitHome(): Chainable<void>;
}
}
}
export {}

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"lib": ["dom"],
"target": "esnext",
"types": ["cypress", "node"]
},
"include": ["./**/*.ts"]
}

View File

@@ -619,6 +619,8 @@ output: "出力"
script: "スクリプト"
disablePagesScript: "Pagesのスクリプトを無効にする"
updateRemoteUser: "リモートユーザー情報の更新"
unsetMfa: "二要素認証を解除"
unsetMfaConfirm: "二要素認証を解除しますか?"
unsetUserAvatar: "アイコンを解除"
unsetUserAvatarConfirm: "アイコンを解除しますか?"
unsetUserBanner: "バナーを解除"
@@ -1361,14 +1363,11 @@ information: "情報"
chat: "チャット"
directMessage: "ダイレクトメッセージ"
directMessage_short: "メッセージ"
migrateOldSettings: "旧設定情報を移行"
migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。"
compress: "圧縮"
right: "右"
bottom: "下"
top: "上"
embed: "埋め込み"
settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)"
readonly: "読み取り専用"
goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ"
@@ -1420,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加"
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
append: "末尾に追加"
prepend: "先頭に追加"
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
_imageEditing:
_vars:
@@ -2526,6 +2527,7 @@ _permissions:
"read:admin:show-moderation-log": "モデレーションログを見る"
"read:admin:show-user": "ユーザーのプライベートな情報を見る"
"write:admin:suspend-user": "ユーザーを凍結する"
"write:admin:unset-mfa": "ユーザーの二要素認証を解除する"
"write:admin:unset-user-avatar": "ユーザーのアバターを削除する"
"write:admin:unset-user-banner": "ユーザーのバーナーを削除する"
"write:admin:unsuspend-user": "ユーザーの凍結を解除する"
@@ -3071,6 +3073,7 @@ _moderationLogTypes:
createAvatarDecoration: "アイコンデコレーションを作成"
updateAvatarDecoration: "アイコンデコレーションを更新"
deleteAvatarDecoration: "アイコンデコレーションを削除"
unsetMfa: "ユーザーの二要素認証を解除"
unsetUserAvatar: "ユーザーのアイコンを解除"
unsetUserBanner: "ユーザーのバナーを解除"
createSystemWebhook: "SystemWebhookを作成"

View File

@@ -1,12 +1,12 @@
{
"name": "misskey",
"version": "2026.6.1-alpha.0",
"version": "2026.7.0-alpha.3",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@11.5.2",
"packageManager": "pnpm@11.9.0",
"workspaces": [
"packages/misskey-js",
"packages/i18n",
@@ -39,11 +39,10 @@
"migrateandstart": "pnpm migrate && pnpm start",
"watch": "pnpm dev",
"dev": "node scripts/dev.mjs",
"lint": "pnpm --no-bail -r lint",
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"check-dts": "node scripts/check-dts.mjs",
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
"e2e-dev-container": "ncp ./.config/playwright-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
"backend-unit-test": "cd packages/backend && pnpm test",
"backend-unit-test-and-coverage": "cd packages/backend && pnpm test-and-coverage",
"test": "pnpm -r test",
@@ -53,30 +52,25 @@
"cleanall": "pnpm clean-all"
},
"dependencies": {
"cssnano": "8.0.1",
"esbuild": "0.28.1",
"execa": "9.6.1",
"ignore-walk": "8.0.0",
"js-yaml": "4.2.0",
"postcss": "8.5.15",
"tar": "7.5.16",
"terser": "5.48.0"
"ignore-walk": "9.0.0",
"js-yaml": "5.2.0",
"tar": "7.5.19"
},
"devDependencies": {
"@eslint/js": "9.39.4",
"@misskey-dev/eslint-plugin": "2.1.0",
"@types/js-yaml": "4.0.9",
"@types/node": "24.13.1",
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@types/node": "26.0.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@typescript/native-preview": "7.0.0-dev.20260426.1",
"cross-env": "10.1.0",
"cypress": "15.17.0",
"eslint": "9.39.4",
"globals": "17.6.0",
"globals": "17.7.0",
"ncp": "2.0.0",
"pnpm": "11.5.2",
"start-server-and-test": "3.0.9",
"pnpm": "11.9.0",
"start-server-and-test": "3.0.11",
"typescript": "5.9.3"
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UrlPreviewSensitiveList1782581064131 {
name = 'UrlPreviewSensitiveList1782581064131'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewSensitiveList" character varying(3072) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewSensitiveList"`);
}
}

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": "^22.15.0 || ^24.10.0"
"node": "^22.22.2 || ^24.17.0 || ^26.4.0"
},
"scripts": {
"start": "pnpm compile-config && node ./built/entry.js",
@@ -51,8 +51,8 @@
"utf-8-validate": "6.0.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1065.0",
"@aws-sdk/lib-storage": "3.1065.0",
"@aws-sdk/client-s3": "3.1075.0",
"@aws-sdk/lib-storage": "3.1075.0",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/http-proxy": "11.5.0",
@@ -61,25 +61,24 @@
"@kitajs/html": "4.2.13",
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/sharp-read-bmp": "1.3.1",
"@misskey-dev/summaly": "5.5.1",
"@napi-rs/canvas": "1.0.0",
"@nestjs/common": "11.1.26",
"@nestjs/core": "11.1.26",
"@nestjs/testing": "11.1.26",
"@oxc-project/runtime": "0.135.0",
"@napi-rs/canvas": "1.0.1",
"@nestjs/common": "11.1.27",
"@nestjs/core": "11.1.27",
"@nestjs/testing": "11.1.27",
"@oxc-project/runtime": "0.137.0",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.57.0",
"@sentry/profiling-node": "10.57.0",
"@simplewebauthn/server": "13.3.1",
"@sinonjs/fake-timers": "15.4.0",
"@smithy/node-http-handler": "4.7.7",
"@sentry/node": "10.62.0",
"@sentry/profiling-node": "10.62.0",
"@simplewebauthn/server": "13.3.2",
"@smithy/node-http-handler": "4.8.2",
"accepts": "1.3.8",
"ajv": "8.20.0",
"archiver": "8.0.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"bullmq": "5.78.0",
"bullmq": "5.79.2",
"cacheable-lookup": "7.0.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
@@ -88,12 +87,12 @@
"content-disposition": "2.0.1",
"date-fns": "4.4.0",
"deep-email-validator": "0.1.27",
"fastify": "5.8.5",
"fastify": "5.9.0",
"fastify-raw-body": "5.0.0",
"feed": "5.2.1",
"file-type": "22.0.1",
"fluent-ffmpeg": "2.1.3",
"got": "15.0.5",
"got": "15.0.7",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
@@ -103,40 +102,39 @@
"is-svg": "6.1.0",
"json5": "2.2.3",
"jsonld": "9.0.0",
"juice": "12.1.0",
"juice": "12.1.1",
"meilisearch": "0.58.0",
"mfm-js": "0.26.0",
"mime-types": "3.0.2",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.202508261828",
"nanoid": "5.1.11",
"nanoid": "5.1.16",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.1.0",
"nodemailer": "8.0.10",
"node-html-parser": "8.0.3",
"nodemailer": "9.0.1",
"os-utils": "0.0.14",
"otpauth": "9.5.1",
"pg": "8.21.0",
"pg": "8.22.0",
"pkce-challenge": "6.0.0",
"probe-image-size": "7.3.0",
"promise-limit": "2.7.0",
"qrcode": "1.5.4",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.24.1",
"re2": "1.25.0",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.2",
"sanitize-html": "2.17.4",
"sanitize-html": "2.17.5",
"secure-json-parse": "4.1.0",
"semver": "7.8.4",
"sharp": "0.33.5",
"semver": "7.8.5",
"sharp": "0.35.2",
"slacc": "0.1.5",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.31.7",
"systeminformation": "5.31.11",
"tinycolor2": "1.6.0",
"tmp": "0.2.7",
"tsc-alias": "1.8.17",
@@ -149,19 +147,19 @@
},
"devDependencies": {
"@kitajs/ts-html-plugin": "4.1.4",
"@nestjs/platform-express": "11.1.26",
"@nestjs/platform-express": "11.1.27",
"@rollup/plugin-esm-shim": "0.1.8",
"@sentry/vue": "10.57.0",
"@sentry/vue": "10.62.0",
"@sinonjs/fake-timers": "15.4.0",
"@types/accepts": "1.3.7",
"@types/archiver": "8.0.0",
"@types/fluent-ffmpeg": "2.1.28",
"@types/http-link-header": "1.0.7",
"@types/js-yaml": "4.0.9",
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "24.13.1",
"@types/nodemailer": "8.0.0",
"@types/node": "26.0.1",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0",
"@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5",
@@ -171,28 +169,26 @@
"@types/semver": "7.7.1",
"@types/simple-oauth2": "5.0.8",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/supertest": "7.2.0",
"@types/tinycolor2": "1.4.6",
"@types/tmp": "0.2.6",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@vitest/coverage-v8": "4.1.8",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/coverage-v8": "4.1.9",
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.12",
"cbor2": "2.3.0",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.3",
"js-yaml": "4.2.0",
"js-yaml": "5.2.0",
"pid-port": "2.1.1",
"rolldown": "1.1.0",
"rolldown": "1.1.3",
"simple-oauth2": "5.1.0",
"supertest": "7.2.2",
"vite": "8.0.16",
"vitest": "4.1.8",
"vite": "8.1.0",
"vitest": "4.1.9",
"vitest-mock-extended": "4.0.0"
}
}

View File

@@ -3,6 +3,7 @@ import { version as summalyVersion } from '@misskey-dev/summaly';
import type { Plugin, ExternalOption } from 'rolldown';
import { execa, execaNode } from 'execa';
import type { ResultPromise } from 'execa';
import fkill from 'fkill';
import esmShim from '@rollup/plugin-esm-shim';
/**
@@ -10,6 +11,7 @@ import esmShim from '@rollup/plugin-esm-shim';
*/
function backendDevServerPlugin(): Plugin {
let backendProcess: ResultPromise | null = null;
let backendShutdownPromise: Promise<void> | null = null;
async function runBuildAssets() {
await execa('pnpm', ['run', 'build-assets'], {
@@ -20,12 +22,31 @@ function backendDevServerPlugin(): Plugin {
}
async function killBackendProcess() {
if (backendProcess) {
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
backendProcess.kill();
await new Promise(resolve => backendProcess!.on('exit', resolve));
backendProcess = null;
}
if (backendShutdownPromise) return backendShutdownPromise;
if (!backendProcess) return;
const processToKill = backendProcess;
backendProcess = null;
processToKill.catch(() => {}); // プロセスの終了によって発生する例外を無視するためにcatch()を呼び出す
backendShutdownPromise = (async () => {
if (process.platform === 'win32' && processToKill.pid != null) {
await fkill(processToKill.pid, {
force: true,
tree: true,
silent: true,
waitForExit: 5000,
});
} else {
processToKill.kill();
}
await processToKill.catch(() => {});
})().finally(() => {
backendShutdownPromise = null;
});
return backendShutdownPromise;
}
return {
@@ -49,6 +70,9 @@ function backendDevServerPlugin(): Plugin {
await runBuildAssets();
}
},
async closeWatcher() {
await killBackendProcess();
},
};
}
@@ -75,6 +99,8 @@ export default defineConfig((args) => {
're2',
'ipaddr.js',
'file-type',
// バンドルするとSentryの自動計装が正しく行われなくなるため外しておく
'pg',
];
const define: Record<string, string> = {

View File

@@ -11,7 +11,7 @@
import fs from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import { load as loadYaml } from 'js-yaml';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -34,7 +34,7 @@ function yamlToJson(ymlPath) {
console.log(`${ymlPath}${OUTPUT_PATH}`);
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
const jsonContent = yaml.load(yamlContent);
const jsonContent = loadYaml(yamlContent);
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
}

View File

@@ -41,37 +41,25 @@ const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH;
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
const typedArrayNames = new Set([
'ArrayBuffer',
'SharedArrayBuffer',
'DataView',
'Int8Array',
'Uint8Array',
'Uint8ClampedArray',
'Int16Array',
'Uint16Array',
'Int32Array',
'Uint32Array',
'Float16Array',
'Float32Array',
'Float64Array',
'BigInt64Array',
'BigUint64Array',
'system / JSArrayBufferData',
]);
const otherJsNodeTypes = new Set([
'object',
'closure',
'regexp',
'number',
'symbol',
'bigint',
]);
type GcMessage = 'gc ok' | 'gc unavailable';
type RuntimeMemoryUsageMessage = {
type: 'memory usage';
value: NodeJS.MemoryUsage;
};
type HeapSnapshotMessage = {
type: 'heap snapshot';
path?: string;
};
type HeapSnapshotErrorMessage = {
type: 'heap snapshot error';
message: string;
};
type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage;
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): Record<KS[number], number> {
const result = {} as Record<KS[number], number>;
@@ -91,29 +79,6 @@ function bytesToKiB(value: number) {
return Math.round(value / 1024);
}
function isTypedArrayNode(type, name) {
return typedArrayNames.has(name) ||
(type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray')));
}
function isSystemNode(type, name) {
return type === 'hidden' ||
type === 'synthetic' ||
type === 'object shape' ||
name.startsWith('system /') ||
name.startsWith('(system ');
}
function classifyHeapSnapshotNode(type, name): keyof typeof heapSnapshotCategory {
if (type === 'code') return 'code';
if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'strings';
if (isTypedArrayNode(type, name)) return 'typedArrays';
if (type === 'array' || (type === 'object' && name === 'Array')) return 'jsArrays';
if (isSystemNode(type, name)) return 'systemObjects';
if (otherJsNodeTypes.has(type)) return 'otherJsObjects';
return 'otherNonJsObjects';
}
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
if (label === '') return fallback;
@@ -125,17 +90,13 @@ function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCatego
if (category === 'strings') return type;
if (category === 'jsArrays') {
if (type === 'array') return 'array nodes';
if (type === 'array elements') return 'Array elements';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'typedArrays') {
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
if (name === 'Uint8Array') return 'Uint8Array / Buffer';
if (typedArrayNames.has(name)) return name;
if (type === 'native' && name.includes('ArrayBuffer')) return 'native ArrayBuffer';
if (type === 'native' && name.includes('TypedArray')) return 'native TypedArray';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
@@ -151,6 +112,7 @@ function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCatego
}
if (category === 'otherNonJsObjects') {
if (type === 'extra native bytes') return 'Extra native bytes';
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
@@ -188,32 +150,56 @@ function collapseHeapSnapshotBreakdown(breakdowns: Record<string, Record<string,
return collapsed;
}
// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
function analyzeHeapSnapshot(snapshot) {
const meta = snapshot?.snapshot?.meta;
const nodes = snapshot?.nodes;
const edges = snapshot?.edges;
const strings = snapshot?.strings;
if (meta == null || !Array.isArray(nodes) || !Array.isArray(strings)) {
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
throw new Error('Invalid heap snapshot format');
}
const nodeFields = meta.node_fields;
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
const edgeFields = meta.edge_fields;
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
const typeOffset = nodeFields.indexOf('type');
const nameOffset = nodeFields.indexOf('name');
const selfSizeOffset = nodeFields.indexOf('self_size');
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0) {
const edgeCountOffset = nodeFields.indexOf('edge_count');
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
throw new Error('Heap snapshot is missing required node fields');
}
const edgeTypeOffset = edgeFields.indexOf('type');
const edgeNameOffset = edgeFields.indexOf('name_or_index');
const edgeToNodeOffset = edgeFields.indexOf('to_node');
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
throw new Error('Heap snapshot is missing required edge fields');
}
const nodeTypeNames = meta.node_types?.[typeOffset];
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
function createEmptyHeapSnapshotCategoryMap() {
return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record<keyof typeof heapSnapshotCategory, number>;
}
const fieldCount = nodeFields.length;
const nodeFieldCount = nodeFields.length;
const edgeFieldCount = edgeFields.length;
const nativeType = nodeTypeNames.indexOf('native');
const codeType = nodeTypeNames.indexOf('code');
const hiddenType = nodeTypeNames.indexOf('hidden');
const stringTypes = new Set([
nodeTypeNames.indexOf('string'),
nodeTypeNames.indexOf('concatenated string'),
nodeTypeNames.indexOf('sliced string'),
]);
const internalEdgeType = edgeTypeNames.indexOf('internal');
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
const categories = createEmptyHeapSnapshotCategoryMap();
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
const breakdowns = Object.fromEntries(
@@ -226,17 +212,104 @@ function analyzeHeapSnapshot(snapshot) {
map[key] = (map[key] ?? 0) + value;
}
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
const name = strings[nodes[offset + nameOffset]] ?? '';
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
const category = classifyHeapSnapshotNode(type, name);
const edgeStartIndexes = new Map<number, number>();
const retainerCounts = new Map<number, number>();
let edgeIndex = 0;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
edgeStartIndexes.set(nodeIndex, edgeIndex);
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
}
}
categories[category] += selfSize;
const jsArrayElementNodeIndexes = new Set<number>();
function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
if (value <= 0) return;
categories[category] += value;
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
if (nodeIndex != null) nodeCounts[category]++;
}
function addJsArrayElementSize(nodeIndex: number) {
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
if (edgeType !== internalEdgeType) continue;
const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
if (edgeName !== 'elements') continue;
const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
jsArrayElementNodeIndexes.add(elementsNodeIndex);
}
break;
}
}
if (extraNativeBytes > 0) {
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
}
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
const typeId = nodes[nodeIndex + typeOffset];
const type = nodeTypeNames[typeId] ?? 'unknown';
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
categories.total += selfSize;
nodeCounts[category]++;
nodeCounts.total++;
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), selfSize);
if (typeId === hiddenType) {
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
continue;
}
if (typeId === nativeType) {
if (name === 'system / JSArrayBufferData') {
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
} else {
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
}
continue;
}
if (typeId === codeType) {
addCategoryValue('code', selfSize, type, name, nodeIndex);
continue;
}
if (stringTypes.has(typeId)) {
addCategoryValue('strings', selfSize, type, name, nodeIndex);
continue;
}
if (name === 'Array') {
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
addJsArrayElementSize(nodeIndex);
continue;
}
}
categories.total += extraNativeBytes;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
const typeId = nodes[nodeIndex + typeOffset];
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
if (name === 'Array') continue;
const type = nodeTypeNames[typeId] ?? 'unknown';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
}
return {
@@ -258,14 +331,32 @@ async function getSmapsRollupMemoryUsage(pid: number) {
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
}
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
return new Promise((resolve, reject) => {
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object';
}
function isGcMessage(message: unknown): message is GcMessage {
return message === 'gc ok' || message === 'gc unavailable';
}
function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage {
return isRecord(message) && message.type === 'memory usage' && isRecord(message.value);
}
function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage {
if (!isRecord(message)) return false;
if (message.type === 'heap snapshot') return true;
return message.type === 'heap snapshot error' && typeof message.message === 'string';
}
function waitForMessage<T>(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) {
return new Promise<T>((resolve, reject) => {
const timer = globalThis.setTimeout(() => {
serverProcess.off('message', onMessage);
reject(new Error(`Timed out waiting for ${description}`));
}, timeout);
const onMessage = (message) => {
const onMessage = (message: unknown) => {
if (!predicate(message)) return;
globalThis.clearTimeout(timer);
serverProcess.off('message', onMessage);
@@ -279,7 +370,7 @@ function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIM
async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
const response = waitForMessage(
serverProcess,
message => message != null && typeof message === 'object' && message.type === 'memory usage',
isRuntimeMemoryUsageMessage,
'memory usage',
);
@@ -302,7 +393,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise<H
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
const response = waitForMessage(
serverProcess,
message => message != null && typeof message === 'object' && (message.type === 'heap snapshot' || message.type === 'heap snapshot error'),
isHeapSnapshotResponseMessage,
'heap snapshot',
HEAP_SNAPSHOT_TIMEOUT,
);
@@ -320,6 +411,11 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise<H
const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath;
try {
if (HEAP_SNAPSHOT_SAVE_PATH != null && HEAP_SNAPSHOT_SAVE_PATH !== '') {
await fs.mkdir(dirname(HEAP_SNAPSHOT_SAVE_PATH), { recursive: true });
await fs.copyFile(writtenPath, HEAP_SNAPSHOT_SAVE_PATH);
}
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
return analyzeHeapSnapshot(snapshot);
} finally {
@@ -379,7 +475,7 @@ async function measureMemory() {
async function triggerGc() {
const ok = waitForMessage(
serverProcess,
message => message === 'gc ok' || message === 'gc unavailable',
isGcMessage,
'GC completion',
);

View File

@@ -26,7 +26,6 @@ declare module 'probe-image-size' {
function probeImageSize(src: string | ReadStream, callback: (err: Error | null, result?: ProbeResult) => void): void;
function probeImageSize(src: string | ReadStream, options: ProbeOptions, callback: (err: Error | null, result?: ProbeResult) => void): void;
namespace probeImageSize {} // Hack
export = probeImageSize;
// eslint-disable-next-line import/no-default-export
export { probeImageSize as default };
}

View File

@@ -13,6 +13,7 @@ import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
const logger = new Logger('core', 'cyan');
@@ -66,26 +67,7 @@ export async function masterMain() {
initExtraThreadPool(config);
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
}
await initTelemetry(config);
bootLogger.info(
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,

View File

@@ -6,6 +6,7 @@
import cluster from 'node:cluster';
import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js';
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
/**
@@ -16,26 +17,7 @@ export async function workerMain() {
initExtraThreadPool(config);
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
}
await initTelemetry(config);
if (envOption.onlyServer) {
await server();

View File

@@ -20,6 +20,12 @@ type RedisOptionsSource = Partial<RedisOptions> & {
prefix?: string;
};
type SentryBackendConfig = {
options: Partial<Sentry.NodeOptions>;
enableNodeProfiling: boolean;
disabledIntegrations?: string[];
};
/**
* 設定ファイルの型
*/
@@ -64,7 +70,7 @@ type Source = {
index: string;
scope?: 'local' | 'global' | string[];
};
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
sentryForBackend?: SentryBackendConfig;
sentryForFrontend?: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -201,7 +207,7 @@ export type Config = {
redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
sentryForBackend: SentryBackendConfig | undefined;
sentryForFrontend: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;

View File

@@ -137,7 +137,8 @@ export class AiService {
try {
const form = new FormData();
for (let i = 0; i < chunk.length; i++) {
form.append(`image${i}`, new Blob([chunk[i]], { type: 'image/png' }), `${i}.png`);
const image = Uint8Array.from(chunk[i]);
form.append(`image${i}`, new Blob([image], { type: 'image/png' }), `${i}.png`);
}
// Content-Type は FormData から boundary 付きで自動設定させるため、手動設定はしない。

View File

@@ -154,10 +154,12 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
import { QueueModule } from './QueueModule.js';
import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js';
import { TelemetryService } from './telemetry/TelemetryService.js';
import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
const $TelemetryService: Provider = { provide: 'TelemetryService', useExisting: TelemetryService };
const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService };
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
@@ -458,6 +460,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApPersonService,
ApQuestionService,
QueueService,
TelemetryService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
@@ -606,6 +609,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApNoteService,
$ApPersonService,
$ApQuestionService,
$TelemetryService,
//#endregion
],
exports: [
@@ -757,6 +761,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApPersonService,
ApQuestionService,
QueueService,
TelemetryService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
@@ -903,6 +908,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApNoteService,
$ApPersonService,
$ApQuestionService,
$TelemetryService,
//#endregion
],
})

View File

@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import sharp from 'sharp';
import type { Sharp } from 'sharp';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import { In, IsNull } from 'typeorm';
import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3';
@@ -300,7 +301,7 @@ export class DriveService {
};
}
let img: sharp.Sharp | null = null;
let img: Sharp | null = null;
let satisfyWebpublic: boolean;
let isAnimated: boolean;

View File

@@ -3,11 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { URLSearchParams } from 'node:url';
import * as nodemailer from 'nodemailer';
import juice from 'juice';
import { Inject, Injectable } from '@nestjs/common';
import { validate as validateEmail } from 'deep-email-validator';
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -197,6 +195,7 @@ export class EmailService {
} else if (this.meta.enableTruemailApi && this.meta.truemailInstance && this.meta.truemailAuthKey != null) {
validated = await this.trueMail(this.meta.truemailInstance, emailAddress, this.meta.truemailAuthKey);
} else {
const { validate: validateEmail } = await import('deep-email-validator');
validated = await validateEmail({
email: emailAddress,
validateRegex: true,

View File

@@ -5,20 +5,28 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DataSource } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { IdService } from '@/core/IdService.js';
import type { MiHashtag } from '@/models/Hashtag.js';
import { MiHashtag } from '@/models/Hashtag.js';
import type { HashtagsRepository, MiMeta } from '@/models/_.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import Logger from '../logger.js';
const logger = new Logger('hashtag/create');
@Injectable()
export class HashtagService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.meta)
private meta: MiMeta,
@@ -60,19 +68,74 @@ export class HashtagService {
// TODO: サンプリング
this.updateHashtagsRanking(tag, user.id);
const index = await this.hashtagsRepository.findOneBy({ name: tag });
{
const index = await this.hashtagsRepository.findOneBy({ name: tag });
if (index == null && !inc) return;
if (index == null && inc) {
try {
if (isUserAttached) {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [],
mentionedUsersCount: 0,
mentionedLocalUserIds: [],
mentionedLocalUsersCount: 0,
mentionedRemoteUserIds: [],
mentionedRemoteUsersCount: 0,
attachedUserIds: [user.id],
attachedUsersCount: 1,
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
} as MiHashtag);
} else {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [user.id],
mentionedUsersCount: 1,
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
attachedUserIds: [],
attachedUsersCount: 0,
attachedLocalUserIds: [],
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as MiHashtag);
}
return;
} catch (err) {
if (isDuplicateKeyValueError(err)) {
logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`);
} else {
throw err;
}
}
}
}
if (index != null) {
const q = this.hashtagsRepository.createQueryBuilder('tag').update()
.where('name = :name', { name: tag });
await this.db.transaction(async transactionalEntityManager => {
const transactionalHashtagRepository = transactionalEntityManager
.getRepository(MiHashtag);
const index = await transactionalHashtagRepository
.createQueryBuilder()
.setLock('pessimistic_write')
.where('name = :name', { name: tag })
.getOne();
if (index == null) return;
const set = {} as any;
if (isUserAttached) {
if (inc) {
// 自分が初めてこのタグを使ったなら
// 自分が初めてこのタグを使ったなら
if (!index.attachedUserIds.some(id => id === user.id)) {
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
@@ -117,46 +180,14 @@ export class HashtagService {
}
if (Object.keys(set).length > 0) {
q.set(set);
q.execute();
await transactionalHashtagRepository
.createQueryBuilder()
.update()
.where('id = :id', { id: index.id })
.set(set)
.execute();
}
} else {
if (isUserAttached) {
this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [],
mentionedUsersCount: 0,
mentionedLocalUserIds: [],
mentionedLocalUsersCount: 0,
mentionedRemoteUserIds: [],
mentionedRemoteUsersCount: 0,
attachedUserIds: [user.id],
attachedUsersCount: 1,
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
} as MiHashtag);
} else {
this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [user.id],
mentionedUsersCount: 1,
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
attachedUserIds: [],
attachedUsersCount: 0,
attachedLocalUserIds: [],
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as MiHashtag);
}
}
});
}
@bindThis

View File

@@ -5,6 +5,7 @@
import { Injectable } from '@nestjs/common';
import sharp from 'sharp';
import type { Sharp, WebpOptions, AvifOptions } from 'sharp';
export type IImage = {
data: Buffer;
@@ -19,14 +20,14 @@ export type IImageStream = {
};
export type IImageSharp = {
data: sharp.Sharp;
data: Sharp;
ext: string | null;
type: string;
};
export type IImageStreamable = IImage | IImageStream | IImageSharp;
export const webpDefault: sharp.WebpOptions = {
export const webpDefault: WebpOptions = {
quality: 77,
alphaQuality: 95,
lossless: false,
@@ -37,7 +38,7 @@ export const webpDefault: sharp.WebpOptions = {
loop: 0,
};
export const avifDefault: sharp.AvifOptions = {
export const avifDefault: AvifOptions = {
quality: 60,
lossless: false,
effort: 2,
@@ -57,12 +58,12 @@ export class ImageProcessingService {
* with resize, remove metadata, resolve orientation, stop animation
*/
@bindThis
public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
public async convertToWebp(path: string, width: number, height: number, options: WebpOptions = webpDefault): Promise<IImage> {
return this.convertSharpToWebp(sharp(path), width, height, options);
}
@bindThis
public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
public async convertSharpToWebp(sharp: Sharp, width: number, height: number, options: WebpOptions = webpDefault): Promise<IImage> {
const result = this.convertSharpToWebpStream(sharp, width, height, options);
return {
@@ -73,12 +74,12 @@ export class ImageProcessingService {
}
@bindThis
public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp {
public convertToWebpStream(path: string, width: number, height: number, options: WebpOptions = webpDefault): IImageSharp {
return this.convertSharpToWebpStream(sharp(path), width, height, options);
}
@bindThis
public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp {
public convertSharpToWebpStream(sharp: Sharp, width: number, height: number, options: WebpOptions = webpDefault): IImageSharp {
const data = sharp
.resize(width, height, {
fit: 'inside',
@@ -99,12 +100,12 @@ export class ImageProcessingService {
* with resize, remove metadata, resolve orientation, stop animation
*/
@bindThis
public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> {
public async convertToAvif(path: string, width: number, height: number, options: AvifOptions = avifDefault): Promise<IImage> {
return this.convertSharpToAvif(sharp(path), width, height, options);
}
@bindThis
public async convertSharpToAvif(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> {
public async convertSharpToAvif(sharp: Sharp, width: number, height: number, options: AvifOptions = avifDefault): Promise<IImage> {
const result = this.convertSharpToAvifStream(sharp, width, height, options);
return {
@@ -115,12 +116,12 @@ export class ImageProcessingService {
}
@bindThis
public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp {
public convertToAvifStream(path: string, width: number, height: number, options: AvifOptions = avifDefault): IImageSharp {
return this.convertSharpToAvifStream(sharp(path), width, height, options);
}
@bindThis
public convertSharpToAvifStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp {
public convertSharpToAvifStream(sharp: Sharp, width: number, height: number, options: AvifOptions = avifDefault): IImageSharp {
const data = sharp
.resize(width, height, {
fit: 'inside',
@@ -146,7 +147,7 @@ export class ImageProcessingService {
}
@bindThis
public async convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
public async convertSharpToPng(sharp: Sharp, width: number, height: number): Promise<IImage> {
const data = await sharp
.resize(width, height, {
fit: 'inside',

View File

@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
@Injectable()
export class TelemetryService implements OnApplicationShutdown {
@bindThis
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
captureMessage(message, opts);
}
@bindThis
public startSpan<T>(name: string, fn: () => T): T {
return startSpan(name, fn);
}
@bindThis
public async onApplicationShutdown(_signal?: string): Promise<void> {
await shutdownTelemetry();
}
}

View File

@@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
type SentryIntegrationsOption = NonNullable<import('@sentry/node').NodeOptions['integrations']>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SentryIntegrationFactory = Extract<SentryIntegrationsOption, (integrations: any[]) => any[]>;
type SentryIntegration = Parameters<SentryIntegrationFactory>[0][number];
type SentryNodeOptions = import('@sentry/node').NodeOptions;
type BuildSentryIntegrationsOptions = {
disabledIntegrations?: string[];
enableNodeProfiling: boolean;
nodeProfilingIntegration?: () => SentryIntegration;
warn?: (message: string) => void;
};
export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions): SentryIntegrationFactory {
return (defaults) => {
const disabledIntegrations = new Set(options.disabledIntegrations ?? []);
const defaultIntegrationNames = new Set(defaults.map((integration) => integration.name));
const unknownIntegrations = [...disabledIntegrations].filter((name) => !defaultIntegrationNames.has(name));
if (unknownIntegrations.length > 0) {
(options.warn ?? console.warn)(`Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: ${unknownIntegrations.join(', ')}`);
}
return [
...defaults.filter((integration) => !disabledIntegrations.has(integration.name)),
...(options.enableNodeProfiling && options.nodeProfilingIntegration != null ? [options.nodeProfilingIntegration()] : []),
];
};
}
export function buildSentryNodeOptions(
config: NonNullable<Config['sentryForBackend']>,
nodeProfilingIntegration?: () => SentryIntegration,
): SentryNodeOptions {
return {
// Do not send Sentry trace headers to remote ActivityPub/Webhook/etc. hosts by default.
// Admins can opt in for trusted internal services via sentryForBackend.options.
tracePropagationTargets: [],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.options,
integrations: buildSentryIntegrations({
disabledIntegrations: config.disabledIntegrations,
enableNodeProfiling: config.enableNodeProfiling,
nodeProfilingIntegration,
}),
};
}
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
private readonly Sentry: typeof import('@sentry/node'),
) {
}
public static async create(config: NonNullable<Config['sentryForBackend']>): Promise<SentryTelemetryAdapter> {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init(buildSentryNodeOptions(config, nodeProfilingIntegration));
return new SentryTelemetryAdapter(Sentry);
}
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
this.Sentry.captureMessage(message, {
level: opts.level,
...(opts.userId != null ? { user: { id: opts.userId } } : {}),
extra: opts.extra,
});
}
public startSpan<T>(name: string, fn: () => T): T {
return this.Sentry.startSpan({ name }, fn);
}
public async shutdown(): Promise<void> {
await this.Sentry.close();
}
}

View File

@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface TelemetryCaptureMessageOptions {
level: 'error';
userId?: string;
extra?: Record<string, unknown>;
}
/**
* Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
* 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、
* telemetry-registry.tsのinitTelemetry内で登録する。
*/
export interface TelemetryAdapter {
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
startSpan<T>(name: string, fn: () => T): T;
shutdown(): Promise<void>;
}

View File

@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
/**
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
* DIを介さないモジュールレベルの状態として有効なアダプタを保持する。
* TelemetryServiceはこの状態への薄いラッパーとして振る舞う。
*/
const adapters: TelemetryAdapter[] = [];
export async function initTelemetry(config: Config): Promise<void> {
if (config.sentryForBackend) {
adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend));
}
}
export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
for (const adapter of adapters) {
adapter.captureMessage(message, opts);
}
}
export function startSpan<T>(name: string, fn: () => T): T {
const wrapped = adapters.reduceRight<() => T>(
(inner, adapter) => () => adapter.startSpan(name, inner),
fn,
);
return wrapped();
}
export async function shutdownTelemetry(): Promise<void> {
await Promise.all(adapters.map(adapter => adapter.shutdown()));
}

View File

@@ -11,6 +11,7 @@ const envOption = {
verbose: false,
withLogTime: false,
quiet: false,
enableCrossOriginIsolation: false,
};
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {

View File

@@ -204,14 +204,13 @@ export class RedisSingleCache<T> {
}
}
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> {
private readonly cache = new Map<string, { date: number; value: T; }>();
private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m
constructor(
private readonly lifetime: number,
private readonly limit: number = Infinity,
) {}
@bindThis
@@ -220,6 +219,25 @@ export class MemoryKVCache<T> {
* @deprecated これを直接呼び出すべきではない。InternalEventなどで変更を全てのプロセス/マシンに通知するべき
*/
public set(key: string, value: T): void {
if (this.limit <= 0) {
throw new Error('Limit must be greater than 0');
}
if (this.limit !== Infinity) {
this.gc();
// 挿入順を更新して LRU を保つため、同一キーは一度削除する
this.cache.delete(key);
while (this.cache.size >= this.limit) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey == null) {
throw new Error('Cache is empty but size exceeds the limit');
}
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
date: Date.now(),
value,
@@ -234,6 +252,11 @@ export class MemoryKVCache<T> {
this.cache.delete(key);
return undefined;
}
if (this.limit !== Infinity) {
// access 順を更新して LRU を保つ
this.cache.delete(key);
this.cache.set(key, cached);
}
return cached.value;
}

View File

@@ -672,6 +672,11 @@ export class MiMeta {
})
public urlPreviewUserAgent: string | null;
@Column('varchar', {
length: 3072, array: true, default: '{}',
})
public urlPreviewSensitiveList: string[];
@Column('varchar', {
length: 128,
default: 'none',

View File

@@ -9,6 +9,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
@@ -92,6 +93,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private config: Config,
private queueLoggerService: QueueLoggerService,
private telemetryService: TelemetryService,
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
@@ -156,13 +158,6 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
}
let Sentry: typeof import('@sentry/node') | undefined;
if (this.config.sentryForBackend) {
import('@sentry/node').then((mod) => {
Sentry = mod;
});
}
//#region system
{
const processer = (job: Bull.Job) => {
@@ -181,11 +176,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: System: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.SYSTEM),
autorun: false,
@@ -198,12 +189,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err: Error) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -238,11 +227,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: DB: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.DB),
autorun: false,
@@ -255,12 +240,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -270,11 +253,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region deliver
{
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job));
} else {
return this.deliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: Deliver', () => this.deliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.DELIVER),
autorun: false,
@@ -295,12 +274,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -310,11 +287,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region inbox
{
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job));
} else {
return this.inboxProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: Inbox', () => this.inboxProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.INBOX),
autorun: false,
@@ -335,12 +308,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -350,11 +321,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region user-webhook deliver
{
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job));
} else {
return this.userWebhookDeliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: UserWebhookDeliver', () => this.userWebhookDeliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER),
autorun: false,
@@ -375,12 +342,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -390,11 +355,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region system-webhook deliver
{
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job));
} else {
return this.systemWebhookDeliverProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: SystemWebhookDeliver', () => this.systemWebhookDeliverProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER),
autorun: false,
@@ -415,12 +376,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
if (Sentry != null) {
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -440,11 +399,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: Relationship: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP),
autorun: false,
@@ -462,12 +417,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -485,11 +438,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
};
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job));
} else {
return processer(job);
}
return this.telemetryService.startSpan('Queue: ObjectStorage: ' + job.name, () => processer(job));
}, {
...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE),
autorun: false,
@@ -503,12 +452,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
if (Sentry != null) {
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
}
this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error',
extra: { job, err },
});
})
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
@@ -518,11 +465,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region ended poll notification
{
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job));
} else {
return this.endedPollNotificationProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: EndedPollNotification', () => this.endedPollNotificationProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
autorun: false,
@@ -533,11 +476,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region post scheduled note
{
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
if (Sentry != null) {
return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
} else {
return this.postScheduledNoteProcessorService.process(job);
}
return this.telemetryService.startSpan('Queue: PostScheduledNote', () => this.postScheduledNoteProcessorService.process(job));
}, {
...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
autorun: false,

View File

@@ -21,6 +21,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { envOption } from '@/env.js';
import { ActivityPubServerService } from './ActivityPubServerService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ApiServerService } from './api/ApiServerService.js';
@@ -89,6 +90,15 @@ export class ServerService implements OnApplicationShutdown {
});
}
// for test
if (envOption.enableCrossOriginIsolation) {
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Cross-Origin-Opener-Policy', 'same-origin');
reply.header('Cross-Origin-Embedder-Policy', 'credentialless');
done();
});
}
// Register raw-body parser for ActivityPub HTTP signature validation.
await fastify.register(fastifyRawBody, {
global: false,

View File

@@ -16,6 +16,7 @@ import type { MiMeta, UserIpsRepository } from '@/models/_.js';
import { createTemp } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
import type { Config } from '@/config.js';
import { ApiError } from './error.js';
import { RateLimiterService } from './RateLimiterService.js';
@@ -36,7 +37,6 @@ export class ApiCallService implements OnApplicationShutdown {
private logger: Logger;
private userIpHistories: Map<MiUser['id'], Set<string>>;
private userIpHistoriesClearIntervalId: NodeJS.Timeout;
private Sentry: typeof import('@sentry/node') | null = null;
constructor(
@Inject(DI.meta)
@@ -52,6 +52,7 @@ export class ApiCallService implements OnApplicationShutdown {
private rateLimiterService: RateLimiterService,
private roleService: RoleService,
private apiLoggerService: ApiLoggerService,
private telemetryService: TelemetryService,
) {
this.logger = this.apiLoggerService.logger;
this.userIpHistories = new Map<MiUser['id'], Set<string>>();
@@ -59,12 +60,6 @@ export class ApiCallService implements OnApplicationShutdown {
this.userIpHistoriesClearIntervalId = setInterval(() => {
this.userIpHistories.clear();
}, 1000 * 60 * 60);
if (this.config.sentryForBackend) {
import('@sentry/node').then((Sentry) => {
this.Sentry = Sentry;
});
}
}
#sendApiError(reply: FastifyReply, err: ApiError): void {
@@ -126,24 +121,20 @@ export class ApiCallService implements OnApplicationShutdown {
},
});
if (this.Sentry != null) {
this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
level: 'error',
user: {
id: userId,
this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
level: 'error',
userId,
extra: {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
stack: err.stack,
id: errId,
},
extra: {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
stack: err.stack,
id: errId,
},
},
});
}
},
});
throw new ApiError(null, {
e: {
@@ -441,15 +432,8 @@ export class ApiCallService implements OnApplicationShutdown {
}
// API invoking
if (this.Sentry != null) {
return await this.Sentry.startSpan({
name: 'API: ' + ep.name,
}, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
} else {
return await ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id));
}
return await this.telemetryService.startSpan('API: ' + ep.name, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
}
@bindThis

View File

@@ -104,6 +104,7 @@ export * as 'admin/system-webhook/list' from './endpoints/admin/system-webhook/l
export * as 'admin/system-webhook/show' from './endpoints/admin/system-webhook/show.js';
export * as 'admin/system-webhook/test' from './endpoints/admin/system-webhook/test.js';
export * as 'admin/system-webhook/update' from './endpoints/admin/system-webhook/update.js';
export * as 'admin/unset-mfa' from './endpoints/admin/unset-mfa.js';
export * as 'admin/unset-user-avatar' from './endpoints/admin/unset-user-avatar.js';
export * as 'admin/unset-user-banner' from './endpoints/admin/unset-user-banner.js';
export * as 'admin/unsuspend-user' from './endpoints/admin/unsuspend-user.js';

View File

@@ -544,6 +544,14 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
urlPreviewSensitiveList: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
federation: {
type: 'string',
enum: ['all', 'specified', 'none'],
@@ -760,6 +768,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
urlPreviewUserAgent: instance.urlPreviewUserAgent,
urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
urlPreviewSensitiveList: instance.urlPreviewSensitiveList,
federation: instance.federation,
federationHosts: instance.federationHosts,
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,

View File

@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '@/server/api/error.js';
import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:unset-mfa',
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
if (user == null) {
throw new ApiError(meta.errors.noSuchUser);
}
await this.db.transaction(async (transactionalEntityManager) => {
// パスキーを全て削除
await transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id });
// TOTP・パスワードレスログインを無効化
await transactionalEntityManager.update(MiUserProfile, { userId: user.id }, {
twoFactorSecret: null,
twoFactorBackupSecret: null,
twoFactorEnabled: false,
usePasswordLessLogin: false,
});
}).then(() => {
this.moderationLogService.log(me, 'unsetMfa', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
});
});
}
}

View File

@@ -189,6 +189,12 @@ export const paramDef = {
urlPreviewRequireContentLength: { type: 'boolean' },
urlPreviewUserAgent: { type: 'string', nullable: true },
urlPreviewSummaryProxyUrl: { type: 'string', nullable: true },
urlPreviewSensitiveList: {
type: 'array', nullable: true,
items: {
type: 'string',
}
},
federation: {
type: 'string',
enum: ['all', 'none', 'specified'],
@@ -734,6 +740,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.urlPreviewSummaryProxyUrl = value === '' ? null : value;
}
if (Array.isArray(ps.urlPreviewSensitiveList)) {
set.urlPreviewSensitiveList = ps.urlPreviewSensitiveList.filter(Boolean);
}
if (ps.federation !== undefined) {
set.federation = ps.federation;
}

View File

@@ -35,6 +35,14 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
reactionsCount: {
type: 'number',
optional: false, nullable: false,
},
//originalReactionsCount: {
// type: 'number',
// optional: false, nullable: false,
//},
instances: {
type: 'number',
optional: false, nullable: false,

View File

@@ -3,22 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import type { SummalyResult } from '@misskey-dev/summaly';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import type Logger from '@/logger.js';
import { deepClone } from '@/misc/clone.js';
import { query } from '@/misc/prelude/url.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { ApiError } from '@/server/api/error.js';
import { MiMeta } from '@/models/Meta.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
@Injectable()
export class UrlPreviewService {
export class UrlPreviewService implements OnApplicationShutdown {
private logger: Logger;
private summaryCache: MemoryKVCache<SummalyResult>;
private readonly summalyDefaultUserAgent: string;
constructor(
@@ -29,9 +33,11 @@ export class UrlPreviewService {
private meta: MiMeta,
private httpRequestService: HttpRequestService,
private utilityService: UtilityService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('url-preview');
this.summaryCache = new MemoryKVCache<SummalyResult>(1000 * 60 * 60, 100); // 1h, 100件
this.summalyDefaultUserAgent = `SummalyBot/${_SUMMALY_VERSION_} (${this.config.url}; +https://github.com/misskey-dev/summaly/blob/master/README.md)`;
}
@@ -78,23 +84,39 @@ export class UrlPreviewService {
: `Getting preview of ${url}@${lang} ...`);
try {
const summary = this.meta.urlPreviewSummaryProxyUrl
? await this.fetchSummaryFromProxy(url, this.meta, lang)
: await this.fetchSummary(url, this.meta, lang);
const fetcher = async () => {
const result = await (
this.meta.urlPreviewSummaryProxyUrl
? this.fetchSummaryFromProxy(url, lang)
: this.fetchSummary(url, lang)
);
if (!result.url.startsWith('http://') && !result.url.startsWith('https://')) {
return undefined;
}
if (result.player.url && !result.player.url.startsWith('http://') && !result.player.url.startsWith('https://')) {
return undefined;
}
return result;
};
const summary = deepClone(await this.summaryCache.fetchMaybe(`${url}@${lang ?? '_DEFAULT_'}`, fetcher));
if (summary == null) {
throw new Error('Invalid summary');
}
this.logger.succ(`Got preview of ${url}: ${summary.title}`);
if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
summary.icon = this.wrap(summary.icon);
summary.thumbnail = this.wrap(summary.thumbnail);
if (summary.sensitive !== true) {
summary.sensitive = this.utilityService.isKeyWordIncluded(summary.url, this.meta.urlPreviewSensitiveList);
}
// Cache 1day
reply.header('Cache-Control', 'max-age=86400, immutable');
@@ -114,7 +136,8 @@ export class UrlPreviewService {
}
}
private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
@bindThis
private async fetchSummary(url: string, lang?: string): Promise<SummalyResult> {
const { summaly } = await import('@misskey-dev/summaly');
return summaly(url, {
@@ -124,25 +147,36 @@ export class UrlPreviewService {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
},
userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
operationTimeout: meta.urlPreviewTimeout,
contentLengthLimit: meta.urlPreviewMaximumContentLength,
contentLengthRequired: meta.urlPreviewRequireContentLength,
userAgent: this.meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
operationTimeout: this.meta.urlPreviewTimeout,
contentLengthLimit: this.meta.urlPreviewMaximumContentLength,
contentLengthRequired: this.meta.urlPreviewRequireContentLength,
});
}
private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
const proxy = meta.urlPreviewSummaryProxyUrl!;
@bindThis
private fetchSummaryFromProxy(url: string, lang?: string): Promise<SummalyResult> {
const proxy = this.meta.urlPreviewSummaryProxyUrl!;
const queryStr = query({
url: url,
lang: lang ?? 'ja-JP',
followRedirects: this.meta.urlPreviewAllowRedirect,
userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
operationTimeout: meta.urlPreviewTimeout,
contentLengthLimit: meta.urlPreviewMaximumContentLength,
contentLengthRequired: meta.urlPreviewRequireContentLength,
userAgent: this.meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
operationTimeout: this.meta.urlPreviewTimeout,
contentLengthLimit: this.meta.urlPreviewMaximumContentLength,
contentLengthRequired: this.meta.urlPreviewRequireContentLength,
});
return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
}
@bindThis
public dispose(): void {
this.summaryCache.dispose();
}
@bindThis
public onApplicationShutdown(): void {
this.dispose();
}
}

View File

@@ -120,6 +120,7 @@ export const moderationLogTypes = [
'createAvatarDecoration',
'updateAvatarDecoration',
'deleteAvatarDecoration',
'unsetMfa',
'unsetUserAvatar',
'unsetUserBanner',
'createSystemWebhook',
@@ -327,6 +328,11 @@ export type ModerationLogPayloads = {
avatarDecorationId: string;
avatarDecoration: any;
};
unsetMfa: {
userId: string;
userUsername: string;
userHost: string | null;
};
unsetUserAvatar: {
userId: string;
userUsername: string;

View File

@@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as crypto from 'node:crypto';
import cbor from 'cbor';
import { encode as encodeToCbor } from 'cbor2';
import * as OTPAuth from 'otpauth';
import { loadConfig } from '@/config.js';
import { api, signup, sendEnvUpdateRequest } from '../utils.js';
@@ -61,7 +61,7 @@ describe('2要素認証', () => {
const keyDoneParam = (param: {
token: string,
keyName: string,
credentialId: Buffer,
credentialId: Uint8Array,
creationOptions: PublicKeyCredentialCreationOptionsJSON,
}): {
token: string,
@@ -70,10 +70,10 @@ describe('2要素認証', () => {
credential: RegistrationResponseJSON,
} => {
// A COSE encoded public key
const credentialPublicKey = cbor.encode(new Map<number, unknown>([
const credentialPublicKey = encodeToCbor(new Map<number, unknown>([
[-1, coseEc2CrvP256],
[-2, Buffer.from(coseEc2X, 'hex')],
[-3, Buffer.from(coseEc2Y, 'hex')],
[-2, Uint8Array.from(Buffer.from(coseEc2X, 'hex'))],
[-3, Uint8Array.from(Buffer.from(coseEc2Y, 'hex'))],
[1, coseKtyEc2],
[2, coseKid],
[3, coseAlgEs256],
@@ -85,21 +85,23 @@ describe('2要素認証', () => {
credentialIdLength.writeUInt16BE(param.credentialId.length, 0);
const authData = Buffer.concat([
rpIdHash(), // rpIdHash(32)
Buffer.from([0x45]), // flags(1)
Buffer.from([0x00, 0x00, 0x00, 0x00]), // signCount(4)
Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // AAGUID(16)
new Uint8Array([0x45]), // flags(1)
new Uint8Array(4), // signCount(4)
new Uint8Array(16), // AAGUID(16)
credentialIdLength,
param.credentialId,
credentialPublicKey,
]);
const credentialIdBase64url = Buffer.from(param.credentialId).toString('base64url');
return {
password,
token: param.token,
name: param.keyName,
credential: <RegistrationResponseJSON>{
id: param.credentialId.toString('base64url'),
rawId: param.credentialId.toString('base64url'),
id: credentialIdBase64url,
rawId: credentialIdBase64url,
response: <AuthenticatorAttestationResponseJSON>{
clientDataJSON: Buffer.from(JSON.stringify({
type: 'webauthn.create',
@@ -107,11 +109,11 @@ describe('2要素認証', () => {
origin: config.scheme + '://' + config.host,
androidPackageName: 'org.mozilla.firefox',
}), 'utf-8').toString('base64url'),
attestationObject: cbor.encode({
attestationObject: Buffer.from(encodeToCbor({
fmt: 'none',
attStmt: {},
authData,
}).toString('base64url'),
authData: new Uint8Array(authData),
})).toString('base64url'),
},
clientExtensionResults: {},
type: 'public-key',

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, beforeEach, test } from 'vitest';
import { describe, beforeAll, beforeEach, test, vi } from 'vitest';
import { UserToken, api, post, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('API visibility', () => {
describe('Note visibility', () => {
//#region vars
@@ -409,10 +411,12 @@ describe('API visibility', () => {
//#region HTL
test('[HTL] public-post が 自分が見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x');
await vi.waitFor(async () => {
const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x');
}, waitForPushToTlOptions);
});
test('[HTL] public-post が 非フォロワーから見れない', async () => {
@@ -423,10 +427,12 @@ describe('API visibility', () => {
});
test('[HTL] followers-post が フォロワーから見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x');
await vi.waitFor(async () => {
const res = await api('notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter(n => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x');
}, waitForPushToTlOptions);
});
//#endregion

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test } from 'vitest';
import { describe, beforeAll, test, vi } from 'vitest';
import { api, castAsError, post, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Block', () => {
// alice blocks bob
let alice: misskey.entities.SignupResponse;
@@ -75,13 +77,15 @@ describe('Block', () => {
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
const res = await api('notes/local-timeline', {}, bob);
const body = res.body as misskey.entities.Note[];
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, bob);
const body = res.body as misskey.entities.Note[];
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(body.some(note => note.id === aliceNote.id), false);
assert.strictEqual(body.some(note => note.id === bobNote.id), true);
assert.strictEqual(body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(body.some(note => note.id === aliceNote.id), false);
assert.strictEqual(body.some(note => note.id === bobNote.id), true);
assert.strictEqual(body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
});

View File

@@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test, expect } from 'vitest';
import { describe, beforeAll, test, expect, vi } from 'vitest';
// node-fetch only supports it's own Blob yet
// https://github.com/node-fetch/node-fetch/pull/1664
import { Blob } from 'node-fetch';
@@ -14,6 +14,8 @@ import { api, castAsError, initTestDb, post, role, signup, simpleGet, uploadFile
import type * as misskey from 'misskey-js';
import { MiUser } from '@/models/_.js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Endpoints', () => {
let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse;
@@ -1149,12 +1151,14 @@ describe('Endpoints', () => {
visibility: 'followers',
});
const res = await api('notes/timeline', {}, dave);
await vi.waitFor(async () => {
const res = await api('notes/timeline', {}, dave);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, carolPost.id);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, carolPost.id);
}, waitForPushToTlOptions);
});
});

View File

@@ -7,9 +7,8 @@ import { INestApplicationContext } from '@nestjs/common';
process.env.NODE_ENV = 'test';
import { setTimeout } from 'node:timers/promises';
import * as assert from 'assert';
import { afterAll, beforeAll, afterEach, describe, test } from 'vitest';
import { afterAll, beforeAll, afterEach, describe, test, vi } from 'vitest';
import { loadConfig } from '@/config.js';
import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
@@ -17,6 +16,11 @@ import { jobQueue } from '@/boot/common.js';
import { api, castAsError, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js';
import type * as misskey from 'misskey-js';
// i/move 呼び出しで大部分が同期的に完了する後続ジョブ (フォロー解除/ブロック・ミュート引き継ぎ/リスト更新等) を待つ
const waitForMoveJobOptions = { timeout: 5000, interval: 50 };
// AccountMoveService がテスト環境向けに 10000ms に短縮している遅延アンフォロージョブを待つ (安全マージンを載せた上限)
const waitForDelayedUnfollowJobOptions = { timeout: 15000, interval: 100 };
describe('Account Move', () => {
let jq: INestApplicationContext;
let url: URL;
@@ -273,53 +277,63 @@ describe('Account Move', () => {
assert.strictEqual(move.status, 200);
await setTimeout(1000 * 3); // wait for jobs to finish
// Unfollow delayed?
const aliceFollowings = await api('users/following', {
userId: alice.id,
}, alice);
assert.strictEqual(aliceFollowings.status, 200);
assert.ok(aliceFollowings);
assert.strictEqual(aliceFollowings.body.length, 3);
await vi.waitFor(async () => {
const aliceFollowings = await api('users/following', {
userId: alice.id,
}, alice);
assert.strictEqual(aliceFollowings.status, 200);
assert.ok(aliceFollowings);
assert.strictEqual(aliceFollowings.body.length, 3);
}, waitForMoveJobOptions);
const carolFollowings = await api('users/following', {
userId: carol.id,
}, carol);
assert.strictEqual(carolFollowings.status, 200);
assert.ok(carolFollowings);
assert.strictEqual(carolFollowings.body.length, 2);
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
await vi.waitFor(async () => {
const carolFollowings = await api('users/following', {
userId: carol.id,
}, carol);
assert.strictEqual(carolFollowings.status, 200);
assert.ok(carolFollowings);
assert.strictEqual(carolFollowings.body.length, 2);
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
}, waitForMoveJobOptions);
const blockings = await api('blocking/list', {}, dave);
assert.strictEqual(blockings.status, 200);
assert.ok(blockings);
assert.strictEqual(blockings.body.length, 2);
assert.strictEqual(blockings.body[0].blockeeId, bob.id);
assert.strictEqual(blockings.body[1].blockeeId, alice.id);
await vi.waitFor(async () => {
const blockings = await api('blocking/list', {}, dave);
assert.strictEqual(blockings.status, 200);
assert.ok(blockings);
assert.strictEqual(blockings.body.length, 2);
assert.strictEqual(blockings.body[0].blockeeId, bob.id);
assert.strictEqual(blockings.body[1].blockeeId, alice.id);
}, waitForMoveJobOptions);
const mutings = await api('mute/list', {}, dave);
assert.strictEqual(mutings.status, 200);
assert.ok(mutings);
assert.strictEqual(mutings.body.length, 2);
assert.strictEqual(mutings.body[0].muteeId, bob.id);
assert.strictEqual(mutings.body[1].muteeId, alice.id);
await vi.waitFor(async () => {
const mutings = await api('mute/list', {}, dave);
assert.strictEqual(mutings.status, 200);
assert.ok(mutings);
assert.strictEqual(mutings.body.length, 2);
assert.strictEqual(mutings.body[0].muteeId, bob.id);
assert.strictEqual(mutings.body[1].muteeId, alice.id);
}, waitForMoveJobOptions);
const rootLists = await api('users/lists/list', {}, root);
assert.strictEqual(rootLists.status, 200);
assert.ok(rootLists);
assert.ok(rootLists.body[0].userIds);
assert.strictEqual(rootLists.body[0].userIds.length, 2);
assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id));
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
await vi.waitFor(async () => {
const rootLists = await api('users/lists/list', {}, root);
assert.strictEqual(rootLists.status, 200);
assert.ok(rootLists);
assert.ok(rootLists.body[0].userIds);
assert.strictEqual(rootLists.body[0].userIds.length, 2);
assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id));
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
}, waitForMoveJobOptions);
const eveLists = await api('users/lists/list', {}, eve);
assert.strictEqual(eveLists.status, 200);
assert.ok(eveLists);
assert.ok(eveLists.body[0].userIds);
assert.strictEqual(eveLists.body[0].userIds.length, 1);
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
await vi.waitFor(async () => {
const eveLists = await api('users/lists/list', {}, eve);
assert.strictEqual(eveLists.status, 200);
assert.ok(eveLists);
assert.ok(eveLists.body[0].userIds);
assert.strictEqual(eveLists.body[0].userIds.length, 1);
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
}, waitForMoveJobOptions);
});
test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => {
@@ -340,14 +354,14 @@ describe('Account Move', () => {
});
test('Unfollowed after 10 sec (24 hours in production).', async () => {
await setTimeout(1000 * 8);
await vi.waitFor(async () => {
const following = await api('users/following', {
userId: alice.id,
}, alice);
const following = await api('users/following', {
userId: alice.id,
}, alice);
assert.strictEqual(following.status, 200);
assert.strictEqual(following.body.length, 0);
assert.strictEqual(following.status, 200);
assert.strictEqual(following.body.length, 0);
}, waitForDelayedUnfollowJobOptions);
});
test('Unable to move if the destination account has already moved.', async () => {

View File

@@ -6,10 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { beforeAll, describe, test } from 'vitest';
import { beforeAll, describe, test, vi } from 'vitest';
import { api, post, react, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Mute', () => {
// alice mutes carol
let alice: misskey.entities.SignupResponse;
@@ -67,13 +69,15 @@ describe('Mute', () => {
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
const res = await api('notes/local-timeline', {}, alice);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}, waitForPushToTlOptions);
});
test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => {
@@ -83,13 +87,15 @@ describe('Mute', () => {
renoteId: carolNote.id,
});
const res = await api('notes/local-timeline', {}, alice);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}, waitForPushToTlOptions);
});
});

View File

@@ -6,11 +6,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { beforeAll, describe, test } from 'vitest';
import { setTimeout } from 'node:timers/promises';
import { beforeAll, describe, test, vi } from 'vitest';
import { api, post, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
describe('Renote Mute', () => {
// alice mutes carol
let alice: misskey.entities.SignupResponse;
@@ -36,16 +37,15 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id });
const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => {
@@ -53,16 +53,15 @@ describe('Renote Mute', () => {
const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' });
const carolNote = await post(carol, { text: 'hi' });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}, waitForPushToTlOptions);
});
// #12956
@@ -70,15 +69,14 @@ describe('Renote Mute', () => {
const carolNote = await post(carol, { text: 'hi' });
const bobRenote = await post(bob, { renoteId: carolNote.id });
// redisに追加されるのを待つ
await setTimeout(100);
await vi.waitFor(async () => {
const res = await api('notes/local-timeline', {}, alice);
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
}, waitForPushToTlOptions);
});
test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => {

Some files were not shown because too many files have changed in this diff Show More