Compare commits

..

22 Commits

Author SHA1 Message Date
syuilo
407a965c1d Merge pull request #10932 from misskey-dev/develop
Release: 13.13.0
2023-06-05 19:47:08 +09:00
syuilo
de6348e8a0 Merge pull request #10833 from misskey-dev/develop
* refactor(frontend): use css modules

* feat: 投稿したコンテンツのAIによる学習を軽減するオプションを追加

Resolve #10819

* enhance(backend): publicReactionsをデフォルトtrueに

* 念のためnoimageaiもつける

* add X-Robots-Tag: noai

* Update ja-JP.yml

* fix(frontend): ブラーエフェクトを有効にしている状態で高負荷になる問題を修正

* enhance(backend): graceful shutdown for job queue and refactor

* fix(backend): テスト時は一部のサービスを停止

* fix test

* New Crowdin updates (#10815)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (Chinese Traditional)

* refactor

* bump

* refactor(frontend): use css module

* refactor(frontend): use css module

* delete unused component

* センシティブワードを正規表現、CWにも適用するように (#10688)

* cwにセンシティブが効いてない

* CWが無いときにTextを見るように

* 比較演算子間違えた

* とりあえずチェック

* 正規表現対応

* /test/giにも対応

* matchでしなくてもいいのでは感

* レビュー修正

* Update packages/backend/src/core/NoteCreateService.ts

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

* Update packages/backend/src/core/NoteCreateService.ts

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

* 修正

* wipかも

* wordsでスペース区切りのものできたかも

* なんか動いたかも

* test作成

* 文言の修正

* 修正

* note参照

---------

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

* Update CHANGELOG.md

* New Crowdin updates (#10823)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (German)

* ci: fix typo

* fix(frontend): より明確な説明にしたのとtypo修正

* fix typo

* fix(frontend): カラーバーがリプライには表示されないのを修正

* fix(frontend): チャンネル内の検索ボックスが挙動不審な問題を修正

Fix #10793

* enhance(backend): ノートのハッシュタグもMeilisearchに突っ込むように

今後ハッシュタグ検索とか実装するときのため

* feat(frontend): ユーザー指定ノート検索

* fix(frontend): fix retention chart rendering

* Update about-misskey.vue

* meta: Remove @rinsuki from reviewer-lottery (#10830)

* New Crowdin updates (#10824)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Norwegian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Indonesian)

* New translations ja-JP.yml (Thai)

* enhance(frontend): アカウント初期設定ウィザードにプライバシー設定を追加

* Update CHANGELOG.md

* fix(backend): ひとつのMeilisearchサーバーを複数のMisskeyサーバーで使えない問題を修正

* fix MkUserSetupDialog.Privacy.vue

* ci: skip non-Japanese locale on TurboSnap

* ci: notify on changes for push events

* ci: fix missing branch

* Update basic.cy.js

* [ci skip] New Crowdin updates (#10834)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Arabic)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Arabic)

* 🎨

* 🎨

* enhance(frontend): add retention line chart

* update deps

* refactor

* fix(frontend): Pageにおいて画像ブロックに画像を設定できない問題を修正

Fix #10837

---------

Co-authored-by: nenohi <kimutipartylove@gmail.com>
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
Co-authored-by: rinsuki <428rinsuki+git@gmail.com>
2023-05-12 12:41:53 +09:00
syuilo
9ad57324db Merge pull request #10814 from misskey-dev/develop
Release: 13.12.1
2023-05-09 15:38:17 +09:00
syuilo
94690c835e Merge pull request #10774 from misskey-dev/develop
Release: 13.12.0
2023-05-09 09:17:34 +09:00
syuilo
c5d2dba28d Merge pull request #10608 from misskey-dev/develop
Release: 13.11.3
2023-04-13 12:18:07 +09:00
syuilo
272e0c874f Merge pull request #10606 from misskey-dev/EbiseLutica-patch-1
Update CHANGELOG.md
2023-04-13 08:35:14 +09:00
Ebise Lutica
d429f810a9 Update CHANGELOG.md 2023-04-13 00:31:22 +09:00
syuilo
75b28d6782 Merge pull request #10578 from misskey-dev/develop
Release: 13.11.2
2023-04-11 15:51:07 +09:00
syuilo
8b1362ab03 Merge pull request #10543 from misskey-dev/develop
Release: 13.11.1
2023-04-09 10:29:36 +09:00
syuilo
a096f621cf Merge pull request #10506 from misskey-dev/develop
13.11.0
2023-04-08 21:27:21 +09:00
syuilo
f54a9542bb Merge pull request #10402 from misskey-dev/develop
Release: 13.10.3
2023-03-25 08:36:41 +09:00
syuilo
a52bbc7c8d Merge pull request #10388 from misskey-dev/develop
Release: 13.10.2
2023-03-22 18:47:10 +09:00
syuilo
59768bdf3f Merge pull request #10383 from misskey-dev/develop
Release: 13.10.1
2023-03-22 16:30:36 +09:00
syuilo
1e67e9c661 Merge pull request #10342 from misskey-dev/develop
Release: 13.10.0
2023-03-22 09:55:38 +09:00
syuilo
ae517a99a7 Merge pull request #10218 from misskey-dev/develop
Release: 13.9.2
2023-03-06 11:54:12 +09:00
syuilo
b23a9b1a88 Merge pull request #10181 from misskey-dev/develop
Release: 13.9.1
2023-03-03 20:56:50 +09:00
syuilo
5bd68aa3e0 Merge pull request #10177 from misskey-dev/develop
Release: 13.9.0
2023-03-03 15:35:40 +09:00
syuilo
647ce174b3 Merge pull request #10112 from misskey-dev/develop
Release: 13.8.1
2023-02-26 20:57:13 +09:00
syuilo
02c8fd9de5 Merge pull request #10108 from misskey-dev/develop
* Add dialog to remove follower (#9718)

* update PULL_REQUEST_TEMPLATE

* 起動時にRedisの疎通確認を行う (#9832)

* 起動時にRedisの疎通確認を行う

* check:connectをstart内に移動

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* Pass `--detectOpenHandles` to Jest (#9895)

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* enhance(client): MkUrlPreviewの閉じるボタンを見やすく (#9913)

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* test(backend): restore ap-request tests (#9997)

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* fix/refaftor(client): MkTime.vueの変更 (#10061)

* fix(client): MkTime.timeにstringでもDateでない値が入った場合、?を表示

* fix(client): MkTimeを改良

* numberを許容

* falsyな値もとる

* 不明

* ありません

* fix

* fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする (#9911)

* fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする

* NO_SUCH_FILE

* Update codecov.yml

* Update apple-touch-icon.png

* デプロイされているプレビュー環境がない場合はプレビュー環境を削除しないようにする (#10062)

* デプロイされているプレビュー環境がない場合はDestroy preview environmentを実行しないようにする

* CIがない場合の処理追加

* enhance(client): improve clip menu ux

* 未知のユーザーが deleteActor されたら処理をスキップする (#10067)

* fix(client): Android ChromeでPWAとしてインストールできない問題を修正 (#10069)

* fix(client): Android ChromeでPWAとしてインストールできない問題を修正

* 順番関係ある?

* Windows環境でswcを使うと正常にビルドができない問題の修正 (#10074)

* Update @swc/core to v1.3.36

* Update CHANGELOG.md

* Update CHANGELOG.md

* バックグラウンドで一定時間経過したらページネーションのアイテム更新をしない (#10053)

* 🎨

* feat: 2つの検索画面の統合 (#9949) (#10038)

* feat: 検索画面の UI を統一

* fix: エラーの修正

* add: changelog

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* enhance(client): ノートメニューからユーザーメニューを開けるように

Resolve #10019

* enhance(client): renoteした際の表示を改善

Resolve #10078

* Update CHANGELOG.md

* enhance(client): tweak contextmenu position calculation

* 🎨

* 🎨

* feat: in-channel featured note

Resolve #9938

* refactor(frontend): fix eslint error (#10084)

* Simplify search.vue (remove dead code) (#10088)

* Simplify search.vue

This is already handled by the code above it, no need to handle it twice

* Remove unused imports

* Update about-misskey.vue

* test(server): add validation test of api:notes/create (#10090)

* fix(server): notes/createのバリデーションが効いていない
Fix #10079

Co-Authored-By: mei23 <m@m544.net>

* anyOf内にバリデーションを書いても最初の一つしかチェックされない

* ✌️

* wip

* wip

* ✌️

* RequiredProp

* Revert "RequiredProp"

This reverts commit 7469390011.

* add api:notes/create

* fix lint

* text

* ✌️

* improve readability

---------

Co-authored-by: mei23 <m@m544.net>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* New Crowdin updates (#10059)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Romanian)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Arabic)

* New translations ja-JP.yml (Czech)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Polish)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Slovak)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Vietnamese)

* New translations ja-JP.yml (Indonesian)

* New translations ja-JP.yml (Bengali)

* New translations ja-JP.yml (Thai)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Thai)

* New translations ja-JP.yml (Thai)

* New translations ja-JP.yml (Thai)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Spanish)

* enhance(client): improve user menu ux

* enhance(client): photoswipe 表示時に戻る操作をしても前の画面に戻らないように (#10098)

* enhance(client): photoswipe 表示時に戻る操作をしても前の画面に戻らないように

* add: changelog

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* enhance(client): メニューの「もっと」からインスタンス情報を見れるように

* [Fix] fixed an typo in error message (#10102)

* Update codecov.yml

* Update CHANGELOG.md

* fix(server): エラーのスタックトレースは返さないように

Fix #10064

* [chore]Editorconfig: ymlに加えてyamlファイルに対しても同じ規約を適用する (#10081)

* Added yaml file in addition to yml file, in editorconfig

* Applied editorconfig for pnpm-workspace.yaml

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* update deps

* ホームタイムラインの読み込みでクエリタイムアウトになるのを修正する (#10106)

* refactor

* New translations ja-JP.yml (French) (#10103)

* Update CHANGELOG.md

* 13.8.0

---------

Co-authored-by: atsuchan <83960488+atsu1125@users.noreply.github.com>
Co-authored-by: Masaya Suzuki <15100604+massongit@users.noreply.github.com>
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Co-authored-by: Kagami Sascha Rosylight <saschanaz@outlook.com>
Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
Co-authored-by: xianon <xianon@hotmail.co.jp>
Co-authored-by: kabo2468 <28654659+kabo2468@users.noreply.github.com>
Co-authored-by: YS <47836716+yszkst@users.noreply.github.com>
Co-authored-by: Khsmty <me@khsmty.com>
Co-authored-by: Soni L <EnderMoneyMod@gmail.com>
Co-authored-by: mei23 <m@m544.net>
Co-authored-by: daima3629 <52790780+daima3629@users.noreply.github.com>
Co-authored-by: Windymelt <1113940+windymelt@users.noreply.github.com>
2023-02-26 20:21:54 +09:00
syuilo
1ba49b614d Merge pull request #10058 from misskey-dev/develop
Release: 13.7.5
2023-02-24 13:06:55 +09:00
tamaina
40de14415c Release: 13.7.4
Merge pull request #10050 from misskey-dev/develop
2023-02-23 23:11:25 +09:00
tamaina
7c9330a02f Release: 13.7.3
Merge pull request #10048 from misskey-dev/develop
2023-02-23 22:15:56 +09:00
405 changed files with 5747 additions and 6314 deletions

View File

@@ -6,7 +6,7 @@
"features": {
"ghcr.io/devcontainers-contrib/features/pnpm:2": {},
"ghcr.io/devcontainers/features/node:1": {
"version": "20.3.1"
"version": "18.16.0"
}
},
"forwardPorts": [3000],

View File

@@ -2,7 +2,7 @@ version: '3.8'
services:
app:
build:
build:
context: .
dockerfile: Dockerfile

View File

@@ -6,10 +6,6 @@ indent_size = 2
charset = utf-8
insert_final_newline = true
end_of_line = lf
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_style = space

View File

@@ -39,22 +39,8 @@ Please include errors from the developer console and/or server log files if you
<!-- Tell us where on the platform it happens -->
<!-- DO NOT WRITE "latest". Please provide the specific version. -->
### 💻 Frontend
* Model and OS of the device(s):
<!-- Example: MacBook Pro (14inch, 2021), macOS Ventura 13.4 -->
* Browser:
<!-- Example: Chrome 113.0.5672.126 -->
* Server URL:
<!-- Example: misskey.io -->
* Misskey:
13.x.x
### 🛰 Backend (for server admin)
<!-- If you are using a managed service, put that after the version. -->
* Installation Method or Hosting Service: <!-- Example: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment -->
* Misskey: 13.x.x
* Node: 20.x.x
* PostgreSQL: 15.x.x
* Redis: 7.x.x
* OS and Architecture: <!-- Example: Ubuntu 22.04.2 LTS aarch64 -->
Misskey version:
PostgreSQL version:
Redis version:
Your OS:
Your browser:

View File

@@ -37,7 +37,7 @@ jobs:
with:
version: 8
run_install: false
- name: Use Node.js 20.x
- name: Use Node.js 18.x
uses: actions/setup-node@v3.6.0
with:
node-version-file: '.node-version'

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
node-version: [20.x]
node-version: [18.x]
services:
postgres:

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
node-version: [20.x]
node-version: [18.x]
steps:
- uses: actions/checkout@v3.3.0
@@ -51,7 +51,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [20.x]
node-version: [18.x]
browser: [chrome]
services:

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [20.x]
node-version: [18.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [20.x]
node-version: [18.x]
steps:
- uses: actions/checkout@v3.3.0

3
.gitignore vendored
View File

@@ -64,6 +64,3 @@ temp
*.blend3
*.blend4
*.blend5
# VSCode addon
.favorites.json

View File

@@ -1 +1 @@
20.3.1
18.16.0

View File

@@ -12,51 +12,6 @@
-->
## 13.x.x (unreleased)
### General
- identicon生成を無効にしてパフォーマンスを向上させることができるようになりました
- サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました
### Client
- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように
- ドライブファイルのメニューで画像をクロップできるように
- 画像を動画と同様に簡単に隠せるように
- オリジナル画像を保持せずにアップロードする場合webpでアップロードされるように(Safari以外)
- 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように
- Fix: サーバーメトリクスが90度傾いている
- Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正
- Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正
- Fix: ZenUIでポップアップの表示位置がおかしい問題を修正
- Fix: ページ遷移でスクロール位置が保持されない問題を修正
### Server
- JSON.parse の回数を削減することで、ストリーミングのパフォーマンスを向上しました
- nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように
- 連合の配送ジョブのパフォーマンスを向上ロック機構の見直し、Redisキャッシュの活用
- 全体的なDBクエリのパフォーマンスを向上
## 13.13.2
### General
- エラー時や項目が存在しないときなどのアイコン画像をサーバー管理者が設定できるように
- ロールが付与されているユーザーリストを非公開にできるように
- サーバーの負荷が非常に高いため、ユーザー統計表示機能を削除しました
### Client
- Fix: タブがバックグラウンドでもstreamが切断されないように
### Server
- Fix: キャッシュが溜まり続けないように
## 13.13.1
### Client
- Fix: タブがアクティブな間はstreamが切断されないように
### Server
- Fix: api/metaで`TypeError: JSON5.parse is not a function`エラーが発生する問題を修正
## 13.13.0
### General
@@ -133,12 +88,11 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
## 13.12.0
### NOTE
- Node.js 18.16.0以上が必要になりました
- Node.js 18.6.0以上が必要になりました
### General
- アカウントの引っ越し(フォロワー引き継ぎ)に対応
- Meilisearchを全文検索に使用できるようになりました
* 「フォロワーのみ」の投稿は検索結果に表示されません。
- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加
- ユーザーへの自分用メモ機能
* ユーザーに対して、自分だけが見られるメモを追加できるようになりました。

View File

@@ -106,7 +106,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
During development, it is useful to use the
```
pnpm dev
@@ -150,7 +150,7 @@ Prepare DB/Redis for testing.
```
docker compose -f packages/backend/test/docker-compose.yml up
```
Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`.
Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`.
Run all test.
```

View File

@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.4
ARG NODE_VERSION=20.3.1-bullseye
ARG NODE_VERSION=18.16.0-bullseye
# build assets & compile TypeScript

View File

@@ -2,9 +2,9 @@
<a href="https://misskey-hub.net">
<img src="./assets/title_float.svg" alt="Misskey logo" style="border-radius:50%" width="400"/>
</a>
**🌎 **[Misskey](https://misskey-hub.net/)** is an open source, decentralized social media platform that's free forever! 🚀**
---
<a href="https://misskey-hub.net/instances.html">
@@ -21,7 +21,7 @@
<a href="https://www.patreon.com/syuilo">
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-patron-F96854?logoColor=F96854&style=for-the-badge&logo=patreon&labelColor=363B40" alt="become a patron"/></a>
---
[![codecov](https://codecov.io/gh/misskey-dev/misskey/branch/develop/graph/badge.svg?token=R6IQZ3QJOL)](https://codecov.io/gh/misskey-dev/misskey)

View File

@@ -23,13 +23,13 @@
</rdf:RDF>
</metadata>
<style>
#g8 {
#g8 {
animation-name: floating;
animation-duration: 3s;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
@keyframes floating {
0% { transform: translate(0, 0px); }
50% { transform: translate(0, -5px); }

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -56,7 +56,7 @@ describe('After setup instance', () => {
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-submit]').should('be.disabled');

View File

@@ -991,7 +991,7 @@ postToTheChannel: "In Kanal senden"
cannotBeChangedLater: "Kann später nicht mehr geändert werden."
reactionAcceptance: "Reaktionsannahme"
likeOnly: "Nur \"Gefällt mir\""
likeOnlyForRemote: "Alle (Nur \"Gefällt mir\" für fremde Instanzen)"
likeOnlyForRemote: "Nur \"Gefällt mir\" für fremde Instanzen"
nonSensitiveOnly: "Keine Sensitiven"
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Keine Sensitiven (Nur \"Gefällt mir\" von fremden Instanzen)"
rolesAssignedToMe: "Mir zugewiesene Rollen"
@@ -1062,7 +1062,6 @@ later: "Später"
goToMisskey: "Zu Misskey"
additionalEmojiDictionary: "Zusätzliche Emoji-Wörterbücher"
installed: "Installiert"
branding: "Branding"
_initialAccountSetting:
accountCreated: "Dein Konto wurde erfolgreich erstellt!"
letsStartAccountSetup: "Lass uns nun dein Konto einrichten."
@@ -1094,7 +1093,7 @@ _accountMigration:
migrationConfirm: "Dieses Konto wirklich zu {account} umziehen? Sobald der Umzug beginnt, kann er nicht rückgängig gemacht werden, und dieses Konto nicht wieder im ursprünglichen Zustand verwendet werden."
movedAndCannotBeUndone: "\nDieses Konto wurde migriert.\nDiese Aktion ist unwiderruflich."
postMigrationNote: "Dieses Konto wird 24 Stunden nach Abschluss der Migration allen Konten, denen es derzeit folgt, nicht mehr folgen.\n\nSowohl die Anzahl der Follower als auch die der Konten, denen dieses Konto folgt, wird dann auf Null gesetzt. Um zu vermeiden, dass Follower dieses Kontos dessen Beiträge, welche nur für Follower bestimmt sind, nicht mehr sehen können, werden sie diesem Konto jedoch weiterhin folgen."
movedTo: "Neues Konto:"
movedTo: "Umzugsziel:"
_achievements:
earnedAt: "Freigeschaltet am"
_types:
@@ -1348,7 +1347,7 @@ _role:
condition: "Bedingung"
isConditionalRole: "Dies ist eine konditionale Rolle."
isPublic: "Öffentliche Rolle"
descriptionOfIsPublic: "Diese Rolle wird im Profil zugewiesener Benutzer angezeigt."
descriptionOfIsPublic: "Ist dies aktiviert, so kann jeder die Liste der Benutzer, die dieser Rolle zugewiesen sind, einsehen. Zusätzlich wird diese Rolle im Profil zugewiesener Benutzer angezeigt."
options: "Optionen"
policies: "Richtlinien"
baseRole: "Rollenvorlage"
@@ -1357,8 +1356,8 @@ _role:
iconUrl: "Icon-URL"
asBadge: "Als Abzeichen anzeigen"
descriptionOfAsBadge: "Ist dies aktiviert, so wird das Icon dieser Rolle an der Seite der Namen von Benutzern mit dieser Rolle angezeigt."
isExplorable: "Benutzerliste veröffentlichen"
descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Chronik dieser Rolle, sowie eine Liste der Benutzer mit dieser Rolle, frei zugänglich."
isExplorable: "Rollenchronik veröffentlichen"
descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Rollenchronik dieser Rolle frei zugänglich. Die Chronik von Rollen, welche nicht öffentlich sind, wird auch bei Aktivierung nicht veröffentlicht."
displayOrder: "Position"
descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position."
canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen"

View File

@@ -991,7 +991,7 @@ postToTheChannel: "Post to channel"
cannotBeChangedLater: "This cannot be changed later."
reactionAcceptance: "Reaction Acceptance"
likeOnly: "Only likes"
likeOnlyForRemote: "All (Only likes for remote instances)"
likeOnlyForRemote: "Only likes for remote instances"
nonSensitiveOnly: "Non-sensitive only"
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Non-sensitive only (Only likes from remote)"
rolesAssignedToMe: "Roles assigned to me"
@@ -1062,7 +1062,6 @@ later: "Later"
goToMisskey: "To Misskey"
additionalEmojiDictionary: "Additional emoji dictionaries"
installed: "Installed"
branding: "Branding"
_initialAccountSetting:
accountCreated: "Your account was successfully created!"
letsStartAccountSetup: "For starters, let's set up your profile."
@@ -1094,7 +1093,7 @@ _accountMigration:
migrationConfirm: "Really migrate this account to {account}? Once started, this process cannot be stopped or taken back, and you will not be able to use this account in its original state anymore."
movedAndCannotBeUndone: "\nThis account has been migrated.\nMigration cannot be reversed."
postMigrationNote: "This account will unfollow all accounts it is currently following 24 hours after migration finishes.\nBoth the number of follows and followers will then become zero. To avoid your followers from being unable to see followers only posts of this account, they will however continue following this account."
movedTo: "New account:"
movedTo: "Account to move to:"
_achievements:
earnedAt: "Unlocked at"
_types:
@@ -1348,7 +1347,7 @@ _role:
condition: "Condition"
isConditionalRole: "This is a conditional role."
isPublic: "Public role"
descriptionOfIsPublic: "This role will be displayed in the profiles of assigned users."
descriptionOfIsPublic: "Anyone will be able to view a list of users assigned to this role. In addition, this role will be displayed in the profiles of assigned users."
options: "Options"
policies: "Policies"
baseRole: "Role template"
@@ -1357,8 +1356,8 @@ _role:
iconUrl: "Icon URL"
asBadge: "Show as badge"
descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on."
isExplorable: "Make role explorable"
descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled."
isExplorable: "Role timeline is public"
descriptionOfIsExplorable: "This role's timeline will become publicly accessible if enabled. Timelines of non-public roles will not be made public even if set."
displayOrder: "Position"
descriptionOfDisplayOrder: "The higher the number, the higher its UI position."
canEditMembersByModerator: "Allow moderators to edit the list of members for this role"

11
locales/index.d.ts vendored
View File

@@ -139,10 +139,8 @@ export interface Locale {
"suspendConfirm": string;
"unsuspendConfirm": string;
"selectList": string;
"editList": string;
"selectChannel": string;
"selectAntenna": string;
"editAntenna": string;
"selectWidget": string;
"editWidgets": string;
"editWidgetsExit": string;
@@ -316,7 +314,7 @@ export interface Locale {
"rename": string;
"avatar": string;
"banner": string;
"displayOfSensitiveMedia": string;
"nsfw": string;
"whenServerDisconnected": string;
"disconnectedFromServer": string;
"reload": string;
@@ -1067,10 +1065,6 @@ export interface Locale {
"goToMisskey": string;
"additionalEmojiDictionary": string;
"installed": string;
"branding": string;
"enableServerMachineStats": string;
"enableIdenticonGeneration": string;
"turnOffToImprovePerformance": string;
"_initialAccountSetting": {
"accountCreated": string;
"letsStartAccountSetup": string;
@@ -1531,7 +1525,6 @@ export interface Locale {
"back": string;
"reduceFrequencyOfThisAd": string;
"hide": string;
"timezoneinfo": string;
};
"_forgotPassword": {
"enterEmail": string;
@@ -1593,7 +1586,7 @@ export interface Locale {
"morePatrons": string;
"patrons": string;
};
"_displayOfSensitiveMedia": {
"_nsfw": {
"respect": string;
"ignore": string;
"force": string;

View File

@@ -112,7 +112,7 @@ pinnedNote: "ピン留めされたノート"
pinned: "ピン留め"
you: "あなた"
clickToShow: "クリックして表示"
sensitive: "センシティブ"
sensitive: "閲覧注意"
add: "追加"
reaction: "リアクション"
reactions: "リアクション"
@@ -120,8 +120,8 @@ reactionSetting: "ピッカーに表示するリアクション"
reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。"
rememberNoteVisibility: "公開範囲を記憶する"
attachCancel: "添付取り消し"
markAsSensitive: "センシティブとして設定"
unmarkAsSensitive: "センシティブを解除する"
markAsSensitive: "閲覧注意にする"
unmarkAsSensitive: "閲覧注意を解除する"
enterFileName: "ファイル名を入力"
mute: "ミュート"
unmute: "ミュート解除"
@@ -136,10 +136,8 @@ unblockConfirm: "ブロック解除しますか?"
suspendConfirm: "凍結しますか?"
unsuspendConfirm: "解凍しますか?"
selectList: "リストを選択"
editList: "リストを編集"
selectChannel: "チャンネルを選択"
selectAntenna: "アンテナを選択"
editAntenna: "アンテナを編集"
selectWidget: "ウィジェットを選択"
editWidgets: "ウィジェットを編集"
editWidgetsExit: "編集を終了"
@@ -313,7 +311,7 @@ copyUrl: "URLをコピー"
rename: "名前を変更"
avatar: "アイコン"
banner: "バナー"
displayOfSensitiveMedia: "センシティブなメディアの表示"
nsfw: "閲覧注意"
whenServerDisconnected: "サーバーとの接続が失われたとき"
disconnectedFromServer: "サーバーから切断されました"
reload: "リロード"
@@ -695,7 +693,7 @@ driveUsage: "ドライブ使用量"
noCrawle: "クローラーによるインデックスを拒否"
noCrawleDescription: "外部の検索エンジンにあなたのユーザーページ、ート、Pagesなどのコンテンツを登録(インデックス)しないよう要求します。"
lockedAccountInfo: "フォローを承認制にしても、ノートの公開範囲を「フォロワー」にしない限り、誰でもあなたのノートを見ることができます。"
alwaysMarkSensitive: "デフォルトでメディアをセンシティブ設定にする"
alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にする"
loadRawImages: "添付画像のサムネイルをオリジナル画質にする"
disableShowingAnimatedImages: "アニメーション画像を再生しない"
verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。"
@@ -922,8 +920,8 @@ cannotUploadBecauseInappropriate: "不適切な内容を含む可能性がある
cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。"
cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。"
beta: "ベータ"
enableAutoSensitive: "自動センシティブ判定"
enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにセンシティブフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。"
enableAutoSensitive: "自動NSFW判定"
enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。"
activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。"
navbar: "ナビゲーションバー"
shuffle: "シャッフル"
@@ -1064,10 +1062,6 @@ later: "あとで"
goToMisskey: "Misskeyへ"
additionalEmojiDictionary: "絵文字の追加辞書"
installed: "インストール済み"
branding: "ブランディング"
enableServerMachineStats: "サーバーのマシン情報を公開する"
enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。"
_initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!"
@@ -1357,8 +1351,8 @@ _role:
conditional: "コンディショナル"
condition: "条件"
isConditionalRole: "これはコンディショナルロールです。"
isPublic: "公開ロール"
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
isPublic: "ロールを公開"
descriptionOfIsPublic: "ロールにアサインされたユーザーを誰でも見ることができます。また、ユーザーのプロフィールでこのロールが表示されます。"
options: "オプション"
policies: "ポリシー"
baseRole: "ベースロール"
@@ -1367,8 +1361,8 @@ _role:
iconUrl: "アイコン画像のURL"
asBadge: "バッジとして表示"
descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。"
isExplorable: "ユーザーを見つけやすくする"
descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。"
isExplorable: "ロールタイムラインを公開"
descriptionOfIsExplorable: "オンにすると、ロールのタイムラインを公開します。ロールの公開がオフの場合、タイムラインの公開はされません。"
displayOrder: "表示順"
descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。"
canEditMembersByModerator: "モデレーターのメンバー編集を許可"
@@ -1417,7 +1411,7 @@ _sensitiveMediaDetection:
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"
sensitivity: "検出感度"
sensitivityDescription: "感度を低くすると、誤検知(偽陽性)が減ります。感度を高くすると、検知漏れ(偽陰性)が減ります。"
setSensitiveFlagAutomatically: "センシティブフラグを設定する"
setSensitiveFlagAutomatically: "NSFWフラグを設定する"
setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されます。"
analyzeVideos: "動画の解析を有効化"
analyzeVideosDescription: "静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。"
@@ -1451,7 +1445,6 @@ _ad:
back: "戻る"
reduceFrequencyOfThisAd: "この広告の表示頻度を下げる"
hide: "表示しない"
timezoneinfo: "曜日はサーバーのタイムゾーンを元に指定されます。"
_forgotPassword:
enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。"
@@ -1511,9 +1504,9 @@ _aboutMisskey:
morePatrons: "他にも多くの方が支援してくれています。ありがとうございます🥰"
patrons: "支援者"
_displayOfSensitiveMedia:
respect: "センシティブ設定されたメディア隠す"
ignore: "センシティブ設定されたメディアを隠さない"
_nsfw:
respect: "閲覧注意のメディア隠す"
ignore: "閲覧注意のメディアを隠さない"
force: "常にメディアを隠す"
_instanceTicker:

View File

@@ -792,7 +792,6 @@ noMaintainerInformationWarning: "管理者情報が設定されてへんで"
noBotProtectionWarning: "Botプロテクションが設定されてへんで。"
configure: "設定する"
postToGallery: "ギャラリーへ投稿"
postToHashtag: "このハッシュタグで投稿"
gallery: "ギャラリー"
recentPosts: "最近の投稿"
popularPosts: "人気の投稿"
@@ -826,7 +825,6 @@ translatedFrom: "{x}から翻訳するで"
accountDeletionInProgress: "アカウント削除しとるで待っとってなー"
usernameInfo: "サーバー上であんたのアカウントをあんたやと分かるようにするための名前やで。アルファベット(a~z, A~Z)、数字(0~9)、それとアンダーバー(_)が使って考えてな。この名前は後から変更することはできへんからちゃんと考えるんやで。"
aiChanMode: "藍モードやで"
devMode: "開発者モード"
keepCw: "CWを維持するで"
pubSub: "Pub/Subのアカウント"
lastCommunication: "直近の通信"
@@ -836,8 +834,6 @@ breakFollow: "フォロワーを解除するで"
breakFollowConfirm: "フォロワー解除してもええか?"
itsOn: "オンになっとるよ"
itsOff: "オフになってるで"
on: "オン"
off: "オフ"
emailRequiredForSignup: "アカウント登録にメールアドレスを必須にするで"
unread: "未読"
filter: "フィルタ"
@@ -992,8 +988,6 @@ cannotBeChangedLater: "後からは変えられへんで。"
reactionAcceptance: "ツッコミの受け入れ"
likeOnly: "いいねだけ"
likeOnlyForRemote: "リモートからはいいねだけな"
nonSensitiveOnly: "センシティブじゃないやつだけ"
nonSensitiveOnlyForLocalLikeOnlyForRemote: "センシティブじゃないやつだけ (リモートはいいねだけ)"
rolesAssignedToMe: "自分に割り当てられたロール"
resetPasswordConfirm: "パスワード作り直すんでええな?"
sensitiveWords: "けったいな単語"
@@ -1051,17 +1045,10 @@ preventAiLearning: "生成AIの学習に使わんといて"
preventAiLearningDescription: "他の文章生成AIとか画像生成AIに、投稿したートとか画像なんかを勝手に使わんように頼むで。具体的にはnoaiフラグをHTMLレスポンスに含めるんやけど、これ聞いてくれるんはAIの気分次第やから、使われる可能性もちょっとはあるな。"
options: "オプション"
specifyUser: "ユーザー指定"
failedToPreviewUrl: "プレビューできへん"
update: "更新"
rolesThatCanBeUsedThisEmojiAsReaction: "ツッコミとして使えるロール"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールが一個も指定されてへんかったら、誰でもツッコミとして使えるで。"
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "ロールは公開ロールじゃないとアカンで。"
cancelReactionConfirm: "ツッコむんをやっぱやめるか?"
changeReactionConfirm: "ツッコミを別のに変えるか?"
later: "あとで"
goToMisskey: "Misskeyへ"
additionalEmojiDictionary: "絵文字の追加辞書"
installed: "インストール済み"
_initialAccountSetting:
accountCreated: "アカウント作り終わったで。"
letsStartAccountSetup: "アカウントの初期設定をしよか。"
@@ -1076,7 +1063,6 @@ _initialAccountSetting:
haveFun: "{name}、楽しんでな~"
ifYouNeedLearnMore: "{name}(Misskey)の使い方とかをよー知りたいんやったら{link}をみてな。"
skipAreYouSure: "初期設定飛ばすか?"
laterAreYouSure: "初期設定あとでやり直すん?"
_serverRules:
description: "新規登録前に見せる、サーバーの簡潔なルールを設定すんで。内容は使うための決め事の要約とすることを推奨するわ。"
_accountMigration:

View File

@@ -1,7 +1,6 @@
---
_lang_: "Türkçe"
introMisskey: "Açık kaynaklı bir dağıtılmış mikroblog hizmeti olan Misskey'e hoş geldiniz.\nMisskey, neler olup bittiğini paylaşmak ve herkese sizden bahsetmek için \"notlar\" oluşturmanıza olanak tanıyan, açık kaynaklı, dağıtılmış bir mikroblog hizmetidir.\nHerkesin notlarına kendi tepkilerinizi hızlıca eklemek için \"Tepkiler\" özelliğini de kullanabilirsiniz👍.\nYeni bir dünyayı keşfedin🚀."
poweredByMisskeyDescription: "name}Açık kaynak bir platform\n<b>Misskey</b>Dünya'nın en sunucularında biri。"
monthAndDay: "{month}Ay {day}Gün"
search: "Arama"
notifications: "Bildirim"
@@ -14,9 +13,7 @@ cancel: "İptal"
enterUsername: "Kullanıcı adınızı giriniz"
noNotes: "Notlar mevcut değil."
noNotifications: "Bildirim bulunmuyor"
instance: "Sunucu"
settings: "Ayarlar"
notificationSettings: "Bildirim Ayarları"
basicSettings: "Temel Ayarlar"
otherSettings: "Diğer Ayarlar"
openInWindow: "Bir pencere ile aç"
@@ -24,11 +21,9 @@ profile: "Profil"
timeline: "Zaman çizelgesi"
noAccountDescription: "Bu kullanıcı henüz biyografisini yazmadı"
login: "Giriş Yap "
loggingIn: "Oturum aç"
logout: ıkış Yap"
signup: "Kayıt Ol"
uploading: "Yükleniyor"
save: "Kaydet"
users: "Kullanıcı"
addUser: "Kullanıcı Ekle"
favorite: "Favoriler"
@@ -36,7 +31,6 @@ favorites: "Favoriler"
unfavorite: "Favorilerden Kaldır"
favorited: "Favorilerime eklendi."
alreadyFavorited: "Zaten favorilerinizde kayıtlı."
cantFavorite: "Favorilere kayıt yapılamadı"
pin: "Sabitlenmiş"
unpin: "Sabitlemeyi kaldır"
copyContent: "İçeriği kopyala"
@@ -46,88 +40,23 @@ deleteAndEdit: "Sil ve yeniden düzenle"
deleteAndEditConfirm: "Bu notu silip yeniden düzenlemek istiyor musunuz? Bu nota ilişkin tüm Tepkiler, Yeniden Notlar ve Yanıtlar da silinecektir."
addToList: "Listeye ekle"
sendMessage: "Mesaj Gönder"
copyRSS: "RSSKopyala"
copyUsername: "Kullanıcı Adını Kopyala"
copyUserId: "KullanıcıyıKopyala"
copyNoteId: "Kimlik notunu kopyala"
searchUser: "Kullanıcıları ara"
reply: "yanıt"
loadMore: "Devamını yükle"
showMore: "Devamını yükle"
lists: "Listeler"
noLists: "Liste yok"
note: "not"
notes: "notlar"
following: "takipçi"
followers: "takipçi"
followsYou: "seni takip ediyor"
createList: "Liste oluştur"
manageLists: "Yönetici Listeleri"
error: "hata"
follow: "takipçi"
followRequest: "Takip isteği"
followRequests: "Takip istekleri"
unfollow: "takip etmeyi bırak"
followRequestPending: "Bekleyen Takip Etme Talebi"
enterEmoji: "Emoji Giriniz"
renote: "vazgeçme"
unrenote: "not alma"
renoted: "yeniden adlandırılmış"
cantRenote: "Ayrılamama"
cantReRenote: "not alabilirmiyim"
quote: "alıntı"
pinnedNote: "Sabitlenen"
pinned: "Sabitlenmiş"
you: "sen"
unmute: "sesi aç"
renoteMute: "sesi kapat"
renoteUnmute: "sesi açmayı iptal et"
block: "engelle"
unblock: "engellemeyi kaldır"
suspend: "askıya al"
unsuspend: "askıya alma"
blockConfirm: "Onayı engelle"
unblockConfirm: "engellemeyi kaldır onayla"
selectChannel: "Kanal seç"
flagAsBot: "Bot olarak işaretle"
instances: "Sunucu"
remove: "Sil"
pinnedNotes: "Sabitlenen"
userList: "Listeler"
smtpUser: "Kullanıcı Adı"
smtpPass: "Şifre"
user: "Kullanıcı"
searchByGoogle: "Arama"
_theme:
keys:
renote: "vazgeçme"
_sfx:
note: "notlar"
notification: "Bildirim"
_widgets:
profile: "Profil"
notifications: "Bildirim"
timeline: "Zaman çizelgesi"
_cw:
show: "Devamını yükle"
_visibility:
followers: "takipçi"
_profile:
username: "Kullanıcı Adı"
_exportOrImport:
followingList: "takipçi"
blockingList: "engelle"
userLists: "Listeler"
_notification:
_types:
follow: "takipçi"
renote: "vazgeçme"
quote: "alıntı"
_actions:
reply: "yanıt"
renote: "vazgeçme"
_deck:
_columns:
notifications: "Bildirim"
tl: "Zaman çizelgesi"
list: "Listeler"

View File

@@ -1060,7 +1060,6 @@ cancelReactionConfirm: "要取消回应吗?"
changeReactionConfirm: "要更改回应吗?"
later: "一会再说"
goToMisskey: "去往Misskey"
additionalEmojiDictionary: "表情符号追加字典"
installed: "已安装"
_initialAccountSetting:
accountCreated: "账户创建完成了!"

View File

@@ -1062,7 +1062,6 @@ later: "稍後再說"
goToMisskey: "往Misskey"
additionalEmojiDictionary: "表情符號的附加辭典"
installed: "已安裝"
branding: "品牌宣傳"
_initialAccountSetting:
accountCreated: "帳戶已建立完成!"
letsStartAccountSetup: "來進行帳戶的初始設定吧。"

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "13.14.0-beta.1",
"version": "13.13.0",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"migrateandstart": "pnpm migrate && pnpm start",
"gulp": "pnpm exec gulp build",
"watch": "pnpm dev",
"dev": "node ./scripts/dev.mjs",
"dev": "node ./scripts/dev.js",
"lint": "pnpm -r lint",
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
@@ -44,23 +44,23 @@
"lodash": "4.17.21"
},
"dependencies": {
"execa": "7.1.1",
"execa": "5.1.1",
"gulp": "4.0.2",
"gulp-cssnano": "2.1.3",
"gulp-rename": "2.0.0",
"gulp-replace": "1.1.4",
"gulp-terser": "2.1.0",
"js-yaml": "4.1.0",
"typescript": "5.1.6"
"typescript": "5.1.3"
},
"devDependencies": {
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@typescript-eslint/eslint-plugin": "5.61.0",
"@typescript-eslint/parser": "5.61.0",
"@typescript-eslint/eslint-plugin": "5.59.8",
"@typescript-eslint/parser": "5.59.8",
"cross-env": "7.0.3",
"cypress": "12.17.0",
"eslint": "8.44.0",
"cypress": "12.13.0",
"eslint": "8.41.0",
"start-server-and-test": "2.0.0"
},
"optionalDependencies": {

View File

@@ -17,7 +17,7 @@
"paths": {
"@/*": ["*"]
},
"target": "es2022"
"target": "es2021"
},
"minify": false
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,9 +0,0 @@
export class ad1677054292210 {
name = 'ad1677054292210';
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "ad" ADD "dayOfWeek" integer NOT NULL Default 0`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "dayOfWeek"`);
}
}

View File

@@ -1,17 +0,0 @@
export class ErrorImageUrl1685973839966 {
name = 'ErrorImageUrl1685973839966'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "errorImageUrl"`);
await queryRunner.query(`ALTER TABLE "meta" ADD "serverErrorImageUrl" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "notFoundImageUrl" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "infoImageUrl" character varying(1024)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "infoImageUrl"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "notFoundImageUrl"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "serverErrorImageUrl"`);
await queryRunner.query(`ALTER TABLE "meta" ADD "errorImageUrl" character varying(1024) DEFAULT 'https://xn--931a.moe/aiart/yubitun.png'`);
}
}

View File

@@ -1,13 +0,0 @@
export class AddMetaOptions1688280713783 {
name = 'AddMetaOptions1688280713783'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableServerMachineStats" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "meta" ADD "enableIdenticonGeneration" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableIdenticonGeneration"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableServerMachineStats"`);
}
}

View File

@@ -3,9 +3,6 @@
"main": "./index.js",
"private": true,
"type": "module",
"engines": {
"node": ">=18.16.0"
},
"scripts": {
"start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js",
@@ -54,36 +51,35 @@
"utf-8-validate": "^6.0.3"
},
"dependencies": {
"@aws-sdk/client-s3": "3.367.0",
"@aws-sdk/lib-storage": "3.367.0",
"@aws-sdk/node-http-handler": "3.360.0",
"@bull-board/api": "5.6.0",
"@bull-board/fastify": "5.6.0",
"@bull-board/ui": "5.6.0",
"@aws-sdk/client-s3": "3.321.1",
"@aws-sdk/lib-storage": "3.321.1",
"@aws-sdk/node-http-handler": "3.321.1",
"@bull-board/api": "5.2.0",
"@bull-board/fastify": "5.2.0",
"@bull-board/ui": "5.2.0",
"@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.2.0",
"@fastify/accepts": "4.1.0",
"@fastify/cookie": "8.3.0",
"@fastify/cors": "8.3.0",
"@fastify/http-proxy": "9.2.1",
"@fastify/multipart": "7.7.0",
"@fastify/http-proxy": "9.1.0",
"@fastify/multipart": "7.6.0",
"@fastify/static": "6.10.2",
"@fastify/view": "8.0.0",
"@nestjs/common": "10.0.5",
"@nestjs/core": "10.0.5",
"@nestjs/testing": "10.0.5",
"@fastify/view": "7.4.1",
"@nestjs/common": "9.4.2",
"@nestjs/core": "9.4.2",
"@nestjs/testing": "9.4.2",
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.3.0",
"@sinonjs/fake-timers": "10.2.0",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.68",
"@swc/core": "1.3.61",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
"async-mutex": "^0.4.0",
"autwh": "0.1.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"bullmq": "4.2.0",
"cacheable-lookup": "7.0.0",
"bullmq": "3.15.0",
"cacheable-lookup": "6.1.0",
"cbor": "9.0.0",
"chalk": "5.2.0",
"chalk-template": "0.4.0",
@@ -94,24 +90,23 @@
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
"fastify": "4.19.2",
"fastify": "4.17.0",
"feed": "4.2.2",
"file-type": "18.5.0",
"file-type": "18.4.0",
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "13.0.0",
"happy-dom": "10.0.3",
"got": "12.6.0",
"happy-dom": "9.20.3",
"hpagent": "1.2.0",
"ioredis": "5.3.2",
"ip-cidr": "3.1.0",
"ipaddr.js": "2.1.0",
"is-svg": "5.0.0",
"is-svg": "4.3.2",
"js-yaml": "4.1.0",
"jsdom": "22.1.0",
"json5": "2.2.3",
"jsonld": "8.2.0",
"jsrsasign": "10.8.6",
"meilisearch": "0.33.0",
"meilisearch": "0.32.5",
"mfm-js": "0.23.3",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
@@ -122,9 +117,10 @@
"nsfwjs": "2.4.2",
"oauth": "0.10.0",
"os-utils": "0.0.14",
"otpauth": "9.1.3",
"otpauth": "9.1.2",
"parse5": "7.1.2",
"pg": "8.11.1",
"pg": "8.11.0",
"private-ip": "3.0.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"pug": "3.0.2",
@@ -133,39 +129,41 @@
"qrcode": "1.5.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.19.1",
"re2": "1.19.0",
"redis-lock": "0.1.4",
"reflect-metadata": "0.1.13",
"rename": "1.0.4",
"rndstr": "1.0.0",
"rss-parser": "3.13.0",
"rxjs": "7.8.1",
"s-age": "1.1.2",
"sanitize-html": "2.11.0",
"semver": "7.5.3",
"sanitize-html": "2.10.0",
"seedrandom": "3.0.5",
"semver": "7.5.1",
"sharp": "0.32.1",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"slacc": "0.0.9",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
"systeminformation": "5.18.6",
"systeminformation": "5.17.16",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.7",
"tsc-alias": "1.8.6",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.17",
"typescript": "5.1.6",
"typeorm": "0.3.16",
"typescript": "5.1.3",
"ulid": "2.3.0",
"unzipper": "0.10.14",
"uuid": "9.0.0",
"vary": "1.1.2",
"web-push": "3.6.3",
"web-push": "3.6.1",
"ws": "8.13.0",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.6.1",
"@jest/globals": "29.5.0",
"@swc/jest": "0.2.26",
"@types/accepts": "1.3.5",
"@types/archiver": "5.3.2",
@@ -178,18 +176,17 @@
"@types/jest": "29.5.2",
"@types/js-yaml": "4.0.5",
"@types/jsdom": "21.1.1",
"@types/jsonld": "1.5.9",
"@types/jsonld": "1.5.8",
"@types/jsrsasign": "10.5.8",
"@types/mime-types": "2.1.1",
"@types/ms": "^0.7.31",
"@types/node": "20.4.0",
"@types/node": "20.2.5",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.8",
"@types/oauth": "0.9.1",
"@types/pg": "8.10.2",
"@types/pg": "8.10.1",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.1",
"@types/qrcode": "1.5.0",
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4",
"@types/redis": "4.0.11",
@@ -201,19 +198,19 @@
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "0.10.6",
"@types/uuid": "9.0.2",
"@types/uuid": "9.0.1",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.5",
"@typescript-eslint/eslint-plugin": "5.61.0",
"@typescript-eslint/parser": "5.61.0",
"aws-sdk-client-mock": "3.0.0",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.59.8",
"@typescript-eslint/parser": "5.59.8",
"aws-sdk-client-mock": "2.1.1",
"cross-env": "7.0.3",
"eslint": "8.44.0",
"eslint": "8.41.0",
"eslint-plugin-import": "2.27.5",
"execa": "7.1.1",
"jest": "29.6.1",
"jest-mock": "29.6.1"
"execa": "6.1.0",
"jest": "29.5.0",
"jest-mock": "29.5.0"
}
}

View File

@@ -96,6 +96,12 @@ function showNodejsVersion(): void {
const nodejsLogger = bootLogger.createSubLogger('nodejs');
nodejsLogger.info(`Version ${process.version} detected.`);
const minVersion = fs.readFileSync(`${_dirname}/../../../../.node-version`, 'utf-8').trim();
if (semver.lt(process.version, minVersion)) {
nodejsLogger.error(`At least Node.js ${minVersion} required!`);
process.exit(1);
}
}
function loadConfigBoot(): Config {

View File

@@ -295,7 +295,7 @@ export class AccountMoveService {
* dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる
*
* @param dst movedToUrlを指定するユーザー
* @param check
* @param check
* @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか
* @returns Promise<LocalUser | RemoteUser | null>
*/

View File

@@ -4,7 +4,6 @@ import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common';
import * as nsfw from 'nsfwjs';
import si from 'systeminformation';
import { Mutex } from 'async-mutex';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@@ -18,7 +17,6 @@ let isSupportedCpu: undefined | boolean = undefined;
@Injectable()
export class AiService {
private model: nsfw.NSFWJS;
private modelLoadMutex: Mutex = new Mutex();
constructor(
@Inject(DI.config)
@@ -33,22 +31,16 @@ export class AiService {
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) {
await this.modelLoadMutex.runExclusive(async () => {
if (this.model == null) {
this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
}
});
}
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 {

View File

@@ -99,7 +99,7 @@ export class AntennaService implements OnApplicationShutdown {
'MAXLEN', '~', '200',
'*',
'note', note.id);
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
}
@@ -112,16 +112,16 @@ export class AntennaService implements OnApplicationShutdown {
public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }): Promise<boolean> {
if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false;
if (!antenna.withReplies && note.replyId != null) return false;
if (antenna.src === 'home') {
// TODO
} 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 === 'users') {
const accts = antenna.users.map(x => {
@@ -130,32 +130,32 @@ export class AntennaService implements OnApplicationShutdown {
});
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 && note.cw == null) return false;
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
const matched = keywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
? _text.includes(keyword)
: _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 && note.cw == null) return false;
@@ -167,16 +167,16 @@ export class AntennaService implements OnApplicationShutdown {
? _text.includes(keyword)
: _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;
}
@@ -188,7 +188,7 @@ export class AntennaService implements OnApplicationShutdown {
});
this.antennasFetched = true;
}
return this.antennas;
}

View File

@@ -32,6 +32,11 @@ export class AppLockService {
return this.lock(`ap-object:${uri}`, timeout);
}
@bindThis
public getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`instance:${host}`, timeout);
}
@bindThis
public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`chart-insert:${lockKey}`, timeout);

View File

@@ -168,17 +168,6 @@ export class CacheService implements OnApplicationShutdown {
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.userByIdCache.dispose();
this.localUserByNativeTokenCache.dispose();
this.localUserByIdCache.dispose();
this.uriPersonCache.dispose();
this.userProfileCache.dispose();
this.userMutingsCache.dispose();
this.userBlockingCache.dispose();
this.userBlockedCache.dispose();
this.renoteMutingsCache.dispose();
this.userFollowingsCache.dispose();
this.userFollowingChannelsCache.dispose();
}
@bindThis

View File

@@ -20,7 +20,7 @@ export class CaptchaService {
secret,
response,
});
const res = await this.httpRequestService.send(url, {
method: 'POST',
body: params.toString(),
@@ -28,14 +28,14 @@ export class CaptchaService {
'Content-Type': 'application/x-www-form-urlencoded',
},
}, { throwErrorWhenResponseNotOk: false });
if (!res.ok) {
throw new Error(`${res.status}`);
}
return await res.json() as CaptchaResponse;
}
}
@bindThis
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
@@ -73,7 +73,7 @@ export class CaptchaService {
if (response == null) {
throw new Error('turnstile-failed: no response provided');
}
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
throw new Error(`turnstile-request-failed: ${err}`);
});

View File

@@ -25,27 +25,27 @@ export class CreateSystemUserService {
@bindThis
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(),
@@ -58,25 +58,25 @@ export class CreateSystemUserService {
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

@@ -1,4 +1,4 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
@@ -18,7 +18,7 @@ import type { Serialized } from '@/server/api/stream/types.js';
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
@Injectable()
export class CustomEmojiService implements OnApplicationShutdown {
export class CustomEmojiService {
private cache: MemoryKVCache<Emoji | null>;
public localEmojisCache: RedisSingleCache<Map<string, Emoji>>;
@@ -140,7 +140,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
this.globalEventService.publishBroadcastStream('emojiAdded', {
emoji: updated,
});
});
}
}
@@ -194,7 +194,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
}
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
@@ -215,7 +215,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async setLicenseBulk(ids: Emoji['id'][], license: string | null) {
await this.emojisRepository.update({
@@ -349,14 +349,4 @@ export class CustomEmojiService implements OnApplicationShutdown {
this.cache.set(`${emoji.name} ${emoji.host}`, emoji);
}
}
@bindThis
public dispose(): void {
this.cache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -28,11 +28,11 @@ export class DeleteAccountService {
// 物理削除する前にDelete activityを送信する
await this.userSuspendService.doPostSuspend(user).catch(e => {});
this.queueService.createDeleteAccountJob(user, {
soft: false,
});
await this.usersRepository.update(user.id, {
isDeleted: true,
});

View File

@@ -2,7 +2,8 @@ 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 ipaddr from 'ipaddr.js';
import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip';
import chalk from 'chalk';
import got, * as Got from 'got';
import { parse } from 'content-disposition';
@@ -122,15 +123,15 @@ export class DownloadService {
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();
@@ -139,14 +140,13 @@ export class DownloadService {
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
if (parsedIp.match(ipaddr.parseCIDR(net))) {
const cidr = new IPCIDR(net);
if (cidr.contains(ip)) {
return false;
}
}
return parsedIp.range() !== 'unicast';
return PrivateIp(ip) ?? false;
}
}

View File

@@ -29,12 +29,12 @@ export class EmailService {
@bindThis
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,
@@ -46,7 +46,7 @@ export class EmailService {
pass: meta.smtpPass,
} : undefined,
} as any);
try {
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
@@ -135,7 +135,7 @@ export class EmailService {
</body>
</html>`,
});
this.logger.info(`Message sent: ${info.messageId}`);
} catch (err) {
this.logger.error(err as Error);
@@ -149,12 +149,12 @@ export class EmailService {
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,
@@ -163,9 +163,9 @@ export class EmailService {
validateDisposable: true, // 捨てアドかどうかチェック
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
}) : { valid: true, reason: null };
const available = exist === 0 && validated.valid;
return {
available,
reason: available ? null :

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { InstancesRepository } from '@/models/index.js';
import type { Instance } from '@/models/entities/Instance.js';
@@ -9,7 +9,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class FederatedInstanceService implements OnApplicationShutdown {
export class FederatedInstanceService {
public federatedInstanceCache: RedisKVCache<Instance | null>;
constructor(
@@ -43,19 +43,19 @@ export class FederatedInstanceService implements OnApplicationShutdown {
@bindThis
public async fetch(host: string): Promise<Instance> {
host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.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,
firstRetrievedAt: new Date(),
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
this.federatedInstanceCache.set(host, i);
return i;
} else {
@@ -74,17 +74,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
.then((response) => {
return response.raw[0];
});
this.federatedInstanceCache.set(result.host, result);
}
@bindThis
public dispose(): void {
this.federatedInstanceCache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -3,6 +3,8 @@ import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
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';
@@ -10,7 +12,6 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { DOMWindow } from 'jsdom';
import * as Redis from 'ioredis';
type NodeInfo = {
openRegistrations?: unknown;
@@ -36,49 +37,39 @@ export class FetchInstanceMetadataService {
private logger: Logger;
constructor(
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private appLockService: AppLockService,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
private federatedInstanceService: FederatedInstanceService,
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
this.logger = this.loggerService.getLogger('metadata', 'cyan');
}
@bindThis
public async tryLock(host: string): Promise<boolean> {
const mutex = await this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '1', 'GET');
return mutex !== '1';
}
@bindThis
public unlock(host: string): Promise<'OK'> {
return this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '0');
}
@bindThis
public async fetchInstanceMetadata(instance: Instance, force = false): Promise<void> {
const host = instance.host;
// Acquire mutex to ensure no parallel runs
if (!await this.tryLock(host)) return;
try {
if (!force) {
const _instance = await this.federatedInstanceService.fetch(host);
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
// unlock at the finally caluse
return;
}
}
const unlock = await this.appLockService.getFetchInstanceMetadataLock(instance.host);
this.logger.info(`Fetching metadata of ${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),
@@ -86,13 +77,13 @@ export class FetchInstanceMetadataService {
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;
@@ -100,27 +91,27 @@ export class FetchInstanceMetadataService {
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.federatedInstanceService.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 {
await this.unlock(host);
unlock();
}
}
@bindThis
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 => {
@@ -130,33 +121,33 @@ export class FetchInstanceMetadataService {
throw err.statusCode ?? err.message;
}
}) as Record<string, unknown>;
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
throw new Error('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 new Error('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;
}
}
@@ -164,51 +155,51 @@ export class FetchInstanceMetadataService {
@bindThis
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;
}
@bindThis
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;
}
@bindThis
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 this.httpRequestService.send(faviconUrl, {
method: 'HEAD',
}, { throwErrorWhenResponseNotOk: false });
if (favicon.ok) {
return faviconUrl;
}
return null;
}
@@ -218,38 +209,38 @@ export class FetchInstanceMetadataService {
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 =
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;
}
@bindThis
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;
}
@@ -262,19 +253,19 @@ export class FetchInstanceMetadataService {
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;
}
@@ -287,23 +278,23 @@ export class FetchInstanceMetadataService {
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

@@ -161,20 +161,20 @@ export class FileInfoService {
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',
@@ -253,10 +253,10 @@ export class FileInfoService {
disposeOutDir();
}
}
return [sensitive, porn];
}
private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {
const watcher = new FSWatcher({
cwd,
@@ -295,7 +295,7 @@ export class FileInfoService {
}
}
}
@bindThis
private exists(path: string): Promise<boolean> {
return fs.promises.access(path).then(() => true, () => false);
@@ -304,11 +304,11 @@ export class FileInfoService {
@bindThis
public fixMime(mime: string | fileType.MimeType): string {
// see https://github.com/misskey-dev/misskey/pull/10686
if (mime === 'audio/x-flac') {
return 'audio/flac';
if (mime === "audio/x-flac") {
return "audio/flac";
}
if (mime === 'audio/vnd.wave') {
return 'audio/wav';
if (mime === "audio/vnd.wave") {
return "audio/wav";
}
return mime;
@@ -355,12 +355,11 @@ export class FileInfoService {
* Check the file is SVG or not
*/
@bindThis
public async checkSvg(path: string): Promise<boolean> {
public async checkSvg(path: string) {
try {
const size = await this.getFileSize(path);
if (size > 1 * 1024 * 1024) return false;
const buffer = await fs.promises.readFile(path);
return isSvg(buffer.toString());
return isSvg(fs.readFileSync(path));
} catch {
return false;
}

View File

@@ -20,7 +20,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { Role } from '@/models/index.js';
import { Role } from '@/models';
@Injectable()
export class GlobalEventService {

View File

@@ -42,21 +42,21 @@ export class HttpRequestService {
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,

View File

@@ -5,7 +5,7 @@ import type { Config } from '@/config.js';
import { genAid, parseAid } from '@/misc/id/aid.js';
import { genMeid, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId, parseObjectId } from '@/misc/id/object-id.js';
import { genObjectId } from '@/misc/id/object-id.js';
import { bindThis } from '@/decorators.js';
import { parseUlid } from '@/misc/id/ulid.js';
@@ -23,7 +23,7 @@ export class IdService {
@bindThis
public genId(date?: Date): string {
if (!date || (date > new Date())) date = new Date();
switch (this.method) {
case 'aid': return genAid(date);
case 'meid': return genMeid(date);
@@ -38,7 +38,7 @@ export class IdService {
public parse(id: string): { date: Date; } {
switch (this.method) {
case 'aid': return parseAid(id);
case 'objectid': return parseObjectId(id);
case 'objectid':
case 'meid': return parseMeid(id);
case 'meidg': return parseMeidg(id);
case 'ulid': return parseUlid(id);

View File

@@ -26,12 +26,12 @@ export class InstanceActorService {
public async getInstanceActor(): Promise<LocalUser> {
const cached = this.cache.get();
if (cached) return cached;
const user = await this.usersRepository.findOneBy({
host: IsNull(),
username: ACTOR_USERNAME,
}) as LocalUser | undefined;
if (user) {
this.cache.set(user);
return user;

View File

@@ -3,7 +3,7 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import type { KEYWORD } from 'color-convert/conversions.js';
import type { KEYWORD } from 'color-convert/conversions';
@Injectable()
export class LoggerService {

View File

@@ -56,7 +56,7 @@ export class MetaService implements OnApplicationShutdown {
@bindThis
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, {
@@ -64,9 +64,9 @@ export class MetaService implements OnApplicationShutdown {
id: 'DESC',
},
});
const meta = metas[0];
if (meta) {
this.cache = meta;
return meta;
@@ -81,7 +81,7 @@ export class MetaService implements OnApplicationShutdown {
['id'],
)
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
this.cache = saved;
return saved;
}

View File

@@ -27,29 +27,29 @@ export class MfmService {
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) {
@@ -57,35 +57,35 @@ export class MfmService {
}
}
}
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.startsWith('me '))) {
const part = txt.split('@');
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`;
@@ -116,12 +116,12 @@ export class MfmService {
return `[${txt}](${href.value})`;
}
};
text += generateLink();
}
break;
}
case 'h1':
{
text += '【';
@@ -129,7 +129,7 @@ export class MfmService {
text += '】\n';
break;
}
case 'b':
case 'strong':
{
@@ -138,7 +138,7 @@ export class MfmService {
text += '**';
break;
}
case 'small':
{
text += '<small>';
@@ -146,7 +146,7 @@ export class MfmService {
text += '</small>';
break;
}
case 's':
case 'del':
{
@@ -155,7 +155,7 @@ export class MfmService {
text += '~~';
break;
}
case 'i':
case 'em':
{
@@ -164,7 +164,7 @@ export class MfmService {
text += '</i>';
break;
}
// block code (<pre><code>)
case 'pre': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
@@ -176,7 +176,7 @@ export class MfmService {
}
break;
}
// inline code (<code>)
case 'code': {
text += '`';
@@ -184,7 +184,7 @@ export class MfmService {
text += '`';
break;
}
case 'blockquote': {
const t = getText(node);
if (t) {
@@ -193,7 +193,7 @@ export class MfmService {
}
break;
}
case 'p':
case 'h2':
case 'h3':
@@ -205,7 +205,7 @@ export class MfmService {
appendChildren(node.childNodes);
break;
}
// other block elements
case 'div':
case 'header':
@@ -219,7 +219,7 @@ export class MfmService {
appendChildren(node.childNodes);
break;
}
default: // includes inline elements
{
appendChildren(node.childNodes);
@@ -234,48 +234,48 @@ export class MfmService {
if (nodes == null) {
return null;
}
const { window } = new Window();
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');
@@ -283,21 +283,21 @@ export class MfmService {
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.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
@@ -305,32 +305,32 @@ export class MfmService {
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.setAttribute('href', node.props.url);
appendChildren(node.children, a);
return a;
},
mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
@@ -340,47 +340,47 @@ export class MfmService {
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.setAttribute('href', node.props.url);
a.textContent = node.props.url;
return a;
},
search: (node) => {
const a = doc.createElement('a');
a.setAttribute('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

@@ -672,7 +672,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// Register to search database
this.index(note);
}
@bindThis
private isSensitive(note: Option, sensitiveWord: string[]): boolean {
if (sensitiveWord.length > 0) {
@@ -758,7 +758,7 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private index(note: Note) {
if (note.text == null && note.cw == null) return;
this.searchService.indexNote(note);
}

View File

@@ -45,7 +45,7 @@ export class NoteDeleteService {
private perUserNotesChart: PerUserNotesChart,
private instanceChart: InstanceChart,
) {}
/**
* 投稿を削除します。
* @param user 投稿者
@@ -121,8 +121,10 @@ export class NoteDeleteService {
}
@bindThis
private async findCascadingNotes(note: Note): Promise<Note[]> {
const recursive = async (noteId: string): Promise<Note[]> => {
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 => {
@@ -131,14 +133,12 @@ export class NoteDeleteService {
}))
.leftJoinAndSelect('note.user', 'user');
const replies = await query.getMany();
return [
replies,
...await Promise.all(replies.map(reply => recursive(reply.id))),
].flat();
for (const reply of replies) {
cascadingNotes.push(reply);
await recursive(reply.id);
}
};
const cascadingNotes: Note[] = await recursive(note.id);
await recursive(note.id);
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
}

View File

@@ -99,7 +99,7 @@ export class NoteReadService implements OnApplicationShutdown {
});
// TODO: ↓まとめてクエリしたい
this.noteUnreadsRepository.countBy({
userId: userId,
isMentioned: true,
@@ -109,7 +109,7 @@ export class NoteReadService implements OnApplicationShutdown {
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
}
});
this.noteUnreadsRepository.countBy({
userId: userId,
isSpecified: true,

View File

@@ -46,7 +46,7 @@ export class NotificationService implements OnApplicationShutdown {
force = false,
) {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
const latestNotificationIdsRes = await this.redisClient.xrevrange(
`notificationTimeline:${userId}`,
'+',

View File

@@ -39,12 +39,12 @@ export class PollService {
@bindThis
public async vote(user: User, 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 blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@@ -52,13 +52,13 @@ export class PollService {
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');
@@ -66,7 +66,7 @@ export class PollService {
} else if (exist.length !== 0) {
throw new Error('already voted');
}
// Create vote
await this.pollVotesRepository.insert({
id: this.idService.genId(),
@@ -75,11 +75,11 @@ export class PollService {
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.globalEventService.publishNoteStream(note.id, 'pollVoted', {
choice: choice,
userId: user.id,
@@ -90,10 +90,10 @@ export class PollService {
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.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user));
this.apDeliverManagerService.deliverToFollowers(user, content);

View File

@@ -1,9 +1,9 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import push from 'web-push';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { Packed } from '@/misc/json-schema.js';
import type { Packed } from '@/misc/json-schema';
import { getNoteSummary } from '@/misc/get-note-summary.js';
import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js';
import { MetaService } from '@/core/MetaService.js';
@@ -31,7 +31,7 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
...body.note,
// textをgetNoteSummaryしたものに置き換える
text: getNoteSummary(('type' in body && body.type === 'renote') ? body.note.renote as Packed<'Note'> : body.note),
cw: undefined,
reply: undefined,
renote: undefined,
@@ -42,7 +42,7 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
}
@Injectable()
export class PushNotificationService implements OnApplicationShutdown {
export class PushNotificationService {
private subscriptionsCache: RedisKVCache<SwSubscription[]>;
constructor(
@@ -69,16 +69,16 @@ export class PushNotificationService implements OnApplicationShutdown {
@bindThis
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);
const subscriptions = await this.subscriptionsCache.fetch(userId);
for (const subscription of subscriptions) {
if ([
'readAllNotifications',
@@ -103,7 +103,7 @@ export class PushNotificationService implements OnApplicationShutdown {
//swLogger.info(err.statusCode);
//swLogger.info(err.headers);
//swLogger.info(err.body);
if (err.statusCode === 410) {
this.swSubscriptionsRepository.delete({
userId: userId,
@@ -115,14 +115,4 @@ export class PushNotificationService implements OnApplicationShutdown {
});
}
}
@bindThis
public dispose(): void {
this.subscriptionsCache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -60,8 +60,8 @@ export class QueryService {
q.orderBy(`${q.alias}.id`, 'DESC');
}
return q;
}
}
// ここでいうBlockedは被Blockedの意
@bindThis
public generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
@@ -109,18 +109,18 @@ export class QueryService {
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());
}
}
@@ -130,9 +130,9 @@ export class QueryService {
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());
}
@@ -141,13 +141,13 @@ export class QueryService {
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());
}
@@ -156,15 +156,15 @@ export class QueryService {
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 });
// 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない
@@ -191,7 +191,7 @@ export class QueryService {
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
}
@@ -201,9 +201,9 @@ export class QueryService {
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());
}
@@ -245,7 +245,7 @@ export class QueryService {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { qb
// 公開投稿である
.where(new Brackets(qb => { qb
@@ -268,7 +268,7 @@ export class QueryService {
}));
}));
}));
q.setParameters({ meId: me.id });
}
}
@@ -278,10 +278,10 @@ export class QueryService {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
.select('renote_muting.muteeId')
.where('renote_muting.muterId = :muterId', { muterId: me.id });
q.andWhere(new Brackets(qb => {
qb
.where(new Brackets(qb => {
.where(new Brackets(qb => {
qb.where('note.renoteId IS NOT NULL');
qb.andWhere('note.text IS NULL');
qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`);
@@ -289,7 +289,7 @@ export class QueryService {
.orWhere('note.renoteId IS NULL')
.orWhere('note.text IS NOT NULL');
}));
q.setParameters(mutingQuery.getParameters());
}
}

View File

@@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq';
@@ -69,7 +69,7 @@ export class QueueService {
if (content == null) return null;
if (to == null) return null;
const data: DeliverJobData = {
const data = {
user: {
id: user.id,
},
@@ -88,40 +88,6 @@ export class QueueService {
});
}
/**
* ApDeliverManager-DeliverManager.execute()からinboxesを突っ込んでaddBulkしたい
* @param user `{ id: string; }` この関数ではThinUserに変換しないので前もって変換してください
* @param content IActivity | null
* @param inboxes `Map<string, boolean>` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox)
* @returns void
*/
@bindThis
public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) {
if (content == null) return null;
const opts = {
attempts: this.config.deliverJobMaxAttempts ?? 12,
backoff: {
type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
};
await this.deliverQueue.addBulk(Array.from(inboxes.entries()).map(d => ({
name: d[0],
data: {
user,
content,
to: d[0],
isSharedInbox: d[1],
} as DeliverJobData,
opts,
})));
return;
}
@bindThis
public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) {
const data = {
@@ -434,11 +400,11 @@ export class QueueService {
this.deliverQueue.once('cleaned', (jobs, status) => {
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
this.deliverQueue.clean(0, 0, 'delayed');
this.deliverQueue.clean(0, Infinity, 'delayed');
this.inboxQueue.once('cleaned', (jobs, status) => {
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
this.inboxQueue.clean(0, 0, 'delayed');
this.inboxQueue.clean(0, Infinity, 'delayed');
}
}

View File

@@ -39,9 +39,9 @@ export class RelayService {
host: IsNull(),
username: ACTOR_USERNAME,
});
if (user) return user as LocalUser;
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME);
return created as LocalUser;
}
@@ -53,12 +53,12 @@ export class RelayService {
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.addContext(follow);
this.queueService.deliver(relayActor, activity, relay.inbox, false);
return relay;
}
@@ -67,17 +67,17 @@ export class RelayService {
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.addContext(undo);
this.queueService.deliver(relayActor, activity, relay.inbox, false);
await this.relaysRepository.delete(relay.id);
}
@@ -86,13 +86,13 @@ export class RelayService {
const relays = await this.relaysRepository.find();
return relays;
}
@bindThis
public async relayAccepted(id: string): Promise<string> {
const result = await this.relaysRepository.update(id, {
status: 'accepted',
});
return JSON.stringify(result);
}
@@ -101,24 +101,24 @@ export class RelayService {
const result = await this.relaysRepository.update(id, {
status: 'rejected',
});
return JSON.stringify(result);
}
@bindThis
public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> {
if (activity == null) return;
const relays = await this.relaysCache.fetch(() => 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, false);
}

View File

@@ -35,7 +35,7 @@ export class RemoteUserResolveService {
@bindThis
public async resolveUser(username: string, host: string | null): Promise<LocalUser | RemoteUser> {
const usernameLower = username.toLowerCase();
if (host == null) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
@@ -46,9 +46,9 @@ export class RemoteUserResolveService {
}
}) as LocalUser;
}
host = this.utilityService.toPuny(host);
if (this.config.host === host) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
@@ -59,39 +59,39 @@ export class RemoteUserResolveService {
}
}) as LocalUser;
}
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null;
const acctLower = `${usernameLower}@${host}`;
if (user == null) {
const self = await this.resolveSelf(acctLower);
this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
return await this.apPersonService.createPerson(self.href);
}
// ユーザー情報が古い場合は、WebFilgerからやりなおして返す
if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
// 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する
await this.usersRepository.update(user.id, {
lastFetchedAt: new Date(),
});
this.logger.info(`try resync: ${acctLower}`);
const self = await this.resolveSelf(acctLower);
if (user.uri !== self.href) {
// if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping.
this.logger.info(`uri missmatch: ${acctLower}`);
this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`);
// validate uri
const uri = new URL(self.href);
if (uri.hostname !== host) {
throw new Error('Invalid uri');
}
await this.usersRepository.update({
usernameLower,
host: host,
@@ -101,9 +101,9 @@ export class RemoteUserResolveService {
} else {
this.logger.info(`uri is fine: ${acctLower}`);
}
await this.apPersonService.updatePerson(self.href);
this.logger.info(`return resynced remote user: ${acctLower}`);
return await this.usersRepository.findOneBy({ uri: self.href }).then(u => {
if (u == null) {
@@ -113,7 +113,7 @@ export class RemoteUserResolveService {
}
});
}
this.logger.info(`return existing remote user: ${acctLower}`);
return user;
}

View File

@@ -13,7 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Packed } from '@/misc/json-schema.js';
import type { Packed } from '@/misc/json-schema';
import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = {
@@ -392,7 +392,7 @@ export class RoleService implements OnApplicationShutdown {
@bindThis
public async unassign(userId: User['id'], roleId: Role['id']): Promise<void> {
const now = new Date();
const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId });
if (existing == null) {
throw new RoleService.NotAssignedError();
@@ -435,7 +435,6 @@ export class RoleService implements OnApplicationShutdown {
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.roleAssignmentByUserIdCache.dispose();
}
@bindThis

View File

@@ -174,7 +174,7 @@ export class SearchService {
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
return await query.limit(pagination.limit).getMany();
return await query.take(pagination.limit).getMany();
}
}
}

View File

@@ -50,31 +50,31 @@ export class SignupService {
}) {
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');
@@ -106,18 +106,18 @@ export class SignupService {
}, (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(),
@@ -127,27 +127,27 @@ export class SignupService {
token: secret,
isRoot: isTheFirstUser,
}));
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

@@ -69,7 +69,7 @@ function verifyCertificateChain(certificates: string[]) {
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();
@@ -143,19 +143,19 @@ export class TwoFactorAuthenticationService {
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)
@@ -168,7 +168,7 @@ export class TwoFactorAuthenticationService {
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');
}
@@ -176,12 +176,12 @@ export class TwoFactorAuthenticationService {
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,
@@ -207,16 +207,16 @@ export class TwoFactorAuthenticationService {
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');
}
@@ -224,23 +224,23 @@ export class TwoFactorAuthenticationService {
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,
@@ -267,43 +267,43 @@ export class TwoFactorAuthenticationService {
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');
}
@@ -311,7 +311,7 @@ export class TwoFactorAuthenticationService {
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,
@@ -342,17 +342,17 @@ export class TwoFactorAuthenticationService {
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');
}
@@ -360,12 +360,12 @@ export class TwoFactorAuthenticationService {
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,
@@ -375,12 +375,12 @@ export class TwoFactorAuthenticationService {
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,
@@ -401,13 +401,13 @@ export class TwoFactorAuthenticationService {
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');
}
@@ -415,12 +415,12 @@ export class TwoFactorAuthenticationService {
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,
@@ -428,12 +428,12 @@ export class TwoFactorAuthenticationService {
credentialId,
publicKeyU2F,
]);
const validSignature = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
return {
valid: validSignature,
publicKey: publicKeyU2F,

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { User } from '@/models/entities/User.js';
import type { UserKeypairsRepository } from '@/models/index.js';
@@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class UserKeypairService implements OnApplicationShutdown {
export class UserKeypairService {
private cache: RedisKVCache<UserKeypair>;
constructor(
@@ -31,14 +31,4 @@ export class UserKeypairService implements OnApplicationShutdown {
public async getUserKeypair(userId: User['id']): Promise<UserKeypair> {
return await this.cache.fetch(userId);
}
@bindThis
public dispose(): void {
this.cache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -32,13 +32,13 @@ export class UserSuspendService {
@bindThis
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.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
@@ -46,13 +46,13 @@ export class UserSuspendService {
],
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, true);
}
@@ -62,13 +62,13 @@ export class UserSuspendService {
@bindThis
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.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
@@ -76,13 +76,13 @@ export class UserSuspendService {
],
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, true);
}

View File

@@ -21,7 +21,7 @@ export class VideoProcessingService {
@bindThis
public async generateVideoThumbnail(source: string): Promise<IImage> {
const [dir, cleanup] = await createTempDir();
try {
await new Promise((res, rej) => {
FFmpeg({

View File

@@ -31,7 +31,7 @@ export class WebhookService implements OnApplicationShutdown {
});
this.webhooksFetched = true;
}
return this.webhooks;
}

View File

@@ -27,14 +27,14 @@ export class ApAudienceService {
public async parseAudience(actor: RemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise<AudienceInfo> {
const toGroups = this.groupingAudience(getApIds(to), actor);
const ccGroups = this.groupingAudience(getApIds(cc), actor);
const others = unique(concat([toGroups.other, ccGroups.other]));
const limit = promiseLimit<User | null>(2);
const mentionedUsers = (await Promise.all(
others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))),
)).filter((x): x is User => x != null);
if (toGroups.public.length > 0) {
return {
visibility: 'public',
@@ -42,7 +42,7 @@ export class ApAudienceService {
visibleUsers: [],
};
}
if (ccGroups.public.length > 0) {
return {
visibility: 'home',
@@ -50,7 +50,7 @@ export class ApAudienceService {
visibleUsers: [],
};
}
if (toGroups.followers.length > 0) {
return {
visibility: 'followers',
@@ -58,14 +58,14 @@ export class ApAudienceService {
visibleUsers: [],
};
}
return {
visibility: 'specified',
mentionedUsers,
visibleUsers: mentionedUsers,
};
}
@bindThis
private groupingAudience(ids: string[], actor: RemoteUser) {
const groups = {
@@ -73,7 +73,7 @@ export class ApAudienceService {
followers: [] as string[],
other: [] as string[],
};
for (const id of ids) {
if (this.isPublic(id)) {
groups.public.push(id);
@@ -83,12 +83,12 @@ export class ApAudienceService {
groups.other.push(id);
}
}
groups.other = unique(groups.other);
return groups;
}
@bindThis
private isPublic(id: string) {
return [
@@ -97,7 +97,7 @@ export class ApAudienceService {
'Public',
].includes(id);
}
@bindThis
private isFollowers(id: string, actor: RemoteUser) {
return (

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import escapeRegexp from 'escape-regexp';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -29,7 +30,7 @@ export type UriParseResult = {
};
@Injectable()
export class ApDbResolverService implements OnApplicationShutdown {
export class ApDbResolverService {
private publicKeyCache: MemoryKVCache<UserPublickey | null>;
private publicKeyByUserIdCache: MemoryKVCache<UserPublickey | null>;
@@ -55,18 +56,25 @@ export class ApDbResolverService implements OnApplicationShutdown {
@bindThis
public parseUri(value: string | IObject): UriParseResult {
const separator = '/';
const uri = new URL(getApId(value));
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
const [, type, id, ...rest] = uri.pathname.split(separator);
return {
local: true,
type,
id,
rest: rest.length === 0 ? undefined : rest.join(separator),
};
const uri = getApId(value);
// the host part of a URL is case insensitive, so use the 'i' flag.
const localRegex = new RegExp('^' + escapeRegexp(this.config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i');
const matchLocal = uri.match(localRegex);
if (matchLocal) {
return {
local: true,
type: matchLocal[1],
id: matchLocal[2],
rest: matchLocal[3],
};
} else {
return {
local: false,
uri,
};
}
}
/**
@@ -121,7 +129,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
const key = await this.userPublickeysRepository.findOneBy({
keyId,
});
if (key == null) return null;
return key;
@@ -147,22 +155,11 @@ export class ApDbResolverService implements OnApplicationShutdown {
if (user == null) return null;
const key = await this.publicKeyByUserIdCache.fetch(user.id, () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null);
const key = await this.publicKeyByUserIdCache.fetch(user.id, () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null);
return {
user,
key,
};
}
@bindThis
public dispose(): void {
this.publicKeyCache.dispose();
this.publicKeyByUserIdCache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -7,8 +7,6 @@ import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { QueueService } from '@/core/QueueService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import type { IActivity } from '@/core/activitypub/type.js';
import { ThinUser } from '@/queue/types.js';
interface IRecipe {
type: string;
@@ -23,10 +21,10 @@ interface IDirectRecipe extends IRecipe {
to: RemoteUser;
}
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
const isFollowers = (recipe: any): recipe is IFollowersRecipe =>
recipe.type === 'Followers';
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
const isDirect = (recipe: any): recipe is IDirectRecipe =>
recipe.type === 'Direct';
@Injectable()
@@ -48,11 +46,11 @@ export class ApDeliverManagerService {
/**
* Deliver activity to followers
* @param actor
* @param activity Activity
* @param from Followee
*/
@bindThis
public async deliverToFollowers(actor: { id: LocalUser['id']; host: null; }, activity: IActivity) {
public async deliverToFollowers(actor: { id: LocalUser['id']; host: null; }, activity: any) {
const manager = new DeliverManager(
this.userEntityService,
this.followingsRepository,
@@ -66,12 +64,11 @@ export class ApDeliverManagerService {
/**
* Deliver activity to user
* @param actor
* @param activity Activity
* @param to Target user
*/
@bindThis
public async deliverToUser(actor: { id: LocalUser['id']; host: null; }, activity: IActivity, to: RemoteUser) {
public async deliverToUser(actor: { id: LocalUser['id']; host: null; }, activity: any, to: RemoteUser) {
const manager = new DeliverManager(
this.userEntityService,
this.followingsRepository,
@@ -84,28 +81,25 @@ export class ApDeliverManagerService {
}
@bindThis
public createDeliverManager(actor: { id: User['id']; host: null; }, activity: IActivity | null) {
public createDeliverManager(actor: { id: User['id']; host: null; }, activity: any) {
return new DeliverManager(
this.userEntityService,
this.followingsRepository,
this.queueService,
actor,
actor,
activity,
);
}
}
class DeliverManager {
private actor: ThinUser;
private activity: IActivity | null;
private actor: { id: User['id']; host: null; };
private activity: any;
private recipes: IRecipe[] = [];
/**
* Constructor
* @param userEntityService
* @param followingsRepository
* @param queueService
* @param actor Actor
* @param activity Activity to deliver
*/
@@ -115,15 +109,9 @@ class DeliverManager {
private queueService: QueueService,
actor: { id: User['id']; host: null; },
activity: IActivity | null,
activity: any,
) {
// 型で弾いてはいるが一応ローカルユーザーかチェック
if (actor.host != null) throw new Error('actor.host must be null');
// パフォーマンス向上のためキューに突っ込むのはidのみに絞る
this.actor = {
id: actor.id,
};
this.actor = actor;
this.activity = activity;
}
@@ -167,8 +155,9 @@ class DeliverManager {
*/
@bindThis
public async execute() {
if (!this.userEntityService.isLocalUser(this.actor)) return;
// The value flags whether it is shared or not.
// key: inbox URL, value: whether it is sharedInbox
const inboxes = new Map<string, boolean>();
/*
@@ -212,6 +201,9 @@ class DeliverManager {
.forEach(recipe => inboxes.set(recipe.to.inbox!, false));
// deliver
this.queueService.deliverMany(this.actor, this.activity, inboxes);
for (const inbox of inboxes) {
// inbox[0]: inbox, inbox[1]: whether it is sharedInbox
this.queueService.deliver(this.actor, this.activity, inbox[0], inbox[1]);
}
}
}

View File

@@ -21,7 +21,7 @@ export class ApMfmService {
@bindThis
public htmlToMfm(html: string, tag?: IObject | IObject[]) {
const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null);
return this.mfmService.fromHtml(html, hashtagNames);
}
@@ -29,5 +29,5 @@ export class ApMfmService {
public getNoteHtml(note: Note) {
if (!note.text) return '';
return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers));
}
}
}

View File

@@ -10,10 +10,9 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js';
import { DriveService } from '@/core/DriveService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { ApResolverService } from '../ApResolverService.js';
import { ApLoggerService } from '../ApLoggerService.js';
import type { IObject } from '../type.js';
import { checkHttps } from '@/misc/check-https.js';
@Injectable()
export class ApImageService {
@@ -33,27 +32,23 @@ export class ApImageService {
) {
this.logger = this.apLoggerService.logger;
}
/**
* Imageを作成します。
*/
@bindThis
public async createImage(actor: RemoteUser, value: string | IObject): Promise<DriveFile> {
public async createImage(actor: RemoteUser, value: any): Promise<DriveFile> {
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
}
const image = await this.apResolverService.createResolver().resolve(value);
const image = await this.apResolverService.createResolver().resolve(value) as any;
if (image.url == null) {
throw new Error('invalid image: url not privided');
}
if (typeof image.url !== 'string') {
throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2));
}
if (!checkHttps(image.url)) {
throw new Error('invalid image: unexpected schema of url: ' + image.url);
}
@@ -62,19 +57,29 @@ export class ApImageService {
const instance = await this.metaService.fetch();
const file = await this.driveService.uploadFromUrl({
let file = await this.driveService.uploadFromUrl({
url: image.url,
user: actor,
uri: image.url,
sensitive: image.sensitive,
isLink: !instance.cacheRemoteFiles,
comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH),
comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH),
});
if (!file.isLink || file.url === image.url) return file;
// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、URLを更新する
await this.driveFilesRepository.update({ id: file.id }, { url: image.url, uri: image.url });
return await this.driveFilesRepository.findOneByOrFail({ id: file.id });
if (file.isLink) {
// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、
// URLを更新する
if (file.url !== image.url) {
await this.driveFilesRepository.update({ id: file.id }, {
url: image.url,
uri: image.url,
});
file = await this.driveFilesRepository.findOneByOrFail({ id: file.id });
}
}
return file;
}
/**
@@ -84,7 +89,7 @@ export class ApImageService {
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
*/
@bindThis
public async resolveImage(actor: RemoteUser, value: string | IObject): Promise<DriveFile> {
public async resolveImage(actor: RemoteUser, value: any): Promise<DriveFile> {
// TODO
// リモートサーバーからフェッチしてきて登録

View File

@@ -22,17 +22,17 @@ export class ApMentionService {
}
@bindThis
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise<User[]> {
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href));
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver) {
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string));
const limit = promiseLimit<User | null>(2);
const mentionedUsers = (await Promise.all(
hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))),
)).filter((x): x is User => x != null);
return mentionedUsers;
}
@bindThis
public extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] {
if (tags == null) return [];

View File

@@ -20,6 +20,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js';
import { ApDbResolverService } from '../ApDbResolverService.js';
@@ -54,7 +55,7 @@ export class ApNoteService {
// 循環参照のため / for circular dependency
@Inject(forwardRef(() => ApPersonService))
private apPersonService: ApPersonService,
private utilityService: UtilityService,
private apAudienceService: ApAudienceService,
private apMentionService: ApMentionService,
@@ -71,13 +72,17 @@ export class ApNoteService {
}
@bindThis
public validateNote(object: IObject, uri: string): Error | null {
public validateNote(object: IObject, uri: string) {
const expectHost = this.utilityService.extractDbHost(uri);
if (object == null) {
return new Error('invalid Note: object is null');
}
if (!validPost.includes(getApType(object))) {
return new Error(`invalid Note: invalid object type ${getApType(object)}`);
}
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
}
@@ -86,10 +91,10 @@ export class ApNoteService {
if (object.attributedTo && actualHost !== expectHost) {
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
}
return null;
}
/**
* Noteをフェッチします。
*
@@ -99,30 +104,31 @@ export class ApNoteService {
public async fetchNote(object: string | IObject): Promise<Note | null> {
return await this.apDbResolverService.getNoteFromApId(object);
}
/**
* Noteを作成します。
*/
@bindThis
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<Note | null> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value);
const entryUri = getApId(value);
const err = this.validateNote(object, entryUri);
if (err) {
this.logger.error(err.message, {
resolver: { history: resolver.getHistory() },
value,
object,
this.logger.error(`${err.message}`, {
resolver: {
history: resolver.getHistory(),
},
value: value,
object: object,
});
throw new Error('invalid note');
}
const note = object as IPost;
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id && !checkHttps(note.id)) {
@@ -134,25 +140,21 @@ export class ApNoteService {
if (url && !checkHttps(url)) {
throw new Error('unexpected shcema of note url: ' + url);
}
this.logger.info(`Creating the Note: ${note.id}`);
// 投稿者をフェッチ
if (note.attributedTo == null) {
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
}
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as RemoteUser;
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo!), resolver) as RemoteUser;
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
}
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
let visibility = noteAudience.visibility;
const visibleUsers = noteAudience.visibleUsers;
// Audience (to, cc) が指定されてなかった場合
if (visibility === 'specified' && visibleUsers.length === 0) {
if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している
@@ -160,71 +162,81 @@ export class ApNoteService {
visibility = 'public';
}
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = extractApHashtags(note.tag);
const apHashtags = await extractApHashtags(note.tag);
// 添付ファイル
// TODO: attachmentは必ずしもImageではない
// TODO: attachmentは必ずしも配列ではない
const limit = promiseLimit<DriveFile>(2);
const files = (await Promise.all(toArray(note.attachment).map(attach => (
limit(() => this.apImageService.resolveImage(actor, {
...attach,
sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする
}))
))));
// Noteがsensitiveなら添付もsensitiveにする
const limit = promiseLimit(2);
note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : [];
const files = note.attachment
.map(attach => attach.sensitive = note.sensitive)
? (await Promise.all(note.attachment.map(x => limit(() => this.apImageService.resolveImage(actor, x)) as Promise<DriveFile>)))
.filter(image => image != null)
: [];
// リプライ
const reply: Note | null = note.inReplyTo
? await this.resolveNote(note.inReplyTo, resolver)
.then(x => {
if (x == null) {
this.logger.warn('Specified inReplyTo, but not found');
throw new Error('inReplyTo not found');
}
? await this.resolveNote(note.inReplyTo, resolver).then(x => {
if (x == null) {
this.logger.warn('Specified inReplyTo, but not found');
throw new Error('inReplyTo not found');
} else {
return x;
})
.catch(async err => {
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
throw err;
})
}
}).catch(async err => {
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
throw err;
})
: null;
// 引用
let quote: Note | undefined | null = null;
let quote: Note | undefined | null;
if (note._misskey_quote || note.quoteUrl) {
const tryResolveNote = async (uri: string): Promise<
| { status: 'ok'; res: Note }
| { status: 'permerror' | 'temperror' }
> => {
if (!uri.match(/^https?:/)) return { status: 'permerror' };
const tryResolveNote = async (uri: string): Promise<{
status: 'ok';
res: Note | null;
} | {
status: 'permerror' | 'temperror';
}> => {
if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' };
try {
const res = await this.resolveNote(uri);
if (res == null) return { status: 'permerror' };
return { status: 'ok', res };
if (res) {
return {
status: 'ok',
res,
};
} else {
return {
status: 'permerror',
};
}
} catch (e) {
return {
status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror',
};
}
};
const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string'));
const results = await Promise.all(uris.map(tryResolveNote));
quote = results.filter((x): x is { status: 'ok', res: Note } => x.status === 'ok').map(x => x.res).at(0);
const results = await Promise.all(uris.map(uri => tryResolveNote(uri)));
quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x);
if (!quote) {
if (results.some(x => x.status === 'temperror')) {
throw new Error('quote resolve failed');
}
}
}
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
@@ -234,38 +246,38 @@ export class ApNoteService {
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
// vote
if (reply && reply.hasPoll) {
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
const tryCreateVote = async (name: string, index: number): Promise<null> => {
if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) {
this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
} else if (index >= 0) {
this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
await this.pollService.vote(actor, reply, index);
// リモートフォロワーにUpdate配信
this.pollService.deliverQuestionUpdate(reply.id);
}
return null;
};
if (note.name) {
return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name));
}
}
const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => {
this.logger.info(`extractEmojis: ${e}`);
return [];
return [] as Emoji[];
});
const apEmojis = emojis.map(emoji => emoji.name);
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
return await this.noteCreateService.create(actor, {
createdAt: note.published ? new Date(note.published) : null,
files,
@@ -285,7 +297,7 @@ export class ApNoteService {
url: url,
}, silent);
}
/**
* Noteを解決します。
*
@@ -296,25 +308,26 @@ export class ApNoteService {
public async resolveNote(value: string | IObject, resolver?: Resolver): Promise<Note | null> {
const uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('missing uri');
// ブロックしてたら中断
// ブロックしてたら中断
const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) {
throw new StatusError('blocked host', 451);
}
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw new StatusError('blocked host', 451);
const unlock = await this.appLockService.getApLock(uri);
try {
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.fetchNote(uri);
if (exist) return exist;
if (exist) {
return exist;
}
//#endregion
if (uri.startsWith(this.config.url)) {
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
}
// リモートサーバーからフェッチしてきて登録
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
@@ -323,61 +336,63 @@ export class ApNoteService {
unlock();
}
}
@bindThis
public async extractEmojis(tags: IObject | IObject[], host: string): Promise<Emoji[]> {
// eslint-disable-next-line no-param-reassign
host = this.utilityService.toPuny(host);
if (!tags) return [];
const eomjiTags = toArray(tags).filter(isEmoji);
const existingEmojis = await this.emojisRepository.findBy({
host,
name: In(eomjiTags.map(tag => tag.name.replaceAll(':', ''))),
name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))),
});
return await Promise.all(eomjiTags.map(async tag => {
const name = tag.name.replaceAll(':', '');
const name = tag.name!.replaceAll(':', '');
tag.icon = toSingle(tag.icon);
const exists = existingEmojis.find(x => x.name === name);
if (exists) {
if ((exists.updatedAt == null)
if ((tag.updated != null && exists.updatedAt == null)
|| (tag.id != null && exists.uri == null)
|| (new Date(tag.updated) > exists.updatedAt)
|| (tag.icon.url !== exists.originalUrl)
|| (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt)
|| (tag.icon!.url !== exists.originalUrl)
) {
await this.emojisRepository.update({
host,
name,
}, {
uri: tag.id,
originalUrl: tag.icon.url,
publicUrl: tag.icon.url,
originalUrl: tag.icon!.url,
publicUrl: tag.icon!.url,
updatedAt: new Date(),
});
const emoji = await this.emojisRepository.findOneBy({ host, name });
if (emoji == null) throw new Error('emoji update failed');
return emoji;
return await this.emojisRepository.findOneBy({
host,
name,
}) as Emoji;
}
return exists;
}
this.logger.info(`register emoji host=${host}, name=${name}`);
return await this.emojisRepository.insert({
id: this.idService.genId(),
host,
name,
uri: tag.id,
originalUrl: tag.icon.url,
publicUrl: tag.icon.url,
originalUrl: tag.icon!.url,
publicUrl: tag.icon!.url,
updatedAt: new Date(),
aliases: [],
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
} as Partial<Emoji>).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
}));
}
}

View File

@@ -3,7 +3,7 @@ import promiseLimit from 'promise-limit';
import { DataSource } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { User } from '@/models/entities/User.js';
@@ -15,6 +15,7 @@ import type Logger from '@/logger.js';
import type { Note } from '@/models/entities/Note.js';
import type { IdService } from '@/core/IdService.js';
import type { MfmService } from '@/core/MfmService.js';
import type { Emoji } from '@/models/entities/Emoji.js';
import { toArray } from '@/misc/prelude/array.js';
import type { GlobalEventService } from '@/core/GlobalEventService.js';
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@@ -47,8 +48,6 @@ import type { IActor, IObject } from '../type.js';
const nameLength = 128;
const summaryLength = 2048;
type Field = Record<'name' | 'value', string>;
@Injectable()
export class ApPersonService implements OnModuleInit {
private utilityService: UtilityService;
@@ -95,10 +94,28 @@ export class ApPersonService implements OnModuleInit {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
//private utilityService: UtilityService,
//private userEntityService: UserEntityService,
//private idService: IdService,
//private globalEventService: GlobalEventService,
//private metaService: MetaService,
//private federatedInstanceService: FederatedInstanceService,
//private fetchInstanceMetadataService: FetchInstanceMetadataService,
//private cacheService: CacheService,
//private apResolverService: ApResolverService,
//private apNoteService: ApNoteService,
//private apImageService: ApImageService,
//private apMfmService: ApMfmService,
//private mfmService: MfmService,
//private hashtagService: HashtagService,
//private usersChart: UsersChart,
//private instanceChart: InstanceChart,
//private apLoggerService: ApLoggerService,
) {
}
onModuleInit(): void {
onModuleInit() {
this.utilityService = this.moduleRef.get('UtilityService');
this.userEntityService = this.moduleRef.get('UserEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
@@ -136,6 +153,10 @@ export class ApPersonService implements OnModuleInit {
private validateActor(x: IObject, uri: string): IActor {
const expectHost = this.punyHost(uri);
if (x == null) {
throw new Error('invalid Actor: object is null');
}
if (!isActor(x)) {
throw new Error(`invalid Actor type '${x.type}'`);
}
@@ -197,19 +218,21 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
public async fetchPerson(uri: string): Promise<LocalUser | RemoteUser | null> {
const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null | undefined;
if (typeof uri !== 'string') throw new Error('uri is not string');
const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null;
if (cached) return cached;
// URIがこのサーバーを指しているならデータベースからフェッチ
if (uri.startsWith(`${this.config.url}/`)) {
const id = uri.split('/').pop();
const u = await this.usersRepository.findOneBy({ id }) as LocalUser | null;
const u = await this.usersRepository.findOneBy({ id }) as LocalUser;
if (u) this.cacheService.uriPersonCache.set(uri, u);
return u;
}
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser | null;
const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser;
if (exist) {
this.cacheService.uriPersonCache.set(uri, exist);
@@ -231,11 +254,9 @@ export class ApPersonService implements OnModuleInit {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
}
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
const object = await resolver.resolve(uri) as any;
const person = this.validateActor(object, uri);
@@ -243,9 +264,9 @@ export class ApPersonService implements OnModuleInit {
const host = this.punyHost(object.id);
const fields = this.analyzeAttachments(person.attachment ?? []);
const { fields } = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
const isBot = getApType(object) === 'Service';
@@ -258,7 +279,7 @@ export class ApPersonService implements OnModuleInit {
}
// Create user
let user: RemoteUser | null = null;
let user: RemoteUser;
try {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
@@ -269,16 +290,16 @@ export class ApPersonService implements OnModuleInit {
createdAt: new Date(),
lastFetchedAt: new Date(),
name: truncate(person.name, nameLength),
isLocked: person.manuallyApprovesFollowers,
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
movedAt: person.movedTo ? new Date() : null,
alsoKnownAs: person.alsoKnownAs,
isExplorable: person.discoverable,
isExplorable: !!person.discoverable,
username: person.preferredUsername,
usernameLower: person.preferredUsername?.toLowerCase(),
usernameLower: person.preferredUsername!.toLowerCase(),
host,
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured ? getApId(person.featured) : undefined,
uri: person.id,
@@ -290,9 +311,9 @@ export class ApPersonService implements OnModuleInit {
await transactionalEntityManager.save(new UserProfile({
userId: user.id,
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
url,
url: url,
fields,
birthday: bday?.[0] ?? null,
birthday: bday ? bday[0] : null,
location: person['vcard:Address'] ?? null,
userHost: host,
}));
@@ -309,18 +330,21 @@ export class ApPersonService implements OnModuleInit {
// duplicate key error
if (isDuplicateKeyValueError(e)) {
// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
const u = await this.usersRepository.findOneBy({ uri: person.id });
if (u == null) throw new Error('already registered');
const u = await this.usersRepository.findOneBy({
uri: person.id,
});
user = u as RemoteUser;
if (u) {
user = u as RemoteUser;
} else {
throw new Error('already registered');
}
} else {
this.logger.error(e instanceof Error ? e : new Error(e as string));
throw e;
}
}
if (user == null) throw new Error('failed to create user: user is null');
// Register host
this.federatedInstanceService.fetch(host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
@@ -330,26 +354,29 @@ export class ApPersonService implements OnModuleInit {
}
});
this.usersChart.update(user, true);
this.usersChart.update(user!, true);
// ハッシュタグ更新
this.hashtagService.updateUsertags(user, tags);
this.hashtagService.updateUsertags(user!, tags);
//#region アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => {
if (img == null) return null;
if (user == null) throw new Error('failed to create user: user is null');
return this.apImageService.resolveImage(user, img).catch(() => null);
}));
const [avatar, banner] = await Promise.all([
person.icon,
person.image,
].map(img =>
img == null
? Promise.resolve(null)
: this.apImageService.resolveImage(user!, img).catch(() => null),
));
const avatarId = avatar?.id ?? null;
const bannerId = banner?.id ?? null;
const avatarId = avatar ? avatar.id : null;
const bannerId = banner ? banner.id : null;
const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null;
const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null;
const avatarBlurhash = avatar?.blurhash ?? null;
const bannerBlurhash = banner?.blurhash ?? null;
const avatarBlurhash = avatar ? avatar.blurhash : null;
const bannerBlurhash = banner ? banner.blurhash : null;
await this.usersRepository.update(user.id, {
await this.usersRepository.update(user!.id, {
avatarId,
bannerId,
avatarUrl,
@@ -358,35 +385,37 @@ export class ApPersonService implements OnModuleInit {
bannerBlurhash,
});
user.avatarId = avatarId;
user.bannerId = bannerId;
user.avatarUrl = avatarUrl;
user.bannerUrl = bannerUrl;
user.avatarBlurhash = avatarBlurhash;
user.bannerBlurhash = bannerBlurhash;
user!.avatarId = avatarId;
user!.bannerId = bannerId;
user!.avatarUrl = avatarUrl;
user!.bannerUrl = bannerUrl;
user!.avatarBlurhash = avatarBlurhash;
user!.bannerBlurhash = bannerBlurhash;
//#endregion
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [];
return [] as Emoji[];
});
const emojiNames = emojis.map(emoji => emoji.name);
await this.usersRepository.update(user.id, { emojis: emojiNames });
await this.usersRepository.update(user!.id, {
emojis: emojiNames,
});
//#endregion
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
return user;
return user!;
}
/**
* Personの情報を更新します。
* Misskeyに対象のPersonが登録されていなければ無視します。
* もしアカウントの移行が確認された場合、アカウント移行処理を行います。
*
*
* @param uri URI of Person
* @param resolver Resolver
* @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します)
@@ -397,14 +426,18 @@ export class ApPersonService implements OnModuleInit {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(`${this.config.url}/`)) return;
if (uri.startsWith(`${this.config.url}/`)) {
return;
}
//#region このサーバーに既に登録されているか
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null;
if (exist === null) return;
if (exist === null) {
return;
}
//#endregion
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = hint ?? await resolver.resolve(uri);
@@ -414,22 +447,26 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Updating the Person: ${person.id}`);
// アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => {
if (img == null) return null;
return this.apImageService.resolveImage(exist, img).catch(() => null);
}));
const [avatar, banner] = await Promise.all([
person.icon,
person.image,
].map(img =>
img == null
? Promise.resolve(null)
: this.apImageService.resolveImage(exist, img).catch(() => null),
));
// カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => {
this.logger.info(`extractEmojis: ${e}`);
return [];
return [] as Emoji[];
});
const emojiNames = emojis.map(emoji => emoji.name);
const fields = this.analyzeAttachments(person.attachment ?? []);
const { fields } = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
@@ -442,7 +479,7 @@ export class ApPersonService implements OnModuleInit {
const updates = {
lastFetchedAt: new Date(),
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured,
emojis: emojiNames,
@@ -450,29 +487,18 @@ export class ApPersonService implements OnModuleInit {
tags,
isBot: getApType(object) === 'Service',
isCat: (person as any).isCat === true,
isLocked: person.manuallyApprovesFollowers,
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: person.discoverable,
isExplorable: !!person.discoverable,
} as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
const moving = ((): boolean => {
const moving =
// 移行先がない→ある
if (
exist.movedToUri === null &&
updates.movedToUri
) return true;
(!exist.movedToUri && updates.movedToUri) ||
// 移行先がある→別のもの
if (
exist.movedToUri !== null &&
updates.movedToUri !== null &&
exist.movedToUri !== updates.movedToUri
) return true;
(exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri);
// 移行先がある→ない、ない→ないは無視
return false;
})();
if (moving) updates.movedAt = new Date();
@@ -499,10 +525,10 @@ export class ApPersonService implements OnModuleInit {
}
await this.userProfilesRepository.update({ userId: exist.id }, {
url,
url: url,
fields,
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
birthday: bday?.[0] ?? null,
birthday: bday ? bday[0] : null,
location: person['vcard:Address'] ?? null,
});
@@ -512,10 +538,11 @@ export class ApPersonService implements OnModuleInit {
this.hashtagService.updateUsertags(exist, tags);
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
await this.followingsRepository.update(
{ followerId: exist.id },
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox },
);
await this.followingsRepository.update({
followerId: exist.id,
}, {
followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
});
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
@@ -553,22 +580,27 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
public async resolvePerson(uri: string, resolver?: Resolver): Promise<LocalUser | RemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.fetchPerson(uri);
if (exist) return exist;
if (exist) {
return exist;
}
//#endregion
// リモートサーバーからフェッチしてきて登録
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
return await this.createPerson(uri, resolver);
}
@bindThis
// TODO: `attachments`が`IObject`だった場合、返り値が`[]`になるようだが構わないのか?
public analyzeAttachments(attachments: IObject | IObject[] | undefined): Field[] {
const fields: Field[] = [];
public analyzeAttachments(attachments: IObject | IObject[] | undefined) {
const fields: {
name: string,
value: string
}[] = [];
if (Array.isArray(attachments)) {
for (const attachment of attachments.filter(isPropertyValue)) {
fields.push({
@@ -578,11 +610,11 @@ export class ApPersonService implements OnModuleInit {
}
}
return fields;
return { fields };
}
@bindThis
public async updateFeatured(userId: User['id'], resolver?: Resolver): Promise<void> {
public async updateFeatured(userId: User['id'], resolver?: Resolver) {
const user = await this.usersRepository.findOneByOrFail({ id: userId });
if (!this.userEntityService.isRemoteUser(user)) return;
if (!user.featured) return;
@@ -611,13 +643,13 @@ export class ApPersonService implements OnModuleInit {
// とりあえずidを別の時間で生成して順番を維持
let td = 0;
for (const note of featuredNotes.filter((note): note is Note => note != null)) {
for (const note of featuredNotes.filter(note => note != null)) {
td -= 1000;
transactionalEntityManager.insert(UserNotePining, {
id: this.idService.genId(new Date(Date.now() + td)),
createdAt: new Date(),
userId: user.id,
noteId: note.id,
noteId: note!.id,
});
}
});
@@ -656,7 +688,7 @@ export class ApPersonService implements OnModuleInit {
// (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする)
dst = await this.resolvePerson(src.movedToUri);
}
if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; //
if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; //
if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri';

View File

@@ -4,12 +4,12 @@ import type { NotesRepository, PollsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { IPoll } from '@/models/entities/Poll.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isQuestion } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApResolverService } from '../ApResolverService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject, IQuestion } from '../type.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class ApQuestionService {
@@ -33,25 +33,33 @@ export class ApQuestionService {
@bindThis
public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(source);
if (!isQuestion(question)) throw new Error('invalid type');
const multiple = question.oneOf === undefined;
if (multiple && question.anyOf === undefined) throw new Error('invalid question');
if (!isQuestion(question)) {
throw new Error('invalid type');
}
const multiple = !question.oneOf;
const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null;
const choices = question[multiple ? 'anyOf' : 'oneOf']
?.map((x) => x.name)
.filter((x): x is string => typeof x === 'string')
?? [];
if (multiple && !question.anyOf) {
throw new Error('invalid question');
}
const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0);
const choices = question[multiple ? 'anyOf' : 'oneOf']!
.map((x, i) => x.name!);
return { choices, votes, multiple, expiresAt };
const votes = question[multiple ? 'anyOf' : 'oneOf']!
.map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0);
return {
choices,
votes,
multiple,
expiresAt,
};
}
/**
@@ -60,9 +68,8 @@ export class ApQuestionService {
* @returns true if updated
*/
@bindThis
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
public async updateQuestion(value: any, resolver?: Resolver) {
const uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('uri is null');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
@@ -76,7 +83,6 @@ export class ApQuestionService {
//#endregion
// resolve new Question object
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(value) as IQuestion;
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
@@ -84,14 +90,12 @@ export class ApQuestionService {
if (question.type !== 'Question') throw new Error('object is not a Question');
const apChoices = question.oneOf ?? question.anyOf;
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
let changed = false;
for (const choice of poll.choices) {
const oldCount = poll.votes[poll.choices.indexOf(choice)];
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems;
if (oldCount !== newCount) {
changed = true;
@@ -99,7 +103,9 @@ export class ApQuestionService {
}
}
await this.pollsRepository.update({ noteId: note.id }, { votes: poll.votes });
await this.pollsRepository.update({ noteId: note.id }, {
votes: poll.votes,
});
return changed;
}

View File

@@ -2,7 +2,7 @@ import { toArray } from '@/misc/prelude/array.js';
import { isHashtag } from '../type.js';
import type { IObject, IApHashtag } from '../type.js';
export function extractApHashtags(tags: IObject | IObject[] | null | undefined): string[] {
export function extractApHashtags(tags: IObject | IObject[] | null | undefined) {
if (tags == null) return [];
const hashtags = extractApHashtagObjects(tags);

View File

@@ -47,7 +47,7 @@ export class DriveFileEntityService {
private videoProcessingService: VideoProcessingService,
) {
}
@bindThis
public validateFileName(name: string): boolean {
return (

View File

@@ -24,7 +24,7 @@ export class NoteEntityService implements OnModuleInit {
private driveFileEntityService: DriveFileEntityService;
private customEmojiService: CustomEmojiService;
private reactionService: ReactionService;
constructor(
private moduleRef: ModuleRef,
@@ -68,7 +68,7 @@ export class NoteEntityService implements OnModuleInit {
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.reactionService = this.moduleRef.get('ReactionService');
}
@bindThis
private async hideNote(packedNote: Packed<'Note'>, meId: User['id'] | null) {
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
@@ -457,12 +457,12 @@ export class NoteEntityService implements OnModuleInit {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.userId = :userId', { userId })
.andWhere('note.renoteId = :renoteId', { renoteId });
// 指定した投稿を除く
if (excludeNoteId) {
query.andWhere('note.id != :excludeNoteId', { excludeNoteId });
}
return await query.getCount();
}
}
}

View File

@@ -17,7 +17,7 @@ export class NoteReactionEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
private noteEntityService: NoteEntityService;
private reactionService: ReactionService;
constructor(
private moduleRef: ModuleRef,

View File

@@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit {
meId: User['id'],
// eslint-disable-next-line @typescript-eslint/ban-types
options: {
},
hint?: {
packedNotes: Map<Note['id'], Packed<'Note'>>;

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import * as Redis from 'ioredis';
import _Ajv from 'ajv';
import Ajv from 'ajv';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -31,7 +31,6 @@ type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends bo
Packed<'UserDetailed'> :
Packed<'UserLite'>;
const Ajv = _Ajv.default;
const ajv = new Ajv();
function isLocalUser(user: User): user is LocalUser;
@@ -113,7 +112,7 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
@Inject(DI.userMemosRepository)
private userMemosRepository: UserMemoRepository,

View File

@@ -81,7 +81,7 @@ export class QueueStatsService implements OnApplicationShutdown {
this.intervalId = setInterval(tick, interval);
}
@bindThis
public dispose(): void {
clearInterval(this.intervalId);

View File

@@ -3,7 +3,6 @@ import si from 'systeminformation';
import Xev from 'xev';
import * as osUtils from 'os-utils';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
const ev = new Xev();
@@ -15,10 +14,9 @@ const round = (num: number) => Math.round(num * 10) / 10;
@Injectable()
export class ServerStatsService implements OnApplicationShutdown {
private intervalId: NodeJS.Timer | null = null;
private intervalId: NodeJS.Timer;
constructor(
private metaService: MetaService,
) {
}
@@ -26,9 +24,7 @@ export class ServerStatsService implements OnApplicationShutdown {
* Report server stats regularly
*/
@bindThis
public async start(): Promise<void> {
if (!(await this.metaService.fetch(true)).enableServerMachineStats) return;
public start(): void {
const log = [] as any[];
ev.on('requestServerStatsLog', x => {
@@ -68,9 +64,7 @@ export class ServerStatsService implements OnApplicationShutdown {
@bindThis
public dispose(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
}
clearInterval(this.intervalId);
}
@bindThis

View File

@@ -4,7 +4,7 @@ import { default as convertColor } from 'color-convert';
import { format as dateFormat } from 'date-fns';
import { bindThis } from '@/decorators.js';
import { envOption } from './env.js';
import type { KEYWORD } from 'color-convert/conversions.js';
import type { KEYWORD } from 'color-convert/conversions';
type Context = {
name: string;

View File

@@ -83,16 +83,6 @@ export class RedisKVCache<T> {
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
}
@bindThis
public gc() {
this.memoryCache.gc();
}
@bindThis
public dispose() {
this.memoryCache.dispose();
}
}
export class RedisSingleCache<T> {
@@ -184,15 +174,10 @@ export class RedisSingleCache<T> {
export class MemoryKVCache<T> {
public cache: Map<string, { date: number; value: T; }>;
private lifetime: number;
private gcIntervalHandle: NodeJS.Timer;
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
this.cache = new Map();
this.lifetime = lifetime;
this.gcIntervalHandle = setInterval(() => {
this.gc();
}, 1000 * 60 * 3);
}
@bindThis
@@ -215,7 +200,7 @@ export class MemoryKVCache<T> {
}
@bindThis
public delete(key: string): void {
public delete(key: string) {
this.cache.delete(key);
}
@@ -270,21 +255,6 @@ export class MemoryKVCache<T> {
}
return value;
}
@bindThis
public gc(): void {
const now = Date.now();
for (const [key, { date }] of this.cache.entries()) {
if ((now - date) > this.lifetime) {
this.cache.delete(key);
}
}
}
@bindThis
public dispose(): void {
clearInterval(this.gcIntervalHandle);
}
}
export class MemorySingleCache<T> {

View File

@@ -1,4 +1,4 @@
export function checkHttps(url: string): boolean {
return url.startsWith('https://') ||
(url.startsWith('http://') && process.env.NODE_ENV !== 'production');
export function checkHttps(url: string) {
return url.startsWith('https://') ||
(url.startsWith('http://') && process.env.NODE_ENV !== 'production');
}

View File

@@ -1,3 +1,3 @@
import { secureRndstr } from '@/misc/secure-rndstr.js';
export default () => secureRndstr(16);
export default () => secureRndstr(16, true);

View File

@@ -1,6 +1,6 @@
import IPCIDR from 'ip-cidr';
export function getIpHash(ip: string): string {
export function getIpHash(ip: string) {
try {
// because a single person may control many IPv6 addresses,
// only a /64 subnet prefix of any IP will be taken into account.

View File

@@ -131,7 +131,7 @@ type NullOrUndefined<p extends Schema, T> =
| T;
// https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
// Get intersection from union
// Get intersection from union
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type PartialIntersection<T> = Partial<UnionToIntersection<T>>;

View File

@@ -2,7 +2,7 @@
* 1. 配列に何も入っていない時はクエリを付けない
* 2. プロパティがundefinedの時はクエリを付けない
* new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない)
*/
*/
export function query(obj: Record<string, unknown>): string {
const params = Object.entries(obj)
.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)

View File

@@ -1,9 +1,10 @@
import * as crypto from 'node:crypto';
export const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function secureRndstr(length = 32, { chars = LU_CHARS } = {}): string {
export function secureRndstr(length = 32, useLU = true): string {
const chars = useLU ? LU_CHARS : L_CHARS;
const chars_len = chars.length;
let str = '';

View File

@@ -55,10 +55,7 @@ export class Ad {
length: 8192, nullable: false,
})
public memo: string;
@Column('integer', {
default: 0, nullable: false,
})
public dayOfWeek: number;
constructor(data: Partial<Ad>) {
if (data == null) return;

View File

@@ -97,30 +97,18 @@ export class Meta {
})
public logoImageUrl: string | null;
@Column('varchar', {
length: 1024,
nullable: true,
})
public errorImageUrl: string | null;
@Column('varchar', {
length: 1024,
nullable: true,
})
public iconUrl: string | null;
@Column('varchar', {
length: 1024,
nullable: true,
})
public serverErrorImageUrl: string | null;
@Column('varchar', {
length: 1024,
nullable: true,
})
public notFoundImageUrl: string | null;
@Column('varchar', {
length: 1024,
nullable: true,
})
public infoImageUrl: string | null;
@Column('boolean', {
default: true,
})
@@ -413,16 +401,6 @@ export class Meta {
})
public enableChartsForFederatedInstances: boolean;
@Column('boolean', {
default: false,
})
public enableServerMachineStats: boolean;
@Column('boolean', {
default: true,
})
public enableIdenticonGeneration: boolean;
@Column('jsonb', {
default: { },
})

View File

@@ -207,7 +207,7 @@ export class UserProfile {
public mutedInstances: string[];
@Column('enum', {
enum: [
enum: [
...notificationTypes,
// マイグレーションで削除が困難なので古いenumは残しておく
...obsoleteNotificationTypes,

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