Compare commits

...

210 Commits

Author SHA1 Message Date
tamaina 461b36cac6 move jest-mock, update lockfile 2022-11-22 14:34:04 +09:00
tamaina 61568ad780 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-11-22 14:26:15 +09:00
syuilo be313b8d78 chore: Unflag JSON modules
https://github.com/nodejs/node/pull/41736
2022-11-19 10:26:23 +09:00
syuilo aa8693e8df update deps 2022-11-19 10:25:16 +09:00
syuilo 521f97d03e Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2022-11-17 09:38:41 +09:00
kabo2468 456705a3d5
fix: 引用内の文章をnyaizeをしないように (#9141)
* fix nyaize in quote

* Update CHANGELOG.md

* for ofのほうが早いらしい

* Update NoteEntityService.ts

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2022-11-17 09:34:23 +09:00
syuilo d5aee2ea58 improve performance 2022-11-17 09:31:07 +09:00
tamaina af8fd01c41 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-11-14 21:11:01 +09:00
heka 746fac0dfe
fix typo on CleanRemoteFilesProcessorService (#9171) 2022-11-14 06:53:50 +09:00
tamaina 16151353b3 update lockfile 2022-11-13 21:34:57 +09:00
tamaina 7c406ea97f Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-11-13 18:48:18 +09:00
syuilo fb19456b61
12.120.0-alpha.8 2022-11-13 14:53:27 +09:00
syuilo 831be69cec refactor(client): use dvh 2022-11-13 11:43:23 +09:00
syuilo 1751bfea5f update node to v18 2022-11-13 11:23:14 +09:00
syuilo 49daa56a64 update deps 2022-11-13 11:14:07 +09:00
nenohi 7462a1e816
ノートの詳細ボタンを...に追加 (#9166)
* noteの詳細を追加

* add detail option to note menu

* add detailed , fix typo

* delete button

Co-authored-by: Gray Olson <gray@grayolson.com>
2022-11-13 07:54:05 +09:00
syuilo 1f3b1e7074 fix(client): インスタンスティッカーのfaviconを読み込む際に偽サイト警告が出ることがあるのを修正 2022-11-12 09:39:11 +09:00
syuilo 8935eaec3b Update QueueProcessorService.ts 2022-11-11 16:55:16 +09:00
squidicuz 2492f4e81e
fix for pinned users. update changelog (#9159) 2022-11-11 11:22:31 +09:00
tamaina d4b8c0ea4d Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-11-07 15:37:22 +00:00
futchitwo 24d18a7b19
fix(client): clarify to use props.url (#9143) 2022-11-04 22:02:35 +09:00
tamaina fad2d65441 update lockfile 2022-10-29 18:39:13 +09:00
tamaina 24a2bb53e5 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-10-29 18:36:51 +09:00
syuilo 5e21fd2caf update deps 2022-10-28 16:08:27 +09:00
syuilo b19ef59671
12.120.0-alpha.7 2022-10-13 13:42:11 +09:00
tamaina 7e5d7b50b7 update-lockfile 2022-10-13 03:52:04 +00:00
tamaina 088d66a252 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-10-13 03:50:57 +00:00
CyberRex 1309367884
Add Cloudflare Turnstile CAPTCHA support (#9111)
* Add Cloudflare Turnstile CAPTCHA support

* Update packages/client/src/components/MkCaptcha.vue

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
2022-10-13 09:19:57 +09:00
hayabusa 166067f746
add webhookId to api request (#9113) 2022-10-13 08:34:57 +09:00
hayabusa dae82514dc
fix: 一部の状態のファイルをドロップしてアップロードできない場合がある問題を修正 (投稿フォーム以外についても) (#9114)
* dropEffectとeffectAllowedの関連付けを投稿フォームと同様に修正

* 文字列をDropできるようにしてしまったのを修正
2022-10-13 08:34:23 +09:00
Acid Chicken (硫酸鶏) 56a719f0d4
fix: typo 2022-10-02 20:57:17 +09:00
syuilo 118dedb441
Update CHANGELOG.md 2022-09-28 10:13:05 +09:00
syuilo df0a90f69f fix type 2022-09-25 07:44:42 +09:00
syuilo 7670f364e3 type 2022-09-24 17:13:09 +09:00
syuilo fd5976f378 fix type 2022-09-24 17:07:56 +09:00
syuilo 3c4b7d3bd0 fix type 2022-09-24 17:02:19 +09:00
syuilo 89ef21e3b0 fix type 2022-09-24 16:53:53 +09:00
syuilo da88e3a3b1 test: 後で戻す 2022-09-24 15:22:15 +09:00
syuilo dabe5bf7e9 fix type errors 2022-09-24 14:45:44 +09:00
syuilo aa3ca438a2 fix type 2022-09-24 10:46:52 +09:00
syuilo cd6a6738c2 fix 2022-09-24 10:45:42 +09:00
syuilo 349f37bf57 fix 2022-09-24 10:43:23 +09:00
syuilo 0f7cbb5922 fix type 2022-09-24 10:39:17 +09:00
syuilo f4b981cefe fix type 2022-09-24 10:25:32 +09:00
syuilo ab6bbb9e23 12.120.0-alpha.6 2022-09-24 09:03:23 +09:00
syuilo 2c45c5b13e fix 2022-09-24 09:03:04 +09:00
syuilo 6eace8894a fixes 2022-09-24 08:41:27 +09:00
syuilo b1abf47ce7 fix type 2022-09-24 07:17:45 +09:00
syuilo 614b11951b refactor 2022-09-24 07:15:16 +09:00
syuilo 786f1d8be8 remove unused files 2022-09-24 07:13:01 +09:00
syuilo c8f6bc0dab fixes 2022-09-24 07:12:11 +09:00
syuilo 417f52359d fixes 2022-09-24 06:45:44 +09:00
tamaina 4b8becb108 add jest-mock 2022-09-23 05:40:02 +00:00
tamaina a7d3585b57 update yarnlock 2022-09-23 05:14:26 +00:00
tamaina c8200e63b4 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-09-23 05:09:49 +00:00
syuilo 174a8b1b3e fixes 2022-09-23 06:21:31 +09:00
syuilo 31f2f6616c chore: fix type import 2022-09-23 06:07:29 +09:00
syuilo 3ae66e2988
Update package.json 2022-09-23 04:24:16 +09:00
syuilo 92bfcb9b60
Update package.json 2022-09-23 04:19:13 +09:00
syuilo 234ced3c26 12.120.0-alpha.5 2022-09-22 09:12:01 +09:00
tamaina 13f4e92b0f Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-09-21 23:21:25 +00:00
こけっち bcbda6940a
feat: Youtube window player (#9095)
* wip: feat: Youtube Player Window

* fix: player fill window

* fix: improve design

* fix: disable at mobile and creanup code

* fix: tailing comma

* fix: delete debug output

* fix: eslint

* fix: switch to component

* fix(backend): add missing dependency

Fix #9101

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2022-09-22 08:20:31 +09:00
tamaina 585519fa6c use yarnrc 2022-09-21 23:00:08 +00:00
syuilo e6077b03c3 refactor(backend): refactor dependency 2022-09-22 05:11:26 +09:00
syuilo db2d391b3a update test 2022-09-22 04:59:58 +09:00
syuilo 8e6f1508ed use swc for jest 2022-09-22 04:51:16 +09:00
Takuya Yoshida 2e0075e79c
Fix ioredis import typo (#9099)
* Fix import typo

* Fix type
2022-09-21 10:12:18 +09:00
syuilo 8583b96402 fix wrong impot 2022-09-21 07:59:18 +09:00
syuilo 01d4d55e78 fix import type 2022-09-21 05:33:11 +09:00
syuilo 18fe773923 add test for metaService 2022-09-21 04:58:32 +09:00
syuilo 62bce14709 test 2022-09-21 04:54:21 +09:00
syuilo e7cdc53c7b test 2022-09-21 04:50:16 +09:00
syuilo 3bc6205150 refactor(backend): ロジックをサービスに切り出す 2022-09-21 02:52:19 +09:00
syuilo dc43fc68ef enhance(backend): metaのポーリング頻度を減らし、redisで更新を受け取るように 2022-09-21 02:35:49 +09:00
syuilo 55f8a641a6 fix(backend): add missing noteEntityService dep 2022-09-21 02:19:49 +09:00
syuilo 192a1bd69e 🎨 2022-09-21 02:10:59 +09:00
tamaina 155af07e2f modify lockfile 2022-09-20 04:08:30 +00:00
tamaina 673c5228eb ダメ 2022-09-20 03:56:03 +00:00
tamaina f7f4628890 fix: add @tensorflow/tfjs 2022-09-20 03:22:27 +00:00
tamaina 3eda1d1ae7 fix 2022-09-20 02:53:15 +00:00
tamaina 029f6c3de0 fix 2022-09-20 02:51:06 +00:00
tamaina b44dc9dbe8 fix build 2022-09-20 02:45:50 +00:00
tamaina e54ab1de78 update yarn.lock 2022-09-20 02:42:05 +00:00
tamaina 338a076c5f Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-09-20 02:35:06 +00:00
syuilo 8fb0cf3064 12.120.0-alpha.4 2022-09-20 07:52:32 +09:00
syuilo 320ee29e2a revert blurhash
https://github.com/woltapp/blurhash/issues/202
2022-09-20 07:52:23 +09:00
syuilo 622eb37dfe 12.120.0-alpha.3 2022-09-20 07:33:34 +09:00
syuilo 88a6c312e2 update deps 2022-09-20 07:33:27 +09:00
syuilo 27b40053c7 🎨 2022-09-20 07:14:47 +09:00
syuilo 4a7bec4e57 lint 2022-09-20 05:36:51 +09:00
syuilo 567c550120 lint 2022-09-20 05:32:18 +09:00
syuilo 1ed06e490c Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2022-09-20 05:19:39 +09:00
syuilo 3010dc207a refactor(backend): refactor ChartManagementService 2022-09-20 05:19:37 +09:00
syuilo f2663d37e9
Update test.yml 2022-09-20 04:56:19 +09:00
syuilo 72253a1029 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2022-09-20 04:02:05 +09:00
syuilo eeca400fae
12.120.0-alpha.2 2022-09-20 03:24:11 +09:00
syuilo 0ea15f1c8a Create .madgerc 2022-09-20 01:04:08 +09:00
syuilo 89174904bc fix(test): fix chart test 2022-09-19 08:25:44 +09:00
syuilo a2eac9fff6 test 2022-09-19 03:11:50 +09:00
syuilo 92c78218bc revert 2022-09-19 03:08:54 +09:00
syuilo 0163cb7bc1 test 2022-09-19 03:03:11 +09:00
syuilo 6c5a42e745 fix queue bug 2022-09-19 03:00:25 +09:00
syuilo 2fbd09a07e fix 2022-09-18 23:36:00 +09:00
syuilo 91d2c954fc typo 2022-09-18 23:30:08 +09:00
syuilo a66111ac1f refactor(backend): simplify boot 2022-09-18 23:17:32 +09:00
syuilo 0abecffa8b 12.120.0-alpha.1 2022-09-18 23:09:12 +09:00
syuilo dd35f2cce6 refactor(backend): refactor logger 2022-09-18 23:07:41 +09:00
syuilo 1cb85c5c76 fix(backend): サービスが二重にインスタンス化されるのを修正 2022-09-18 06:24:30 +09:00
syuilo 6b639f186d Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2022-09-18 06:10:13 +09:00
syuilo b69bd0ee7c refactor 2022-09-18 06:10:10 +09:00
dependabot[bot] 131c05f18c
chore(deps): bump undici from 5.8.0 to 5.10.0 in /packages/backend (#9073)
Bumps [undici](https://github.com/nodejs/undici) from 5.8.0 to 5.10.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.8.0...v5.10.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-18 05:48:29 +09:00
nenohi c1667dc43c
フォロー、フォロワーのページでフォローされていることを表示 (#9093)
* followed-view

* Update MkUserInfo.vue

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2022-09-18 04:07:59 +09:00
syuilo b75184ec8e
なんかもうめっちゃ変えた 2022-09-18 03:27:08 +09:00
tamaina 656e672615 update-lockfile 2022-08-19 20:50:19 +09:00
tamaina 03efc872e2 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-08-19 20:47:06 +09:00
tamaina 9326445aac Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-08-09 02:12:01 +00:00
tamaina 84d01acaa4 update lockfie 2022-08-01 10:22:21 +00:00
tamaina 23c0c97e90 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-08-01 10:21:32 +00:00
tamaina 79b6ad2fe5 ✌️ 2022-07-19 06:21:49 +00:00
tamaina 6fab21c6a7 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-07-19 06:21:39 +00:00
tamaina c81e5ae8f5 move resolutions 2022-07-05 05:17:37 +00:00
tamaina 02a18fb8f9 ? 2022-07-05 05:16:20 +00:00
tamaina 9cd1526073 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-07-05 05:16:06 +00:00
ThatOneCalculator bfedb98fb0 Add .yarn/cache to gitignore for packages 2022-06-28 10:12:41 -07:00
ThatOneCalculator 4f0f4ed1ff Merge branch 'yarn-3' of https://github.com/ThatOneCalculator/misskey into yarn-3 2022-06-28 10:11:20 -07:00
Kainoa Kanter 47f05adc13
Merge branch 'misskey-dev:develop' into yarn-3 2022-06-28 10:10:26 -07:00
tamaina 173960310e update lockfile 2022-06-28 05:10:29 +00:00
tamaina b9154cda2f Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-06-28 05:08:57 +00:00
Kainoa Kanter 4335e09802
Update CHANGELOG.md 2022-06-17 01:37:42 -07:00
Kainoa Kanter 308f57d18d
`yarn run gulp` in `build` instead of just `gulp` 2022-06-17 01:15:12 -07:00
Kainoa Kanter 15ec284ef7
Merge branch 'misskey-dev:develop' into yarn-3 2022-06-17 01:04:50 -07:00
tamaina 4746ef58fc Merge branch 'yarn-3' of https://github.com/thatonecalculator/misskey into pr/ThatOneCalculator/8764 2022-06-16 21:23:14 +09:00
tamaina f2e593a35c remove packagemanager 2022-06-16 21:23:11 +09:00
tamaina 9851b444d4
Update packages/backend/package.json
Co-authored-by: iwata <ishowta@gmail.com>
2022-06-16 21:22:10 +09:00
tamaina 7ddfd049a4 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-06-16 21:19:17 +09:00
tamaina 11fbd546c1 fix lockfile 2022-06-04 23:51:59 +09:00
tamaina 014ef17f25 revival gulp build
66ab7591bf (r885899944)
2022-06-04 23:51:16 +09:00
tamaina 772cf15985
Update .gitignore
Co-authored-by: iwata <ishowta@gmail.com>
2022-06-04 23:48:39 +09:00
tamaina b53a67b8cd enable actions/setup-node's global cache 2022-06-04 23:48:07 +09:00
tamaina fba88e110a https://github.com/misskey-dev/misskey/pull/8764#discussion_r885749892 2022-06-04 23:34:23 +09:00
tamaina b85397c181 yarn install --immutable
Co-authored-by: ishowta <ishowta@gmail.com>
2022-06-04 23:33:36 +09:00
tamaina ff1f65291f remove `yarn set version berry` 2022-06-04 23:31:51 +09:00
tamaina 93b5dd70c8 ✌️ 2022-06-04 23:00:55 +09:00
tamaina d8ef0ca27a add packageExtensions 2022-06-04 22:41:27 +09:00
tamaina c0fea1c1ff fix 2022-06-04 08:44:36 +00:00
tamaina 77a3c7639e fix 2022-06-04 08:36:50 +00:00
tamaina 09d1ba9f68 Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-06-04 08:34:56 +00:00
tamaina fd5595724c fix http-signature 2022-06-04 08:26:36 +00:00
tamaina dea1322847 fix 2022-06-04 08:09:38 +00:00
tamaina 41fadc9ae9 yarn node => node
依存関係の記載不足の解消がかなり多いため、yarn nodeは使わない
2022-06-04 08:07:27 +00:00
tamaina 1a837bbef4 wip 2022-06-04 08:01:02 +00:00
tamaina ac89f25b79 npm run => yarn 2022-06-03 22:39:07 +09:00
tamaina 9b727f6c2d add packageExtensions for chartjs_date-fns
Co-authored-by: acid-chicken <root@acid-chicken.com>
2022-06-03 22:06:36 +09:00
tamaina 71538d4cbb add @rollup/pluginutils 2022-06-03 07:47:23 +00:00
tamaina 3e38e47b3a Fix yarn.lock
Co-authored-by: ishowta <ishowta@gmail.com>
2022-06-02 22:37:19 +09:00
tamaina 9264b50c32 continue-on-error: true 2022-06-02 10:51:29 +00:00
tamaina a53663f4df Merge branch 'develop' into pr/ThatOneCalculator/8764 2022-06-02 10:21:08 +00:00
ThatOneCalculator 021a52cb40 ????? 2022-05-31 20:03:15 -07:00
ThatOneCalculator d34bb62dea test 2022-05-31 20:02:42 -07:00
ThatOneCalculator f4059eb6cb I don't know why github workflows hates me... 2022-05-31 19:59:15 -07:00
ThatOneCalculator e21919c296 :bowtie: 2022-05-31 19:51:54 -07:00
ThatOneCalculator 4d16fb70ce Fix changelog 2022-05-31 19:45:57 -07:00
ThatOneCalculator db0344e6ca pnpm = dont change paths 2022-05-31 19:44:04 -07:00
ThatOneCalculator 3820a0722d package.json names 2022-05-31 19:41:15 -07:00
ThatOneCalculator 66ab7591bf Make client/backend lints seperate workflows 2022-05-31 08:50:03 -07:00
ThatOneCalculator e489c5390e Merge branch 'yarn-3' of https://github.com/ThatOneCalculator/misskey into yarn-3 2022-05-31 08:44:49 -07:00
Kainoa Kanter 789d0f8201
Update test.yml 2022-05-31 08:38:27 -07:00
Kainoa Kanter e0c57cf1da
Update lint.yml 2022-05-31 08:37:15 -07:00
Kainoa Kanter 7bba4876af
Update .dockerignore 2022-05-31 08:33:03 -07:00
ThatOneCalculator e4dde132b4 uhh 2022-05-31 00:26:50 -07:00
ThatOneCalculator 2208df17cc New steps for test.yml too 2022-05-31 00:24:40 -07:00
ThatOneCalculator d5af0a6bc7 Skip extra install 2022-05-31 00:18:29 -07:00
ThatOneCalculator 099c62370c Add proper packages for eslint 2022-05-31 00:16:01 -07:00
ThatOneCalculator 75d2336d8e 🙏 2022-05-31 00:13:41 -07:00
ThatOneCalculator 33c58df79c Remove yarnrc for workspaces 2022-05-30 23:56:49 -07:00
ThatOneCalculator a872e6e3bb :godmode: 2022-05-30 23:55:47 -07:00
ThatOneCalculator 71a907007c :goberserk: 2022-05-30 21:03:11 -07:00
ThatOneCalculator 9daea49275 Merge branch 'yarn-3' of https://github.com/ThatOneCalculator/misskey into yarn-3 2022-05-30 20:57:49 -07:00
Kainoa Kanter 54ed997e53
Merge branch 'misskey-dev:develop' into yarn-3 2022-05-30 20:57:45 -07:00
ThatOneCalculator a2f9e1cec2 :finnadie: 2022-05-30 20:53:23 -07:00
ThatOneCalculator de29d9adb2 Change `browser-image-resizer` 2022-05-30 20:45:54 -07:00
ThatOneCalculator 5cf5a836df 🍀 2022-05-30 17:35:28 -07:00
ThatOneCalculator 95aff06dfc More yarn workspaces stuff 2022-05-30 17:31:24 -07:00
ThatOneCalculator d76ec576b6 tbh 2022-05-30 15:02:52 -07:00
ThatOneCalculator 299ec96e0e tbh 2022-05-30 15:02:31 -07:00
ThatOneCalculator dd1bfae823 fix the code that depends on node_modules 2022-05-29 22:19:40 -07:00
Kainoa Kanter 88c7594d2d
Update CHANGELOG.md 2022-05-29 20:29:32 -07:00
ThatOneCalculator bc94eb8baf corepack enable for mocha/e2e 2022-05-29 20:26:38 -07:00
ThatOneCalculator 44c42f1715 yarn lint my beloved 2022-05-29 20:19:03 -07:00
ThatOneCalculator db86110b97 Fix workflow! 2022-05-29 20:10:30 -07:00
Kainoa Kanter c05cc01191
Merge branch 'misskey-dev:develop' into yarn-3 2022-05-29 19:43:38 -07:00
Kainoa Kanter 6f2a9d567d
Update lint.yml
🤔
2022-05-29 19:06:52 -07:00
Kainoa Kanter 39f8cb3006
🙏 2022-05-29 19:03:59 -07:00
ThatOneCalculator 322fe727cb Regenerate yarn.lock 2022-05-29 18:44:10 -07:00
ThatOneCalculator 4970befc10 Add `eslint` 2022-05-29 18:42:19 -07:00
Kainoa Kanter 3208a2ef94
Update .github/workflows/lint.yml
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
2022-05-29 18:39:49 -07:00
Kainoa Kanter ff99a92f4d
Update .github/workflows/lint.yml
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
2022-05-29 18:39:44 -07:00
ThatOneCalculator 6254a1e163 Improve lint workflow 2022-05-29 18:28:01 -07:00
ThatOneCalculator f035817489 Merge branch 'yarn-3' of https://github.com/ThatOneCalculator/misskey into yarn-3 2022-05-29 18:19:39 -07:00
ThatOneCalculator 7bd65cf986 Remove `packages/*/yarn.lock` 2022-05-29 18:19:30 -07:00
Kainoa Kanter 7df83d7252
`corepack enable` for linting 2022-05-29 18:11:20 -07:00
ThatOneCalculator 9afb41042c Typo 2022-05-29 17:16:31 -07:00
ThatOneCalculator 6ba6dd1720 Replace `install-packages.js` with workspaces 2022-05-29 17:15:11 -07:00
ThatOneCalculator fd8170f1dc Replace `install-packages.js` with workspaces 2022-05-29 17:14:52 -07:00
ThatOneCalculator 9d1c8b4f00 use `"packageManager"` 2022-05-29 17:08:57 -07:00
ThatOneCalculator 3140a3f9a2 Proper upgrade to yarn 2022-05-29 13:13:27 -07:00
Kainoa Kanter 9652183eff
Update Dockerfile 2022-05-29 12:48:03 -07:00
Kainoa Kanter ea7d5353ca
Mention in CHANGELOG 2022-05-29 12:47:19 -07:00
Kainoa Kanter 76e399cd81
Add `.yarn` to dockerignore 2022-05-29 12:46:23 -07:00
ThatOneCalculator 5c7fca456c Yarn berry 2022-05-29 12:40:08 -07:00
1030 changed files with 59336 additions and 46842 deletions

View File

@ -13,3 +13,10 @@ node_modules/
redis/
files/
misskey-assets/
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@ -8,32 +8,22 @@ on:
pull_request:
jobs:
backend:
lint:
strategy:
matrix:
workspace:
- backend
- client
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
submodules: true
- uses: actions/setup-node@v3
- uses: actions/setup-node@v3.2.0
with:
node-version: 18.x
cache: 'yarn'
cache-dependency-path: |
packages/backend/yarn.lock
- run: yarn install
- run: yarn --cwd ./packages/backend lint
client:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: true
- uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'yarn'
cache-dependency-path: |
packages/client/yarn.lock
- run: yarn install
- run: yarn --cwd ./packages/client lint
- run: corepack enable
- run: yarn install --immutable
- run: yarn workspace ${{ matrix.workspace }} run lint

View File

@ -8,7 +8,7 @@ on:
pull_request:
jobs:
mocha:
jest:
runs-on: ubuntu-latest
strategy:
@ -23,6 +23,7 @@ jobs:
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
YARN_CHECKSUM_BEHAVIOR: update
redis:
image: redis:6
ports:
@ -33,15 +34,12 @@ jobs:
with:
submodules: true
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v3.2.0
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
cache-dependency-path: |
packages/backend/yarn.lock
packages/client/yarn.lock
- name: Install dependencies
run: yarn install
- run: corepack enable
- run: yarn install --immutable
- name: Check yarn.lock
run: git diff --exit-code yarn.lock
- name: Copy Configure
@ -49,7 +47,12 @@ jobs:
- name: Build
run: yarn build
- name: Test
run: yarn mocha
run: yarn jest-and-coverage
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/backend/coverage/coverage-final.json
e2e:
runs-on: ubuntu-latest
@ -84,17 +87,14 @@ jobs:
#- uses: browser-actions/setup-firefox@latest
# if: ${{ matrix.browser == 'firefox' }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v3.2.0
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
cache-dependency-path: |
packages/backend/yarn.lock
packages/client/yarn.lock
- name: Install dependencies
run: yarn install
- name: Check yarn.lock
run: git diff --exit-code yarn.lock
- run: corepack enable
- run: yarn install --immutable
env:
YARN_CHECKSUM_BEHAVIOR: update
- name: Copy Configure
run: cp .github/misskey/test.yml .config
- name: Build
@ -106,7 +106,7 @@ jobs:
uses: cypress-io/github-action@v4
with:
install: false
start: npm run start:test
start: yarn start:test
wait-on: 'http://localhost:61812'
headless: false
browser: ${{ matrix.browser }}

11
.gitignore vendored
View File

@ -9,6 +9,17 @@
node_modules
report.*.json
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
packages/client/.yarn/cache
packages/backend/.yarn/cache
packages/sw/.yarn/cache
# Cypress
cypress/screenshots
cypress/videos

View File

@ -1 +1 @@
v16.15.0
v18.12.1

2
.npmrc
View File

@ -1,2 +0,0 @@
save-exact = true
package-lock = false

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

30
.yarnrc.yml Normal file
View File

@ -0,0 +1,30 @@
httpTimeout: 600000
nmHoistingLimits: none
nodeLinker: pnpm
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
progressBarStyle: "patrick"
packageExtensions:
"chartjs-adapter-date-fns@*":
peerDependencies:
"date-fns": "*"
"@bull-board/api@*":
peerDependencies:
"@bull-board/ui": "*"
"koa-views@*":
dependencies:
"pug": "*"
"consolidate@*":
dependencies:
"ejs": "*"
"@tensorflow/tfjs@*":
dependencies:
"long": "*"

View File

@ -9,6 +9,21 @@
You should also include the user name that made the change.
-->
## 12.x.x (unreleased)
### Changes
- Node.js 18.x or later is required
- Elasticsearchのサポートが削除されました
- 代わりに今後任意の検索プロバイダを設定できる仕組みを構想しています。その仕組みを使えば今まで通りElasticsearchも利用できます
- ノートのウォッチ機能が削除されました
### Improvements
### Bugfixes
- Server: 引用内の文章がnyaizeされてしまう問題を修正 @kabo2468
- Server: Bug fix for Pinned Users lookup on instance @squidicuzz
- Client: インスタンスティッカーのfaviconを読み込む際に偽サイト警告が出ることがあるのを修正 @syuilo
## 12.119.0 (2022/09/10)
### Improvements
@ -155,6 +170,8 @@ same as 12.112.0
IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`.
- Server: Add possibility to log IP addresses of users @syuilo
- Add additional drive capacity change support @CyberRex0
- Migrate to Yarn Berry (v3.2.1) @ThatOneCalculator
- You may have to `yarn run clean-all` and `yarn set version berry` before running `yarn install` if you're still on yarn classic
### Bugfixes
- Server: Fix GenerateVideoThumbnail failed @mei23
@ -364,7 +381,7 @@ same as 12.112.0
## 12.104.0 (2022/02/09)
### Note
ビルドする前に`npm run clean`を実行してください。
ビルドする前に`yarn clean`を実行してください。
このリリースはマイグレーションの規模が大きいため、インスタンスによってはマイグレーションに時間がかかる可能性があります。
マイグレーションが終わらない場合は、チャートの情報はリセットされてしまいますが`__chart__`で始まるテーブルの**レコード**を全て削除(テーブル自体は消さないでください)してから再度試す方法もあります。

View File

@ -44,7 +44,7 @@ Thank you for your PR! Before creating a PR, please check the following:
- Check if there are any documents that need to be created or updated due to this change.
- If you have added a feature or fixed a bug, please add a test case if possible.
- Please make sure that tests and Lint are passed in advance.
- You can run it with `npm run test` and `npm run lint`. [See more info](#testing)
- You can run it with `yarn test` and `yarn lint`. [See more info](#testing)
- If this PR includes UI changes, please attach a screenshot in the text.
Thanks for your cooperation 🤗
@ -99,7 +99,7 @@ If your language is not listed in Crowdin, please open an issue.
![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg)
## Development
During development, it is useful to use the `npm run dev` command.
During development, it is useful to use the `yarn dev` command.
This command monitors the server-side and client-side source files and automatically builds them if they are modified.
In addition, it will also automatically start the Misskey server process.
@ -119,12 +119,12 @@ Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.y
Run all test.
```
npm run test
yarn test
```
#### Run specify test
```
npx cross-env TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT="./test/tsconfig.json" npx mocha test/foo.ts --require ts-node/register
yarn jest -- foo.ts
```
### e2e tests
@ -257,7 +257,7 @@ MongoDBは`null`で返してきてたので、その感覚で`if (x === null)`
### Migration作成方法
packages/backendで:
```sh
npx typeorm migration:generate -d ormconfig.js -o <migration name>
yarn dlx typeorm migration:generate -d ormconfig.js -o <migration name>
```
- 生成後、ファイルをmigration下に移してください

View File

@ -1,4 +1,4 @@
FROM node:16.15.1-bullseye AS builder
FROM node:18.12.1-bullseye AS builder
ARG NODE_ENV=production
@ -13,7 +13,7 @@ RUN yarn install
RUN yarn build
RUN rm -rf .git
FROM node:16.15.1-bullseye-slim AS runner
FROM node:18.12.1-bullseye-slim AS runner
WORKDIR /misskey

View File

@ -2,6 +2,7 @@ _lang_: "日本語"
headlineMisskey: "ノートでつながるネットワーク"
introMisskey: "ようこそMisskeyは、オープンソースの分散型マイクロブログサービスです。\n「ート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀"
poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォーム<b>Misskey</b>を使ったサービス(Misskeyインスタンスと呼ばれます)のひとつです。"
monthAndDay: "{month}月 {day}日"
search: "検索"
notifications: "通知"
@ -348,6 +349,10 @@ recaptcha: "reCAPTCHA"
enableRecaptcha: "reCAPTCHAを有効にする"
recaptchaSiteKey: "サイトキー"
recaptchaSecretKey: "シークレットキー"
turnstile: "Turnstile"
enableTurnstile: "Turnstileを有効にする"
turnstileSiteKey: "サイトキー"
turnstileSecretKey: "シークレットキー"
avoidMultiCaptchaConfirm: "複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますかキャンセルして複数のCaptchaを有効化したままにすることも可能です。"
antennas: "アンテナ"
manageAntennas: "アンテナの管理"

View File

@ -1,35 +1,47 @@
{
"name": "misskey",
"version": "12.119.0",
"version": "12.120.0-alpha.8",
"codename": "indigo",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "yarn@3.2.1",
"workspaces": [
"packages/client",
"packages/backend",
"packages/sw"
],
"private": true,
"scripts": {
"postinstall": "node ./scripts/install-packages.js",
"build": "node ./scripts/build.js",
"start": "cd packages/backend && node --experimental-json-modules ./built/index.js",
"start:test": "cd packages/backend && cross-env NODE_ENV=test node --experimental-json-modules ./built/index.js",
"init": "npm run migrate",
"build": "yarn workspaces foreach run build && yarn run gulp",
"start": "cd packages/backend && node ./built/boot/index.js",
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/index.js",
"init": "yarn migrate",
"migrate": "cd packages/backend && npx typeorm migration:run -d ormconfig.js",
"migrateandstart": "npm run migrate && npm run start",
"migrateandstart": "yarn migrate && yarn start",
"gulp": "gulp build",
"watch": "npm run dev",
"watch": "yarn dev",
"dev": "node ./scripts/dev.js",
"lint": "node ./scripts/lint.js",
"lint": "yarn workspaces foreach run lint",
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "cypress run",
"e2e": "start-server-and-test start:test http://localhost:61812 cy:run",
"mocha": "cd packages/backend && cross-env NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" npx mocha",
"test": "npm run mocha",
"jest": "cd packages/backend && cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand",
"jest-and-coverage": "cd packages/backend && cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand",
"test": "yarn jest",
"test-and-coverage": "yarn jest-and-coverage",
"format": "gulp format",
"clean": "node ./scripts/clean.js",
"clean-all": "node ./scripts/clean-all.js",
"cleanall": "npm run clean-all"
"cleanall": "yarn clean-all"
},
"resolutions": {
"chokidar": "^3.3.1",
"lodash": "^4.17.21"
},
"dependencies": {
"eslint": "^8.16.0",
"execa": "5.1.1",
"gulp": "4.0.2",
"gulp-cssnano": "2.1.3",
@ -39,12 +51,13 @@
"js-yaml": "4.1.0"
},
"devDependencies": {
"@types/gulp": "4.0.9",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@typescript-eslint/parser": "5.36.2",
"@typescript-eslint/eslint-plugin": "latest",
"@typescript-eslint/parser": "5.43.0",
"cross-env": "7.0.3",
"cypress": "10.7.0",
"cypress": "11.1.0",
"start-server-and-test": "1.14.0",
"typescript": "4.8.3"
"typescript": "4.9.3"
}
}

View File

@ -0,0 +1,3 @@
{
"tsConfig": "./tsconfig.json"
}

View File

@ -1,10 +0,0 @@
{
"extension": ["ts","js","cjs","mjs"],
"node-option": [
"experimental-specifier-resolution=node",
"loader=./test/loader.js"
],
"slow": 1000,
"timeout": 30000,
"exit": true
}

View File

@ -1,2 +0,0 @@
save-exact = true
package-lock = false

15
packages/backend/.swcrc Normal file
View File

@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"parser": {
"syntax": "typescript",
"dynamicImport": true,
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
}
},
"minify": false
}

View File

@ -1 +0,0 @@
network-timeout 600000

View File

@ -0,0 +1,14 @@
// https://github.com/facebook/jest/issues/12270#issuecomment-1194746382
const nativeModule = require('node:module');
function resolver(module, options) {
const { basedir, defaultResolver } = options;
try {
return defaultResolver(module, options);
} catch (error) {
return nativeModule.createRequire(basedir).resolve(module);
}
}
module.exports = resolver;

View File

@ -0,0 +1,202 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/en/configuration.html
*/
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "C:\\Users\\ai\\AppData\\Local\\Temp\\jest",
// Automatically clear mock calls and instances between every test
// clearMocks: false,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: ['src/**/*.ts'],
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "\\\\node_modules\\\\"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
globals: {
},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
"^@/(.*?).js": "<rootDir>/src/$1.ts",
'^(\\.{1,2}/.*)\\.js$': '$1',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
//preset: "ts-jest/presets/js-with-ts-esm",
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
resolver: './jest-resolver.cjs',
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
roots: [
"<rootDir>"
],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: "node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
testMatch: [
"<rootDir>/test/unit/**/*.ts",
//"<rootDir>/test/e2e/**/*.ts"
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "\\\\node_modules\\\\"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
transform: {
"^.+\\.(t|j)sx?$": ["@swc/jest"],
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "\\\\node_modules\\\\",
// "\\.pnp\\.[^\\\\]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
extensionsToTreatAsEsm: ['.ts'],
};

View File

@ -0,0 +1,15 @@
export class turnstile1664694635394 {
name = 'turnstile1664694635394'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableTurnstile" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "meta" ADD "turnstileSiteKey" character varying(64)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "turnstileSecretKey" character varying(64)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "turnstileSecretKey"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "turnstileSiteKey"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTurnstile"`);
}
}

View File

@ -1,6 +1,8 @@
import { DataSource } from 'typeorm';
import config from './built/config/index.js';
import { entities } from './built/db/postgre.js';
import { loadConfig } from './built/config.js';
import { entities } from './built/postgre.js';
const config = loadConfig();
export default new DataSource({
type: 'postgres',

View File

@ -1,64 +1,74 @@
{
"name": "backend",
"main": "./index.js",
"private": true,
"type": "module",
"scripts": {
"start": "node --experimental-json-modules ./built/index.js",
"start:test": "NODE_ENV=test node --experimental-json-modules ./built/index.js",
"migrate": "typeorm migration:run -d ormconfig.js",
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs",
"lint": "eslint --quiet \"src/**/*.ts\"",
"mocha": "cross-env NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" mocha",
"test": "npm run mocha"
},
"resolutions": {
"chokidar": "^3.3.1",
"lodash": "^4.17.21"
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand",
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"test": "yarn jest",
"test-and-coverage": "yarn jest-and-coverage"
},
"optionalDependencies": {
"@tensorflow/tfjs-node": "3.20.0"
"@tensorflow/tfjs": "^4.0.0",
"@tensorflow/tfjs-node": "4.0.0"
},
"dependencies": {
"@bull-board/koa": "4.2.2",
"@bull-board/api": "4.3.1",
"@bull-board/koa": "4.3.1",
"@bull-board/ui": "4.3.1",
"@discordapp/twemoji": "14.0.2",
"@elastic/elasticsearch": "7.11.0",
"@koa/cors": "3.1.0",
"@elastic/elasticsearch": "7.17.0",
"@koa/cors": "3.3.0",
"@koa/multer": "3.0.0",
"@koa/router": "9.0.1",
"@nestjs/common": "9.2.0",
"@nestjs/core": "9.2.0",
"@nestjs/testing": "9.2.0",
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "9.1.2",
"@sinonjs/fake-timers": "10.0.0",
"@syuilo/aiscript": "0.11.1",
"ajv": "8.11.0",
"ajv": "8.11.2",
"archiver": "5.3.1",
"autobind-decorator": "2.4.0",
"autwh": "0.1.0",
"aws-sdk": "2.1213.0",
"aws-sdk": "2.1258.0",
"bcryptjs": "2.4.3",
"blurhash": "1.1.5",
"bull": "4.9.0",
"blurhash": "2.0.4",
"bull": "4.10.1",
"cacheable-lookup": "6.1.0",
"cbor": "8.1.0",
"chalk": "5.0.1",
"chalk": "5.1.2",
"chalk-template": "0.4.0",
"chokidar": "3.5.3",
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
"date-fns": "2.29.2",
"date-fns": "2.29.3",
"deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
"feed": "4.2.2",
"file-type": "17.1.6",
"file-type": "18.0.0",
"fluent-ffmpeg": "2.1.2",
"got": "12.3.1",
"hpagent": "0.1.2",
"form-data": "^4.0.0",
"got": "12.5.3",
"hpagent": "1.2.0",
"ioredis": "4.28.5",
"ip-cidr": "3.0.10",
"ip-cidr": "3.0.11",
"is-svg": "4.3.2",
"js-yaml": "4.1.0",
"jsdom": "20.0.0",
"jsdom": "20.0.2",
"json5": "2.2.1",
"json5-loader": "4.0.1",
"jsonld": "6.0.0",
"jsrsasign": "10.5.27",
"jsonld": "8.1.0",
"jsrsasign": "10.6.0",
"koa": "2.13.4",
"koa-bodyparser": "4.3.0",
"koa-favicon": "2.1.0",
@ -71,17 +81,17 @@
"mfm-js": "0.23.0",
"mime-types": "2.1.35",
"misskey-js": "0.0.14",
"mocha": "10.0.0",
"ms": "3.0.0-canary.1",
"multer": "1.4.4",
"nested-property": "4.0.0",
"node-fetch": "3.2.10",
"nodemailer": "6.7.8",
"node-fetch": "3.3.0",
"nodemailer": "6.8.0",
"nsfwjs": "2.4.2",
"oauth": "^0.10.0",
"os-utils": "0.0.14",
"parse5": "7.1.1",
"pg": "8.8.0",
"private-ip": "2.3.4",
"private-ip": "3.0.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"pug": "3.0.2",
@ -96,83 +106,93 @@
"rename": "1.0.4",
"rndstr": "1.0.0",
"rss-parser": "3.12.0",
"rxjs": "7.5.7",
"s-age": "1.1.2",
"sanitize-html": "2.7.1",
"semver": "7.3.7",
"sanitize-html": "2.7.3",
"seedrandom": "^3.0.5",
"semver": "7.3.8",
"sharp": "0.29.3",
"speakeasy": "2.0.0",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "2.7.0",
"syslog-pro": "1.0.0",
"systeminformation": "5.12.6",
"systeminformation": "5.13.5",
"tinycolor2": "1.4.2",
"tmp": "0.2.1",
"ts-loader": "9.3.1",
"ts-loader": "9.4.1",
"ts-node": "10.9.1",
"tsc-alias": "1.7.0",
"tsc-alias": "1.7.1",
"tsconfig-paths": "4.1.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.9",
"typeorm": "0.3.10",
"ulid": "2.3.0",
"unzipper": "0.10.11",
"uuid": "9.0.0",
"web-push": "3.5.0",
"websocket": "1.0.34",
"ws": "8.8.1",
"ws": "8.11.0",
"xev": "3.0.2"
},
"devDependencies": {
"@redocly/openapi-core": "1.0.0-beta.108",
"@redocly/openapi-core": "1.0.0-beta.114",
"@swc/core": "1.3.18",
"@swc/jest": "0.2.23",
"@types/archiver": "5.3.1",
"@types/bcryptjs": "2.4.2",
"@types/bull": "3.15.9",
"@types/bull": "4.10.0",
"@types/cbor": "6.0.0",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.20",
"@types/jest": "29.2.3",
"@types/js-yaml": "4.0.5",
"@types/jsdom": "20.0.0",
"@types/jsonld": "1.5.6",
"@types/jsrsasign": "10.5.2",
"@types/jsdom": "20.0.1",
"@types/jsonld": "1.5.7",
"@types/jsrsasign": "10.5.4",
"@types/koa": "2.13.5",
"@types/koa-bodyparser": "4.3.7",
"@types/koa-bodyparser": "4.3.8",
"@types/koa-cors": "0.0.2",
"@types/koa-favicon": "2.0.21",
"@types/koa-logger": "3.1.2",
"@types/koa-mount": "4.0.1",
"@types/koa-send": "4.1.3",
"@types/koa-views": "7.0.0",
"@types/koa__cors": "3.1.1",
"@types/koa__cors": "3.3.0",
"@types/koa__multer": "2.0.4",
"@types/koa__router": "8.0.11",
"@types/mocha": "9.1.1",
"@types/node": "18.7.16",
"@types/mime-types": "2.1.1",
"@types/node": "18.11.9",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.5",
"@types/nodemailer": "6.4.6",
"@types/oauth": "0.9.1",
"@types/pg": "8.6.5",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.0",
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.3",
"@types/ratelimiter": "3.4.4",
"@types/redis": "4.0.11",
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.6.2",
"@types/semver": "7.3.12",
"@types/sharp": "0.30.5",
"@types/semver": "7.3.13",
"@types/sharp": "0.31.0",
"@types/sinonjs__fake-timers": "8.1.2",
"@types/speakeasy": "2.0.7",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "0.10.5",
"@types/uuid": "8.3.4",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.3",
"@typescript-eslint/eslint-plugin": "5.36.2",
"@typescript-eslint/parser": "5.36.2",
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",
"cross-env": "7.0.3",
"eslint": "8.23.0",
"eslint": "8.28.0",
"eslint-plugin-import": "2.26.0",
"execa": "6.1.0",
"typescript": "4.8.3"
"jest": "29.3.1",
"jest-mock": "^29.0.3",
"typescript": "4.9.3"
}
}

View File

@ -1,5 +1,5 @@
declare module '@peertube/http-signature' {
import { IncomingMessage, ClientRequest } from 'node:http';
import type { IncomingMessage, ClientRequest } from 'node:http';
interface ISignature {
keyId: string;

View File

@ -1,5 +1,5 @@
declare module 'koa-json-body' {
import { Middleware } from 'koa';
import type { Middleware } from 'koa';
interface IKoaJsonBodyOptions {
strict: boolean;

View File

@ -1,5 +1,5 @@
declare module 'koa-slow' {
import { Middleware } from 'koa';
import type { Middleware } from 'koa';
interface ISlowOptions {
url?: RegExp;

View File

@ -1,5 +1,5 @@
declare module 'probe-image-size' {
import { ReadStream } from 'node:fs';
import type { ReadStream } from 'node:fs';
type ProbeOptions = {
retries: 1;

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ServerModule } from '@/server/ServerModule.js';
import { GlobalModule } from '@/GlobalModule.js';
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
@Module({
imports: [
GlobalModule,
ServerModule,
QueueProcessorModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,66 @@
import { Global, Inject, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { DataSource } from 'typeorm';
import { createRedisConnection } from '@/redis.js';
import { DI } from './di-symbols.js';
import { loadConfig } from './config.js';
import { createPostgreDataSource } from './postgre.js';
import { RepositoryModule } from './RepositoryModule.js';
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
const config = loadConfig();
const $config: Provider = {
provide: DI.config,
useValue: config,
};
const $db: Provider = {
provide: DI.db,
useFactory: async (config) => {
const db = createPostgreDataSource(config);
return await db.initialize();
},
inject: [DI.config],
};
const $redis: Provider = {
provide: DI.redis,
useFactory: (config) => {
const redisClient = createRedisConnection(config);
return redisClient;
},
inject: [DI.config],
};
const $redisSubscriber: Provider = {
provide: DI.redisSubscriber,
useFactory: (config) => {
const redisSubscriber = createRedisConnection(config);
redisSubscriber.subscribe(config.host);
return redisSubscriber;
},
inject: [DI.config],
};
@Global()
@Module({
imports: [RepositoryModule],
providers: [$config, $db, $redis, $redisSubscriber],
exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule],
})
export class GlobalModule implements OnApplicationShutdown {
constructor(
@Inject(DI.db) private db: DataSource,
@Inject(DI.redis) private redisClient: Redis.Redis,
@Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis,
) {}
async onApplicationShutdown(signal: string): Promise<void> {
await Promise.all([
this.db.destroy(),
this.redisClient.disconnect(),
this.redisSubscriber.disconnect(),
]);
}
}

View File

@ -0,0 +1,519 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest } from './models/index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
const $usersRepository: Provider = {
provide: DI.usersRepository,
useFactory: (db: DataSource) => db.getRepository(User),
inject: [DI.db],
};
const $notesRepository: Provider = {
provide: DI.notesRepository,
useFactory: (db: DataSource) => db.getRepository(Note),
inject: [DI.db],
};
const $announcementsRepository: Provider = {
provide: DI.announcementsRepository,
useFactory: (db: DataSource) => db.getRepository(Announcement),
inject: [DI.db],
};
const $announcementReadsRepository: Provider = {
provide: DI.announcementReadsRepository,
useFactory: (db: DataSource) => db.getRepository(AnnouncementRead),
inject: [DI.db],
};
const $appsRepository: Provider = {
provide: DI.appsRepository,
useFactory: (db: DataSource) => db.getRepository(App),
inject: [DI.db],
};
const $noteFavoritesRepository: Provider = {
provide: DI.noteFavoritesRepository,
useFactory: (db: DataSource) => db.getRepository(NoteFavorite),
inject: [DI.db],
};
const $noteThreadMutingsRepository: Provider = {
provide: DI.noteThreadMutingsRepository,
useFactory: (db: DataSource) => db.getRepository(NoteThreadMuting),
inject: [DI.db],
};
const $noteReactionsRepository: Provider = {
provide: DI.noteReactionsRepository,
useFactory: (db: DataSource) => db.getRepository(NoteReaction),
inject: [DI.db],
};
const $noteUnreadsRepository: Provider = {
provide: DI.noteUnreadsRepository,
useFactory: (db: DataSource) => db.getRepository(NoteUnread),
inject: [DI.db],
};
const $pollsRepository: Provider = {
provide: DI.pollsRepository,
useFactory: (db: DataSource) => db.getRepository(Poll),
inject: [DI.db],
};
const $pollVotesRepository: Provider = {
provide: DI.pollVotesRepository,
useFactory: (db: DataSource) => db.getRepository(PollVote),
inject: [DI.db],
};
const $userProfilesRepository: Provider = {
provide: DI.userProfilesRepository,
useFactory: (db: DataSource) => db.getRepository(UserProfile),
inject: [DI.db],
};
const $userKeypairsRepository: Provider = {
provide: DI.userKeypairsRepository,
useFactory: (db: DataSource) => db.getRepository(UserKeypair),
inject: [DI.db],
};
const $userPendingsRepository: Provider = {
provide: DI.userPendingsRepository,
useFactory: (db: DataSource) => db.getRepository(UserPending),
inject: [DI.db],
};
const $attestationChallengesRepository: Provider = {
provide: DI.attestationChallengesRepository,
useFactory: (db: DataSource) => db.getRepository(AttestationChallenge),
inject: [DI.db],
};
const $userSecurityKeysRepository: Provider = {
provide: DI.userSecurityKeysRepository,
useFactory: (db: DataSource) => db.getRepository(UserSecurityKey),
inject: [DI.db],
};
const $userPublickeysRepository: Provider = {
provide: DI.userPublickeysRepository,
useFactory: (db: DataSource) => db.getRepository(UserPublickey),
inject: [DI.db],
};
const $userListsRepository: Provider = {
provide: DI.userListsRepository,
useFactory: (db: DataSource) => db.getRepository(UserList),
inject: [DI.db],
};
const $userListJoiningsRepository: Provider = {
provide: DI.userListJoiningsRepository,
useFactory: (db: DataSource) => db.getRepository(UserListJoining),
inject: [DI.db],
};
const $userGroupsRepository: Provider = {
provide: DI.userGroupsRepository,
useFactory: (db: DataSource) => db.getRepository(UserGroup),
inject: [DI.db],
};
const $userGroupJoiningsRepository: Provider = {
provide: DI.userGroupJoiningsRepository,
useFactory: (db: DataSource) => db.getRepository(UserGroupJoining),
inject: [DI.db],
};
const $userGroupInvitationsRepository: Provider = {
provide: DI.userGroupInvitationsRepository,
useFactory: (db: DataSource) => db.getRepository(UserGroupInvitation),
inject: [DI.db],
};
const $userNotePiningsRepository: Provider = {
provide: DI.userNotePiningsRepository,
useFactory: (db: DataSource) => db.getRepository(UserNotePining),
inject: [DI.db],
};
const $userIpsRepository: Provider = {
provide: DI.userIpsRepository,
useFactory: (db: DataSource) => db.getRepository(UserIp),
inject: [DI.db],
};
const $usedUsernamesRepository: Provider = {
provide: DI.usedUsernamesRepository,
useFactory: (db: DataSource) => db.getRepository(UsedUsername),
inject: [DI.db],
};
const $followingsRepository: Provider = {
provide: DI.followingsRepository,
useFactory: (db: DataSource) => db.getRepository(Following),
inject: [DI.db],
};
const $followRequestsRepository: Provider = {
provide: DI.followRequestsRepository,
useFactory: (db: DataSource) => db.getRepository(FollowRequest),
inject: [DI.db],
};
const $instancesRepository: Provider = {
provide: DI.instancesRepository,
useFactory: (db: DataSource) => db.getRepository(Instance),
inject: [DI.db],
};
const $emojisRepository: Provider = {
provide: DI.emojisRepository,
useFactory: (db: DataSource) => db.getRepository(Emoji),
inject: [DI.db],
};
const $driveFilesRepository: Provider = {
provide: DI.driveFilesRepository,
useFactory: (db: DataSource) => db.getRepository(DriveFile),
inject: [DI.db],
};
const $driveFoldersRepository: Provider = {
provide: DI.driveFoldersRepository,
useFactory: (db: DataSource) => db.getRepository(DriveFolder),
inject: [DI.db],
};
const $notificationsRepository: Provider = {
provide: DI.notificationsRepository,
useFactory: (db: DataSource) => db.getRepository(Notification),
inject: [DI.db],
};
const $metasRepository: Provider = {
provide: DI.metasRepository,
useFactory: (db: DataSource) => db.getRepository(Meta),
inject: [DI.db],
};
const $mutingsRepository: Provider = {
provide: DI.mutingsRepository,
useFactory: (db: DataSource) => db.getRepository(Muting),
inject: [DI.db],
};
const $blockingsRepository: Provider = {
provide: DI.blockingsRepository,
useFactory: (db: DataSource) => db.getRepository(Blocking),
inject: [DI.db],
};
const $swSubscriptionsRepository: Provider = {
provide: DI.swSubscriptionsRepository,
useFactory: (db: DataSource) => db.getRepository(SwSubscription),
inject: [DI.db],
};
const $hashtagsRepository: Provider = {
provide: DI.hashtagsRepository,
useFactory: (db: DataSource) => db.getRepository(Hashtag),
inject: [DI.db],
};
const $abuseUserReportsRepository: Provider = {
provide: DI.abuseUserReportsRepository,
useFactory: (db: DataSource) => db.getRepository(AbuseUserReport),
inject: [DI.db],
};
const $registrationTicketsRepository: Provider = {
provide: DI.registrationTicketsRepository,
useFactory: (db: DataSource) => db.getRepository(RegistrationTicket),
inject: [DI.db],
};
const $authSessionsRepository: Provider = {
provide: DI.authSessionsRepository,
useFactory: (db: DataSource) => db.getRepository(AuthSession),
inject: [DI.db],
};
const $accessTokensRepository: Provider = {
provide: DI.accessTokensRepository,
useFactory: (db: DataSource) => db.getRepository(AccessToken),
inject: [DI.db],
};
const $signinsRepository: Provider = {
provide: DI.signinsRepository,
useFactory: (db: DataSource) => db.getRepository(Signin),
inject: [DI.db],
};
const $messagingMessagesRepository: Provider = {
provide: DI.messagingMessagesRepository,
useFactory: (db: DataSource) => db.getRepository(MessagingMessage),
inject: [DI.db],
};
const $pagesRepository: Provider = {
provide: DI.pagesRepository,
useFactory: (db: DataSource) => db.getRepository(Page),
inject: [DI.db],
};
const $pageLikesRepository: Provider = {
provide: DI.pageLikesRepository,
useFactory: (db: DataSource) => db.getRepository(PageLike),
inject: [DI.db],
};
const $galleryPostsRepository: Provider = {
provide: DI.galleryPostsRepository,
useFactory: (db: DataSource) => db.getRepository(GalleryPost),
inject: [DI.db],
};
const $galleryLikesRepository: Provider = {
provide: DI.galleryLikesRepository,
useFactory: (db: DataSource) => db.getRepository(GalleryLike),
inject: [DI.db],
};
const $moderationLogsRepository: Provider = {
provide: DI.moderationLogsRepository,
useFactory: (db: DataSource) => db.getRepository(ModerationLog),
inject: [DI.db],
};
const $clipsRepository: Provider = {
provide: DI.clipsRepository,
useFactory: (db: DataSource) => db.getRepository(Clip),
inject: [DI.db],
};
const $clipNotesRepository: Provider = {
provide: DI.clipNotesRepository,
useFactory: (db: DataSource) => db.getRepository(ClipNote),
inject: [DI.db],
};
const $antennasRepository: Provider = {
provide: DI.antennasRepository,
useFactory: (db: DataSource) => db.getRepository(Antenna),
inject: [DI.db],
};
const $antennaNotesRepository: Provider = {
provide: DI.antennaNotesRepository,
useFactory: (db: DataSource) => db.getRepository(AntennaNote),
inject: [DI.db],
};
const $promoNotesRepository: Provider = {
provide: DI.promoNotesRepository,
useFactory: (db: DataSource) => db.getRepository(PromoNote),
inject: [DI.db],
};
const $promoReadsRepository: Provider = {
provide: DI.promoReadsRepository,
useFactory: (db: DataSource) => db.getRepository(PromoRead),
inject: [DI.db],
};
const $relaysRepository: Provider = {
provide: DI.relaysRepository,
useFactory: (db: DataSource) => db.getRepository(Relay),
inject: [DI.db],
};
const $mutedNotesRepository: Provider = {
provide: DI.mutedNotesRepository,
useFactory: (db: DataSource) => db.getRepository(MutedNote),
inject: [DI.db],
};
const $channelsRepository: Provider = {
provide: DI.channelsRepository,
useFactory: (db: DataSource) => db.getRepository(Channel),
inject: [DI.db],
};
const $channelFollowingsRepository: Provider = {
provide: DI.channelFollowingsRepository,
useFactory: (db: DataSource) => db.getRepository(ChannelFollowing),
inject: [DI.db],
};
const $channelNotePiningsRepository: Provider = {
provide: DI.channelNotePiningsRepository,
useFactory: (db: DataSource) => db.getRepository(ChannelNotePining),
inject: [DI.db],
};
const $registryItemsRepository: Provider = {
provide: DI.registryItemsRepository,
useFactory: (db: DataSource) => db.getRepository(RegistryItem),
inject: [DI.db],
};
const $webhooksRepository: Provider = {
provide: DI.webhooksRepository,
useFactory: (db: DataSource) => db.getRepository(Webhook),
inject: [DI.db],
};
const $adsRepository: Provider = {
provide: DI.adsRepository,
useFactory: (db: DataSource) => db.getRepository(Ad),
inject: [DI.db],
};
const $passwordResetRequestsRepository: Provider = {
provide: DI.passwordResetRequestsRepository,
useFactory: (db: DataSource) => db.getRepository(PasswordResetRequest),
inject: [DI.db],
};
@Module({
imports: [
],
providers: [
$usersRepository,
$notesRepository,
$announcementsRepository,
$announcementReadsRepository,
$appsRepository,
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
$noteUnreadsRepository,
$pollsRepository,
$pollVotesRepository,
$userProfilesRepository,
$userKeypairsRepository,
$userPendingsRepository,
$attestationChallengesRepository,
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
$userListJoiningsRepository,
$userGroupsRepository,
$userGroupJoiningsRepository,
$userGroupInvitationsRepository,
$userNotePiningsRepository,
$userIpsRepository,
$usedUsernamesRepository,
$followingsRepository,
$followRequestsRepository,
$instancesRepository,
$emojisRepository,
$driveFilesRepository,
$driveFoldersRepository,
$notificationsRepository,
$metasRepository,
$mutingsRepository,
$blockingsRepository,
$swSubscriptionsRepository,
$hashtagsRepository,
$abuseUserReportsRepository,
$registrationTicketsRepository,
$authSessionsRepository,
$accessTokensRepository,
$signinsRepository,
$messagingMessagesRepository,
$pagesRepository,
$pageLikesRepository,
$galleryPostsRepository,
$galleryLikesRepository,
$moderationLogsRepository,
$clipsRepository,
$clipNotesRepository,
$antennasRepository,
$antennaNotesRepository,
$promoNotesRepository,
$promoReadsRepository,
$relaysRepository,
$mutedNotesRepository,
$channelsRepository,
$channelFollowingsRepository,
$channelNotePiningsRepository,
$registryItemsRepository,
$webhooksRepository,
$adsRepository,
$passwordResetRequestsRepository,
],
exports: [
$usersRepository,
$notesRepository,
$announcementsRepository,
$announcementReadsRepository,
$appsRepository,
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
$noteUnreadsRepository,
$pollsRepository,
$pollVotesRepository,
$userProfilesRepository,
$userKeypairsRepository,
$userPendingsRepository,
$attestationChallengesRepository,
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
$userListJoiningsRepository,
$userGroupsRepository,
$userGroupJoiningsRepository,
$userGroupInvitationsRepository,
$userNotePiningsRepository,
$userIpsRepository,
$usedUsernamesRepository,
$followingsRepository,
$followRequestsRepository,
$instancesRepository,
$emojisRepository,
$driveFilesRepository,
$driveFoldersRepository,
$notificationsRepository,
$metasRepository,
$mutingsRepository,
$blockingsRepository,
$swSubscriptionsRepository,
$hashtagsRepository,
$abuseUserReportsRepository,
$registrationTicketsRepository,
$authSessionsRepository,
$accessTokensRepository,
$signinsRepository,
$messagingMessagesRepository,
$pagesRepository,
$pageLikesRepository,
$galleryPostsRepository,
$galleryLikesRepository,
$moderationLogsRepository,
$clipsRepository,
$clipNotesRepository,
$antennasRepository,
$antennaNotesRepository,
$promoNotesRepository,
$promoReadsRepository,
$relaysRepository,
$mutedNotesRepository,
$channelsRepository,
$channelFollowingsRepository,
$channelNotePiningsRepository,
$registryItemsRepository,
$webhooksRepository,
$adsRepository,
$passwordResetRequestsRepository,
],
})
export class RepositoryModule {}

View File

@ -1,44 +1,28 @@
/**
* Misskey Entry Point!
*/
import cluster from 'node:cluster';
import { EventEmitter } from 'node:events';
import chalk from 'chalk';
import Xev from 'xev';
import Logger from '@/services/logger.js';
import Logger from '@/logger.js';
import { envOption } from '../env.js';
// for typeorm
import 'reflect-metadata';
import { masterMain } from './master.js';
import { workerMain } from './worker.js';
import 'reflect-metadata';
process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`;
Error.stackTraceLimit = Infinity;
EventEmitter.defaultMaxListeners = 128;
const logger = new Logger('core', 'cyan');
const clusterLogger = logger.createSubLogger('cluster', 'orange', false);
const ev = new Xev();
/**
* Init process
*/
export default async function() {
process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`;
if (cluster.isPrimary || envOption.disableClustering) {
await masterMain();
if (cluster.isPrimary) {
ev.mount();
}
}
if (cluster.isWorker || envOption.disableClustering) {
await workerMain();
}
// ユニットテスト時にMisskeyが子プロセスで起動された時のため
// それ以外のときは process.send は使えないので弾く
if (process.send) {
process.send('ok');
}
}
//#region Events
// Listen new workers
@ -77,3 +61,21 @@ process.on('exit', code => {
});
//#endregion
if (cluster.isPrimary || envOption.disableClustering) {
await masterMain();
if (cluster.isPrimary) {
ev.mount();
}
}
if (cluster.isWorker || envOption.disableClustering) {
await workerMain();
}
// ユニットテスト時にMisskeyが子プロセスで起動された時のため
// それ以外のときは process.send は使えないので弾く
if (process.send) {
process.send('ok');
}

View File

@ -6,14 +6,17 @@ import cluster from 'node:cluster';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import semver from 'semver';
import Logger from '@/services/logger.js';
import loadConfig from '@/config/load.js';
import { Config } from '@/config/types.js';
import { lessThan } from '@/prelude/array.js';
import { envOption } from '../env.js';
import { NestFactory } from '@nestjs/core';
import Logger from '@/logger.js';
import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
import { lessThan } from '@/misc/prelude/array.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { db, initDb } from '../db/postgre.js';
import { DaemonModule } from '@/daemons/DaemonModule.js';
import { JanitorService } from '@/daemons/JanitorService.js';
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
import { envOption } from '../env.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@ -60,7 +63,7 @@ export async function masterMain() {
await showMachineInfo(bootLogger);
showNodejsVersion();
config = loadConfigBoot();
await connectDb();
//await connectDb();
} catch (e) {
bootLogger.error('Fatal error occurred during initialization', null, true);
process.exit(1);
@ -75,9 +78,11 @@ export async function masterMain() {
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
if (!envOption.noDaemons) {
import('../daemons/server-stats.js').then(x => x.default());
import('../daemons/queue-stats.js').then(x => x.default());
import('../daemons/janitor.js').then(x => x.default());
const daemons = await NestFactory.createApplicationContext(DaemonModule);
daemons.enableShutdownHooks();
daemons.get(JanitorService).start();
daemons.get(QueueStatsService).start();
daemons.get(ServerStatsService).start();
}
}
@ -114,8 +119,7 @@ function loadConfigBoot(): Config {
if (typeof exception === 'string') {
configLogger.error(exception);
process.exit(1);
}
if (exception.code === 'ENOENT') {
} else if ((exception as any).code === 'ENOENT') {
configLogger.error('Configuration file not found', null, true);
process.exit(1);
}
@ -127,6 +131,7 @@ function loadConfigBoot(): Config {
return config;
}
/*
async function connectDb(): Promise<void> {
const dbLogger = bootLogger.createSubLogger('db');
@ -136,14 +141,15 @@ async function connectDb(): Promise<void> {
await initDb();
const v = await db.query('SHOW server_version').then(x => x[0].server_version);
dbLogger.succ(`Connected: v${v}`);
} catch (e) {
} catch (err) {
dbLogger.error('Cannot connect', null, true);
dbLogger.error(e);
dbLogger.error(err);
process.exit(1);
}
}
*/
async function spawnWorkers(limit: number = 1) {
async function spawnWorkers(limit = 1) {
const workers = Math.min(limit, os.cpus().length);
bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`);
await Promise.all([...Array(workers)].map(spawnWorker));
@ -155,7 +161,7 @@ function spawnWorker(): Promise<void> {
const worker = cluster.fork();
worker.on('message', message => {
if (message === 'listenFailed') {
bootLogger.error(`The server Listen failed due to the previous error.`);
bootLogger.error('The server Listen failed due to the previous error.');
process.exit(1);
}
if (message !== 'ready') return;

View File

@ -1,17 +1,29 @@
import cluster from 'node:cluster';
import { initDb } from '../db/postgre.js';
import { NestFactory } from '@nestjs/core';
import { envOption } from '@/env.js';
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { ServerService } from '@/server/ServerService.js';
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
import { AppModule } from '../AppModule.js';
/**
* Init worker process
*/
export async function workerMain() {
await initDb();
const app = await NestFactory.createApplicationContext(AppModule);
app.enableShutdownHooks();
// start server
await import('../server/index.js').then(x => x.default());
const serverService = app.get(ServerService);
serverService.launch();
// start job queue
import('../queue/index.js').then(x => x.default());
if (!envOption.onlyServer) {
const queueProcessorService = app.get(QueueProcessorService);
queueProcessorService.start();
}
app.get(ChartManagementService).run();
if (cluster.isWorker) {
// Send a 'ready' message to parent process

View File

@ -0,0 +1,149 @@
/**
* Config loader
*/
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as yaml from 'js-yaml';
/**
*
*/
export type Source = {
repository_url?: string;
feedback_url?: string;
url: string;
port: number;
disableHsts?: boolean;
db: {
host: string;
port: number;
db: string;
user: string;
pass: string;
disableCache?: boolean;
extra?: { [x: string]: string };
};
redis: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
elasticsearch: {
host: string;
port: number;
ssl?: boolean;
user?: string;
pass?: string;
index?: string;
};
proxy?: string;
proxySmtp?: string;
proxyBypassHosts?: string[];
allowedPrivateNetworks?: string[];
maxFileSize?: number;
accesslog?: string;
clusterLimit?: number;
id: string;
outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual';
deliverJobConcurrency?: number;
inboxJobConcurrency?: number;
deliverJobPerSec?: number;
inboxJobPerSec?: number;
deliverJobMaxAttempts?: number;
inboxJobMaxAttempts?: number;
syslog: {
host: string;
port: number;
};
mediaProxy?: string;
proxyRemoteFiles?: boolean;
signToActivityPubGet?: boolean;
};
/**
* Misskeyが自動的に()
*/
export type Mixin = {
version: string;
host: string;
hostname: string;
scheme: string;
wsScheme: string;
apiUrl: string;
wsUrl: string;
authUrl: string;
driveUrl: string;
userAgent: string;
clientEntry: string;
};
export type Config = Source & Mixin;
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
/**
* Path of configuration directory
*/
const dir = `${_dirname}/../../../.config`;
/**
* Path of configuration file
*/
const path = process.env.NODE_ENV === 'test'
? `${dir}/test.yml`
: `${dir}/default.yml`;
export function loadConfig() {
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_client_dist_/manifest.json`, 'utf-8'));
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const mixin = {} as Mixin;
const url = tryCreateUrl(config.url);
config.url = url.origin;
config.port = config.port ?? parseInt(process.env.PORT ?? '', 10);
mixin.version = meta.version;
mixin.host = url.host;
mixin.hostname = url.hostname;
mixin.scheme = url.protocol.replace(/:$/, '');
mixin.wsScheme = mixin.scheme.replace('http', 'ws');
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
mixin.clientEntry = clientManifest['src/init.ts'];
if (!config.redis.prefix) config.redis.prefix = mixin.host;
return Object.assign(config, mixin);
}
function tryCreateUrl(url: string) {
try {
return new URL(url);
} catch (e) {
throw `url="${url}" is not a valid URL.`;
}
}

View File

@ -1,3 +0,0 @@
import load from './load.js';
export default load();

View File

@ -1,62 +0,0 @@
/**
* Config loader
*/
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as yaml from 'js-yaml';
import { Source, Mixin } from './types.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
/**
* Path of configuration directory
*/
const dir = `${_dirname}/../../../../.config`;
/**
* Path of configuration file
*/
const path = process.env.NODE_ENV === 'test'
? `${dir}/test.yml`
: `${dir}/default.yml`;
export default function load() {
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/_client_dist_/manifest.json`, 'utf-8'));
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const mixin = {} as Mixin;
const url = tryCreateUrl(config.url);
config.url = url.origin;
config.port = config.port || parseInt(process.env.PORT || '', 10);
mixin.version = meta.version;
mixin.host = url.host;
mixin.hostname = url.hostname;
mixin.scheme = url.protocol.replace(/:$/, '');
mixin.wsScheme = mixin.scheme.replace('http', 'ws');
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
mixin.clientEntry = clientManifest['src/init.ts'];
if (!config.redis.prefix) config.redis.prefix = mixin.host;
return Object.assign(config, mixin);
}
function tryCreateUrl(url: string) {
try {
return new URL(url);
} catch (e) {
throw `url="${url}" is not a valid URL.`;
}
}

View File

@ -1,87 +0,0 @@
/**
*
*/
export type Source = {
repository_url?: string;
feedback_url?: string;
url: string;
port: number;
disableHsts?: boolean;
db: {
host: string;
port: number;
db: string;
user: string;
pass: string;
disableCache?: boolean;
extra?: { [x: string]: string };
};
redis: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
elasticsearch: {
host: string;
port: number;
ssl?: boolean;
user?: string;
pass?: string;
index?: string;
};
proxy?: string;
proxySmtp?: string;
proxyBypassHosts?: string[];
allowedPrivateNetworks?: string[];
maxFileSize?: number;
accesslog?: string;
clusterLimit?: number;
id: string;
outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual';
deliverJobConcurrency?: number;
inboxJobConcurrency?: number;
deliverJobPerSec?: number;
inboxJobPerSec?: number;
deliverJobMaxAttempts?: number;
inboxJobMaxAttempts?: number;
syslog: {
host: string;
port: number;
};
mediaProxy?: string;
proxyRemoteFiles?: boolean;
signToActivityPubGet?: boolean;
};
/**
* Misskeyが自動的に()
*/
export type Mixin = {
version: string;
host: string;
hostname: string;
scheme: string;
wsScheme: string;
apiUrl: string;
wsUrl: string;
authUrl: string;
driveUrl: string;
userAgent: string;
clientEntry: string;
};
export type Config = Source & Mixin;

View File

@ -0,0 +1,38 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { User } from '@/models/entities/User.js';
import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js';
import { RelayService } from '@/core/RelayService.js';
import { ApDeliverManagerService } from '@/core/remote/activitypub/ApDeliverManagerService.js';
import { UserEntityService } from './entities/UserEntityService.js';
@Injectable()
export class AccountUpdateService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private userEntityService: UserEntityService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private relayService: RelayService,
) {
}
public async publishToFollowers(userId: User['id']) {
const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) throw new Error('user not found');
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content);
}
}
}

View File

@ -0,0 +1,60 @@
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common';
import * as nsfw from 'nsfwjs';
import si from 'systeminformation';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const REQUIRED_CPU_FLAGS = ['avx2', 'fma'];
let isSupportedCpu: undefined | boolean = undefined;
@Injectable()
export class AiService {
private model: nsfw.NSFWJS;
constructor(
@Inject(DI.config)
private config: Config,
) {
}
public async detectSensitive(path: string): Promise<nsfw.predictionType[] | null> {
try {
if (isSupportedCpu === undefined) {
const cpuFlags = await this.getCpuFlags();
isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required));
}
if (!isSupportedCpu) {
console.error('This CPU cannot use TensorFlow.');
return null;
}
const tf = await import('@tensorflow/tfjs-node');
if (this.model == null) this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
const buffer = await fs.promises.readFile(path);
const image = await tf.node.decodeImage(buffer, 3) as any;
try {
const predictions = await this.model.classify(image);
return predictions;
} finally {
image.dispose();
}
} catch (err) {
console.error(err);
return null;
}
}
private async getCpuFlags(): Promise<string[]> {
const str = await si.cpuFlags();
return str.split(/\s+/);
}
}

View File

@ -0,0 +1,228 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import type { Antenna } from '@/models/entities/Antenna.js';
import type { Note } from '@/models/entities/Note.js';
import type { User } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import * as Acct from '@/misc/acct.js';
import { Cache } from '@/misc/cache.js';
import type { Packed } from '@/misc/schema.js';
import { DI } from '@/di-symbols.js';
import type { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js';
import { UtilityService } from './UtilityService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class AntennaService implements OnApplicationShutdown {
private antennasFetched: boolean;
private antennas: Antenna[];
private blockingCache: Cache<User['id'][]>;
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.antennaNotesRepository)
private antennaNotesRepository: AntennaNotesRepository,
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.userGroupJoiningsRepository)
private userGroupJoiningsRepository: UserGroupJoiningsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
private utilityService: UtilityService,
private idService: IdService,
private globalEventServie: GlobalEventService,
) {
this.antennasFetched = false;
this.antennas = [];
this.blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
this.redisSubscriber.on('message', this.onRedisMessage);
}
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onRedisMessage);
}
private async onRedisMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message;
switch (type) {
case 'antennaCreated':
this.antennas.push(body);
break;
case 'antennaUpdated':
this.antennas[this.antennas.findIndex(a => a.id === body.id)] = body;
break;
case 'antennaDeleted':
this.antennas = this.antennas.filter(a => a.id !== body.id);
break;
default:
break;
}
}
}
public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> {
// 通知しない設定になっているか、自分自身の投稿なら既読にする
const read = !antenna.notify || (antenna.userId === noteUser.id);
this.antennaNotesRepository.insert({
id: this.idService.genId(),
antennaId: antenna.id,
noteId: note.id,
read: read,
});
this.globalEventServie.publishAntennaStream(antenna.id, 'note', note);
if (!read) {
const mutings = await this.mutingsRepository.find({
where: {
muterId: antenna.userId,
},
select: ['muteeId'],
});
// Copy
const _note: Note = {
...note,
};
if (note.replyId != null) {
_note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId });
}
if (note.renoteId != null) {
_note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
}
if (isUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) {
return;
}
// 2秒経っても既読にならなかったら通知
setTimeout(async () => {
const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false });
if (unread) {
this.globalEventServie.publishMainStream(antenna.userId, 'unreadAntenna', antenna);
}
}, 2000);
}
}
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
/**
* noteUserFollowers / antennaUserFollowing
*/
public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
if (note.visibility === 'specified') return false;
// アンテナ作成者がノート作成者にブロックされていたらスキップ
const blockings = await this.blockingCache.fetch(noteUser.id, () => this.blockingsRepository.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
if (blockings.some(blocking => blocking === antenna.userId)) return false;
if (note.visibility === 'followers') {
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
}
if (!antenna.withReplies && note.replyId != null) return false;
if (antenna.src === 'home') {
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
} else if (antenna.src === 'list') {
const listUsers = (await this.userListJoiningsRepository.findBy({
userListId: antenna.userListId!,
})).map(x => x.userId);
if (!listUsers.includes(note.userId)) return false;
} else if (antenna.src === 'group') {
const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! });
const groupUsers = (await this.userGroupJoiningsRepository.findBy({
userGroupId: joining.userGroupId,
})).map(x => x.userId);
if (!groupUsers.includes(note.userId)) return false;
} else if (antenna.src === 'users') {
const accts = antenna.users.map(x => {
const { username, host } = Acct.parse(x);
return this.utilityService.getFullApAccount(username, host).toLowerCase();
});
if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
}
const keywords = antenna.keywords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
if (keywords.length > 0) {
if (note.text == null) return false;
const matched = keywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
? note.text!.includes(keyword)
: note.text!.toLowerCase().includes(keyword.toLowerCase()),
));
if (!matched) return false;
}
const excludeKeywords = antenna.excludeKeywords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
if (excludeKeywords.length > 0) {
if (note.text == null) return false;
const matched = excludeKeywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
? note.text!.includes(keyword)
: note.text!.toLowerCase().includes(keyword.toLowerCase()),
));
if (matched) return false;
}
if (antenna.withFile) {
if (note.fileIds && note.fileIds.length === 0) return false;
}
// TODO: eval expression
return true;
}
public async getAntennas() {
if (!this.antennasFetched) {
this.antennas = await this.antennasRepository.find();
this.antennasFetched = true;
}
return this.antennas;
}
}

View File

@ -0,0 +1,40 @@
import { promisify } from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
import redisLock from 'redis-lock';
import Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
/**
* Retry delay (ms) for lock acquisition
*/
const retryDelay = 100;
@Injectable()
export class AppLockService {
private lock: (key: string, timeout?: number) => Promise<() => void>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
this.lock = promisify(redisLock(this.redisClient, retryDelay));
}
/**
* Get AP Object lock
* @param uri AP object ID
* @param timeout Lock timeout (ms), The timeout releases previous lock.
* @returns Unlock function
*/
public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`ap-object:${uri}`, timeout);
}
public getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`instance:${host}`, timeout);
}
public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`chart-insert:${lockKey}`, timeout);
}
}

View File

@ -0,0 +1,81 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from './HttpRequestService.js';
type CaptchaResponse = {
success: boolean;
'error-codes'?: string[];
};
@Injectable()
export class CaptchaService {
constructor(
@Inject(DI.config)
private config: Config,
private httpRequestService: HttpRequestService,
) {
}
private async getCaptchaResponse(url: string, secret: string, response: string): Promise<CaptchaResponse> {
const params = new URLSearchParams({
secret,
response,
});
const res = await fetch(url, {
method: 'POST',
body: params,
headers: {
'User-Agent': this.config.userAgent,
},
// TODO
//timeout: 10 * 1000,
agent: (url, bypassProxy) => this.httpRequestService.getAgentByUrl(url, bypassProxy),
}).catch(err => {
throw `${err.message ?? err}`;
});
if (!res.ok) {
throw `${res.status}`;
}
return await res.json() as CaptchaResponse;
}
public async verifyRecaptcha(secret: string, response: string): Promise<void> {
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => {
throw `recaptcha-request-failed: ${e}`;
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
throw `recaptcha-failed: ${errorCodes}`;
}
}
public async verifyHcaptcha(secret: string, response: string): Promise<void> {
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => {
throw `hcaptcha-request-failed: ${e}`;
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
throw `hcaptcha-failed: ${errorCodes}`;
}
}
public async verifyTurnstile(secret: string, response: string): Promise<void> {
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(e => {
throw `turnstile-request-failed: ${e}`;
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
throw `turnstile-failed: ${errorCodes}`;
}
}
}

View File

@ -0,0 +1,706 @@
import { Module } from '@nestjs/common';
import { DI } from '../di-symbols.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
import { AntennaService } from './AntennaService.js';
import { AppLockService } from './AppLockService.js';
import { CaptchaService } from './CaptchaService.js';
import { CreateNotificationService } from './CreateNotificationService.js';
import { CreateSystemUserService } from './CreateSystemUserService.js';
import { CustomEmojiService } from './CustomEmojiService.js';
import { DeleteAccountService } from './DeleteAccountService.js';
import { DownloadService } from './DownloadService.js';
import { DriveService } from './DriveService.js';
import { EmailService } from './EmailService.js';
import { FederatedInstanceService } from './FederatedInstanceService.js';
import { FetchInstanceMetadataService } from './FetchInstanceMetadataService.js';
import { GlobalEventService } from './GlobalEventService.js';
import { HashtagService } from './HashtagService.js';
import { HttpRequestService } from './HttpRequestService.js';
import { IdService } from './IdService.js';
import { ImageProcessingService } from './ImageProcessingService.js';
import { InstanceActorService } from './InstanceActorService.js';
import { InternalStorageService } from './InternalStorageService.js';
import { MessagingService } from './MessagingService.js';
import { MetaService } from './MetaService.js';
import { MfmService } from './MfmService.js';
import { ModerationLogService } from './ModerationLogService.js';
import { NoteCreateService } from './NoteCreateService.js';
import { NoteDeleteService } from './NoteDeleteService.js';
import { NotePiningService } from './NotePiningService.js';
import { NoteReadService } from './NoteReadService.js';
import { NotificationService } from './NotificationService.js';
import { PollService } from './PollService.js';
import { PushNotificationService } from './PushNotificationService.js';
import { QueryService } from './QueryService.js';
import { ReactionService } from './ReactionService.js';
import { RelayService } from './RelayService.js';
import { S3Service } from './S3Service.js';
import { SignupService } from './SignupService.js';
import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
import { UserBlockingService } from './UserBlockingService.js';
import { UserCacheService } from './UserCacheService.js';
import { UserFollowingService } from './UserFollowingService.js';
import { UserKeypairStoreService } from './UserKeypairStoreService.js';
import { UserListService } from './UserListService.js';
import { UserMutingService } from './UserMutingService.js';
import { UserSuspendService } from './UserSuspendService.js';
import { VideoProcessingService } from './VideoProcessingService.js';
import { WebhookService } from './WebhookService.js';
import { ProxyAccountService } from './ProxyAccountService.js';
import { UtilityService } from './UtilityService.js';
import { FileInfoService } from './FileInfoService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js';
import NotesChart from './chart/charts/notes.js';
import UsersChart from './chart/charts/users.js';
import ActiveUsersChart from './chart/charts/active-users.js';
import InstanceChart from './chart/charts/instance.js';
import PerUserNotesChart from './chart/charts/per-user-notes.js';
import DriveChart from './chart/charts/drive.js';
import PerUserReactionsChart from './chart/charts/per-user-reactions.js';
import HashtagChart from './chart/charts/hashtag.js';
import PerUserFollowingChart from './chart/charts/per-user-following.js';
import PerUserDriveChart from './chart/charts/per-user-drive.js';
import ApRequestChart from './chart/charts/ap-request.js';
import { ChartManagementService } from './chart/ChartManagementService.js';
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
import { AntennaEntityService } from './entities/AntennaEntityService.js';
import { AppEntityService } from './entities/AppEntityService.js';
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
import { BlockingEntityService } from './entities/BlockingEntityService.js';
import { ChannelEntityService } from './entities/ChannelEntityService.js';
import { ClipEntityService } from './entities/ClipEntityService.js';
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js';
import { EmojiEntityService } from './entities/EmojiEntityService.js';
import { FollowingEntityService } from './entities/FollowingEntityService.js';
import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js';
import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js';
import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js';
import { HashtagEntityService } from './entities/HashtagEntityService.js';
import { InstanceEntityService } from './entities/InstanceEntityService.js';
import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js';
import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
import { MutingEntityService } from './entities/MutingEntityService.js';
import { NoteEntityService } from './entities/NoteEntityService.js';
import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js';
import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js';
import { NotificationEntityService } from './entities/NotificationEntityService.js';
import { PageEntityService } from './entities/PageEntityService.js';
import { PageLikeEntityService } from './entities/PageLikeEntityService.js';
import { SigninEntityService } from './entities/SigninEntityService.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { UserGroupEntityService } from './entities/UserGroupEntityService.js';
import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js';
import { UserListEntityService } from './entities/UserListEntityService.js';
import { ApAudienceService } from './remote/activitypub/ApAudienceService.js';
import { ApDbResolverService } from './remote/activitypub/ApDbResolverService.js';
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
import { ApInboxService } from './remote/activitypub/ApInboxService.js';
import { ApLoggerService } from './remote/activitypub/ApLoggerService.js';
import { ApMfmService } from './remote/activitypub/ApMfmService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
import { ApRequestService } from './remote/activitypub/ApRequestService.js';
import { ApResolverService } from './remote/activitypub/ApResolverService.js';
import { LdSignatureService } from './remote/activitypub/LdSignatureService.js';
import { RemoteLoggerService } from './remote/RemoteLoggerService.js';
import { ResolveUserService } from './remote/ResolveUserService.js';
import { WebfingerService } from './remote/WebfingerService.js';
import { ApImageService } from './remote/activitypub/models/ApImageService.js';
import { ApMentionService } from './remote/activitypub/models/ApMentionService.js';
import { ApNoteService } from './remote/activitypub/models/ApNoteService.js';
import { ApPersonService } from './remote/activitypub/models/ApPersonService.js';
import { ApQuestionService } from './remote/activitypub/models/ApQuestionService.js';
import { QueueModule } from './queue/QueueModule.js';
import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js';
import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService };
const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService };
const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService };
const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService };
const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService };
const $DriveService: Provider = { provide: 'DriveService', useExisting: DriveService };
const $EmailService: Provider = { provide: 'EmailService', useExisting: EmailService };
const $FederatedInstanceService: Provider = { provide: 'FederatedInstanceService', useExisting: FederatedInstanceService };
const $FetchInstanceMetadataService: Provider = { provide: 'FetchInstanceMetadataService', useExisting: FetchInstanceMetadataService };
const $GlobalEventService: Provider = { provide: 'GlobalEventService', useExisting: GlobalEventService };
const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService };
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService };
const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
const $MessagingService: Provider = { provide: 'MessagingService', useExisting: MessagingService };
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService };
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService };
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService };
const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService };
const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService };
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService };
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart };
const $UsersChart: Provider = { provide: 'UsersChart', useExisting: UsersChart };
const $ActiveUsersChart: Provider = { provide: 'ActiveUsersChart', useExisting: ActiveUsersChart };
const $InstanceChart: Provider = { provide: 'InstanceChart', useExisting: InstanceChart };
const $PerUserNotesChart: Provider = { provide: 'PerUserNotesChart', useExisting: PerUserNotesChart };
const $DriveChart: Provider = { provide: 'DriveChart', useExisting: DriveChart };
const $PerUserReactionsChart: Provider = { provide: 'PerUserReactionsChart', useExisting: PerUserReactionsChart };
const $HashtagChart: Provider = { provide: 'HashtagChart', useExisting: HashtagChart };
const $PerUserFollowingChart: Provider = { provide: 'PerUserFollowingChart', useExisting: PerUserFollowingChart };
const $PerUserDriveChart: Provider = { provide: 'PerUserDriveChart', useExisting: PerUserDriveChart };
const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRequestChart };
const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService };
const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService };
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService };
const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService };
const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService };
const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService };
const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService };
const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService };
const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService };
const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService };
const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService };
const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService };
const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService };
const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService };
const $MessagingMessageEntityService: Provider = { provide: 'MessagingMessageEntityService', useExisting: MessagingMessageEntityService };
const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService };
const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService };
const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService };
const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService };
const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService };
const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useExisting: NotificationEntityService };
const $PageEntityService: Provider = { provide: 'PageEntityService', useExisting: PageEntityService };
const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useExisting: PageLikeEntityService };
const $SigninEntityService: Provider = { provide: 'SigninEntityService', useExisting: SigninEntityService };
const $UserEntityService: Provider = { provide: 'UserEntityService', useExisting: UserEntityService };
const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useExisting: UserGroupEntityService };
const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useExisting: UserGroupInvitationEntityService };
const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService };
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
const $ApDeliverManagerService: Provider = { provide: 'ApDeliverManagerService', useExisting: ApDeliverManagerService };
const $ApInboxService: Provider = { provide: 'ApInboxService', useExisting: ApInboxService };
const $ApLoggerService: Provider = { provide: 'ApLoggerService', useExisting: ApLoggerService };
const $ApMfmService: Provider = { provide: 'ApMfmService', useExisting: ApMfmService };
const $ApRendererService: Provider = { provide: 'ApRendererService', useExisting: ApRendererService };
const $ApRequestService: Provider = { provide: 'ApRequestService', useExisting: ApRequestService };
const $ApResolverService: Provider = { provide: 'ApResolverService', useExisting: ApResolverService };
const $LdSignatureService: Provider = { provide: 'LdSignatureService', useExisting: LdSignatureService };
const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useExisting: RemoteLoggerService };
const $ResolveUserService: Provider = { provide: 'ResolveUserService', useExisting: ResolveUserService };
const $WebfingerService: Provider = { provide: 'WebfingerService', useExisting: WebfingerService };
const $ApImageService: Provider = { provide: 'ApImageService', useExisting: ApImageService };
const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting: ApMentionService };
const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService };
const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService };
const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService };
//#endregion
@Module({
imports: [
QueueModule,
],
providers: [
LoggerService,
AccountUpdateService,
AiService,
AntennaService,
AppLockService,
CaptchaService,
CreateNotificationService,
CreateSystemUserService,
CustomEmojiService,
DeleteAccountService,
DownloadService,
DriveService,
EmailService,
FederatedInstanceService,
FetchInstanceMetadataService,
GlobalEventService,
HashtagService,
HttpRequestService,
IdService,
ImageProcessingService,
InstanceActorService,
InternalStorageService,
MessagingService,
MetaService,
MfmService,
ModerationLogService,
NoteCreateService,
NoteDeleteService,
NotePiningService,
NoteReadService,
NotificationService,
PollService,
ProxyAccountService,
PushNotificationService,
QueryService,
ReactionService,
RelayService,
S3Service,
SignupService,
TwoFactorAuthenticationService,
UserBlockingService,
UserCacheService,
UserFollowingService,
UserKeypairStoreService,
UserListService,
UserMutingService,
UserSuspendService,
VideoProcessingService,
WebhookService,
UtilityService,
FileInfoService,
ChartLoggerService,
FederationChart,
NotesChart,
UsersChart,
ActiveUsersChart,
InstanceChart,
PerUserNotesChart,
DriveChart,
PerUserReactionsChart,
HashtagChart,
PerUserFollowingChart,
PerUserDriveChart,
ApRequestChart,
ChartManagementService,
AbuseUserReportEntityService,
AntennaEntityService,
AppEntityService,
AuthSessionEntityService,
BlockingEntityService,
ChannelEntityService,
ClipEntityService,
DriveFileEntityService,
DriveFolderEntityService,
EmojiEntityService,
FollowingEntityService,
FollowRequestEntityService,
GalleryLikeEntityService,
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
MessagingMessageEntityService,
ModerationLogEntityService,
MutingEntityService,
NoteEntityService,
NoteFavoriteEntityService,
NoteReactionEntityService,
NotificationEntityService,
PageEntityService,
PageLikeEntityService,
SigninEntityService,
UserEntityService,
UserGroupEntityService,
UserGroupInvitationEntityService,
UserListEntityService,
ApAudienceService,
ApDbResolverService,
ApDeliverManagerService,
ApInboxService,
ApLoggerService,
ApMfmService,
ApRendererService,
ApRequestService,
ApResolverService,
LdSignatureService,
RemoteLoggerService,
ResolveUserService,
WebfingerService,
ApImageService,
ApMentionService,
ApNoteService,
ApPersonService,
ApQuestionService,
QueueService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
$AccountUpdateService,
$AiService,
$AntennaService,
$AppLockService,
$CaptchaService,
$CreateNotificationService,
$CreateSystemUserService,
$CustomEmojiService,
$DeleteAccountService,
$DownloadService,
$DriveService,
$EmailService,
$FederatedInstanceService,
$FetchInstanceMetadataService,
$GlobalEventService,
$HashtagService,
$HttpRequestService,
$IdService,
$ImageProcessingService,
$InstanceActorService,
$InternalStorageService,
$MessagingService,
$MetaService,
$MfmService,
$ModerationLogService,
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
$NoteReadService,
$NotificationService,
$PollService,
$ProxyAccountService,
$PushNotificationService,
$QueryService,
$ReactionService,
$RelayService,
$S3Service,
$SignupService,
$TwoFactorAuthenticationService,
$UserBlockingService,
$UserCacheService,
$UserFollowingService,
$UserKeypairStoreService,
$UserListService,
$UserMutingService,
$UserSuspendService,
$VideoProcessingService,
$WebhookService,
$UtilityService,
$FileInfoService,
$ChartLoggerService,
$FederationChart,
$NotesChart,
$UsersChart,
$ActiveUsersChart,
$InstanceChart,
$PerUserNotesChart,
$DriveChart,
$PerUserReactionsChart,
$HashtagChart,
$PerUserFollowingChart,
$PerUserDriveChart,
$ApRequestChart,
$ChartManagementService,
$AbuseUserReportEntityService,
$AntennaEntityService,
$AppEntityService,
$AuthSessionEntityService,
$BlockingEntityService,
$ChannelEntityService,
$ClipEntityService,
$DriveFileEntityService,
$DriveFolderEntityService,
$EmojiEntityService,
$FollowingEntityService,
$FollowRequestEntityService,
$GalleryLikeEntityService,
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
$MessagingMessageEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$NoteEntityService,
$NoteFavoriteEntityService,
$NoteReactionEntityService,
$NotificationEntityService,
$PageEntityService,
$PageLikeEntityService,
$SigninEntityService,
$UserEntityService,
$UserGroupEntityService,
$UserGroupInvitationEntityService,
$UserListEntityService,
$ApAudienceService,
$ApDbResolverService,
$ApDeliverManagerService,
$ApInboxService,
$ApLoggerService,
$ApMfmService,
$ApRendererService,
$ApRequestService,
$ApResolverService,
$LdSignatureService,
$RemoteLoggerService,
$ResolveUserService,
$WebfingerService,
$ApImageService,
$ApMentionService,
$ApNoteService,
$ApPersonService,
$ApQuestionService,
//#endregion
],
exports: [
QueueModule,
LoggerService,
AccountUpdateService,
AiService,
AntennaService,
AppLockService,
CaptchaService,
CreateNotificationService,
CreateSystemUserService,
CustomEmojiService,
DeleteAccountService,
DownloadService,
DriveService,
EmailService,
FederatedInstanceService,
FetchInstanceMetadataService,
GlobalEventService,
HashtagService,
HttpRequestService,
IdService,
ImageProcessingService,
InstanceActorService,
InternalStorageService,
MessagingService,
MetaService,
MfmService,
ModerationLogService,
NoteCreateService,
NoteDeleteService,
NotePiningService,
NoteReadService,
NotificationService,
PollService,
ProxyAccountService,
PushNotificationService,
QueryService,
ReactionService,
RelayService,
S3Service,
SignupService,
TwoFactorAuthenticationService,
UserBlockingService,
UserCacheService,
UserFollowingService,
UserKeypairStoreService,
UserListService,
UserMutingService,
UserSuspendService,
VideoProcessingService,
WebhookService,
UtilityService,
FileInfoService,
FederationChart,
NotesChart,
UsersChart,
ActiveUsersChart,
InstanceChart,
PerUserNotesChart,
DriveChart,
PerUserReactionsChart,
HashtagChart,
PerUserFollowingChart,
PerUserDriveChart,
ApRequestChart,
ChartManagementService,
AbuseUserReportEntityService,
AntennaEntityService,
AppEntityService,
AuthSessionEntityService,
BlockingEntityService,
ChannelEntityService,
ClipEntityService,
DriveFileEntityService,
DriveFolderEntityService,
EmojiEntityService,
FollowingEntityService,
FollowRequestEntityService,
GalleryLikeEntityService,
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
MessagingMessageEntityService,
ModerationLogEntityService,
MutingEntityService,
NoteEntityService,
NoteFavoriteEntityService,
NoteReactionEntityService,
NotificationEntityService,
PageEntityService,
PageLikeEntityService,
SigninEntityService,
UserEntityService,
UserGroupEntityService,
UserGroupInvitationEntityService,
UserListEntityService,
ApAudienceService,
ApDbResolverService,
ApDeliverManagerService,
ApInboxService,
ApLoggerService,
ApMfmService,
ApRendererService,
ApRequestService,
ApResolverService,
LdSignatureService,
RemoteLoggerService,
ResolveUserService,
WebfingerService,
ApImageService,
ApMentionService,
ApNoteService,
ApPersonService,
ApQuestionService,
QueueService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
$AccountUpdateService,
$AiService,
$AntennaService,
$AppLockService,
$CaptchaService,
$CreateNotificationService,
$CreateSystemUserService,
$CustomEmojiService,
$DeleteAccountService,
$DownloadService,
$DriveService,
$EmailService,
$FederatedInstanceService,
$FetchInstanceMetadataService,
$GlobalEventService,
$HashtagService,
$HttpRequestService,
$IdService,
$ImageProcessingService,
$InstanceActorService,
$InternalStorageService,
$MessagingService,
$MetaService,
$MfmService,
$ModerationLogService,
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
$NoteReadService,
$NotificationService,
$PollService,
$ProxyAccountService,
$PushNotificationService,
$QueryService,
$ReactionService,
$RelayService,
$S3Service,
$SignupService,
$TwoFactorAuthenticationService,
$UserBlockingService,
$UserCacheService,
$UserFollowingService,
$UserKeypairStoreService,
$UserListService,
$UserMutingService,
$UserSuspendService,
$VideoProcessingService,
$WebhookService,
$UtilityService,
$FileInfoService,
$FederationChart,
$NotesChart,
$UsersChart,
$ActiveUsersChart,
$InstanceChart,
$PerUserNotesChart,
$DriveChart,
$PerUserReactionsChart,
$HashtagChart,
$PerUserFollowingChart,
$PerUserDriveChart,
$ApRequestChart,
$ChartManagementService,
$AbuseUserReportEntityService,
$AntennaEntityService,
$AppEntityService,
$AuthSessionEntityService,
$BlockingEntityService,
$ChannelEntityService,
$ClipEntityService,
$DriveFileEntityService,
$DriveFolderEntityService,
$EmojiEntityService,
$FollowingEntityService,
$FollowRequestEntityService,
$GalleryLikeEntityService,
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
$MessagingMessageEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$NoteEntityService,
$NoteFavoriteEntityService,
$NoteReactionEntityService,
$NotificationEntityService,
$PageEntityService,
$PageLikeEntityService,
$SigninEntityService,
$UserEntityService,
$UserGroupEntityService,
$UserGroupInvitationEntityService,
$UserListEntityService,
$ApAudienceService,
$ApDbResolverService,
$ApDeliverManagerService,
$ApInboxService,
$ApLoggerService,
$ApMfmService,
$ApRendererService,
$ApRequestService,
$ApResolverService,
$LdSignatureService,
$RemoteLoggerService,
$ResolveUserService,
$WebfingerService,
$ApImageService,
$ApMentionService,
$ApNoteService,
$ApPersonService,
$ApQuestionService,
//#endregion
],
})
export class CoreModule {}

View File

@ -0,0 +1,114 @@
import { Inject, Injectable } from '@nestjs/common';
import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import type { Notification } from '@/models/entities/Notification.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { NotificationEntityService } from './entities/NotificationEntityService.js';
import { PushNotificationService } from './PushNotificationService.js';
@Injectable()
export class CreateNotificationService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.notificationsRepository)
private notificationsRepository: NotificationsRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
private notificationEntityService: NotificationEntityService,
private idService: IdService,
private globalEventServie: GlobalEventService,
private pushNotificationService: PushNotificationService,
) {
}
public async createNotification(
notifieeId: User['id'],
type: Notification['type'],
data: Partial<Notification>,
): Promise<Notification | null> {
if (data.notifierId && (notifieeId === data.notifierId)) {
return null;
}
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
const isMuted = profile?.mutingNotificationTypes.includes(type);
// Create notification
const notification = await this.notificationsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
notifieeId: notifieeId,
type: type,
// 相手がこの通知をミュートしているようなら、既読を予めつけておく
isRead: isMuted,
...data,
} as Partial<Notification>)
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
const packed = await this.notificationEntityService.pack(notification, {});
// Publish notification event
this.globalEventServie.publishMainStream(notifieeId, 'notification', packed);
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(async () => {
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
if (fresh == null) return; // 既に削除されているかもしれない
if (fresh.isRead) return;
//#region ただしミュートしているユーザーからの通知なら無視
const mutings = await this.mutingsRepository.findBy({
muterId: notifieeId,
});
if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
return;
}
//#endregion
this.globalEventServie.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
}, 2000);
return notification;
}
// TODO
//const locales = await import('../../../../locales/index.js');
// TODO: locale ファイルをクライアント用とサーバー用で分けたい
private async emailNotificationFollow(userId: User['id'], follower: User) {
/*
const userProfile = await UserProfiles.findOneByOrFail({ userId: userId });
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return;
const locale = locales[userProfile.lang ?? 'ja-JP'];
const i18n = new I18n(locale);
// TODO: render user information html
sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
*/
}
private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) {
/*
const userProfile = await UserProfiles.findOneByOrFail({ userId: userId });
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return;
const locale = locales[userProfile.lang ?? 'ja-JP'];
const i18n = new I18n(locale);
// TODO: render user information html
sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
*/
}
}

View File

@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { IsNull, DataSource } from 'typeorm';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { User } from '@/models/entities/User.js';
import { UserProfile } from '@/models/entities/UserProfile.js';
import { IdService } from '@/core/IdService.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UsedUsername } from '@/models/entities/UsedUsername.js';
import { DI } from '@/di-symbols.js';
import generateNativeUserToken from '@/misc/generate-native-user-token.js';
@Injectable()
export class CreateSystemUserService {
constructor(
@Inject(DI.db)
private db: DataSource,
private idService: IdService,
) {
}
public async createSystemUser(username: string): Promise<User> {
const password = uuid();
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
// Generate secret
const secret = generateNativeUserToken();
const keyPair = await genRsaKeyPair(4096);
let account!: User;
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
const exist = await transactionalEntityManager.findOneBy(User, {
usernameLower: username.toLowerCase(),
host: IsNull(),
});
if (exist) throw new Error('the user is already exists');
account = await transactionalEntityManager.insert(User, {
id: this.idService.genId(),
createdAt: new Date(),
username: username,
usernameLower: username.toLowerCase(),
host: null,
token: secret,
isAdmin: false,
isLocked: true,
isExplorable: false,
isBot: true,
}).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0]));
await transactionalEntityManager.insert(UserKeypair, {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
userId: account.id,
});
await transactionalEntityManager.insert(UserProfile, {
userId: account.id,
autoAcceptFollowed: false,
password: hash,
});
await transactionalEntityManager.insert(UsedUsername, {
createdAt: new Date(),
username: username.toLowerCase(),
});
});
return account;
}
}

View File

@ -0,0 +1,175 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { IdService } from '@/core/IdService.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Emoji } from '@/models/entities/Emoji.js';
import { Cache } from '@/misc/cache.js';
import { query } from '@/misc/prelude/url.js';
import type { Note } from '@/models/entities/Note.js';
import type { EmojisRepository } from '@/models/index.js';
import { UtilityService } from './UtilityService.js';
import { ReactionService } from './ReactionService.js';
/**
*
*/
type PopulatedEmoji = {
name: string;
url: string;
};
@Injectable()
export class CustomEmojiService {
private cache: Cache<Emoji | null>;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.db)
private db: DataSource,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private idService: IdService,
private globalEventServie: GlobalEventService,
private utilityService: UtilityService,
private reactionService: ReactionService,
) {
this.cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
}
public async add(data: {
driveFile: DriveFile;
name: string;
category: string | null;
aliases: string[];
host: string | null;
}): Promise<Emoji> {
const emoji = await this.emojisRepository.insert({
id: this.idService.genId(),
updatedAt: new Date(),
name: data.name,
category: data.category,
host: data.host,
aliases: data.aliases,
originalUrl: data.driveFile.url,
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
type: data.driveFile.webpublicType ?? data.driveFile.type,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
await this.db.queryResultCache!.remove(['meta_emojis']);
return emoji;
}
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
// クエリに使うホスト
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
: (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
host = this.utilityService.toPunyNullable(host);
return host;
}
private parseEmojiStr(emojiName: string, noteUserHost: string | null) {
const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/);
if (!match) return { name: null, host: null };
const name = match[1];
// ホスト正規化
const host = this.utilityService.toPunyNullable(this.normalizeHost(match[2], noteUserHost));
return { name, host };
}
/**
*
* @param emojiName (:, @. (decodeReactionで可能))
* @param noteUserHost
* @returns , nullは未マッチを意味する
*/
public async populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
const { name, host } = this.parseEmojiStr(emojiName, noteUserHost);
if (name == null) return null;
const queryOrNull = async () => (await this.emojisRepository.findOneBy({
name,
host: host ?? IsNull(),
})) ?? null;
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null;
const isLocal = emoji.host == null;
const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため
const url = isLocal ? emojiUrl : `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`;
return {
name: emojiName,
url,
};
}
/**
* (, )
*/
public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost)));
return emojis.filter((x): x is PopulatedEmoji => x != null);
}
public aggregateNoteEmojis(notes: Note[]) {
let emojis: { name: string | null; host: string | null; }[] = [];
for (const note of notes) {
emojis = emojis.concat(note.emojis
.map(e => this.parseEmojiStr(e, note.userHost)));
if (note.renote) {
emojis = emojis.concat(note.renote.emojis
.map(e => this.parseEmojiStr(e, note.renote!.userHost)));
if (note.renote.user) {
emojis = emojis.concat(note.renote.user.emojis
.map(e => this.parseEmojiStr(e, note.renote!.userHost)));
}
}
const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis;
emojis = emojis.concat(customReactions);
if (note.user) {
emojis = emojis.concat(note.user.emojis
.map(e => this.parseEmojiStr(e, note.userHost)));
}
}
return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[];
}
/**
*
*/
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null);
const emojisQuery: any[] = [];
const hosts = new Set(notCachedEmojis.map(e => e.host));
for (const host of hosts) {
emojisQuery.push({
name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)),
host: host ?? IsNull(),
});
}
const _emojis = emojisQuery.length > 0 ? await this.emojisRepository.find({
where: emojisQuery,
select: ['name', 'host', 'originalUrl', 'publicUrl'],
}) : [];
for (const emoji of _emojis) {
this.cache.set(`${emoji.name} ${emoji.host}`, emoji);
}
}
}

View File

@ -0,0 +1,38 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/index.js';
import { QueueService } from '@/core/QueueService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
@Injectable()
export class DeleteAccountService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private userSuspendService: UserSuspendService,
private queueService: QueueService,
private globalEventServie: GlobalEventService,
) {
}
public async deleteAccount(user: {
id: string;
host: string | null;
}): Promise<void> {
// 物理削除する前にDelete activityを送信する
await this.userSuspendService.doPostSuspend(user).catch(e => {});
this.queueService.createDeleteAccountJob(user, {
soft: false,
});
await this.usersRepository.update(user.id, {
isDeleted: true,
});
// Terminate streaming
this.globalEventServie.publishUserEvent(user.id, 'terminate', {});
}
}

View File

@ -0,0 +1,125 @@
import * as fs from 'node:fs';
import * as stream from 'node:stream';
import * as util from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip';
import got, * as Got from 'got';
import chalk from 'chalk';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { createTemp } from '@/misc/create-temp.js';
import { StatusError } from '@/misc/status-error.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
const pipeline = util.promisify(stream.pipeline);
@Injectable()
export class DownloadService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('download');
}
public async downloadUrl(url: string, path: string): Promise<void> {
this.logger.info(`Downloading ${chalk.cyan(url)} ...`);
const timeout = 30 * 1000;
const operationTimeout = 60 * 1000;
const maxSize = this.config.maxFileSize ?? 262144000;
const req = got.stream(url, {
headers: {
'User-Agent': this.config.userAgent,
},
timeout: {
lookup: timeout,
connect: timeout,
secureConnect: timeout,
socket: timeout, // read timeout
response: timeout,
send: timeout,
request: operationTimeout, // whole operation timeout
},
agent: {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
},
http2: false, // default
retry: {
limit: 0,
},
}).on('response', (res: Got.Response) => {
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
if (this.isPrivateIp(res.ip)) {
this.logger.warn(`Blocked address: ${res.ip}`);
req.destroy();
}
}
const contentLength = res.headers['content-length'];
if (contentLength != null) {
const size = Number(contentLength);
if (size > maxSize) {
this.logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`);
req.destroy();
}
}
}).on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) {
this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
req.destroy();
}
});
try {
await pipeline(req, fs.createWriteStream(path));
} catch (e) {
if (e instanceof Got.HTTPError) {
throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
} else {
throw e;
}
}
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
}
public async downloadTextFile(url: string): Promise<string> {
// Create temp file
const [path, cleanup] = await createTemp();
this.logger.info(`text file: Temp file is ${path}`);
try {
// write content at URL to temp file
await this.downloadUrl(url, path);
const text = await util.promisify(fs.readFile)(path, 'utf8');
return text;
} finally {
cleanup();
}
}
private isPrivateIp(ip: string): boolean {
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = new IPCIDR(net);
if (cidr.contains(ip)) {
return false;
}
}
return PrivateIp(ip);
}
}

View File

@ -0,0 +1,740 @@
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import sharp from 'sharp';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import Logger from '@/logger.js';
import type { IRemoteUser, User } from '@/models/entities/User.js';
import { MetaService } from '@/core/MetaService.js';
import { DriveFile } from '@/models/entities/DriveFile.js';
import { IdService } from '@/core/IdService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { contentDisposition } from '@/misc/content-disposition.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
import type { IImage } from '@/core/ImageProcessingService.js';
import { QueueService } from '@/core/QueueService.js';
import type { DriveFolder } from '@/models/entities/DriveFolder.js';
import { createTemp } from '@/misc/create-temp.js';
import DriveChart from '@/core/chart/charts/drive.js';
import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import { DownloadService } from '@/core/DownloadService.js';
import { S3Service } from '@/core/S3Service.js';
import { InternalStorageService } from '@/core/InternalStorageService.js';
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { FileInfoService } from './FileInfoService.js';
import type S3 from 'aws-sdk/clients/s3.js';
type AddFileArgs = {
/** User who wish to add file */
user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null;
/** File path */
path: string;
/** Name */
name?: string | null;
/** Comment */
comment?: string | null;
/** Folder ID */
folderId?: any;
/** If set to true, forcibly upload the file even if there is a file with the same hash. */
force?: boolean;
/** Do not save file to local */
isLink?: boolean;
/** URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) */
url?: string | null;
/** URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) */
uri?: string | null;
/** Mark file as sensitive */
sensitive?: boolean | null;
requestIp?: string | null;
requestHeaders?: Record<string, string> | null;
};
type UploadFromUrlArgs = {
url: string;
user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null;
folderId?: DriveFolder['id'] | null;
uri?: string | null;
sensitive?: boolean;
force?: boolean;
isLink?: boolean;
comment?: string | null;
requestIp?: string | null;
requestHeaders?: Record<string, string> | null;
};
@Injectable()
export class DriveService {
private registerLogger: Logger;
private downloaderLogger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.driveFoldersRepository)
private driveFoldersRepository: DriveFoldersRepository,
private fileInfoService: FileInfoService,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private idService: IdService,
private metaService: MetaService,
private downloadService: DownloadService,
private internalStorageService: InternalStorageService,
private s3Service: S3Service,
private imageProcessingService: ImageProcessingService,
private videoProcessingService: VideoProcessingService,
private globalEventService: GlobalEventService,
private queueService: QueueService,
private driveChart: DriveChart,
private perUserDriveChart: PerUserDriveChart,
private instanceChart: InstanceChart,
) {
const logger = new Logger('drive', 'blue');
this.registerLogger = logger.createSubLogger('register', 'yellow');
this.downloaderLogger = logger.createSubLogger('downloader');
}
/***
* Save file
* @param path Path for original
* @param name Name for original
* @param type Content-Type for original
* @param hash Hash for original
* @param size Size for original
*/
private async save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<DriveFile> {
// thunbnail, webpublic を必要なら生成
const alts = await this.generateAlts(path, type, !file.uri);
const meta = await this.metaService.fetch();
if (meta.useObjectStorage) {
//#region ObjectStorage params
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
if (ext === '') {
if (type === 'image/jpeg') ext = '.jpg';
if (type === 'image/png') ext = '.png';
if (type === 'image/webp') ext = '.webp';
if (type === 'image/apng') ext = '.apng';
if (type === 'image/vnd.mozilla.apng') ext = '.apng';
}
// 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
// 許可されているファイル形式でしか拡張子をつけない
if (!FILE_TYPE_BROWSERSAFE.includes(type)) {
ext = '';
}
const baseUrl = meta.objectStorageBaseUrl
?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`;
// for original
const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`;
const url = `${ baseUrl }/${ key }`;
// for alts
let webpublicKey: string | null = null;
let webpublicUrl: string | null = null;
let thumbnailKey: string | null = null;
let thumbnailUrl: string | null = null;
//#endregion
//#region Uploads
this.registerLogger.info(`uploading original: ${key}`);
const uploads = [
this.upload(key, fs.createReadStream(path), type, name),
];
if (alts.webpublic) {
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`;
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name));
}
if (alts.thumbnail) {
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`;
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type));
}
await Promise.all(uploads);
//#endregion
file.url = url;
file.thumbnailUrl = thumbnailUrl;
file.webpublicUrl = webpublicUrl;
file.accessKey = key;
file.thumbnailAccessKey = thumbnailKey;
file.webpublicAccessKey = webpublicKey;
file.webpublicType = alts.webpublic?.type ?? null;
file.name = name;
file.type = type;
file.md5 = hash;
file.size = size;
file.storedInternal = false;
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
} else { // use internal storage
const accessKey = uuid();
const thumbnailAccessKey = 'thumbnail-' + uuid();
const webpublicAccessKey = 'webpublic-' + uuid();
const url = this.internalStorageService.saveFromPath(accessKey, path);
let thumbnailUrl: string | null = null;
let webpublicUrl: string | null = null;
if (alts.thumbnail) {
thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data);
this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
}
if (alts.webpublic) {
webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data);
this.registerLogger.info(`web stored: ${webpublicAccessKey}`);
}
file.storedInternal = true;
file.url = url;
file.thumbnailUrl = thumbnailUrl;
file.webpublicUrl = webpublicUrl;
file.accessKey = accessKey;
file.thumbnailAccessKey = thumbnailAccessKey;
file.webpublicAccessKey = webpublicAccessKey;
file.webpublicType = alts.webpublic?.type ?? null;
file.name = name;
file.type = type;
file.md5 = hash;
file.size = size;
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
}
}
/**
* Generate webpublic, thumbnail, etc
* @param path Path for original
* @param type Content-Type for original
* @param generateWeb Generate webpublic or not
*/
public async generateAlts(path: string, type: string, generateWeb: boolean) {
if (type.startsWith('video/')) {
try {
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path);
return {
webpublic: null,
thumbnail,
};
} catch (err) {
this.registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`);
return {
webpublic: null,
thumbnail: null,
};
}
}
if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) {
this.registerLogger.debug('web image and thumbnail not created (not an required file)');
return {
webpublic: null,
thumbnail: null,
};
}
let img: sharp.Sharp | null = null;
let satisfyWebpublic: boolean;
try {
img = sharp(path);
const metadata = await img.metadata();
const isAnimated = metadata.pages && metadata.pages > 1;
// skip animated
if (isAnimated) {
return {
webpublic: null,
thumbnail: null,
};
}
satisfyWebpublic = !!(
type !== 'image/svg+xml' && type !== 'image/webp' &&
!(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) &&
metadata.width && metadata.width <= 2048 &&
metadata.height && metadata.height <= 2048
);
} catch (err) {
this.registerLogger.warn(`sharp failed: ${err}`);
return {
webpublic: null,
thumbnail: null,
};
}
// #region webpublic
let webpublic: IImage | null = null;
if (generateWeb && !satisfyWebpublic) {
this.registerLogger.info('creating web image');
try {
if (['image/jpeg', 'image/webp'].includes(type)) {
webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048);
} else if (['image/png'].includes(type)) {
webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048);
} else if (['image/svg+xml'].includes(type)) {
webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048);
} else {
this.registerLogger.debug('web image not created (not an required image)');
}
} catch (err) {
this.registerLogger.warn('web image not created (an error occured)', err as Error);
}
} else {
if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)');
else this.registerLogger.info('web image not created (from remote)');
}
// #endregion webpublic
// #region thumbnail
let thumbnail: IImage | null = null;
try {
if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) {
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280);
} else {
this.registerLogger.debug('thumbnail not created (not an required file)');
}
} catch (err) {
this.registerLogger.warn('thumbnail not created (an error occured)', err as Error);
}
// #endregion thumbnail
return {
webpublic,
thumbnail,
};
}
/**
* Upload to ObjectStorage
*/
private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) {
if (type === 'image/apng') type = 'image/png';
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
const meta = await this.metaService.fetch();
const params = {
Bucket: meta.objectStorageBucket,
Key: key,
Body: stream,
ContentType: type,
CacheControl: 'max-age=31536000, immutable',
} as S3.PutObjectRequest;
if (filename) params.ContentDisposition = contentDisposition('inline', filename);
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
const s3 = this.s3Service.getS3(meta);
const upload = s3.upload(params, {
partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024,
});
const result = await upload.promise();
if (result) this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
}
private async deleteOldFile(user: IRemoteUser) {
const q = this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId', { userId: user.id })
.andWhere('file.isLink = FALSE');
if (user.avatarId) {
q.andWhere('file.id != :avatarId', { avatarId: user.avatarId });
}
if (user.bannerId) {
q.andWhere('file.id != :bannerId', { bannerId: user.bannerId });
}
q.orderBy('file.id', 'ASC');
const oldFile = await q.getOne();
if (oldFile) {
this.deleteFile(oldFile, true);
}
}
/**
* Add file to drive
*
*/
public async addFile({
user,
path,
name = null,
comment = null,
folderId = null,
force = false,
isLink = false,
url = null,
uri = null,
sensitive = null,
requestIp = null,
requestHeaders = null,
}: AddFileArgs): Promise<DriveFile> {
let skipNsfwCheck = false;
const instance = await this.metaService.fetch();
if (user == null) skipNsfwCheck = true;
if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true;
if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
const info = await this.fileInfoService.getFileInfo(path, {
skipSensitiveDetection: skipNsfwCheck,
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
0.5,
sensitiveThresholdForPorn: 0.75,
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
});
this.registerLogger.info(`${JSON.stringify(info)}`);
// 現状 false positive が多すぎて実用に耐えない
//if (info.porn && instance.disallowUploadWhenPredictedAsPorn) {
// throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.');
//}
// detect name
const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled');
if (user && !force) {
// Check if there is a file with the same hash
const much = await this.driveFilesRepository.findOneBy({
md5: info.md5,
userId: user.id,
});
if (much) {
this.registerLogger.info(`file with same hash is found: ${much.id}`);
return much;
}
}
//#region Check drive usage
if (user && !isLink) {
const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
const u = await this.usersRepository.findOneBy({ id: user.id });
const instance = await this.metaService.fetch();
let driveCapacity = 1024 * 1024 * (this.userEntityService.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);
if (this.userEntityService.isLocalUser(user) && u?.driveCapacityOverrideMb != null) {
driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb;
this.registerLogger.debug('drive capacity override applied');
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
}
this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`);
// If usage limit exceeded
if (usage + info.size > driveCapacity) {
if (this.userEntityService.isLocalUser(user)) {
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
} else {
// (アバターまたはバナーを含まず)最も古いファイルを削除する
this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as IRemoteUser);
}
}
}
//#endregion
const fetchFolder = async () => {
if (!folderId) {
return null;
}
const driveFolder = await this.driveFoldersRepository.findOneBy({
id: folderId,
userId: user ? user.id : IsNull(),
});
if (driveFolder == null) throw new Error('folder-not-found');
return driveFolder;
};
const properties: {
width?: number;
height?: number;
orientation?: number;
} = {};
if (info.width) {
properties['width'] = info.width;
properties['height'] = info.height;
}
if (info.orientation != null) {
properties['orientation'] = info.orientation;
}
const profile = user ? await this.userProfilesRepository.findOneBy({ userId: user.id }) : null;
const folder = await fetchFolder();
let file = new DriveFile();
file.id = this.idService.genId();
file.createdAt = new Date();
file.userId = user ? user.id : null;
file.userHost = user ? user.host : null;
file.folderId = folder !== null ? folder.id : null;
file.comment = comment;
file.properties = properties;
file.blurhash = info.blurhash ?? null;
file.isLink = isLink;
file.requestIp = requestIp;
file.requestHeaders = requestHeaders;
file.maybeSensitive = info.sensitive;
file.maybePorn = info.porn;
file.isSensitive = user
? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
(sensitive !== null && sensitive !== undefined)
? sensitive
: false
: false;
if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
if (url !== null) {
file.src = url;
if (isLink) {
file.url = url;
// ローカルプロキシ用
file.accessKey = uuid();
file.thumbnailAccessKey = 'thumbnail-' + uuid();
file.webpublicAccessKey = 'webpublic-' + uuid();
}
}
if (uri !== null) {
file.uri = uri;
}
if (isLink) {
try {
file.size = 0;
file.md5 = info.md5;
file.name = detectedName;
file.type = info.type.mime;
file.storedInternal = false;
file = await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
} catch (err) {
// duplicate key error (when already registered)
if (isDuplicateKeyValueError(err)) {
this.registerLogger.info(`already registered ${file.uri}`);
file = await this.driveFilesRepository.findOneBy({
uri: file.uri!,
userId: user ? user.id : IsNull(),
}) as DriveFile;
} else {
this.registerLogger.error(err as Error);
throw err;
}
}
} else {
file = await (this.save(file, path, detectedName, info.type.mime, info.md5, info.size));
}
this.registerLogger.succ(`drive file has been created ${file.id}`);
if (user) {
this.driveFileEntityService.pack(file, { self: true }).then(packedFile => {
// Publish driveFileCreated event
this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile);
this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile);
});
}
// 統計を更新
this.driveChart.update(file, true);
this.perUserDriveChart.update(file, true);
if (file.userHost !== null) {
this.instanceChart.updateDrive(file, true);
}
return file;
}
public async deleteFile(file: DriveFile, isExpired = false) {
if (file.storedInternal) {
this.internalStorageService.del(file.accessKey!);
if (file.thumbnailUrl) {
this.internalStorageService.del(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
this.internalStorageService.del(file.webpublicAccessKey!);
}
} else if (!file.isLink) {
this.queueService.createDeleteObjectStorageFileJob(file.accessKey!);
if (file.thumbnailUrl) {
this.queueService.createDeleteObjectStorageFileJob(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
this.queueService.createDeleteObjectStorageFileJob(file.webpublicAccessKey!);
}
}
this.deletePostProcess(file, isExpired);
}
public async deleteFileSync(file: DriveFile, isExpired = false) {
if (file.storedInternal) {
this.internalStorageService.del(file.accessKey!);
if (file.thumbnailUrl) {
this.internalStorageService.del(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
this.internalStorageService.del(file.webpublicAccessKey!);
}
} else if (!file.isLink) {
const promises = [];
promises.push(this.deleteObjectStorageFile(file.accessKey!));
if (file.thumbnailUrl) {
promises.push(this.deleteObjectStorageFile(file.thumbnailAccessKey!));
}
if (file.webpublicUrl) {
promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!));
}
await Promise.all(promises);
}
this.deletePostProcess(file, isExpired);
}
private async deletePostProcess(file: DriveFile, isExpired = false) {
// リモートファイル期限切れ削除後は直リンクにする
if (isExpired && file.userHost !== null && file.uri != null) {
this.driveFilesRepository.update(file.id, {
isLink: true,
url: file.uri,
thumbnailUrl: null,
webpublicUrl: null,
storedInternal: false,
// ローカルプロキシ用
accessKey: uuid(),
thumbnailAccessKey: 'thumbnail-' + uuid(),
webpublicAccessKey: 'webpublic-' + uuid(),
});
} else {
this.driveFilesRepository.delete(file.id);
}
// 統計を更新
this.driveChart.update(file, false);
this.perUserDriveChart.update(file, false);
if (file.userHost !== null) {
this.instanceChart.updateDrive(file, false);
}
}
public async deleteObjectStorageFile(key: string) {
const meta = await this.metaService.fetch();
const s3 = this.s3Service.getS3(meta);
await s3.deleteObject({
Bucket: meta.objectStorageBucket!,
Key: key,
}).promise();
}
public async uploadFromUrl({
url,
user,
folderId = null,
uri = null,
sensitive = false,
force = false,
isLink = false,
comment = null,
requestIp = null,
requestHeaders = null,
}: UploadFromUrlArgs): Promise<DriveFile> {
let name = new URL(url).pathname.split('/').pop() ?? null;
if (name == null || !this.driveFileEntityService.validateFileName(name)) {
name = null;
}
// If the comment is same as the name, skip comment
// (image.name is passed in when receiving attachment)
if (comment !== null && name === comment) {
comment = null;
}
// Create temp file
const [path, cleanup] = await createTemp();
try {
// write content at URL to temp file
await this.downloadService.downloadUrl(url, path);
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
this.downloaderLogger.succ(`Got: ${driveFile.id}`);
return driveFile!;
} catch (err) {
this.downloaderLogger.error(`Failed to create drive file: ${err}`, {
url: url,
e: err,
});
throw err;
} finally {
cleanup();
}
}
}

View File

@ -0,0 +1,177 @@
import * as nodemailer from 'nodemailer';
import { Inject, Injectable } from '@nestjs/common';
import { validate as validateEmail } from 'deep-email-validator';
import { MetaService } from '@/core/MetaService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import type { UserProfilesRepository } from '@/models/index.js';
import { LoggerService } from '@/core/LoggerService.js';
@Injectable()
export class EmailService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private metaService: MetaService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('email');
}
public async sendEmail(to: string, subject: string, html: string, text: string) {
const meta = await this.metaService.fetch(true);
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
const emailSettingUrl = `${this.config.url}/settings/email`;
const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
const transporter = nodemailer.createTransport({
host: meta.smtpHost,
port: meta.smtpPort,
secure: meta.smtpSecure,
ignoreTLS: !enableAuth,
proxy: this.config.proxySmtp,
auth: enableAuth ? {
user: meta.smtpUser,
pass: meta.smtpPass,
} : undefined,
} as any);
try {
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
from: meta.email!,
to: to,
subject: subject,
text: text,
html: `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${ subject }</title>
<style>
html {
background: #eee;
}
body {
padding: 16px;
margin: 0;
font-family: sans-serif;
font-size: 14px;
}
a {
text-decoration: none;
color: #86b300;
}
a:hover {
text-decoration: underline;
}
main {
max-width: 500px;
margin: 0 auto;
background: #fff;
color: #555;
}
main > header {
padding: 32px;
background: #86b300;
}
main > header > img {
max-width: 128px;
max-height: 28px;
vertical-align: bottom;
}
main > article {
padding: 32px;
}
main > article > h1 {
margin: 0 0 1em 0;
}
main > footer {
padding: 32px;
border-top: solid 1px #eee;
}
nav {
box-sizing: border-box;
max-width: 500px;
margin: 16px auto 0 auto;
padding: 0 32px;
}
nav > a {
color: #888;
}
</style>
</head>
<body>
<main>
<header>
<img src="${ meta.logoImageUrl ?? meta.iconUrl ?? iconUrl }"/>
</header>
<article>
<h1>${ subject }</h1>
<div>${ html }</div>
</article>
<footer>
<a href="${ emailSettingUrl }">${ 'Email setting' }</a>
</footer>
</main>
<nav>
<a href="${ this.config.url }">${ this.config.host }</a>
</nav>
</body>
</html>`,
});
this.logger.info(`Message sent: ${info.messageId}`);
} catch (err) {
this.logger.error(err as Error);
throw err;
}
}
public async validateEmailForAccount(emailAddress: string): Promise<{
available: boolean;
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp';
}> {
const meta = await this.metaService.fetch();
const exist = await this.userProfilesRepository.countBy({
emailVerified: true,
email: emailAddress,
});
const validated = meta.enableActiveEmailValidation ? await validateEmail({
email: emailAddress,
validateRegex: true,
validateMx: true,
validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので
validateDisposable: true, // 捨てアドかどうかチェック
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
}) : { valid: true, reason: null };
const available = exist === 0 && validated.valid;
return {
available,
reason: available ? null :
exist !== 0 ? 'used' :
validated.reason === 'regex' ? 'format' :
validated.reason === 'disposable' ? 'disposable' :
validated.reason === 'mx' ? 'mx' :
validated.reason === 'smtp' ? 'smtp' :
null,
};
}
}

View File

@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import type { InstancesRepository } from '@/models/index.js';
import type { Instance } from '@/models/entities/Instance.js';
import { Cache } from '@/misc/cache.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { UtilityService } from './UtilityService.js';
@Injectable()
export class FederatedInstanceService {
private cache: Cache<Instance>;
constructor(
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private utilityService: UtilityService,
private idService: IdService,
) {
this.cache = new Cache<Instance>(1000 * 60 * 60);
}
public async registerOrFetchInstanceDoc(host: string): Promise<Instance> {
host = this.utilityService.toPuny(host);
const cached = this.cache.get(host);
if (cached) return cached;
const index = await this.instancesRepository.findOneBy({ host });
if (index == null) {
const i = await this.instancesRepository.insert({
id: this.idService.genId(),
host,
caughtAt: new Date(),
lastCommunicatedAt: new Date(),
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
this.cache.set(host, i);
return i;
} else {
this.cache.set(host, index);
return index;
}
}
}

View File

@ -0,0 +1,291 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import fetch from 'node-fetch';
import tinycolor from 'tinycolor2';
import type { Instance } from '@/models/entities/Instance.js';
import type { InstancesRepository } from '@/models/index.js';
import { AppLockService } from '@/core/AppLockService.js';
import type Logger from '@/logger.js';
import { DI } from '@/di-symbols.js';
import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from './HttpRequestService.js';
import type { DOMWindow } from 'jsdom';
type NodeInfo = {
openRegistrations?: unknown;
software?: {
name?: unknown;
version?: unknown;
};
metadata?: {
name?: unknown;
nodeName?: unknown;
nodeDescription?: unknown;
description?: unknown;
maintainer?: {
name?: unknown;
email?: unknown;
};
themeColor?: unknown;
};
};
@Injectable()
export class FetchInstanceMetadataService {
private logger: Logger;
constructor(
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private appLockService: AppLockService,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('metadata', 'cyan');
}
public async fetchInstanceMetadata(instance: Instance, force = false): Promise<void> {
const unlock = await this.appLockService.getFetchInstanceMetadataLock(instance.host);
if (!force) {
const _instance = await this.instancesRepository.findOneBy({ host: instance.host });
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
unlock();
return;
}
}
this.logger.info(`Fetching metadata of ${instance.host} ...`);
try {
const [info, dom, manifest] = await Promise.all([
this.fetchNodeinfo(instance).catch(() => null),
this.fetchDom(instance).catch(() => null),
this.fetchManifest(instance).catch(() => null),
]);
const [favicon, icon, themeColor, name, description] = await Promise.all([
this.fetchFaviconUrl(instance, dom).catch(() => null),
this.fetchIconUrl(instance, dom, manifest).catch(() => null),
this.getThemeColor(info, dom, manifest).catch(() => null),
this.getSiteName(info, dom, manifest).catch(() => null),
this.getDescription(info, dom, manifest).catch(() => null),
]);
this.logger.succ(`Successfuly fetched metadata of ${instance.host}`);
const updates = {
infoUpdatedAt: new Date(),
} as Record<string, any>;
if (info) {
updates.softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?';
updates.softwareVersion = info.software?.version;
updates.openRegistrations = info.openRegistrations;
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
}
if (name) updates.name = name;
if (description) updates.description = description;
if (icon || favicon) updates.iconUrl = icon ?? favicon;
if (favicon) updates.faviconUrl = favicon;
if (themeColor) updates.themeColor = themeColor;
await this.instancesRepository.update(instance.id, updates);
this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
} catch (e) {
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
} finally {
unlock();
}
}
private async fetchNodeinfo(instance: Instance): Promise<NodeInfo> {
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try {
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
.catch(err => {
if (err.statusCode === 404) {
throw 'No nodeinfo provided';
} else {
throw err.statusCode ?? err.message;
}
}) as Record<string, unknown>;
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
throw 'No wellknown links';
}
const links = wellknown.links as any[];
const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0');
const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');
const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1');
const link = lnik2_1 ?? lnik2_0 ?? lnik1_0;
if (link == null) {
throw 'No nodeinfo link provided';
}
const info = await this.httpRequestService.getJson(link.href)
.catch(err => {
throw err.statusCode ?? err.message;
});
this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
return info as NodeInfo;
} catch (err) {
this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`);
throw err;
}
}
private async fetchDom(instance: Instance): Promise<DOMWindow['document']> {
this.logger.info(`Fetching HTML of ${instance.host} ...`);
const url = 'https://' + instance.host;
const html = await this.httpRequestService.getHtml(url);
const { window } = new JSDOM(html);
const doc = window.document;
return doc;
}
private async fetchManifest(instance: Instance): Promise<Record<string, unknown> | null> {
const url = 'https://' + instance.host;
const manifestUrl = url + '/manifest.json';
const manifest = await this.httpRequestService.getJson(manifestUrl) as Record<string, unknown>;
return manifest;
}
private async fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise<string | null> {
const url = 'https://' + instance.host;
if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href;
if (href) {
return (new URL(href, url)).href;
}
}
const faviconUrl = url + '/favicon.ico';
const favicon = await fetch(faviconUrl, {
// TODO
//timeout: 10000,
agent: url => this.httpRequestService.getAgentByUrl(url),
});
if (favicon.ok) {
return faviconUrl;
}
return null;
}
private async fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
const url = 'https://' + instance.host;
return (new URL(manifest.icons[0].src, url)).href;
}
if (doc) {
const url = 'https://' + instance.host;
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const links = Array.from(doc.getElementsByTagName('link')).reverse();
// https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
const href =
[
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href,
links.find(link => link.relList.contains('apple-touch-icon'))?.href,
links.find(link => link.relList.contains('icon'))?.href,
]
.find(href => href);
if (href) {
return (new URL(href, url)).href;
}
}
return null;
}
private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
if (themeColor) {
const color = new tinycolor(themeColor);
if (color.isValid()) return color.toHexString();
}
return null;
}
private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (typeof info.metadata.nodeName === 'string') {
return info.metadata.nodeName;
} else if (typeof info.metadata.name === 'string') {
return info.metadata.name;
}
}
if (doc) {
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest.name ?? manifest.short_name;
}
return null;
}
private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (typeof info.metadata.nodeDescription === 'string') {
return info.metadata.nodeDescription;
} else if (typeof info.metadata.description === 'string') {
return info.metadata.description;
}
}
if (doc) {
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
if (meta) {
return meta;
}
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest.name ?? manifest.short_name;
}
return null;
}
}

View File

@ -0,0 +1,382 @@
import * as fs from 'node:fs';
import * as crypto from 'node:crypto';
import { join } from 'node:path';
import * as stream from 'node:stream';
import * as util from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
import { FSWatcher } from 'chokidar';
import { fileTypeFromFile } from 'file-type';
import FFmpeg from 'fluent-ffmpeg';
import isSvg from 'is-svg';
import probeImageSize from 'probe-image-size';
import { type predictionType } from 'nsfwjs';
import sharp from 'sharp';
import { encode } from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
import { AiService } from '@/core/AiService.js';
const pipeline = util.promisify(stream.pipeline);
export type FileInfo = {
size: number;
md5: string;
type: {
mime: string;
ext: string | null;
};
width?: number;
height?: number;
orientation?: number;
blurhash?: string;
sensitive: boolean;
porn: boolean;
warnings: string[];
};
const TYPE_OCTET_STREAM = {
mime: 'application/octet-stream',
ext: null,
};
const TYPE_SVG = {
mime: 'image/svg+xml',
ext: 'svg',
};
@Injectable()
export class FileInfoService {
constructor(
private aiService: AiService,
) {
}
/**
* Get file information
*/
public async getFileInfo(path: string, opts: {
skipSensitiveDetection: boolean;
sensitiveThreshold?: number;
sensitiveThresholdForPorn?: number;
enableSensitiveMediaDetectionForVideos?: boolean;
}): Promise<FileInfo> {
const warnings = [] as string[];
const size = await this.getFileSize(path);
const md5 = await this.calcHash(path);
let type = await this.detectType(path);
// image dimensions
let width: number | undefined;
let height: number | undefined;
let orientation: number | undefined;
if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) {
const imageSize = await this.detectImageSize(path).catch(e => {
warnings.push(`detectImageSize failed: ${e}`);
return undefined;
});
// うまく判定できない画像は octet-stream にする
if (!imageSize) {
warnings.push('cannot detect image dimensions');
type = TYPE_OCTET_STREAM;
} else if (imageSize.wUnits === 'px') {
width = imageSize.width;
height = imageSize.height;
orientation = imageSize.orientation;
// 制限を超えている画像は octet-stream にする
if (imageSize.width > 16383 || imageSize.height > 16383) {
warnings.push('image dimensions exceeds limits');
type = TYPE_OCTET_STREAM;
}
} else {
warnings.push(`unsupported unit type: ${imageSize.wUnits}`);
}
}
let blurhash: string | undefined;
if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) {
blurhash = await this.getBlurhash(path).catch(e => {
warnings.push(`getBlurhash failed: ${e}`);
return undefined;
});
}
let sensitive = false;
let porn = false;
if (!opts.skipSensitiveDetection) {
await this.detectSensitivity(
path,
type.mime,
opts.sensitiveThreshold ?? 0.5,
opts.sensitiveThresholdForPorn ?? 0.75,
opts.enableSensitiveMediaDetectionForVideos ?? false,
).then(value => {
[sensitive, porn] = value;
}, error => {
warnings.push(`detectSensitivity failed: ${error}`);
});
}
return {
size,
md5,
type,
width,
height,
orientation,
blurhash,
sensitive,
porn,
warnings,
};
}
private async detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> {
let sensitive = false;
let porn = false;
function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] {
let sensitive = false;
let porn = false;
if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true;
return [sensitive, porn];
}
if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) {
const result = await this.aiService.detectSensitive(source);
if (result) {
[sensitive, porn] = judgePrediction(result);
}
} else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) {
const [outDir, disposeOutDir] = await createTempDir();
try {
const command = FFmpeg()
.input(source)
.inputOptions([
'-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない)
'-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない)
])
.noAudio()
.videoFilters([
{
filter: 'select', // フレームのフィルタリング
options: {
e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタするVP9 とかはデコードしてみないとわからないっぽい)
},
},
{
filter: 'blackframe', // 暗いフレームの検出
options: {
amount: '0', // 暗さに関わらず全てのフレームで測定値を取る
},
},
{
filter: 'metadata',
options: {
mode: 'select', // フレーム選択モード
key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する)
value: '50',
function: 'less', // 50% 未満のフレームを選択する50% 以上暗部があるフレームだと誤検知を招くかもしれないので)
},
},
{
filter: 'scale',
options: {
w: 299,
h: 299,
},
},
])
.format('image2')
.output(join(outDir, '%d.png'))
.outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない
const results: ReturnType<typeof judgePrediction>[] = [];
let frameIndex = 0;
let targetIndex = 0;
let nextIndex = 1;
for await (const path of this.asyncIterateFrames(outDir, command)) {
try {
const index = frameIndex++;
if (index !== targetIndex) {
continue;
}
targetIndex = nextIndex;
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
const result = await this.aiService.detectSensitive(path);
if (result) {
results.push(judgePrediction(result));
}
} finally {
fs.promises.unlink(path);
}
}
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
} finally {
disposeOutDir();
}
}
return [sensitive, porn];
}
private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {
const watcher = new FSWatcher({
cwd,
disableGlobbing: true,
});
let finished = false;
command.once('end', () => {
finished = true;
watcher.close();
});
command.run();
for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
const current = `${i}.png`;
const next = `${i + 1}.png`;
const framePath = join(cwd, current);
if (await this.exists(join(cwd, next))) {
yield framePath;
} else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
watcher.add(next);
await new Promise<void>((resolve, reject) => {
watcher.on('add', function onAdd(path) {
if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている
watcher.unwatch(current);
watcher.off('add', onAdd);
resolve();
}
});
command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている
command.once('error', reject);
});
yield framePath;
} else if (await this.exists(framePath)) {
yield framePath;
} else {
return;
}
}
}
private exists(path: string): Promise<boolean> {
return fs.promises.access(path).then(() => true, () => false);
}
/**
* Detect MIME Type and extension
*/
public async detectType(path: string): Promise<{
mime: string;
ext: string | null;
}> {
// Check 0 byte
const fileSize = await this.getFileSize(path);
if (fileSize === 0) {
return TYPE_OCTET_STREAM;
}
const type = await fileTypeFromFile(path);
if (type) {
// XMLはSVGかもしれない
if (type.mime === 'application/xml' && await this.checkSvg(path)) {
return TYPE_SVG;
}
return {
mime: type.mime,
ext: type.ext,
};
}
// 種類が不明でもSVGかもしれない
if (await this.checkSvg(path)) {
return TYPE_SVG;
}
// それでも種類が不明なら application/octet-stream にする
return TYPE_OCTET_STREAM;
}
/**
* Check the file is SVG or not
*/
public async checkSvg(path: string) {
try {
const size = await this.getFileSize(path);
if (size > 1 * 1024 * 1024) return false;
return isSvg(fs.readFileSync(path));
} catch {
return false;
}
}
/**
* Get file size
*/
public async getFileSize(path: string): Promise<number> {
const getStat = util.promisify(fs.stat);
return (await getStat(path)).size;
}
/**
* Calculate MD5 hash
*/
private async calcHash(path: string): Promise<string> {
const hash = crypto.createHash('md5').setEncoding('hex');
await pipeline(fs.createReadStream(path), hash);
return hash.read();
}
/**
* Detect dimensions of image
*/
private async detectImageSize(path: string): Promise<{
width: number;
height: number;
wUnits: string;
hUnits: string;
orientation?: number;
}> {
const readable = fs.createReadStream(path);
const imageSize = await probeImageSize(readable);
readable.destroy();
return imageSize;
}
/**
* Calculate average color of image
*/
private getBlurhash(path: string): Promise<string> {
return new Promise((resolve, reject) => {
sharp(path)
.raw()
.ensureAlpha()
.resize(64, 64, { fit: 'inside' })
.toBuffer((err, buffer, { width, height }) => {
if (err) return reject(err);
let hash;
try {
hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7);
} catch (e) {
return reject(e);
}
resolve(hash);
});
});
}
}

View File

@ -0,0 +1,109 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import type { User } from '@/models/entities/User.js';
import type { Note } from '@/models/entities/Note.js';
import type { UserList } from '@/models/entities/UserList.js';
import type { UserGroup } from '@/models/entities/UserGroup.js';
import type { Antenna } from '@/models/entities/Antenna.js';
import type { Channel } from '@/models/entities/Channel.js';
import type {
StreamChannels,
AdminStreamTypes,
AntennaStreamTypes,
BroadcastTypes,
ChannelStreamTypes,
DriveStreamTypes,
GroupMessagingStreamTypes,
InternalStreamTypes,
MainStreamTypes,
MessagingIndexStreamTypes,
MessagingStreamTypes,
NoteStreamTypes,
UserListStreamTypes,
UserStreamTypes,
} from '@/server/api/stream/types.js';
import type { Packed } from '@/misc/schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@Injectable()
export class GlobalEventService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
}
private publish(channel: StreamChannels, type: string | null, value?: any): void {
const message = type == null ? value : value == null ?
{ type: type, body: null } :
{ type: type, body: value };
this.redisClient.publish(this.config.host, JSON.stringify({
channel: channel,
message: message,
}));
}
public publishInternalEvent<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void {
this.publish('internal', type, typeof value === 'undefined' ? null : value);
}
public publishUserEvent<K extends keyof UserStreamTypes>(userId: User['id'], type: K, value?: UserStreamTypes[K]): void {
this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void {
this.publish('broadcast', type, typeof value === 'undefined' ? null : value);
}
public publishMainStream<K extends keyof MainStreamTypes>(userId: User['id'], type: K, value?: MainStreamTypes[K]): void {
this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishDriveStream<K extends keyof DriveStreamTypes>(userId: User['id'], type: K, value?: DriveStreamTypes[K]): void {
this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void {
this.publish(`noteStream:${noteId}`, type, {
id: noteId,
body: value,
});
}
public publishChannelStream<K extends keyof ChannelStreamTypes>(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void {
this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value);
}
public publishUserListStream<K extends keyof UserListStreamTypes>(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void {
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
}
public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void {
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
}
public publishMessagingStream<K extends keyof MessagingStreamTypes>(userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void {
this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
}
public publishGroupMessagingStream<K extends keyof GroupMessagingStreamTypes>(groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void {
this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value);
}
public publishMessagingIndexStream<K extends keyof MessagingIndexStreamTypes>(userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void {
this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
public publishNotesStream(note: Packed<'Note'>): void {
this.publish('notesStream', null, note);
}
public publishAdminStream<K extends keyof AdminStreamTypes>(userId: User['id'], type: K, value?: AdminStreamTypes[K]): void {
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
}

View File

@ -0,0 +1,147 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { User } from '@/models/entities/User.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { IdService } from '@/core/IdService.js';
import type { Hashtag } from '@/models/entities/Hashtag.js';
import HashtagChart from '@/core/chart/charts/hashtag.js';
import type { HashtagsRepository, UsersRepository } from '@/models/index.js';
import { UserEntityService } from './entities/UserEntityService.js';
@Injectable()
export class HashtagService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.hashtagsRepository)
private hashtagsRepository: HashtagsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private hashtagChart: HashtagChart,
) {
}
public async updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) {
for (const tag of tags) {
await this.updateHashtag(user, tag);
}
}
public async updateUsertags(user: User, tags: string[]) {
for (const tag of tags) {
await this.updateHashtag(user, tag, true, true);
}
for (const tag of (user.tags ?? []).filter(x => !tags.includes(x))) {
await this.updateHashtag(user, tag, true, false);
}
}
public async updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) {
tag = normalizeForSearch(tag);
const index = await this.hashtagsRepository.findOneBy({ name: tag });
if (index == null && !inc) return;
if (index != null) {
const q = this.hashtagsRepository.createQueryBuilder('tag').update()
.where('name = :name', { name: tag });
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';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1';
}
} else {
set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" - 1';
if (this.userEntityService.isLocalUser(user)) {
set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1';
} else {
set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1';
}
}
} else {
// 自分が初めてこのタグを使ったなら
if (!index.mentionedUserIds.some(id => id === user.id)) {
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1';
}
}
if (Object.keys(set).length > 0) {
q.set(set);
q.execute();
}
} else {
if (isUserAttached) {
this.hashtagsRepository.insert({
id: this.idService.genId(),
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 Hashtag);
} else {
this.hashtagsRepository.insert({
id: this.idService.genId(),
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 Hashtag);
}
}
if (!isUserAttached) {
this.hashtagChart.update(tag, user);
}
}
}

View File

@ -0,0 +1,154 @@
import * as http from 'node:http';
import * as https from 'node:https';
import CacheableLookup from 'cacheable-lookup';
import fetch from 'node-fetch';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { StatusError } from '@/misc/status-error.js';
import type { Response } from 'node-fetch';
import type { URL } from 'node:url';
@Injectable()
export class HttpRequestService {
/**
* Get http non-proxy agent
*/
private http: http.Agent;
/**
* Get https non-proxy agent
*/
private https: https.Agent;
/**
* Get http proxy or non-proxy agent
*/
public httpAgent: http.Agent;
/**
* Get https proxy or non-proxy agent
*/
public httpsAgent: https.Agent;
constructor(
@Inject(DI.config)
private config: Config,
) {
const cache = new CacheableLookup({
maxTtl: 3600, // 1hours
errorTtl: 30, // 30secs
lookup: false, // nativeのdns.lookupにfallbackしない
});
this.http = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup,
} as http.AgentOptions);
this.https = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup,
} as https.AgentOptions);
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
this.httpAgent = config.proxy
? new HttpProxyAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
maxSockets,
maxFreeSockets: 256,
scheduling: 'lifo',
proxy: config.proxy,
})
: this.http;
this.httpsAgent = config.proxy
? new HttpsProxyAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
maxSockets,
maxFreeSockets: 256,
scheduling: 'lifo',
proxy: config.proxy,
})
: this.https;
}
/**
* Get agent by URL
* @param url URL
* @param bypassProxy Allways bypass proxy
*/
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
if (bypassProxy || (this.config.proxyBypassHosts || []).includes(url.hostname)) {
return url.protocol === 'http:' ? this.http : this.https;
} else {
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
}
}
public async getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record<string, string>): Promise<unknown> {
const res = await this.getResponse({
url,
method: 'GET',
headers: Object.assign({
'User-Agent': this.config.userAgent,
Accept: accept,
}, headers ?? {}),
timeout,
});
return await res.json();
}
public async getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record<string, string>): Promise<string> {
const res = await this.getResponse({
url,
method: 'GET',
headers: Object.assign({
'User-Agent': this.config.userAgent,
Accept: accept,
}, headers ?? {}),
timeout,
});
return await res.text();
}
public async getResponse(args: {
url: string,
method: string,
body?: string,
headers: Record<string, string>,
timeout?: number,
size?: number,
}): Promise<Response> {
const timeout = args.timeout ?? 10 * 1000;
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, timeout * 6);
const res = await fetch(args.url, {
method: args.method,
headers: args.headers,
body: args.body,
timeout,
size: args.size ?? 10 * 1024 * 1024,
agent: (url) => this.getAgentByUrl(url),
signal: controller.signal,
});
if (!res.ok) {
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
}
return res;
}
}

View File

@ -0,0 +1,33 @@
import { Inject, Injectable } from '@nestjs/common';
import { ulid } from 'ulid';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { genAid } from '@/misc/id/aid.js';
import { genMeid } from '@/misc/id/meid.js';
import { genMeidg } from '@/misc/id/meidg.js';
import { genObjectId } from '@/misc/id/object-id.js';
@Injectable()
export class IdService {
private metohd: string;
constructor(
@Inject(DI.config)
private config: Config,
) {
this.metohd = config.id.toLowerCase();
}
public genId(date?: Date): string {
if (!date || (date > new Date())) date = new Date();
switch (this.metohd) {
case 'aid': return genAid(date);
case 'meid': return genMeid(date);
case 'meidg': return genMeidg(date);
case 'ulid': return ulid(date.getTime());
case 'objectid': return genObjectId(date);
default: throw new Error('unrecognized id generation method');
}
}
}

View File

@ -0,0 +1,99 @@
import { Inject, Injectable } from '@nestjs/common';
import sharp from 'sharp';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
export type IImage = {
data: Buffer;
ext: string | null;
type: string;
};
@Injectable()
export class ImageProcessingService {
constructor(
@Inject(DI.config)
private config: Config,
) {
}
/**
* Convert to JPEG
* with resize, remove metadata, resolve orientation, stop animation
*/
public async convertToJpeg(path: string, width: number, height: number): Promise<IImage> {
return this.convertSharpToJpeg(await sharp(path), width, height);
}
public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
const data = await sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.rotate()
.jpeg({
quality: 85,
progressive: true,
})
.toBuffer();
return {
data,
ext: 'jpg',
type: 'image/jpeg',
};
}
/**
* Convert to WebP
* with resize, remove metadata, resolve orientation, stop animation
*/
public async convertToWebp(path: string, width: number, height: number, quality = 85): Promise<IImage> {
return this.convertSharpToWebp(await sharp(path), width, height, quality);
}
public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, quality = 85): Promise<IImage> {
const data = await sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.rotate()
.webp({
quality,
})
.toBuffer();
return {
data,
ext: 'webp',
type: 'image/webp',
};
}
/**
* Convert to PNG
* with resize, remove metadata, resolve orientation, stop animation
*/
public async convertToPng(path: string, width: number, height: number): Promise<IImage> {
return this.convertSharpToPng(await sharp(path), width, height);
}
public async convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
const data = await sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.rotate()
.png()
.toBuffer();
return {
data,
ext: 'png',
type: 'image/png',
};
}
}

View File

@ -0,0 +1,42 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import type { ILocalUser } from '@/models/entities/User.js';
import type { UsersRepository } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import { DI } from '@/di-symbols.js';
import { CreateSystemUserService } from './CreateSystemUserService.js';
const ACTOR_USERNAME = 'instance.actor' as const;
@Injectable()
export class InstanceActorService {
private cache: Cache<ILocalUser>;
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private createSystemUserService: CreateSystemUserService,
) {
this.cache = new Cache<ILocalUser>(Infinity);
}
public async getInstanceActor(): Promise<ILocalUser> {
const cached = this.cache.get(null);
if (cached) return cached;
const user = await this.usersRepository.findOneBy({
host: IsNull(),
username: ACTOR_USERNAME,
}) as ILocalUser | undefined;
if (user) {
this.cache.set(null, user);
return user;
} else {
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as ILocalUser;
this.cache.set(null, created);
return created;
}
}
}

View File

@ -0,0 +1,45 @@
import * as fs from 'node:fs';
import * as Path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const path = Path.resolve(_dirname, '../../../../files');
@Injectable()
export class InternalStorageService {
constructor(
@Inject(DI.config)
private config: Config,
) {
}
public resolvePath(key: string) {
return Path.resolve(path, key);
}
public read(key: string) {
return fs.createReadStream(this.resolvePath(key));
}
public saveFromPath(key: string, srcPath: string) {
fs.mkdirSync(path, { recursive: true });
fs.copyFileSync(srcPath, this.resolvePath(key));
return `${this.config.url}/files/${key}`;
}
public saveFromBuffer(key: string, data: Buffer) {
fs.mkdirSync(path, { recursive: true });
fs.writeFileSync(this.resolvePath(key), data);
return `${this.config.url}/files/${key}`;
}
public del(key: string) {
fs.unlink(this.resolvePath(key), () => {});
}
}

View File

@ -0,0 +1,33 @@
import { Inject, Injectable } from '@nestjs/common';
import * as SyslogPro from 'syslog-pro';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import Logger from '@/logger.js';
@Injectable()
export class LoggerService {
private syslogClient;
constructor(
@Inject(DI.config)
private config: Config,
) {
if (this.config.syslog) {
this.syslogClient = new SyslogPro.RFC5424({
applacationName: 'Misskey',
timestamp: true,
encludeStructuredData: true,
color: true,
extendedColor: true,
server: {
target: config.syslog.host,
port: config.syslog.port,
},
});
}
}
public getLogger(domain: string, color?: string | undefined, store?: boolean) {
return new Logger(domain, color, store, this.syslogClient);
}
}

View File

@ -0,0 +1,300 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { MessagingMessage } from '@/models/entities/MessagingMessage.js';
import type { Note } from '@/models/entities/Note.js';
import type { User, CacheableUser, IRemoteUser } from '@/models/entities/User.js';
import type { UserGroup } from '@/models/entities/UserGroup.js';
import { QueueService } from '@/core/QueueService.js';
import { toArray } from '@/misc/prelude/array.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MessagingMessagesRepository, MutingsRepository, UserGroupJoiningsRepository, UsersRepository } from '@/models/index.js';
import { IdService } from './IdService.js';
import { GlobalEventService } from './GlobalEventService.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js';
import { PushNotificationService } from './PushNotificationService.js';
@Injectable()
export class MessagingService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.messagingMessagesRepository)
private messagingMessagesRepository: MessagingMessagesRepository,
@Inject(DI.userGroupJoiningsRepository)
private userGroupJoiningsRepository: UserGroupJoiningsRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
private userEntityService: UserEntityService,
private messagingMessageEntityService: MessagingMessageEntityService,
private idService: IdService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
private queueService: QueueService,
private pushNotificationService: PushNotificationService,
) {
}
public async createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: CacheableUser | undefined, recipientGroup: UserGroup | undefined, text: string | null | undefined, file: DriveFile | null, uri?: string) {
const message = {
id: this.idService.genId(),
createdAt: new Date(),
fileId: file ? file.id : null,
recipientId: recipientUser ? recipientUser.id : null,
groupId: recipientGroup ? recipientGroup.id : null,
text: text ? text.trim() : null,
userId: user.id,
isRead: false,
reads: [] as any[],
uri,
} as MessagingMessage;
await this.messagingMessagesRepository.insert(message);
const messageObj = await this.messagingMessageEntityService.pack(message);
if (recipientUser) {
if (this.userEntityService.isLocalUser(user)) {
// 自分のストリーム
this.globalEventService.publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj);
this.globalEventService.publishMessagingIndexStream(message.userId, 'message', messageObj);
this.globalEventService.publishMainStream(message.userId, 'messagingMessage', messageObj);
}
if (this.userEntityService.isLocalUser(recipientUser)) {
// 相手のストリーム
this.globalEventService.publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj);
this.globalEventService.publishMessagingIndexStream(recipientUser.id, 'message', messageObj);
this.globalEventService.publishMainStream(recipientUser.id, 'messagingMessage', messageObj);
}
} else if (recipientGroup) {
// グループのストリーム
this.globalEventService.publishGroupMessagingStream(recipientGroup.id, 'message', messageObj);
// メンバーのストリーム
const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id });
for (const joining of joinings) {
this.globalEventService.publishMessagingIndexStream(joining.userId, 'message', messageObj);
this.globalEventService.publishMainStream(joining.userId, 'messagingMessage', messageObj);
}
}
// 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する
setTimeout(async () => {
const freshMessage = await this.messagingMessagesRepository.findOneBy({ id: message.id });
if (freshMessage == null) return; // メッセージが削除されている場合もある
if (recipientUser && this.userEntityService.isLocalUser(recipientUser)) {
if (freshMessage.isRead) return; // 既読
//#region ただしミュートされているなら発行しない
const mute = await this.mutingsRepository.findBy({
muterId: recipientUser.id,
});
if (mute.map(m => m.muteeId).includes(user.id)) return;
//#endregion
this.globalEventService.publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj);
this.pushNotificationService.pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj);
} else if (recipientGroup) {
const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id, userId: Not(user.id) });
for (const joining of joinings) {
if (freshMessage.reads.includes(joining.userId)) return; // 既読
this.globalEventService.publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj);
this.pushNotificationService.pushNotification(joining.userId, 'unreadMessagingMessage', messageObj);
}
}
}, 2000);
if (recipientUser && this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipientUser)) {
const note = {
id: message.id,
createdAt: message.createdAt,
fileIds: message.fileId ? [message.fileId] : [],
text: message.text,
userId: message.userId,
visibility: 'specified',
mentions: [recipientUser].map(u => u.id),
mentionedRemoteUsers: JSON.stringify([recipientUser].map(u => ({
uri: u.uri,
username: u.username,
host: u.host,
}))),
} as Note;
const activity = this.apRendererService.renderActivity(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note));
this.queueService.deliver(user, activity, recipientUser.inbox);
}
return messageObj;
}
public async deleteMessage(message: MessagingMessage) {
await this.messagingMessagesRepository.delete(message.id);
this.postDeleteMessage(message);
}
private async postDeleteMessage(message: MessagingMessage) {
if (message.recipientId) {
const user = await this.usersRepository.findOneByOrFail({ id: message.userId });
const recipient = await this.usersRepository.findOneByOrFail({ id: message.recipientId });
if (this.userEntityService.isLocalUser(user)) this.globalEventService.publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id);
if (this.userEntityService.isLocalUser(recipient)) this.globalEventService.publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id);
if (this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipient)) {
const activity = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), user));
this.queueService.deliver(user, activity, recipient.inbox);
}
} else if (message.groupId) {
this.globalEventService.publishGroupMessagingStream(message.groupId, 'deleted', message.id);
}
}
/**
* Mark messages as read
*/
public async readUserMessagingMessage(
userId: User['id'],
otherpartyId: User['id'],
messageIds: MessagingMessage['id'][],
) {
if (messageIds.length === 0) return;
const messages = await this.messagingMessagesRepository.findBy({
id: In(messageIds),
});
for (const message of messages) {
if (message.recipientId !== userId) {
throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).');
}
}
// Update documents
await this.messagingMessagesRepository.update({
id: In(messageIds),
userId: otherpartyId,
recipientId: userId,
isRead: false,
}, {
isRead: true,
});
// Publish event
this.globalEventService.publishMessagingStream(otherpartyId, userId, 'read', messageIds);
this.globalEventService.publishMessagingIndexStream(userId, 'read', messageIds);
if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages');
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined);
} else {
// そのユーザーとのメッセージで未読がなければイベント発行
const count = await this.messagingMessagesRepository.count({
where: {
userId: otherpartyId,
recipientId: userId,
isRead: false,
},
take: 1,
});
if (!count) {
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId });
}
}
}
/**
* Mark messages as read
*/
public async readGroupMessagingMessage(
userId: User['id'],
groupId: UserGroup['id'],
messageIds: MessagingMessage['id'][],
) {
if (messageIds.length === 0) return;
// check joined
const joining = await this.userGroupJoiningsRepository.findOneBy({
userId: userId,
userGroupId: groupId,
});
if (joining == null) {
throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).');
}
const messages = await this.messagingMessagesRepository.findBy({
id: In(messageIds),
});
const reads: MessagingMessage['id'][] = [];
for (const message of messages) {
if (message.userId === userId) continue;
if (message.reads.includes(userId)) continue;
// Update document
await this.messagingMessagesRepository.createQueryBuilder().update()
.set({
reads: (() => `array_append("reads", '${joining.userId}')`) as any,
})
.where('id = :id', { id: message.id })
.execute();
reads.push(message.id);
}
// Publish event
this.globalEventService.publishGroupMessagingStream(groupId, 'read', {
ids: reads,
userId: userId,
});
this.globalEventService.publishMessagingIndexStream(userId, 'read', reads);
if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages');
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined);
} else {
// そのグループにおいて未読がなければイベント発行
const unreadExist = await this.messagingMessagesRepository.createQueryBuilder('message')
.where('message.groupId = :groupId', { groupId: groupId })
.andWhere('message.userId != :userId', { userId: userId })
.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない
.getOne().then(x => x != null);
if (!unreadExist) {
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId });
}
}
}
public async deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) {
messages = toArray(messages).filter(x => x.uri);
const contents = messages.map(x => this.apRendererService.renderRead(user, x));
if (contents.length > 1) {
const collection = this.apRendererService.renderOrderedCollection(null, contents.length, undefined, undefined, contents);
this.queueService.deliver(user, this.apRendererService.renderActivity(collection), recipient.inbox);
} else {
for (const content of contents) {
this.queueService.deliver(user, this.apRendererService.renderActivity(content), recipient.inbox);
}
}
}
}

View File

@ -0,0 +1,121 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { Meta } from '@/models/entities/Meta.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class MetaService implements OnApplicationShutdown {
private cache: Meta | undefined;
private intervalId: NodeJS.Timer;
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.db)
private db: DataSource,
private globalEventService: GlobalEventService,
) {
this.onMessage = this.onMessage.bind(this);
if (process.env.NODE_ENV !== 'test') {
this.intervalId = setInterval(() => {
this.fetch(true).then(meta => {
// fetch内でもセットしてるけど仕様変更の可能性もあるため一応
this.cache = meta;
});
}, 1000 * 60 * 5);
}
this.redisSubscriber.on('message', this.onMessage);
}
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message;
switch (type) {
case 'metaUpdated': {
this.cache = body;
break;
}
default:
break;
}
}
}
public async fetch(noCache = false): Promise<Meta> {
if (!noCache && this.cache) return this.cache;
return await this.db.transaction(async transactionalEntityManager => {
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
},
});
const meta = metas[0];
if (meta) {
this.cache = meta;
return meta;
} else {
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
const saved = await transactionalEntityManager
.upsert(
Meta,
{
id: 'x',
},
['id'],
)
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
this.cache = saved;
return saved;
}
});
}
public async update(data: Partial<Meta>): Promise<Meta> {
const updated = await this.db.transaction(async transactionalEntityManager => {
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
},
});
const meta = metas[0];
if (meta) {
await transactionalEntityManager.update(Meta, meta.id, data);
const metas = await transactionalEntityManager.find(Meta, {
order: {
id: 'DESC',
},
});
return metas[0];
} else {
return await transactionalEntityManager.save(Meta, data);
}
});
this.globalEventService.publishInternalEvent('metaUpdated', updated);
return updated;
}
public onApplicationShutdown(signal?: string | undefined) {
clearInterval(this.intervalId);
this.redisSubscriber.off('message', this.onMessage);
}
}

View File

@ -0,0 +1,384 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5';
import { JSDOM } from 'jsdom';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js';
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js';
import type * as mfm from 'mfm-js';
const treeAdapter = TreeAdapter.defaultTreeAdapter;
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
@Injectable()
export class MfmService {
constructor(
@Inject(DI.config)
private config: Config,
) {
}
public fromHtml(html: string, hashtagNames?: string[]): string {
// some AP servers like Pixelfed use br tags as well as newlines
html = html.replace(/<br\s?\/?>\r?\n/gi, '\n');
const dom = parse5.parseFragment(html);
let text = '';
for (const n of dom.childNodes) {
analyze(n);
}
return text.trim();
function getText(node: TreeAdapter.Node): string {
if (treeAdapter.isTextNode(node)) return node.value;
if (!treeAdapter.isElementNode(node)) return '';
if (node.nodeName === 'br') return '\n';
if (node.childNodes) {
return node.childNodes.map(n => getText(n)).join('');
}
return '';
}
function appendChildren(childNodes: TreeAdapter.ChildNode[]): void {
if (childNodes) {
for (const n of childNodes) {
analyze(n);
}
}
}
function analyze(node: TreeAdapter.Node) {
if (treeAdapter.isTextNode(node)) {
text += node.value;
return;
}
// Skip comment or document type node
if (!treeAdapter.isElementNode(node)) return;
switch (node.nodeName) {
case 'br': {
text += '\n';
break;
}
case 'a':
{
const txt = getText(node);
const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href');
// ハッシュタグ
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
text += txt;
// メンション
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
const part = txt.split('@');
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`;
text += acct;
//#endregion
} else if (part.length === 3) {
text += txt;
}
// その他
} else {
const generateLink = () => {
if (!href && !txt) {
return '';
}
if (!href) {
return txt;
}
if (!txt || txt === href.value) { // #6383: Missing text node
if (href.value.match(urlRegexFull)) {
return href.value;
} else {
return `<${href.value}>`;
}
}
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
return `[${txt}](<${href.value}>)`; // #6846
} else {
return `[${txt}](${href.value})`;
}
};
text += generateLink();
}
break;
}
case 'h1':
{
text += '【';
appendChildren(node.childNodes);
text += '】\n';
break;
}
case 'b':
case 'strong':
{
text += '**';
appendChildren(node.childNodes);
text += '**';
break;
}
case 'small':
{
text += '<small>';
appendChildren(node.childNodes);
text += '</small>';
break;
}
case 's':
case 'del':
{
text += '~~';
appendChildren(node.childNodes);
text += '~~';
break;
}
case 'i':
case 'em':
{
text += '<i>';
appendChildren(node.childNodes);
text += '</i>';
break;
}
// block code (<pre><code>)
case 'pre': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
text += '\n```\n';
text += getText(node.childNodes[0]);
text += '\n```\n';
} else {
appendChildren(node.childNodes);
}
break;
}
// inline code (<code>)
case 'code': {
text += '`';
appendChildren(node.childNodes);
text += '`';
break;
}
case 'blockquote': {
const t = getText(node);
if (t) {
text += '\n> ';
text += t.split('\n').join('\n> ');
}
break;
}
case 'p':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
{
text += '\n\n';
appendChildren(node.childNodes);
break;
}
// other block elements
case 'div':
case 'header':
case 'footer':
case 'article':
case 'li':
case 'dt':
case 'dd':
{
text += '\n';
appendChildren(node.childNodes);
break;
}
default: // includes inline elements
{
appendChildren(node.childNodes);
break;
}
}
}
}
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
if (nodes == null) {
return null;
}
const { window } = new JSDOM('');
const doc = window.document;
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
if (children) {
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
}
}
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
bold: (node) => {
const el = doc.createElement('b');
appendChildren(node.children, el);
return el;
},
small: (node) => {
const el = doc.createElement('small');
appendChildren(node.children, el);
return el;
},
strike: (node) => {
const el = doc.createElement('del');
appendChildren(node.children, el);
return el;
},
italic: (node) => {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
},
fn: (node) => {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
},
blockCode: (node) => {
const pre = doc.createElement('pre');
const inner = doc.createElement('code');
inner.textContent = node.props.code;
pre.appendChild(inner);
return pre;
},
center: (node) => {
const el = doc.createElement('div');
appendChildren(node.children, el);
return el;
},
emojiCode: (node) => {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji: (node) => {
return doc.createTextNode(node.props.emoji);
},
hashtag: (node) => {
const a = doc.createElement('a');
a.href = `${this.config.url}/tags/${node.props.hashtag}`;
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
return a;
},
inlineCode: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.code;
return el;
},
mathInline: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
},
mathBlock: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
},
link: (node) => {
const a = doc.createElement('a');
a.href = node.props.url;
appendChildren(node.children, a);
return a;
},
mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`;
a.className = 'u-url mention';
a.textContent = acct;
return a;
},
quote: (node) => {
const el = doc.createElement('blockquote');
appendChildren(node.children, el);
return el;
},
text: (node) => {
const el = doc.createElement('span');
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x);
}
return el;
},
url: (node) => {
const a = doc.createElement('a');
a.href = node.props.url;
a.textContent = node.props.url;
return a;
},
search: (node) => {
const a = doc.createElement('a');
a.href = `https://www.google.com/search?q=${node.props.query}`;
a.textContent = node.props.content;
return a;
},
plain: (node) => {
const el = doc.createElement('span');
appendChildren(node.children, el);
return el;
},
};
appendChildren(nodes, doc.body);
return `<p>${doc.body.innerHTML}</p>`;
}
}

View File

@ -0,0 +1,26 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { ModerationLogsRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js';
@Injectable()
export class ModerationLogService {
constructor(
@Inject(DI.moderationLogsRepository)
private moderationLogsRepository: ModerationLogsRepository,
private idService: IdService,
) {
}
public async insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record<string, any>) {
await this.moderationLogsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: moderator.id,
type: type,
info: info ?? {},
});
}
}

View File

@ -0,0 +1,739 @@
import * as mfm from 'mfm-js';
import { Not, In, DataSource } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
import { Note } from '@/models/entities/Note.js';
import type { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { App } from '@/models/entities/App.js';
import { concat } from '@/misc/prelude/array.js';
import { IdService } from '@/core/IdService.js';
import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js';
import type { IPoll } from '@/models/entities/Poll.js';
import { Poll } from '@/models/entities/Poll.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { checkWordMute } from '@/misc/check-word-mute.js';
import type { Channel } from '@/models/entities/Channel.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { Cache } from '@/misc/cache.js';
import type { UserProfile } from '@/models/entities/UserProfile.js';
import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import NotesChart from '@/core/chart/charts/notes.js';
import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
import { WebhookService } from '@/core/WebhookService.js';
import { HashtagService } from '@/core/HashtagService.js';
import { AntennaService } from '@/core/AntennaService.js';
import { QueueService } from '@/core/QueueService.js';
import { NoteEntityService } from './entities/NoteEntityService.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { NoteReadService } from './NoteReadService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
import { ResolveUserService } from './remote/ResolveUserService.js';
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
class NotificationManager {
private notifier: { id: User['id']; };
private note: Note;
private queue: {
target: ILocalUser['id'];
reason: NotificationType;
}[];
constructor(
private mutingsRepository: MutingsRepository,
private createNotificationService: CreateNotificationService,
notifier: { id: User['id']; },
note: Note,
) {
this.notifier = notifier;
this.note = note;
this.queue = [];
}
public push(notifiee: ILocalUser['id'], reason: NotificationType) {
// 自分自身へは通知しない
if (this.notifier.id === notifiee) return;
const exist = this.queue.find(x => x.target === notifiee);
if (exist) {
// 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
if (reason !== 'mention') {
exist.reason = reason;
}
} else {
this.queue.push({
reason: reason,
target: notifiee,
});
}
}
public async deliver() {
for (const x of this.queue) {
// ミュート情報を取得
const mentioneeMutes = await this.mutingsRepository.findBy({
muterId: x.target,
});
const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId);
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
this.createNotificationService.createNotification(x.target, x.reason, {
notifierId: this.notifier.id,
noteId: this.note.id,
});
}
}
}
}
type MinimumUser = {
id: User['id'];
host: User['host'];
username: User['username'];
uri: User['uri'];
};
type Option = {
createdAt?: Date | null;
name?: string | null;
text?: string | null;
reply?: Note | null;
renote?: Note | null;
files?: DriveFile[] | null;
poll?: IPoll | null;
localOnly?: boolean | null;
cw?: string | null;
visibility?: string;
visibleUsers?: MinimumUser[] | null;
channel?: Channel | null;
apMentions?: MinimumUser[] | null;
apHashtags?: string[] | null;
apEmojis?: string[] | null;
uri?: string | null;
url?: string | null;
app?: App | null;
};
@Injectable()
export class NoteCreateService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.db)
private db: DataSource,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.noteThreadMutingsRepository)
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
private globalEventServie: GlobalEventService,
private queueService: QueueService,
private noteReadService: NoteReadService,
private createNotificationService: CreateNotificationService,
private relayService: RelayService,
private federatedInstanceService: FederatedInstanceService,
private hashtagService: HashtagService,
private antennaService: AntennaService,
private webhookService: WebhookService,
private resolveUserService: ResolveUserService,
private apDeliverManagerService: ApDeliverManagerService,
private apRendererService: ApRendererService,
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
private activeUsersChart: ActiveUsersChart,
private instanceChart: InstanceChart,
) {}
public async create(user: {
id: User['id'];
username: User['username'];
host: User['host'];
isSilenced: User['isSilenced'];
createdAt: User['createdAt'];
}, data: Option, silent = false): Promise<Note> {
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
if (data.reply.channelId) {
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
} else {
data.channel = null;
}
}
// チャンネル内にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && (data.channel == null) && data.reply.channelId) {
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
}
if (data.createdAt == null) data.createdAt = new Date();
if (data.visibility == null) data.visibility = 'public';
if (data.localOnly == null) data.localOnly = false;
if (data.channel != null) data.visibility = 'public';
if (data.channel != null) data.visibleUsers = [];
if (data.channel != null) data.localOnly = true;
// サイレンス
if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
data.visibility = 'home';
}
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
throw new Error('Renote target is not public or home');
}
// Renote対象がpublicではないならhomeにする
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
data.visibility = 'home';
}
// Renote対象がfollowersならfollowersにする
if (data.renote && data.renote.visibility === 'followers') {
data.visibility = 'followers';
}
// 返信対象がpublicではないならhomeにする
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
data.visibility = 'home';
}
// ローカルのみをRenoteしたらローカルのみにする
if (data.renote && data.renote.localOnly && data.channel == null) {
data.localOnly = true;
}
// ローカルのみにリプライしたらローカルのみにする
if (data.reply && data.reply.localOnly && data.channel == null) {
data.localOnly = true;
}
if (data.text) {
data.text = data.text.trim();
} else {
data.text = null;
}
let tags = data.apHashtags;
let emojis = data.apEmojis;
let mentionedUsers = data.apMentions;
// Parse MFM if needed
if (!tags || !emojis || !mentionedUsers) {
const tokens = data.text ? mfm.parse(data.text)! : [];
const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
const choiceTokens = data.poll && data.poll.choices
? concat(data.poll.choices.map(choice => mfm.parse(choice)!))
: [];
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
tags = data.apHashtags ?? extractHashtags(combinedTokens);
emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens);
}
tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32);
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
}
if (data.visibility === 'specified') {
if (data.visibleUsers == null) throw new Error('invalid param');
for (const u of data.visibleUsers) {
if (!mentionedUsers.some(x => x.id === u.id)) {
mentionedUsers.push(u);
}
}
if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) {
data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
}
}
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!));
return note;
}
private async insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
const insert = new Note({
id: this.idService.genId(data.createdAt!),
createdAt: data.createdAt!,
fileIds: data.files ? data.files.map(file => file.id) : [],
replyId: data.reply ? data.reply.id : null,
renoteId: data.renote ? data.renote.id : null,
channelId: data.channel ? data.channel.id : null,
threadId: data.reply
? data.reply.threadId
? data.reply.threadId
: data.reply.id
: null,
name: data.name,
text: data.text,
hasPoll: data.poll != null,
cw: data.cw == null ? null : data.cw,
tags: tags.map(tag => normalizeForSearch(tag)),
emojis,
userId: user.id,
localOnly: data.localOnly!,
visibility: data.visibility as any,
visibleUserIds: data.visibility === 'specified'
? data.visibleUsers
? data.visibleUsers.map(u => u.id)
: []
: [],
attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
// 以下非正規化データ
replyUserId: data.reply ? data.reply.userId : null,
replyUserHost: data.reply ? data.reply.userHost : null,
renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null,
userHost: user.host,
});
if (data.uri != null) insert.uri = data.uri;
if (data.url != null) insert.url = data.url;
// Append mentions data
if (mentionedUsers.length > 0) {
insert.mentions = mentionedUsers.map(u => u.id);
const profiles = await this.userProfilesRepository.findBy({ userId: In(insert.mentions) });
insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => {
const profile = profiles.find(p => p.userId === u.id);
const url = profile != null ? profile.url : null;
return {
uri: u.uri,
url: url == null ? undefined : url,
username: u.username,
host: u.host,
} as IMentionedRemoteUsers[0];
}));
}
// 投稿を作成
try {
if (insert.hasPoll) {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.insert(Note, insert);
const poll = new Poll({
noteId: insert.id,
choices: data.poll!.choices,
expiresAt: data.poll!.expiresAt,
multiple: data.poll!.multiple,
votes: new Array(data.poll!.choices.length).fill(0),
noteVisibility: insert.visibility,
userId: user.id,
userHost: user.host,
});
await transactionalEntityManager.insert(Poll, poll);
});
} else {
await this.notesRepository.insert(insert);
}
return insert;
} catch (e) {
// duplicate key error
if (isDuplicateKeyValueError(e)) {
const err = new Error('Duplicated note');
err.name = 'duplicated';
throw err;
}
console.error(e);
throw e;
}
}
private async postNoteCreated(note: Note, user: {
id: User['id'];
username: User['username'];
host: User['host'];
isSilenced: User['isSilenced'];
createdAt: User['createdAt'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
// 統計を更新
this.notesChart.update(note, true);
this.perUserNotesChart.update(user, note, true);
// Register host
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
this.instanceChart.updateNote(i.host, note, true);
});
}
// ハッシュタグ更新
if (data.visibility === 'public' || data.visibility === 'home') {
this.hashtagService.updateHashtags(user, tags);
}
// Increment notes count (user)
this.incNotesCountOfUser(user);
// Word mute
mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({
where: {
enableWordMute: true,
},
select: ['userId', 'mutedWords'],
})).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
this.mutedNotesRepository.insert({
id: this.idService.genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
// Antenna
for (const antenna of (await this.antennaService.getAntennas())) {
this.antennaService.checkHitAntenna(antenna, note, user).then(hit => {
if (hit) {
this.antennaService.addNoteToAntenna(antenna, note, user);
}
});
}
// Channel
if (note.channelId) {
this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => {
for (const following of followings) {
this.noteReadService.insertNoteUnread(following.followerId, note, {
isSpecified: false,
isMentioned: false,
});
}
});
}
if (data.reply) {
this.saveReply(data.reply, note);
}
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
this.incRenoteCount(data.renote);
}
if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
this.queueService.endedPollNotificationQueue.add({
noteId: note.id,
}, {
delay,
removeOnComplete: true,
});
}
if (!silent) {
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
// 未読通知を作成
if (data.visibility === 'specified') {
if (data.visibleUsers == null) throw new Error('invalid param');
for (const u of data.visibleUsers) {
// ローカルユーザーのみ
if (!this.userEntityService.isLocalUser(u)) continue;
this.noteReadService.insertNoteUnread(u.id, note, {
isSpecified: true,
isMentioned: false,
});
}
} else {
for (const u of mentionedUsers) {
// ローカルユーザーのみ
if (!this.userEntityService.isLocalUser(u)) continue;
this.noteReadService.insertNoteUnread(u.id, note, {
isSpecified: false,
isMentioned: true,
});
}
}
// Pack the note
const noteObj = await this.noteEntityService.pack(note);
this.globalEventServie.publishNotesStream(noteObj);
this.webhookService.getActiveWebhooks().then(webhooks => {
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'note', {
note: noteObj,
});
}
});
const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note);
await this.createMentionedEvents(mentionedUsers, note, nm);
// If has in reply to note
if (data.reply) {
// 通知
if (data.reply.userHost === null) {
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id,
});
if (!threadMuted) {
nm.push(data.reply.userId, 'reply');
this.globalEventServie.publishMainStream(data.reply.userId, 'reply', noteObj);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'reply', {
note: noteObj,
});
}
}
}
}
// If it is renote
if (data.renote) {
const type = data.text ? 'quote' : 'renote';
// Notify
if (data.renote.userHost === null) {
nm.push(data.renote.userId, type);
}
// Publish event
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
this.globalEventServie.publishMainStream(data.renote.userId, 'renote', noteObj);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'renote', {
note: noteObj,
});
}
}
}
nm.deliver();
//#region AP deliver
if (this.userEntityService.isLocalUser(user)) {
(async () => {
const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) {
dm.addDirectRecipe(u as IRemoteUser);
}
// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
if (data.reply && data.reply.userHost !== null) {
const u = await this.usersRepository.findOneBy({ id: data.reply.userId });
if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
}
// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
if (data.renote && data.renote.userHost !== null) {
const u = await this.usersRepository.findOneBy({ id: data.renote.userId });
if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
}
// フォロワーに配送
if (['public', 'home', 'followers'].includes(note.visibility)) {
dm.addFollowersRecipe();
}
if (['public'].includes(note.visibility)) {
this.relayService.deliverToRelays(user, noteActivity);
}
dm.execute();
})();
}
//#endregion
}
if (data.channel) {
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
this.channelsRepository.update(data.channel.id, {
lastNotedAt: new Date(),
});
this.notesRepository.countBy({
userId: user.id,
channelId: data.channel.id,
}).then(count => {
// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
if (count === 1) {
this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1);
}
});
}
// Register to search database
this.index(note);
}
private incRenoteCount(renote: Note) {
this.notesRepository.createQueryBuilder().update()
.set({
renoteCount: () => '"renoteCount" + 1',
score: () => '"score" + 1',
})
.where('id = :id', { id: renote.id })
.execute();
}
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) {
for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
userId: u.id,
threadId: note.threadId ?? note.id,
});
if (threadMuted) {
continue;
}
const detailPackedNote = await this.noteEntityService.pack(note, u, {
detail: true,
});
this.globalEventServie.publishMainStream(u.id, 'mention', detailPackedNote);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'mention', {
note: detailPackedNote,
});
}
// Create notification
nm.push(u.id, 'mention');
}
}
private saveReply(reply: Note, note: Note) {
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
}
private async renderNoteOrRenoteActivity(data: Option, note: Note) {
if (data.localOnly) return null;
const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note);
return this.apRendererService.renderActivity(content);
}
private index(note: Note) {
if (note.text == null || this.config.elasticsearch == null) return;
/*
es!.index({
index: this.config.elasticsearch.index ?? 'misskey_note',
id: note.id.toString(),
body: {
text: normalizeForSearch(note.text),
userId: note.userId,
userHost: note.userHost,
},
});*/
}
private incNotesCountOfUser(user: { id: User['id']; }) {
this.usersRepository.createQueryBuilder().update()
.set({
updatedAt: new Date(),
notesCount: () => '"notesCount" + 1',
})
.where('id = :id', { id: user.id })
.execute();
}
private async extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> {
if (tokens == null) return [];
const mentions = extractMentions(tokens);
let mentionedUsers = (await Promise.all(mentions.map(m =>
this.resolveUserService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
))).filter(x => x != null) as User[];
// Drop duplicate users
mentionedUsers = mentionedUsers.filter((u, i, self) =>
i === self.findIndex(u2 => u.id === u2.id),
);
return mentionedUsers;
}
}

View File

@ -0,0 +1,170 @@
import { Brackets, In } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common';
import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js';
import type { Note, IMentionedRemoteUsers } from '@/models/entities/Note.js';
import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/index.js';
import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import NotesChart from '@/core/chart/charts/notes.js';
import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { NoteEntityService } from './entities/NoteEntityService.js';
@Injectable()
export class NoteDeleteService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private globalEventServie: GlobalEventService,
private relayService: RelayService,
private federatedInstanceService: FederatedInstanceService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
private instanceChart: InstanceChart,
) {}
/**
* 稿
* @param user 稿
* @param note 稿
*/
async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false) {
const deletedAt = new Date();
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
this.notesRepository.decrement({ id: note.renoteId }, 'renoteCount', 1);
this.notesRepository.decrement({ id: note.renoteId }, 'score', 1);
}
if (note.replyId) {
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
}
if (!quiet) {
this.globalEventServie.publishNoteStream(note.id, 'deleted', {
deletedAt: deletedAt,
});
//#region ローカルの投稿なら削除アクティビティを配送
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
let renote: Note | null = null;
// if deletd note is renote
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
renote = await this.notesRepository.findOneBy({
id: note.renoteId,
});
}
const content = this.apRendererService.renderActivity(renote
? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user)
: this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
this.deliverToConcerned(user, note, content);
}
// also deliever delete activity to cascaded notes
const cascadingNotes = (await this.findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes
for (const cascadingNote of cascadingNotes) {
if (!cascadingNote.user) continue;
if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue;
const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
this.deliverToConcerned(cascadingNote.user, cascadingNote, content);
}
//#endregion
// 統計を更新
this.notesChart.update(note, false);
this.perUserNotesChart.update(user, note, false);
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => {
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
this.instanceChart.updateNote(i.host, note, false);
});
}
}
await this.notesRepository.delete({
id: note.id,
userId: user.id,
});
}
private async findCascadingNotes(note: Note) {
const cascadingNotes: Note[] = [];
const recursive = async (noteId: string) => {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.replyId = :noteId', { noteId })
.orWhere(new Brackets(q => {
q.where('note.renoteId = :noteId', { noteId })
.andWhere('note.text IS NOT NULL');
}))
.leftJoinAndSelect('note.user', 'user');
const replies = await query.getMany();
for (const reply of replies) {
cascadingNotes.push(reply);
await recursive(reply.id);
}
};
await recursive(note.id);
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
}
private async getMentionedRemoteUsers(note: Note) {
const where = [] as any[];
// mention / reply / dm
const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
if (uris.length > 0) {
where.push(
{ uri: In(uris) },
);
}
// renote / quote
if (note.renoteUserId) {
where.push({
id: note.renoteUserId,
});
}
if (where.length === 0) return [];
return await this.usersRepository.find({
where,
}) as IRemoteUser[];
}
private async deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any) {
this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content);
const remoteUsers = await this.getMentionedRemoteUsers(note);
for (const remoteUser of remoteUsers) {
this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
}
}
}

View File

@ -0,0 +1,117 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { User } from '@/models/entities/User.js';
import type { Note } from '@/models/entities/Note.js';
import { IdService } from '@/core/IdService.js';
import type { UserNotePining } from '@/models/entities/UserNotePining.js';
import { RelayService } from '@/core/RelayService.js';
import type { Config } from '@/config.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
@Injectable()
export class NotePiningService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private relayService: RelayService,
private apDeliverManagerService: ApDeliverManagerService,
private apRendererService: ApRendererService,
) {
}
/**
* 稿
* @param user
* @param noteId
*/
public async addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
// Fetch pinee
const note = await this.notesRepository.findOneBy({
id: noteId,
userId: user.id,
});
if (note == null) {
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
}
const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id });
if (pinings.length >= 5) {
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
}
if (pinings.some(pining => pining.noteId === note.id)) {
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
}
await this.userNotePiningsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: user.id,
noteId: note.id,
} as UserNotePining);
// Deliver to remote followers
if (this.userEntityService.isLocalUser(user)) {
this.deliverPinnedChange(user.id, note.id, true);
}
}
/**
* 稿
* @param user
* @param noteId
*/
public async removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
// Fetch unpinee
const note = await this.notesRepository.findOneBy({
id: noteId,
userId: user.id,
});
if (note == null) {
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.');
}
this.userNotePiningsRepository.delete({
userId: user.id,
noteId: note.id,
});
// Deliver to remote followers
if (this.userEntityService.isLocalUser(user)) {
this.deliverPinnedChange(user.id, noteId, false);
}
}
public async deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) {
const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) throw new Error('user not found');
if (!this.userEntityService.isLocalUser(user)) return;
const target = `${this.config.url}/users/${user.id}/collections/featured`;
const item = `${this.config.url}/notes/${noteId}`;
const content = this.apRendererService.renderActivity(isAddition ? this.apRendererService.renderAdd(user, target, item) : this.apRendererService.renderRemove(user, target, item));
this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content);
}
}

View File

@ -0,0 +1,215 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, IsNull, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { User } from '@/models/entities/User.js';
import type { Channel } from '@/models/entities/Channel.js';
import type { Packed } from '@/misc/schema.js';
import type { Note } from '@/models/entities/Note.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository, AntennaNotesRepository } from '@/models/index.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { NotificationService } from './NotificationService.js';
import { AntennaService } from './AntennaService.js';
@Injectable()
export class NoteReadService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.noteUnreadsRepository)
private noteUnreadsRepository: NoteUnreadsRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@Inject(DI.noteThreadMutingsRepository)
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.antennaNotesRepository)
private antennaNotesRepository: AntennaNotesRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private globalEventServie: GlobalEventService,
private notificationService: NotificationService,
private antennaService: AntennaService,
) {
}
public async insertNoteUnread(userId: User['id'], note: Note, params: {
// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
isSpecified: boolean;
isMentioned: boolean;
}): Promise<void> {
//#region ミュートしているなら無視
// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
const mute = await this.mutingsRepository.findBy({
muterId: userId,
});
if (mute.map(m => m.muteeId).includes(note.userId)) return;
//#endregion
// スレッドミュート
const threadMute = await this.noteThreadMutingsRepository.findOneBy({
userId: userId,
threadId: note.threadId ?? note.id,
});
if (threadMute) return;
const unread = {
id: this.idService.genId(),
noteId: note.id,
userId: userId,
isSpecified: params.isSpecified,
isMentioned: params.isMentioned,
noteChannelId: note.channelId,
noteUserId: note.userId,
};
await this.noteUnreadsRepository.insert(unread);
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(async () => {
const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id });
if (exist == null) return;
if (params.isMentioned) {
this.globalEventServie.publishMainStream(userId, 'unreadMention', note.id);
}
if (params.isSpecified) {
this.globalEventServie.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
}
if (note.channelId) {
this.globalEventServie.publishMainStream(userId, 'unreadChannel', note.id);
}
}, 2000);
}
public async read(
userId: User['id'],
notes: (Note | Packed<'Note'>)[],
info?: {
following: Set<User['id']>;
followingChannels: Set<Channel['id']>;
},
): Promise<void> {
const following = info?.following ? info.following : new Set<string>((await this.followingsRepository.find({
where: {
followerId: userId,
},
select: ['followeeId'],
})).map(x => x.followeeId));
const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await this.channelFollowingsRepository.find({
where: {
followerId: userId,
},
select: ['followeeId'],
})).map(x => x.followeeId));
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
const readMentions: (Note | Packed<'Note'>)[] = [];
const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
const readChannelNotes: (Note | Packed<'Note'>)[] = [];
const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
for (const note of notes) {
if (note.mentions && note.mentions.includes(userId)) {
readMentions.push(note);
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
readSpecifiedNotes.push(note);
}
if (note.channelId && followingChannels.has(note.channelId)) {
readChannelNotes.push(note);
}
if (note.user != null) { // たぶんnullになることは無いはずだけど一応
for (const antenna of myAntennas) {
if (await this.antennaService.checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) {
readAntennaNotes.push(note);
}
}
}
}
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
// Remove the record
await this.noteUnreadsRepository.delete({
userId: userId,
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]),
});
// TODO: ↓まとめてクエリしたい
this.noteUnreadsRepository.countBy({
userId: userId,
isMentioned: true,
}).then(mentionsCount => {
if (mentionsCount === 0) {
// 全て既読になったイベントを発行
this.globalEventServie.publishMainStream(userId, 'readAllUnreadMentions');
}
});
this.noteUnreadsRepository.countBy({
userId: userId,
isSpecified: true,
}).then(specifiedCount => {
if (specifiedCount === 0) {
// 全て既読になったイベントを発行
this.globalEventServie.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
}
});
this.noteUnreadsRepository.countBy({
userId: userId,
noteChannelId: Not(IsNull()),
}).then(channelNoteCount => {
if (channelNoteCount === 0) {
// 全て既読になったイベントを発行
this.globalEventServie.publishMainStream(userId, 'readAllChannels');
}
});
this.notificationService.readNotificationByQuery(userId, {
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
});
}
if (readAntennaNotes.length > 0) {
await this.antennaNotesRepository.update({
antennaId: In(myAntennas.map(a => a.id)),
noteId: In(readAntennaNotes.map(n => n.id)),
}, {
read: true,
});
// TODO: まとめてクエリしたい
for (const antenna of myAntennas) {
const count = await this.antennaNotesRepository.countBy({
antennaId: antenna.id,
read: false,
});
if (count === 0) {
this.globalEventServie.publishMainStream(userId, 'readAntenna', antenna);
}
}
this.userEntityService.getHasUnreadAntenna(userId).then(unread => {
if (!unread) {
this.globalEventServie.publishMainStream(userId, 'readAllAntennas');
}
});
}
}
}

View File

@ -0,0 +1,67 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { NotificationsRepository } from '@/models/index.js';
import type { UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import type { Notification } from '@/models/entities/Notification.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { GlobalEventService } from './GlobalEventService.js';
import { PushNotificationService } from './PushNotificationService.js';
@Injectable()
export class NotificationService {
constructor(
@Inject(DI.notificationsRepository)
private notificationsRepository: NotificationsRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
private pushNotificationService: PushNotificationService,
) {
}
public async readNotification(
userId: User['id'],
notificationIds: Notification['id'][],
) {
if (notificationIds.length === 0) return;
// Update documents
const result = await this.notificationsRepository.update({
notifieeId: userId,
id: In(notificationIds),
isRead: false,
}, {
isRead: true,
});
if (result.affected === 0) return;
if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId);
else return this.postReadNotifications(userId, notificationIds);
}
public async readNotificationByQuery(
userId: User['id'],
query: Record<string, any>,
) {
const notificationIds = await this.notificationsRepository.findBy({
...query,
notifieeId: userId,
isRead: false,
}).then(notifications => notifications.map(notification => notification.id));
return this.readNotification(userId, notificationIds);
}
private postReadAllNotifications(userId: User['id']) {
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
}
private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) {
this.globalEventService.publishMainStream(userId, 'readNotifications', notificationIds);
return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds });
}
}

View File

@ -0,0 +1,115 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, UsersRepository, BlockingsRepository, PollsRepository, PollVotesRepository } from '@/models/index.js';
import type { Note } from '@/models/entities/Note.js';
import { RelayService } from '@/core/RelayService.js';
import type { CacheableUser } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
@Injectable()
export class PollService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.pollVotesRepository)
private pollVotesRepository: PollVotesRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private relayService: RelayService,
private globalEventServie: GlobalEventService,
private createNotificationService: CreateNotificationService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
) {
}
public async vote(user: CacheableUser, note: Note, choice: number) {
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
if (poll == null) throw new Error('poll not found');
// Check whether is valid choice
if (poll.choices[choice] == null) throw new Error('invalid choice param');
// Check blocking
if (note.userId !== user.id) {
const block = await this.blockingsRepository.findOneBy({
blockerId: note.userId,
blockeeId: user.id,
});
if (block) {
throw new Error('blocked');
}
}
// if already voted
const exist = await this.pollVotesRepository.findBy({
noteId: note.id,
userId: user.id,
});
if (poll.multiple) {
if (exist.some(x => x.choice === choice)) {
throw new Error('already voted');
}
} else if (exist.length !== 0) {
throw new Error('already voted');
}
// Create vote
await this.pollVotesRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
noteId: note.id,
userId: user.id,
choice: choice,
});
// Increment votes count
const index = choice + 1; // In SQL, array index is 1 based
await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
this.globalEventServie.publishNoteStream(note.id, 'pollVoted', {
choice: choice,
userId: user.id,
});
// Notify
this.createNotificationService.createNotification(note.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id,
choice: choice,
});
}
public async deliverQuestionUpdate(noteId: Note['id']) {
const note = await this.notesRepository.findOneBy({ id: noteId });
if (note == null) throw new Error('note not found');
const user = await this.usersRepository.findOneBy({ id: note.userId });
if (user == null) throw new Error('note not found');
if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user));
this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content);
}
}
}

View File

@ -0,0 +1,22 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/index.js';
import type { ILocalUser, User } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js';
import { MetaService } from './MetaService.js';
@Injectable()
export class ProxyAccountService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private metaService: MetaService,
) {
}
public async fetch(): Promise<ILocalUser | null> {
const meta = await this.metaService.fetch();
if (meta.proxyAccountId == null) return null;
return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as ILocalUser;
}
}

View File

@ -0,0 +1,101 @@
import { Inject, Injectable } from '@nestjs/common';
import push from 'web-push';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { Packed } from '@/misc/schema';
import { getNoteSummary } from '@/misc/get-note-summary.js';
import type { SwSubscriptionsRepository } from '@/models/index.js';
import { MetaService } from './MetaService.js';
// Defined also packages/sw/types.ts#L14-L21
type pushNotificationsTypes = {
'notification': Packed<'Notification'>;
'unreadMessagingMessage': Packed<'MessagingMessage'>;
'readNotifications': { notificationIds: string[] };
'readAllNotifications': undefined;
'readAllMessagingMessages': undefined;
'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
};
// プッシュメッセージサーバーには文字数制限があるため、内容を削減します
function truncateNotification(notification: Packed<'Notification'>): any {
if (notification.note) {
return {
...notification,
note: {
...notification.note,
// textをgetNoteSummaryしたものに置き換える
text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
cw: undefined,
reply: undefined,
renote: undefined,
user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
},
};
}
return notification;
}
@Injectable()
export class PushNotificationService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.swSubscriptionsRepository)
private swSubscriptionsRepository: SwSubscriptionsRepository,
private metaService: MetaService,
) {
}
public async pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) {
const meta = await this.metaService.fetch();
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
push.setVapidDetails(this.config.url,
meta.swPublicKey,
meta.swPrivateKey);
// Fetch
const subscriptions = await this.swSubscriptionsRepository.findBy({
userId: userId,
});
for (const subscription of subscriptions) {
const pushSubscription = {
endpoint: subscription.endpoint,
keys: {
auth: subscription.auth,
p256dh: subscription.publickey,
},
};
push.sendNotification(pushSubscription, JSON.stringify({
type,
body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body,
userId,
dateTime: (new Date()).getTime(),
}), {
proxy: this.config.proxy,
}).catch((err: any) => {
//swLogger.info(err.statusCode);
//swLogger.info(err.headers);
//swLogger.info(err.body);
if (err.statusCode === 410) {
this.swSubscriptionsRepository.delete({
userId: userId,
endpoint: subscription.endpoint,
auth: subscription.auth,
publickey: subscription.publickey,
});
}
});
}
}
}

View File

@ -0,0 +1,263 @@
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { User } from '@/models/entities/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository } from '@/models/index.js';
import type { SelectQueryBuilder } from 'typeorm';
@Injectable()
export class QueryService {
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.noteThreadMutingsRepository)
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
) {
}
public makePaginationQuery<T>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder<T> {
if (sinceId && untilId) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.id`, 'DESC');
} else if (sinceId) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
q.orderBy(`${q.alias}.id`, 'ASC');
} else if (untilId) {
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.id`, 'DESC');
} else if (sinceDate && untilDate) {
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
q.orderBy(`${q.alias}.createdAt`, 'DESC');
} else if (sinceDate) {
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
q.orderBy(`${q.alias}.createdAt`, 'ASC');
} else if (untilDate) {
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
q.orderBy(`${q.alias}.createdAt`, 'DESC');
} else {
q.orderBy(`${q.alias}.id`, 'DESC');
}
return q;
}
// ここでいうBlockedは被Blockedの意
public generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
// 投稿の作者にブロックされていない かつ
// 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない
q
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb
.where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => { qb
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
}));
q.setParameters(blockingQuery.getParameters());
}
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockeeId')
.where('blocking.blockerId = :blockerId', { blockerId: me.id });
const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
q.setParameters(blockingQuery.getParameters());
q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
q.setParameters(blockedQuery.getParameters());
}
public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null): void {
if (me == null) {
q.andWhere('note.channelId IS NULL');
} else {
q.leftJoinAndSelect('note.channel', 'channel');
const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
.select('channelFollowing.followeeId')
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
q.andWhere(new Brackets(qb => { qb
// チャンネルのノートではない
.where('note.channelId IS NULL')
// または自分がフォローしているチャンネルのノート
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
}));
q.setParameters(channelFollowingQuery.getParameters());
}
}
public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
.select('muted.noteId')
.where('muted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.setParameters(mutedQuery.getParameters());
}
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('threadMuted.threadId')
.where('threadMuted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.andWhere(new Brackets(qb => { qb
.where('note.threadId IS NULL')
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
}));
q.setParameters(mutedQuery.getParameters());
}
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User): void {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
if (exclude) {
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
}
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: me.id });
// 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない
q
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb
.where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => { qb
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
// mute instances
.andWhere(new Brackets(qb => { qb
.andWhere('note.userHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where('note.replyUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
}
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
q.setParameters(mutingQuery.getParameters());
}
public generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null): void {
if (me == null) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
} else if (!me.showTimelineReplies) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
.where('note.replyId IS NOT NULL')
.andWhere('note.userId = :meId', { meId: me.id });
}))
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
}
}
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null): void {
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) {
q.andWhere(new Brackets(qb => { qb
.where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\'');
}));
} else {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { qb
// 公開投稿である
.where(new Brackets(qb => { qb
.where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\'');
}))
// または 自分自身
.orWhere('note.userId = :meId')
// または 自分宛て
.orWhere(':meId = ANY(note.visibleUserIds)')
.orWhere(':meId = ANY(note.mentions)')
.orWhere(new Brackets(qb => { qb
// または フォロワー宛ての投稿であり、
.where('note.visibility = \'followers\'')
.andWhere(new Brackets(qb => { qb
// 自分がフォロワーである
.where(`note.userId IN (${ followingQuery.getQuery() })`)
// または 自分の投稿へのリプライ
.orWhere('note.replyUserId = :meId');
}));
}));
}));
q.setParameters({ meId: me.id });
}
}
}

View File

@ -0,0 +1,242 @@
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import type { IActivity } from '@/core/remote/activitypub/type.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './queue/QueueModule.js';
import type { ThinUser } from '../queue/types.js';
import type httpSignature from '@peertube/http-signature';
@Injectable()
export class QueueService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
) {}
public deliver(user: ThinUser, content: IActivity | null, to: string | null) {
if (content == null) return null;
if (to == null) return null;
const data = {
user: {
id: user.id,
},
content,
to,
};
return this.deliverQueue.add(data, {
attempts: this.config.deliverJobMaxAttempts ?? 12,
timeout: 1 * 60 * 1000, // 1min
backoff: {
type: 'apBackoff',
},
removeOnComplete: true,
removeOnFail: true,
});
}
public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) {
const data = {
activity: activity,
signature,
};
return this.inboxQueue.add(data, {
attempts: this.config.inboxJobMaxAttempts ?? 8,
timeout: 5 * 60 * 1000, // 5min
backoff: {
type: 'apBackoff',
},
removeOnComplete: true,
removeOnFail: true,
});
}
public createDeleteDriveFilesJob(user: ThinUser) {
return this.dbQueue.add('deleteDriveFiles', {
user: user,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createExportCustomEmojisJob(user: ThinUser) {
return this.dbQueue.add('exportCustomEmojis', {
user: user,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createExportNotesJob(user: ThinUser) {
return this.dbQueue.add('exportNotes', {
user: user,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
return this.dbQueue.add('exportFollowing', {
user: user,
excludeMuting,
excludeInactive,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createExportMuteJob(user: ThinUser) {
return this.dbQueue.add('exportMuting', {
user: user,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createExportBlockingJob(user: ThinUser) {
return this.dbQueue.add('exportBlocking', {
user: user,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createExportUserListsJob(user: ThinUser) {
return this.dbQueue.add('exportUserLists', {
user: user,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importFollowing', {
user: user,
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importMuting', {
user: user,
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importBlocking', {
user: user,
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importUserLists', {
user: user,
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importCustomEmojis', {
user: user,
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
return this.dbQueue.add('deleteAccount', {
user: user,
soft: opts.soft,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createDeleteObjectStorageFileJob(key: string) {
return this.objectStorageQueue.add('deleteFile', {
key: key,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public createCleanRemoteFilesJob() {
return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
removeOnComplete: true,
removeOnFail: true,
});
}
public webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) {
const data = {
type,
content,
webhookId: webhook.id,
userId: webhook.userId,
to: webhook.url,
secret: webhook.secret,
createdAt: Date.now(),
eventId: uuid(),
};
return this.webhookDeliverQueue.add(data, {
attempts: 4,
timeout: 1 * 60 * 1000, // 1min
backoff: {
type: 'apBackoff',
},
removeOnComplete: true,
removeOnFail: true,
});
}
public destroy() {
this.deliverQueue.once('cleaned', (jobs, status) => {
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
this.deliverQueue.clean(0, 'delayed');
this.inboxQueue.once('cleaned', (jobs, status) => {
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
this.inboxQueue.clean(0, 'delayed');
}
}

View File

@ -0,0 +1,340 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { IRemoteUser, User } from '@/models/entities/User.js';
import type { Note } from '@/models/entities/Note.js';
import { IdService } from '@/core/IdService.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
import { emojiRegex } from '@/misc/emoji-regex.js';
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
import { NoteEntityService } from './entities/NoteEntityService.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
import { MetaService } from './MetaService.js';
import { UtilityService } from './UtilityService.js';
const legacies: Record<string, string> = {
'like': '👍',
'love': '❤', // ここに記述する場合は異体字セレクタを入れない
'laugh': '😆',
'hmm': '🤔',
'surprise': '😮',
'congrats': '🎉',
'angry': '💢',
'confused': '😥',
'rip': '😇',
'pudding': '🍮',
'star': '⭐',
};
type DecodedReaction = {
/**
* (Unicode Emoji or ':name@hostname' or ':name@.')
*/
reaction: string;
/**
* name (name, Emojiクエリに使う)
*/
name?: string;
/**
* host (host, Emojiクエリに使う)
*/
host?: string | null;
};
@Injectable()
export class ReactionService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private utilityService: UtilityService,
private metaService: MetaService,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
private globalEventServie: GlobalEventService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private createNotificationService: CreateNotificationService,
private perUserReactionsChart: PerUserReactionsChart,
) {
}
public async create(user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) {
// Check blocking
if (note.userId !== user.id) {
const block = await this.blockingsRepository.findOneBy({
blockerId: note.userId,
blockeeId: user.id,
});
if (block) {
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
}
}
// check visibility
if (!await this.noteEntityService.isVisibleForMe(note, user.id)) {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
}
// TODO: cache
reaction = await this.toDbReaction(reaction, user.host);
const record: NoteReaction = {
id: this.idService.genId(),
createdAt: new Date(),
noteId: note.id,
userId: user.id,
reaction,
};
// Create reaction
try {
await this.noteReactionsRepository.insert(record);
} catch (e) {
if (isDuplicateKeyValueError(e)) {
const exists = await this.noteReactionsRepository.findOneByOrFail({
noteId: note.id,
userId: user.id,
});
if (exists.reaction !== reaction) {
// 別のリアクションがすでにされていたら置き換える
await this.delete(user, note);
await this.noteReactionsRepository.insert(record);
} else {
// 同じリアクションがすでにされていたらエラー
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
}
} else {
throw e;
}
}
// Increment reactions count
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
await this.notesRepository.createQueryBuilder().update()
.set({
reactions: () => sql,
score: () => '"score" + 1',
})
.where('id = :id', { id: note.id })
.execute();
this.perUserReactionsChart.update(user, note);
// カスタム絵文字リアクションだったら絵文字情報も送る
const decodedReaction = this.decodeReaction(reaction);
const emoji = await this.emojisRepository.findOne({
where: {
name: decodedReaction.name,
host: decodedReaction.host ?? IsNull(),
},
select: ['name', 'host', 'originalUrl', 'publicUrl'],
});
this.globalEventServie.publishNoteStream(note.id, 'reacted', {
reaction: decodedReaction.reaction,
emoji: emoji != null ? {
name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`,
url: emoji.publicUrl ?? emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため
} : null,
userId: user.id,
});
// リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) {
this.createNotificationService.createNotification(note.userId, 'reaction', {
notifierId: user.id,
noteId: note.id,
reaction: reaction,
});
}
//#region 配信
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
const content = this.apRendererService.renderActivity(await this.apRendererService.renderLike(record, note));
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
dm.addDirectRecipe(reactee as IRemoteUser);
}
if (['public', 'home', 'followers'].includes(note.visibility)) {
dm.addFollowersRecipe();
} else if (note.visibility === 'specified') {
const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id })));
for (const u of visibleUsers.filter(u => u && this.userEntityService.isRemoteUser(u))) {
dm.addDirectRecipe(u as IRemoteUser);
}
}
dm.execute();
}
//#endregion
}
public async delete(user: { id: User['id']; host: User['host']; }, note: Note) {
// if already unreacted
const exist = await this.noteReactionsRepository.findOneBy({
noteId: note.id,
userId: user.id,
});
if (exist == null) {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
}
// Delete reaction
const result = await this.noteReactionsRepository.delete(exist.id);
if (result.affected !== 1) {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
}
// Decrement reactions count
const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
await this.notesRepository.createQueryBuilder().update()
.set({
reactions: () => sql,
})
.where('id = :id', { id: note.id })
.execute();
this.notesRepository.decrement({ id: note.id }, 'score', 1);
this.globalEventServie.publishNoteStream(note.id, 'unreacted', {
reaction: this.decodeReaction(exist.reaction).reaction,
userId: user.id,
});
//#region 配信
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
dm.addDirectRecipe(reactee as IRemoteUser);
}
dm.addFollowersRecipe();
dm.execute();
}
//#endregion
}
public async getFallbackReaction(): Promise<string> {
const meta = await this.metaService.fetch();
return meta.useStarForReactionFallback ? '⭐' : '👍';
}
public convertLegacyReactions(reactions: Record<string, number>) {
const _reactions = {} as Record<string, number>;
for (const reaction of Object.keys(reactions)) {
if (reactions[reaction] <= 0) continue;
if (Object.keys(legacies).includes(reaction)) {
if (_reactions[legacies[reaction]]) {
_reactions[legacies[reaction]] += reactions[reaction];
} else {
_reactions[legacies[reaction]] = reactions[reaction];
}
} else {
if (_reactions[reaction]) {
_reactions[reaction] += reactions[reaction];
} else {
_reactions[reaction] = reactions[reaction];
}
}
}
const _reactions2 = {} as Record<string, number>;
for (const reaction of Object.keys(_reactions)) {
_reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction];
}
return _reactions2;
}
public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> {
if (reaction == null) return await this.getFallbackReaction();
reacterHost = this.utilityService.toPunyNullable(reacterHost);
// 文字列タイプのリアクションを絵文字に変換
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
// Unicode絵文字
const match = emojiRegex.exec(reaction);
if (match) {
// 合字を含む1つの絵文字
const unicode = match[0];
// 異体字セレクタ除去
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
}
const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
if (custom) {
const name = custom[1];
const emoji = await this.emojisRepository.findOneBy({
host: reacterHost ?? IsNull(),
name,
});
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
}
return await this.getFallbackReaction();
}
public decodeReaction(str: string): DecodedReaction {
const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/);
if (custom) {
const name = custom[1];
const host = custom[2] ?? null;
return {
reaction: `:${name}@${host ?? '.'}:`, // ローカル分は@以降を省略するのではなく.にする
name,
host,
};
}
return {
reaction: str,
name: undefined,
host: undefined,
};
}
public convertLegacyReaction(reaction: string): string {
reaction = this.decodeReaction(reaction).reaction;
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
return reaction;
}
}

View File

@ -0,0 +1,118 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import type { ILocalUser, User } from '@/models/entities/User.js';
import type { RelaysRepository, UsersRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { Cache } from '@/misc/cache.js';
import type { Relay } from '@/models/entities/Relay.js';
import { QueueService } from '@/core/QueueService.js';
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js';
import { DI } from '@/di-symbols.js';
import { deepClone } from '@/misc/clone.js';
const ACTOR_USERNAME = 'relay.actor' as const;
@Injectable()
export class RelayService {
private relaysCache: Cache<Relay[]>;
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.relaysRepository)
private relaysRepository: RelaysRepository,
private idService: IdService,
private queueService: QueueService,
private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService,
) {
this.relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
}
private async getRelayActor(): Promise<ILocalUser> {
const user = await this.usersRepository.findOneBy({
host: IsNull(),
username: ACTOR_USERNAME,
});
if (user) return user as ILocalUser;
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME);
return created as ILocalUser;
}
public async addRelay(inbox: string): Promise<Relay> {
const relay = await this.relaysRepository.insert({
id: this.idService.genId(),
inbox,
status: 'requesting',
}).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0]));
const relayActor = await this.getRelayActor();
const follow = await this.apRendererService.renderFollowRelay(relay, relayActor);
const activity = this.apRendererService.renderActivity(follow);
this.queueService.deliver(relayActor, activity, relay.inbox);
return relay;
}
public async removeRelay(inbox: string): Promise<void> {
const relay = await this.relaysRepository.findOneBy({
inbox,
});
if (relay == null) {
throw new Error('relay not found');
}
const relayActor = await this.getRelayActor();
const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
const undo = this.apRendererService.renderUndo(follow, relayActor);
const activity = this.apRendererService.renderActivity(undo);
this.queueService.deliver(relayActor, activity, relay.inbox);
await this.relaysRepository.delete(relay.id);
}
public async listRelay(): Promise<Relay[]> {
const relays = await this.relaysRepository.find();
return relays;
}
public async relayAccepted(id: string): Promise<string> {
const result = await this.relaysRepository.update(id, {
status: 'accepted',
});
return JSON.stringify(result);
}
public async relayRejected(id: string): Promise<string> {
const result = await this.relaysRepository.update(id, {
status: 'rejected',
});
return JSON.stringify(result);
}
public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> {
if (activity == null) return;
const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({
status: 'accepted',
}));
if (relays.length === 0) return;
const copy = deepClone(activity);
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
const signed = await this.apRendererService.attachLdSignature(copy, user);
for (const relay of relays) {
this.queueService.deliver(user, signed, relay.inbox);
}
}
}

View File

@ -0,0 +1,38 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import S3 from 'aws-sdk/clients/s3.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { Meta } from '@/models/entities/Meta.js';
import { HttpRequestService } from './HttpRequestService.js';
@Injectable()
export class S3Service {
constructor(
@Inject(DI.config)
private config: Config,
private httpRequestService: HttpRequestService,
) {
}
public getS3(meta: Meta) {
const u = meta.objectStorageEndpoint != null
? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}`
: `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`;
return new S3({
endpoint: meta.objectStorageEndpoint ?? undefined,
accessKeyId: meta.objectStorageAccessKey!,
secretAccessKey: meta.objectStorageSecretKey!,
region: meta.objectStorageRegion ?? undefined,
sslEnabled: meta.objectStorageUseSSL,
s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted
? false
: meta.objectStorageS3ForcePathStyle,
httpOptions: {
agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy),
},
});
}
}

View File

@ -0,0 +1,141 @@
import { generateKeyPair } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { DataSource, IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { UsedUsernamesRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import { User } from '@/models/entities/User.js';
import { UserProfile } from '@/models/entities/UserProfile.js';
import { IdService } from '@/core/IdService.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UsedUsername } from '@/models/entities/UsedUsername.js';
import generateUserToken from '@/misc/generate-native-user-token.js';
import UsersChart from './chart/charts/users.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { UtilityService } from './UtilityService.js';
@Injectable()
export class SignupService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.usedUsernamesRepository)
private usedUsernamesRepository: UsedUsernamesRepository,
private utilityService: UtilityService,
private userEntityService: UserEntityService,
private idService: IdService,
private usersChart: UsersChart,
) {
}
public async signup(opts: {
username: User['username'];
password?: string | null;
passwordHash?: UserProfile['password'] | null;
host?: string | null;
}) {
const { username, password, passwordHash, host } = opts;
let hash = passwordHash;
// Validate username
if (!this.userEntityService.validateLocalUsername(username)) {
throw new Error('INVALID_USERNAME');
}
if (password != null && passwordHash == null) {
// Validate password
if (!this.userEntityService.validatePassword(password)) {
throw new Error('INVALID_PASSWORD');
}
// Generate hash of password
const salt = await bcrypt.genSalt(8);
hash = await bcrypt.hash(password, salt);
}
// Generate secret
const secret = generateUserToken();
// Check username duplication
if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) {
throw new Error('DUPLICATED_USERNAME');
}
// Check deleted username duplication
if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) {
throw new Error('USED_USERNAME');
}
const keyPair = await new Promise<string[]>((res, rej) =>
generateKeyPair('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: undefined,
passphrase: undefined,
},
} as any, (err, publicKey, privateKey) =>
err ? rej(err) : res([publicKey, privateKey]),
));
let account!: User;
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
const exist = await transactionalEntityManager.findOneBy(User, {
usernameLower: username.toLowerCase(),
host: IsNull(),
});
if (exist) throw new Error(' the username is already used');
account = await transactionalEntityManager.save(new User({
id: this.idService.genId(),
createdAt: new Date(),
username: username,
usernameLower: username.toLowerCase(),
host: this.utilityService.toPunyNullable(host),
token: secret,
isAdmin: (await this.usersRepository.countBy({
host: IsNull(),
})) === 0,
}));
await transactionalEntityManager.save(new UserKeypair({
publicKey: keyPair[0],
privateKey: keyPair[1],
userId: account.id,
}));
await transactionalEntityManager.save(new UserProfile({
userId: account.id,
autoAcceptFollowed: true,
password: hash,
}));
await transactionalEntityManager.save(new UsedUsername({
createdAt: new Date(),
username: username.toLowerCase(),
}));
});
this.usersChart.update(account, true);
return { account, secret };
}
}

View File

@ -0,0 +1,441 @@
import * as crypto from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import * as jsrsasign from 'jsrsasign';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
const ECC_PRELUDE = Buffer.from([0x04]);
const NULL_BYTE = Buffer.from([0]);
const PEM_PRELUDE = Buffer.from(
'3059301306072a8648ce3d020106082a8648ce3d030107034200',
'hex',
);
// Android Safetynet attestations are signed with this cert:
const GSR2 = `-----BEGIN CERTIFICATE-----
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
-----END CERTIFICATE-----\n`;
function base64URLDecode(source: string) {
return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
}
function getCertSubject(certificate: string) {
const subjectCert = new jsrsasign.X509();
subjectCert.readCertPEM(certificate);
const subjectString = subjectCert.getSubjectString();
const subjectFields = subjectString.slice(1).split('/');
const fields = {} as Record<string, string>;
for (const field of subjectFields) {
const eqIndex = field.indexOf('=');
fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
}
return fields;
}
function verifyCertificateChain(certificates: string[]) {
let valid = true;
for (let i = 0; i < certificates.length; i++) {
const Cert = certificates[i];
const certificate = new jsrsasign.X509();
certificate.readCertPEM(Cert);
const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]);
if (certStruct == null) throw new Error('certStruct is null');
const algorithm = certificate.getSignatureAlgorithmField();
const signatureHex = certificate.getSignatureValueHex();
// Verify against CA
const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm });
Signature.init(CACert);
Signature.updateHex(certStruct);
valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate
}
return valid;
}
function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) {
pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
type = 'PUBLIC KEY';
}
const cert = pemBuffer.toString('base64');
const keyParts = [];
const max = Math.ceil(cert.length / 64);
let start = 0;
for (let i = 0; i < max; i++) {
keyParts.push(cert.substring(start, start + 64));
start += 64;
}
return (
`-----BEGIN ${type}-----\n` +
keyParts.join('\n') +
`\n-----END ${type}-----\n`
);
}
@Injectable()
export class TwoFactorAuthenticationService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
) {
}
public hash(data: Buffer) {
return crypto
.createHash('sha256')
.update(data)
.digest();
}
public verifySignin({
publicKey,
authenticatorData,
clientDataJSON,
clientData,
signature,
challenge,
}: {
publicKey: Buffer,
authenticatorData: Buffer,
clientDataJSON: Buffer,
clientData: any,
signature: Buffer,
challenge: string
}) {
if (clientData.type !== 'webauthn.get') {
throw new Error('type is not webauthn.get');
}
if (this.hash(clientData.challenge).toString('hex') !== challenge) {
throw new Error('challenge mismatch');
}
if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
throw new Error('origin mismatch');
}
const verificationData = Buffer.concat(
[authenticatorData, this.hash(clientDataJSON)],
32 + authenticatorData.length,
);
return crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(publicKey), signature);
}
public getProcedures() {
return {
none: {
verify({ publicKey }: { publicKey: Map<number, Buffer> }) {
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
const negThree = publicKey.get(-3);
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyU2F = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
return {
publicKey: publicKeyU2F,
valid: true,
};
},
},
'android-key': {
verify({
attStmt,
authenticatorData,
clientDataHash,
publicKey,
rpIdHash,
credentialId,
}: {
attStmt: any,
authenticatorData: Buffer,
clientDataHash: Buffer,
publicKey: Map<number, any>;
rpIdHash: Buffer,
credentialId: Buffer,
}) {
if (attStmt.alg !== -7) {
throw new Error('alg mismatch');
}
const verificationData = Buffer.concat([
authenticatorData,
clientDataHash,
]);
const attCert: Buffer = attStmt.x5c[0];
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
const negThree = publicKey.get(-3);
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
if (!attCert.equals(publicKeyData)) {
throw new Error('public key mismatch');
}
const isValid = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
return {
valid: isValid,
publicKey: publicKeyData,
};
},
},
// what a stupid attestation
'android-safetynet': {
verify: ({
attStmt,
authenticatorData,
clientDataHash,
publicKey,
rpIdHash,
credentialId,
}: {
attStmt: any,
authenticatorData: Buffer,
clientDataHash: Buffer,
publicKey: Map<number, any>;
rpIdHash: Buffer,
credentialId: Buffer,
}) => {
const verificationData = this.hash(
Buffer.concat([authenticatorData, clientDataHash]),
);
const jwsParts = attStmt.response.toString('utf-8').split('.');
const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
const response = JSON.parse(
base64URLDecode(jwsParts[1]).toString('utf-8'),
);
const signature = jwsParts[2];
if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
throw new Error('invalid nonce');
}
const certificateChain = header.x5c
.map((key: any) => PEMString(key))
.concat([GSR2]);
if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') {
throw new Error('invalid common name');
}
if (!verifyCertificateChain(certificateChain)) {
throw new Error('Invalid certificate chain!');
}
const signatureBase = Buffer.from(
jwsParts[0] + '.' + jwsParts[1],
'utf-8',
);
const valid = crypto
.createVerify('sha256')
.update(signatureBase)
.verify(certificateChain[0], base64URLDecode(signature));
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
const negThree = publicKey.get(-3);
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
return {
valid,
publicKey: publicKeyData,
};
},
},
packed: {
verify({
attStmt,
authenticatorData,
clientDataHash,
publicKey,
rpIdHash,
credentialId,
}: {
attStmt: any,
authenticatorData: Buffer,
clientDataHash: Buffer,
publicKey: Map<number, any>;
rpIdHash: Buffer,
credentialId: Buffer,
}) {
const verificationData = Buffer.concat([
authenticatorData,
clientDataHash,
]);
if (attStmt.x5c) {
const attCert = attStmt.x5c[0];
const validSignature = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
const negThree = publicKey.get(-3);
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
return {
valid: validSignature,
publicKey: publicKeyData,
};
} else if (attStmt.ecdaaKeyId) {
// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
throw new Error('ECDAA-Verify is not supported');
} else {
if (attStmt.alg !== -7) throw new Error('alg mismatch');
throw new Error('self attestation is not supported');
}
},
},
'fido-u2f': {
verify({
attStmt,
authenticatorData,
clientDataHash,
publicKey,
rpIdHash,
credentialId,
}: {
attStmt: any,
authenticatorData: Buffer,
clientDataHash: Buffer,
publicKey: Map<number, any>,
rpIdHash: Buffer,
credentialId: Buffer
}) {
const x5c: Buffer[] = attStmt.x5c;
if (x5c.length !== 1) {
throw new Error('x5c length does not match expectation');
}
const attCert = x5c[0];
// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
const negTwo: Buffer = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
const negThree: Buffer = publicKey.get(-3);
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyU2F = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
const verificationData = Buffer.concat([
NULL_BYTE,
rpIdHash,
clientDataHash,
credentialId,
publicKeyU2F,
]);
const validSignature = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
return {
valid: validSignature,
publicKey: publicKeyU2F,
};
},
},
};
}
}

View File

@ -0,0 +1,207 @@
import { Inject, Injectable } from '@nestjs/common';
import { IdService } from '@/core/IdService.js';
import type { CacheableUser, User } from '@/models/entities/User.js';
import type { Blocking } from '@/models/entities/Blocking.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
import { DI } from '@/di-symbols.js';
import logger from '@/logger.js';
import type { UsersRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js';
import Logger from '@/logger.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { WebhookService } from './WebhookService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
import { LoggerService } from './LoggerService.js';
@Injectable()
export class UserBlockingService {
private logger: Logger;
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private queueService: QueueService,
private globalEventServie: GlobalEventService,
private webhookService: WebhookService,
private apRendererService: ApRendererService,
private perUserFollowingChart: PerUserFollowingChart,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('user-block');
}
public async block(blocker: User, blockee: User) {
await Promise.all([
this.cancelRequest(blocker, blockee),
this.cancelRequest(blockee, blocker),
this.unFollow(blocker, blockee),
this.unFollow(blockee, blocker),
this.removeFromList(blockee, blocker),
]);
const blocking = {
id: this.idService.genId(),
createdAt: new Date(),
blocker,
blockerId: blocker.id,
blockee,
blockeeId: blockee.id,
} as Blocking;
await this.blockingsRepository.insert(blocking);
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderBlock(blocking));
this.queueService.deliver(blocker, content, blockee.inbox);
}
}
private async cancelRequest(follower: User, followee: User) {
const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
});
if (request == null) {
return;
}
await this.followRequestsRepository.delete({
followeeId: followee.id,
followerId: follower.id,
});
if (this.userEntityService.isLocalUser(followee)) {
this.userEntityService.pack(followee, followee, {
detail: true,
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
}
if (this.userEntityService.isLocalUser(follower)) {
this.userEntityService.pack(followee, follower, {
detail: true,
}).then(async packed => {
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'unfollow', {
user: packed,
});
}
});
}
// リモートにフォローリクエストをしていたらUndoFollow送信
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
this.queueService.deliver(follower, content, followee.inbox);
}
// リモートからフォローリクエストを受けていたらReject送信
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
this.queueService.deliver(followee, content, follower.inbox);
}
}
private async unFollow(follower: User, followee: User) {
const following = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: followee.id,
});
if (following == null) {
return;
}
await Promise.all([
this.followingsRepository.delete(following.id),
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
this.perUserFollowingChart.update(follower, followee, false),
]);
// Publish unfollow event
if (this.userEntityService.isLocalUser(follower)) {
this.userEntityService.pack(followee, follower, {
detail: true,
}).then(async packed => {
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'unfollow', {
user: packed,
});
}
});
}
// リモートにフォローをしていたらUndoFollow送信
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
this.queueService.deliver(follower, content, followee.inbox);
}
}
private async removeFromList(listOwner: User, user: User) {
const userLists = await this.userListsRepository.findBy({
userId: listOwner.id,
});
for (const userList of userLists) {
await this.userListJoiningsRepository.delete({
userListId: userList.id,
userId: user.id,
});
}
}
public async unblock(blocker: CacheableUser, blockee: CacheableUser) {
const blocking = await this.blockingsRepository.findOneBy({
blockerId: blocker.id,
blockeeId: blockee.id,
});
if (blocking == null) {
this.logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした');
return;
}
// Since we already have the blocker and blockee, we do not need to fetch
// them in the query above and can just manually insert them here.
blocking.blocker = blocker;
blocking.blockee = blockee;
await this.blockingsRepository.delete(blocking.id);
// deliver if remote bloking
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker));
this.queueService.deliver(blocker, content, blockee.inbox);
}
}
}

View File

@ -0,0 +1,74 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import type { UsersRepository } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import type { CacheableLocalUser, CacheableUser, ILocalUser } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from './entities/UserEntityService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class UserCacheService implements OnApplicationShutdown {
public userByIdCache: Cache<CacheableUser>;
public localUserByNativeTokenCache: Cache<CacheableLocalUser | null>;
public localUserByIdCache: Cache<CacheableLocalUser>;
public uriPersonCache: Cache<CacheableUser | null>;
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private userEntityService: UserEntityService,
) {
this.onMessage = this.onMessage.bind(this);
this.userByIdCache = new Cache<CacheableUser>(Infinity);
this.localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(Infinity);
this.localUserByIdCache = new Cache<CacheableLocalUser>(Infinity);
this.uriPersonCache = new Cache<CacheableUser | null>(Infinity);
this.redisSubscriber.on('message', this.onMessage);
}
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message;
switch (type) {
case 'userChangeSuspendedState':
case 'userChangeSilencedState':
case 'userChangeModeratorState':
case 'remoteUserUpdated': {
const user = await this.usersRepository.findOneByOrFail({ id: body.id });
this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === user.id) {
this.uriPersonCache.set(k, user);
}
}
if (this.userEntityService.isLocalUser(user)) {
this.localUserByNativeTokenCache.set(user.token, user);
this.localUserByIdCache.set(user.id, user);
}
break;
}
case 'userTokenRegenerated': {
const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as ILocalUser;
this.localUserByNativeTokenCache.delete(body.oldToken);
this.localUserByNativeTokenCache.set(body.newToken, user);
break;
}
default:
break;
}
}
}
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
}
}

View File

@ -0,0 +1,575 @@
import { Inject, Injectable } from '@nestjs/common';
import type { CacheableUser, ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueService } from '@/core/QueueService.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import type { Packed } from '@/misc/schema.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { WebhookService } from '@/core/WebhookService.js';
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
import { DI } from '@/di-symbols.js';
import type { BlockingsRepository, FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import Logger from '../logger.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
const logger = new Logger('following/create');
type Local = ILocalUser | {
id: ILocalUser['id'];
host: ILocalUser['host'];
uri: ILocalUser['uri']
};
type Remote = IRemoteUser | {
id: IRemoteUser['id'];
host: IRemoteUser['host'];
uri: IRemoteUser['uri'];
inbox: IRemoteUser['inbox'];
};
type Both = Local | Remote;
@Injectable()
export class UserFollowingService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private queueService: QueueService,
private globalEventServie: GlobalEventService,
private createNotificationService: CreateNotificationService,
private federatedInstanceService: FederatedInstanceService,
private webhookService: WebhookService,
private apRendererService: ApRendererService,
private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart,
) {
}
public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> {
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }),
]);
// check blocking
const [blocking, blocked] = await Promise.all([
this.blockingsRepository.findOneBy({
blockerId: follower.id,
blockeeId: followee.id,
}),
this.blockingsRepository.findOneBy({
blockerId: followee.id,
blockeeId: follower.id,
}),
]);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) {
// リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee));
this.queueService.deliver(followee, content, follower.inbox);
return;
} else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) {
// リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。
await this.blockingsRepository.delete(blocking.id);
} else {
// それ以外は単純に例外
if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
}
const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
// フォロー対象が鍵アカウントである or
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
let autoAccept = false;
// 鍵アカウントであっても、既にフォローされていた場合はスルー
const following = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: followee.id,
});
if (following) {
autoAccept = true;
}
// フォローしているユーザーは自動承認オプション
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
const followed = await this.followingsRepository.findOneBy({
followerId: followee.id,
followeeId: follower.id,
});
if (followed) autoAccept = true;
}
if (!autoAccept) {
await this.createFollowRequest(follower, followee, requestId);
return;
}
}
await this.insertFollowingDoc(followee, follower);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
this.queueService.deliver(followee, content, follower.inbox);
}
}
private async insertFollowingDoc(
followee: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
},
follower: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
},
): Promise<void> {
if (follower.id === followee.id) return;
let alreadyFollowed = false as boolean;
await this.followingsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
followerId: follower.id,
followeeId: followee.id,
// 非正規化
followerHost: follower.host,
followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null,
followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : null,
followeeHost: followee.host,
followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : null,
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null,
}).catch(err => {
if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
alreadyFollowed = true;
} else {
throw err;
}
});
const req = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
});
if (req) {
await this.followRequestsRepository.delete({
followeeId: followee.id,
followerId: follower.id,
});
// 通知を作成
this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', {
notifierId: followee.id,
});
}
if (alreadyFollowed) return;
//#region Increment counts
await Promise.all([
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
]);
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
this.instanceChart.updateFollowing(i.host, true);
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
this.instanceChart.updateFollowers(i.host, true);
});
}
//#endregion
this.perUserFollowingChart.update(follower, followee, true);
// Publish follow event
if (this.userEntityService.isLocalUser(follower)) {
this.userEntityService.pack(followee.id, follower, {
detail: true,
}).then(async packed => {
this.globalEventServie.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
this.globalEventServie.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'follow', {
user: packed,
});
}
});
}
// Publish followed event
if (this.userEntityService.isLocalUser(followee)) {
this.userEntityService.pack(follower.id, followee).then(async packed => {
this.globalEventServie.publishMainStream(followee.id, 'followed', packed);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'followed', {
user: packed,
});
}
});
// 通知を作成
this.createNotificationService.createNotification(followee.id, 'follow', {
notifierId: follower.id,
});
}
}
public async unfollow(
follower: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
},
followee: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
},
silent = false,
): Promise<void> {
const following = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: followee.id,
});
if (following == null) {
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
return;
}
await this.followingsRepository.delete(following.id);
this.decrementFollowing(follower, followee);
// Publish unfollow event
if (!silent && this.userEntityService.isLocalUser(follower)) {
this.userEntityService.pack(followee.id, follower, {
detail: true,
}).then(async packed => {
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'unfollow', {
user: packed,
});
}
});
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
this.queueService.deliver(follower, content, followee.inbox);
}
if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) {
// local user has null host
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee));
this.queueService.deliver(followee, content, follower.inbox);
}
}
private async decrementFollowing(
follower: {id: User['id']; host: User['host']; },
followee: { id: User['id']; host: User['host']; },
): Promise<void> {
//#region Decrement following / followers counts
await Promise.all([
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
]);
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
this.instanceChart.updateFollowing(i.host, false);
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
this.instanceChart.updateFollowers(i.host, false);
});
}
//#endregion
this.perUserFollowingChart.update(follower, followee, false);
}
public async createFollowRequest(
follower: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
},
followee: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
},
requestId?: string,
): Promise<void> {
if (follower.id === followee.id) return;
// check blocking
const [blocking, blocked] = await Promise.all([
this.blockingsRepository.findOneBy({
blockerId: follower.id,
blockeeId: followee.id,
}),
this.blockingsRepository.findOneBy({
blockerId: followee.id,
blockeeId: follower.id,
}),
]);
if (blocking != null) throw new Error('blocking');
if (blocked != null) throw new Error('blocked');
const followRequest = await this.followRequestsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
followerId: follower.id,
followeeId: followee.id,
requestId,
// 非正規化
followerHost: follower.host,
followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined,
followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : undefined,
followeeHost: followee.host,
followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined,
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined,
}).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0]));
// Publish receiveRequest event
if (this.userEntityService.isLocalUser(followee)) {
this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventServie.publishMainStream(followee.id, 'receiveFollowRequest', packed));
this.userEntityService.pack(followee.id, followee, {
detail: true,
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
// 通知を作成
this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', {
notifierId: follower.id,
followRequestId: followRequest.id,
});
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee));
this.queueService.deliver(follower, content, followee.inbox);
}
}
public async cancelFollowRequest(
followee: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']
},
follower: {
id: User['id']; host: User['host']; uri: User['host']
},
): Promise<void> {
if (this.userEntityService.isRemoteUser(followee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
this.queueService.deliver(follower, content, followee.inbox);
}
}
const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
});
if (request == null) {
throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
}
await this.followRequestsRepository.delete({
followeeId: followee.id,
followerId: follower.id,
});
this.userEntityService.pack(followee.id, followee, {
detail: true,
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
}
public async acceptFollowRequest(
followee: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
},
follower: CacheableUser,
): Promise<void> {
const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
});
if (request == null) {
throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.');
}
await this.insertFollowingDoc(followee, follower);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
this.queueService.deliver(followee, content, follower.inbox);
}
this.userEntityService.pack(followee.id, followee, {
detail: true,
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
}
public async acceptAllFollowRequests(
user: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
},
): Promise<void> {
const requests = await this.followRequestsRepository.findBy({
followeeId: user.id,
});
for (const request of requests) {
const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId });
this.acceptFollowRequest(user, follower);
}
}
/**
* API following/request/reject
*/
public async rejectFollowRequest(user: Local, follower: Both): Promise<void> {
if (this.userEntityService.isRemoteUser(follower)) {
this.deliverReject(user, follower);
}
await this.removeFollowRequest(user, follower);
if (this.userEntityService.isLocalUser(follower)) {
this.publishUnfollow(user, follower);
}
}
/**
* API following/reject
*/
public async rejectFollow(user: Local, follower: Both): Promise<void> {
if (this.userEntityService.isRemoteUser(follower)) {
this.deliverReject(user, follower);
}
await this.removeFollow(user, follower);
if (this.userEntityService.isLocalUser(follower)) {
this.publishUnfollow(user, follower);
}
}
/**
* AP Reject/Follow
*/
public async remoteReject(actor: Remote, follower: Local): Promise<void> {
await this.removeFollowRequest(actor, follower);
await this.removeFollow(actor, follower);
this.publishUnfollow(actor, follower);
}
/**
* Remove follow request record
*/
private async removeFollowRequest(followee: Both, follower: Both): Promise<void> {
const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
});
if (!request) return;
await this.followRequestsRepository.delete(request.id);
}
/**
* Remove follow record
*/
private async removeFollow(followee: Both, follower: Both): Promise<void> {
const following = await this.followingsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
});
if (!following) return;
await this.followingsRepository.delete(following.id);
this.decrementFollowing(follower, followee);
}
/**
* Deliver Reject to remote
*/
private async deliverReject(followee: Local, follower: Remote): Promise<void> {
const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
});
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee));
this.queueService.deliver(followee, content, follower.inbox);
}
/**
* Publish unfollow to local
*/
private async publishUnfollow(followee: Both, follower: Local): Promise<void> {
const packedFollowee = await this.userEntityService.pack(followee.id, follower, {
detail: true,
});
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packedFollowee);
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packedFollowee);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'unfollow', {
user: packedFollowee,
});
}
}
}

View File

@ -0,0 +1,22 @@
import { Inject, Injectable } from '@nestjs/common';
import type { User } from '@/models/entities/User.js';
import type { UserKeypairsRepository } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
import { DI } from '@/di-symbols.js';
@Injectable()
export class UserKeypairStoreService {
private cache: Cache<UserKeypair>;
constructor(
@Inject(DI.userKeypairsRepository)
private userKeypairsRepository: UserKeypairsRepository,
) {
this.cache = new Cache<UserKeypair>(Infinity);
}
public async getUserKeypair(userId: User['id']): Promise<UserKeypair> {
return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId }));
}
}

View File

@ -0,0 +1,48 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import type { UserList } from '@/models/entities/UserList.js';
import type { UserListJoining } from '@/models/entities/UserListJoining.js';
import { IdService } from '@/core/IdService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from './entities/UserEntityService.js';
import { ProxyAccountService } from './ProxyAccountService.js';
@Injectable()
export class UserListService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private userFollowingService: UserFollowingService,
private globalEventServie: GlobalEventService,
private proxyAccountService: ProxyAccountService,
) {
}
public async push(target: User, list: UserList) {
await this.userListJoiningsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: target.id,
userListId: list.id,
} as UserListJoining);
this.globalEventServie.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
if (this.userEntityService.isRemoteUser(target)) {
const proxy = await this.proxyAccountService.fetch();
if (proxy) {
this.userFollowingService.follow(proxy, target);
}
}
}
}

View File

@ -0,0 +1,32 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, MutingsRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { User } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js';
@Injectable()
export class UserMutingService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
private idService: IdService,
private queueService: QueueService,
private globalEventServie: GlobalEventService,
) {
}
public async mute(user: User, target: User): Promise<void> {
await this.mutingsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
muterId: user.id,
muteeId: target.id,
});
}
}

View File

@ -0,0 +1,88 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
import { UserEntityService } from './entities/UserEntityService.js';
@Injectable()
export class UserSuspendService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
) {
}
public async doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user, content, inbox);
}
}
}
public async doPostUnsuspend(user: User): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user as any, content, inbox);
}
}
}
}

View File

@ -0,0 +1,37 @@
import { URL } from 'node:url';
import { toASCII } from 'punycode';
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@Injectable()
export class UtilityService {
constructor(
@Inject(DI.config)
private config: Config,
) {
}
public getFullApAccount(username: string, host: string | null): string {
return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`;
}
public isSelfHost(host: string | null): boolean {
if (host == null) return true;
return this.toPuny(this.config.host) === this.toPuny(host);
}
public extractDbHost(uri: string): string {
const url = new URL(uri);
return this.toPuny(url.hostname);
}
public toPuny(host: string): string {
return toASCII(host.toLowerCase());
}
public toPunyNullable(host: string | null | undefined): string | null {
if (host == null) return null;
return toASCII(host.toLowerCase());
}
}

View File

@ -0,0 +1,44 @@
import { Inject, Injectable } from '@nestjs/common';
import FFmpeg from 'fluent-ffmpeg';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
import type { IImage } from '@/core/ImageProcessingService.js';
import { createTempDir } from '@/misc/create-temp.js';
@Injectable()
export class VideoProcessingService {
constructor(
@Inject(DI.config)
private config: Config,
private imageProcessingService: ImageProcessingService,
) {
}
public async generateVideoThumbnail(source: string): Promise<IImage> {
const [dir, cleanup] = await createTempDir();
try {
await new Promise((res, rej) => {
FFmpeg({
source,
})
.on('end', res)
.on('error', rej)
.screenshot({
folder: dir,
filename: 'out.png', // must have .png extension
count: 1,
timestamps: ['5%'],
});
});
// JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる)
return await this.imageProcessingService.convertToJpeg(`${dir}/out.png`, 498, 280);
} finally {
cleanup();
}
}
}

View File

@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import type { WebhooksRepository } from '@/models/index.js';
import type { Webhook } from '@/models/entities/Webhook.js';
import { DI } from '@/di-symbols.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class WebhookService implements OnApplicationShutdown {
private webhooksFetched = false;
private webhooks: Webhook[] = [];
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
) {
this.onMessage = this.onMessage.bind(this);
this.redisSubscriber.on('message', this.onMessage);
}
public async getActiveWebhooks() {
if (!this.webhooksFetched) {
this.webhooks = await this.webhooksRepository.findBy({
active: true,
});
this.webhooksFetched = true;
}
return this.webhooks;
}
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message;
switch (type) {
case 'webhookCreated':
if (body.active) {
this.webhooks.push(body);
}
break;
case 'webhookUpdated':
if (body.active) {
const i = this.webhooks.findIndex(a => a.id === body.id);
if (i > -1) {
this.webhooks[i] = body;
} else {
this.webhooks.push(body);
}
} else {
this.webhooks = this.webhooks.filter(a => a.id !== body.id);
}
break;
case 'webhookDeleted':
this.webhooks = this.webhooks.filter(a => a.id !== body.id);
break;
default:
break;
}
}
}
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
}
}

View File

@ -0,0 +1,14 @@
import { Inject, Injectable } from '@nestjs/common';
import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
@Injectable()
export class ChartLoggerService {
public logger: Logger;
constructor(
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('chart', 'white', process.env.NODE_ENV !== 'test');
}
}

View File

@ -0,0 +1,67 @@
import { Injectable, Inject } from '@nestjs/common';
import FederationChart from './charts/federation.js';
import NotesChart from './charts/notes.js';
import UsersChart from './charts/users.js';
import ActiveUsersChart from './charts/active-users.js';
import InstanceChart from './charts/instance.js';
import PerUserNotesChart from './charts/per-user-notes.js';
import DriveChart from './charts/drive.js';
import PerUserReactionsChart from './charts/per-user-reactions.js';
import HashtagChart from './charts/hashtag.js';
import PerUserFollowingChart from './charts/per-user-following.js';
import PerUserDriveChart from './charts/per-user-drive.js';
import ApRequestChart from './charts/ap-request.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class ChartManagementService implements OnApplicationShutdown {
private charts;
private saveIntervalId: NodeJS.Timer;
constructor(
private federationChart: FederationChart,
private notesChart: NotesChart,
private usersChart: UsersChart,
private activeUsersChart: ActiveUsersChart,
private instanceChart: InstanceChart,
private perUserNotesChart: PerUserNotesChart,
private driveChart: DriveChart,
private perUserReactionsChart: PerUserReactionsChart,
private hashtagChart: HashtagChart,
private perUserFollowingChart: PerUserFollowingChart,
private perUserDriveChart: PerUserDriveChart,
private apRequestChart: ApRequestChart,
) {
this.charts = [
this.federationChart,
this.notesChart,
this.usersChart,
this.activeUsersChart,
this.instanceChart,
this.perUserNotesChart,
this.driveChart,
this.perUserReactionsChart,
this.hashtagChart,
this.perUserFollowingChart,
this.perUserDriveChart,
this.apRequestChart,
];
}
public async run() {
// 20分おきにメモリ情報をDBに書き込み
this.saveIntervalId = setInterval(() => {
for (const chart of this.charts) {
chart.save();
}
}, 1000 * 60 * 20);
}
async onApplicationShutdown(signal: string): Promise<void> {
clearInterval(this.saveIntervalId);
await Promise.all(
this.charts.map(chart => chart.save()),
);
}
}

View File

@ -1,7 +1,12 @@
import Chart, { KVs } from '../core.js';
import { User } from '@/models/entities/user.js';
import { Users } from '@/models/index.js';
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AppLockService } from '@/core/AppLockService.js';
import type { User } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/active-users.js';
import type { KVs } from '../core.js';
const week = 1000 * 60 * 60 * 24 * 7;
const month = 1000 * 60 * 60 * 24 * 30;
@ -11,9 +16,16 @@ const year = 1000 * 60 * 60 * 24 * 365;
*
*/
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class ActiveUsersChart extends Chart<typeof schema> {
constructor() {
super(name, schema);
constructor(
@Inject(DI.db)
private db: DataSource,
private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService,
) {
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -1,13 +1,26 @@
import Chart, { KVs } from '../core.js';
import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/ap-request.js';
import type { KVs } from '../core.js';
/**
* Chart about ActivityPub requests
*/
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class ApRequestChart extends Chart<typeof schema> {
constructor() {
super(name, schema);
constructor(
@Inject(DI.db)
private db: DataSource,
private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService,
) {
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -1,16 +1,27 @@
import Chart, { KVs } from '../core.js';
import { DriveFiles } from '@/models/index.js';
import { Not, IsNull } from 'typeorm';
import { DriveFile } from '@/models/entities/drive-file.js';
import { Injectable, Inject } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js';
import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/drive.js';
import type { KVs } from '../core.js';
/**
*
*/
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class DriveChart extends Chart<typeof schema> {
constructor() {
super(name, schema);
constructor(
@Inject(DI.db)
private db: DataSource,
private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService,
) {
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema);
}
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

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