Merge branch 'develop' into re-ed25519

This commit is contained in:
tamaina 2024-09-21 20:29:41 +09:00
commit 61d5371073
233 changed files with 3595 additions and 951 deletions

View file

@ -0,0 +1,211 @@
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Misskey configuration
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ┌─────┐
#───┘ URL └─────────────────────────────────────────────────────
# Final accessible URL seen by a user.
url: 'http://misskey.local'
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT!
# ┌───────────────────────┐
#───┘ Port and TLS settings └───────────────────────────────────
#
# Misskey requires a reverse proxy to support HTTPS connections.
#
# +----- https://example.tld/ ------------+
# +------+ |+-------------+ +----------------+|
# | User | ---> || Proxy (443) | ---> | Misskey (3000) ||
# +------+ |+-------------+ +----------------+|
# +---------------------------------------+
#
# You need to set up a reverse proxy. (e.g. nginx)
# An encrypted connection with HTTPS is highly recommended
# because tokens may be transferred in GET requests.
# The port that your Misskey server should listen on.
port: 61812
# ┌──────────────────────────┐
#───┘ PostgreSQL configuration └────────────────────────────────
db:
host: db
port: 5432
# Database name
db: misskey
# Auth
user: postgres
pass: postgres
# Whether disable Caching queries
#disableCache: true
# Extra Connection options
#extra:
# ssl: true
dbReplications: false
# You can configure any number of replicas here
#dbSlaves:
# -
# host:
# port:
# db:
# user:
# pass:
# -
# host:
# port:
# db:
# user:
# pass:
# ┌─────────────────────┐
#───┘ Redis configuration └─────────────────────────────────────
redis:
host: redis
port: 6379
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
#pass: example-pass
#prefix: example-prefix
#db: 1
#redisForPubsub:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
#redisForJobQueue:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
#redisForTimelines:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
#redisForReactions:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐
#───┘ MeiliSearch configuration └─────────────────────────────
#meilisearch:
# host: meilisearch
# port: 7700
# apiKey: ''
# ssl: true
# index: ''
# ┌───────────────┐
#───┘ ID generation └───────────────────────────────────────────
# You can select the ID generation method.
# You don't usually need to change this setting, but you can
# change it according to your preferences.
# Available methods:
# aid ... Short, Millisecond accuracy
# aidx ... Millisecond accuracy
# meid ... Similar to ObjectID, Millisecond accuracy
# ulid ... Millisecond accuracy
# objectid ... This is left for backward compatibility
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# ID SETTINGS AFTER THAT!
id: 'aidx'
# ┌────────────────┐
#───┘ Error tracking └──────────────────────────────────────────
# Sentry is available for error tracking.
# See the Sentry documentation for more details on options.
#sentryForBackend:
# enableNodeProfiling: true
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
#sentryForFrontend:
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
# ┌─────────────────────┐
#───┘ Other configuration └─────────────────────────────────────
# Whether disable HSTS
#disableHsts: true
# Number of worker processes
#clusterLimit: 1
# Job concurrency per worker
# deliverJobConcurrency: 128
# inboxJobConcurrency: 16
# Job rate limiter
# deliverJobPerSec: 128
# inboxJobPerSec: 32
# Job attempts
# deliverJobMaxAttempts: 12
# inboxJobMaxAttempts: 8
# IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4
# Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128
proxyBypassHosts:
- api.deepl.com
- api-free.deepl.com
- www.recaptcha.net
- hcaptcha.com
- challenges.cloudflare.com
# Proxy for SMTP/SMTPS
#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5
# Media Proxy
#mediaProxy: https://example.com/proxy
# Proxy remote files (default: true)
proxyRemoteFiles: true
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
allowedPrivateNetworks: [
'127.0.0.1/32'
]
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View file

@ -106,6 +106,14 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForReactions:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View file

@ -172,6 +172,16 @@ redis:
# # You can specify more ioredis options... # # You can specify more ioredis options...
# #username: example-username # #username: example-username
#redisForReactions:
# host: localhost
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# # You can specify more ioredis options...
# #username: example-username
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View file

@ -103,6 +103,14 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForReactions:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

View file

@ -3,6 +3,8 @@
set -xe set -xe
sudo chown node node_modules sudo chown node node_modules
sudo apt-get update
sudo apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb
git config --global --add safe.directory /workspace git config --global --add safe.directory /workspace
git submodule update --init git submodule update --init
corepack install corepack install
@ -12,3 +14,4 @@ pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build pnpm build
pnpm migrate pnpm migrate
pnpm exec cypress install

View file

@ -21,7 +21,7 @@ jobs:
- run: corepack enable - run: corepack enable
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View file

@ -14,7 +14,7 @@ jobs:
- name: Checkout head - name: Checkout head
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'

View file

@ -28,7 +28,7 @@ jobs:
- name: setup node - name: setup node
id: setup-node id: setup-node
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: pnpm cache: pnpm

View file

@ -33,7 +33,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View file

@ -33,7 +33,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.3 - uses: actions/setup-node@v4.0.4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
@ -62,7 +62,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.3 - uses: actions/setup-node@v4.0.4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
@ -92,7 +92,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.3 - uses: actions/setup-node@v4.0.4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View file

@ -19,7 +19,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.3 - uses: actions/setup-node@v4.0.4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View file

@ -26,7 +26,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View file

@ -70,8 +70,16 @@ jobs:
- id: out-diff - id: out-diff
name: Build diff Comment name: Build diff Comment
run: | run: |
cat <<- EOF > ./output.md HEADER="このPRによるapi.jsonの差分"
このPRによるapi.jsonの差分 FOOTER="[Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
DIFF_BYTES="$(stat ./api.json.diff -c '%s' | tr -d '\n')"
echo "$HEADER" > ./output.md
if (( "$DIFF_BYTES" <= 1 )); then
echo '差分はありません。' >> ./output.md
else
cat <<- EOF >> ./output.md
<details> <details>
<summary>差分はこちら</summary> <summary>差分はこちら</summary>
@ -79,9 +87,10 @@ jobs:
$(cat ./api.json.diff) $(cat ./api.json.diff)
\`\`\` \`\`\`
</details> </details>
[Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})
EOF EOF
fi
echo "$FOOTER" >> ./output.md
- uses: thollander/actions-comment-pull-request@v2 - uses: thollander/actions-comment-pull-request@v2
with: with:
pr_number: ${{ steps.load-pr-num.outputs.pr-number }} pr_number: ${{ steps.load-pr-num.outputs.pr-number }}

View file

@ -41,7 +41,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View file

@ -46,7 +46,7 @@ jobs:
- name: Install FFmpeg - name: Install FFmpeg
uses: FedericoCarboni/setup-ffmpeg@v3 uses: FedericoCarboni/setup-ffmpeg@v3
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
@ -93,7 +93,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View file

@ -35,7 +35,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
@ -90,7 +90,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View file

@ -31,7 +31,7 @@ jobs:
- run: corepack enable - run: corepack enable
- name: Setup Node.js ${{ matrix.node-version }} - name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View file

@ -25,7 +25,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View file

@ -27,7 +27,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.0.4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

5
.gitignore vendored
View file

@ -35,6 +35,7 @@ coverage
!/.config/example.yml !/.config/example.yml
!/.config/docker_example.yml !/.config/docker_example.yml
!/.config/docker_example.env !/.config/docker_example.env
!/.config/cypress-devcontainer.yml
docker-compose.yml docker-compose.yml
compose.yml compose.yml
.devcontainer/compose.yml .devcontainer/compose.yml
@ -64,6 +65,10 @@ temp
tsdoc-metadata.json tsdoc-metadata.json
misskey-assets misskey-assets
# Vite temporary files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# blender backups # blender backups
*.blend1 *.blend1
*.blend2 *.blend2

View file

@ -1,18 +1,33 @@
## Unreleased ## 2024.9.0
### General ### General
- - Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445)
- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように
### Client ### Client
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
- 埋め込みコードやウェブサイトへの実装方法の詳細はMisskey Hubに掲載予定です - 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください
- サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように - Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように
- Enhance: アイコンデコレーション管理画面にプレビューを追加 - Enhance: アイコンデコレーション管理画面にプレビューを追加
- Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく
- Enhance: ScratchpadにUIインスペクターを追加
- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正 - Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正
- Fix: 月の違う同じ日はセパレータが表示されないのを修正
- Fix: 縦横比が極端なカスタム絵文字を表示する際にレイアウトが崩れる箇所があるのを修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/725)
- Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正
- Fix: ファイルの詳細ページのファイルの説明で改行が正しく表示されない問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/bde6bb0bd2e8b0d027e724d2acdb8ae0585a8110)
### Server ### Server
- ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正 - Feat: Misskey® Reactions Buffering Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に
- Fix: アンテナの書き込み時にキーワードが与えられなかった場合のエラーをApiErrorとして投げるように
- この変更により、公式フロントエンドでは入力の不備が内部エラーとして報告される代わりに一般的なエラーダイアログで報告されます
- Fix: ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正
- Fix: 外部ページを解析する際に、ページに紐づけられた関連リソースも読み込まれてしまう問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/26e0412fbb91447c37e8fb06ffb0487346063bb8)
- Fix: `Retry-After`ヘッダーが送信されなかった問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/8a982c61c01909e7540ff1be9f019df07c3f0624)
## 2024.8.0 ## 2024.8.0

View file

@ -124,6 +124,14 @@ redis:
# #prefix: example-prefix # #prefix: example-prefix
# #db: 1 # #db: 1
#redisForReactions:
# host: redis
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
# #prefix: example-prefix
# #db: 1
# ┌───────────────────────────┐ # ┌───────────────────────────┐
#───┘ MeiliSearch configuration └───────────────────────────── #───┘ MeiliSearch configuration └─────────────────────────────

38
locales/index.d.ts vendored
View file

@ -2384,6 +2384,14 @@ export interface Locale extends ILocale {
* AiScriptの実験環境を提供しますMisskeyと対話するコードの記述 * AiScriptの実験環境を提供しますMisskeyと対話するコードの記述
*/ */
"scratchpadDescription": string; "scratchpadDescription": string;
/**
* UIインスペクター
*/
"uiInspector": string;
/**
* UIコンポーネントのインスタンスの一覧を見ることができますUIコンポーネントはUi:C:
*/
"uiInspectorDescription": string;
/** /**
* *
*/ */
@ -3121,7 +3129,7 @@ export interface Locale extends ILocale {
*/ */
"narrow": string; "narrow": string;
/** /**
* *
*/ */
"reloadToApplySetting": string; "reloadToApplySetting": string;
/** /**
@ -5575,6 +5583,10 @@ export interface Locale extends ILocale {
* DBへ追加で問い合わせを行うフォールバック処理を行います * DBへ追加で問い合わせを行うフォールバック処理を行います
*/ */
"fanoutTimelineDbFallbackDescription": string; "fanoutTimelineDbFallbackDescription": string;
/**
* Redisのメモリ使用量は増加します
*/
"reactionsBufferingDescription": string;
/** /**
* URL * URL
*/ */
@ -6754,6 +6766,26 @@ export interface Locale extends ILocale {
* *
*/ */
"avatarDecorationLimit": string; "avatarDecorationLimit": string;
/**
*
*/
"canImportAntennas": string;
/**
*
*/
"canImportBlocking": string;
/**
*
*/
"canImportFollowing": string;
/**
*
*/
"canImportMuting": string;
/**
*
*/
"canImportUserLists": string;
}; };
"_condition": { "_condition": {
/** /**
@ -9477,6 +9509,10 @@ export interface Locale extends ILocale {
* Webhookを削除しますか * Webhookを削除しますか
*/ */
"deleteConfirm": string; "deleteConfirm": string;
/**
* 使Webhookを送信できます
*/
"testRemarks": string;
}; };
"_abuseReport": { "_abuseReport": {
"_notificationRecipient": { "_notificationRecipient": {

View file

@ -592,6 +592,8 @@ ascendingOrder: "昇順"
descendingOrder: "降順" descendingOrder: "降順"
scratchpad: "スクラッチパッド" scratchpad: "スクラッチパッド"
scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。Misskeyと対話するコードの記述、実行、結果の確認ができます。" scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。Misskeyと対話するコードの記述、実行、結果の確認ができます。"
uiInspector: "UIインスペクター"
uiInspectorDescription: "メモリ上に存在しているUIコンポーネントのインスタンスの一覧を見ることができます。UIコンポーネントはUi:C:系関数により生成されます。"
output: "出力" output: "出力"
script: "スクリプト" script: "スクリプト"
disablePagesScript: "Pagesのスクリプトを無効にする" disablePagesScript: "Pagesのスクリプトを無効にする"
@ -776,7 +778,7 @@ left: "左"
center: "中央" center: "中央"
wide: "広い" wide: "広い"
narrow: "狭い" narrow: "狭い"
reloadToApplySetting: "設定はページリロード後に反映されます。今すぐリロードしますか?" reloadToApplySetting: "設定はページリロード後に反映されます。"
needReloadToApply: "反映には再起動が必要です。" needReloadToApply: "反映には再起動が必要です。"
showTitlebar: "タイトルバーを表示する" showTitlebar: "タイトルバーを表示する"
clearCache: "キャッシュをクリア" clearCache: "キャッシュをクリア"
@ -1409,6 +1411,7 @@ _serverSettings:
fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。"
fanoutTimelineDbFallback: "データベースへのフォールバック" fanoutTimelineDbFallback: "データベースへのフォールバック"
fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。" fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。"
reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。"
inquiryUrl: "問い合わせ先URL" inquiryUrl: "問い合わせ先URL"
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。"
@ -1745,6 +1748,11 @@ _role:
canSearchNotes: "ノート検索の利用" canSearchNotes: "ノート検索の利用"
canUseTranslator: "翻訳機能の利用" canUseTranslator: "翻訳機能の利用"
avatarDecorationLimit: "アイコンデコレーションの最大取付個数" avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
canImportAntennas: "アンテナのインポートを許可"
canImportBlocking: "ブロックのインポートを許可"
canImportFollowing: "フォローのインポートを許可"
canImportMuting: "ミュートのインポートを許可"
canImportUserLists: "リストのインポートを許可"
_condition: _condition:
roleAssignedTo: "マニュアルロールにアサイン済み" roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー" isLocal: "ローカルユーザー"
@ -2512,6 +2520,7 @@ _webhookSettings:
abuseReportResolved: "ユーザーからの通報を処理したとき" abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき" userCreated: "ユーザーが作成されたとき"
deleteConfirm: "Webhookを削除しますか" deleteConfirm: "Webhookを削除しますか"
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"
_abuseReport: _abuseReport:
_notificationRecipient: _notificationRecipient:

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.8.0", "version": "2024.9.0-alpha.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -37,6 +37,7 @@
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run", "cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e-dev-container": "cp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"jest": "cd packages/backend && pnpm jest", "jest": "cd packages/backend && pnpm jest",
"jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage",
"test": "pnpm -r test", "test": "pnpm -r test",

View file

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

View file

@ -100,7 +100,7 @@
"async-mutex": "0.5.0", "async-mutex": "0.5.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.3",
"bullmq": "5.10.4", "bullmq": "5.10.4",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.2", "cbor": "9.0.2",
@ -119,7 +119,7 @@
"fluent-ffmpeg": "2.1.3", "fluent-ffmpeg": "2.1.3",
"form-data": "4.0.0", "form-data": "4.0.0",
"got": "14.4.2", "got": "14.4.2",
"happy-dom": "10.0.3", "happy-dom": "15.6.1",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"htmlescape": "1.1.1", "htmlescape": "1.1.1",
"http-link-header": "1.1.3", "http-link-header": "1.1.3",
@ -132,6 +132,7 @@
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.2", "jsonld": "8.3.2",
"jsrsasign": "11.1.0", "jsrsasign": "11.1.0",
"juice": "11.0.0",
"meilisearch": "0.41.0", "meilisearch": "0.41.0",
"mfm-js": "0.24.0", "mfm-js": "0.24.0",
"microformats-parser": "2.0.2", "microformats-parser": "2.0.2",

View file

@ -78,11 +78,19 @@ const $redisForTimelines: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $redisForReactions: Provider = {
provide: DI.redisForReactions,
useFactory: (config: Config) => {
return new Redis.Redis(config.redisForReactions);
},
inject: [DI.config],
};
@Global() @Global()
@Module({ @Module({
imports: [RepositoryModule], imports: [RepositoryModule],
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions],
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, RepositoryModule],
}) })
export class GlobalModule implements OnApplicationShutdown { export class GlobalModule implements OnApplicationShutdown {
constructor( constructor(
@ -91,6 +99,7 @@ export class GlobalModule implements OnApplicationShutdown {
@Inject(DI.redisForPub) private redisForPub: Redis.Redis, @Inject(DI.redisForPub) private redisForPub: Redis.Redis,
@Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis,
@Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis,
@Inject(DI.redisForReactions) private redisForReactions: Redis.Redis,
) { } ) { }
public async dispose(): Promise<void> { public async dispose(): Promise<void> {
@ -103,6 +112,7 @@ export class GlobalModule implements OnApplicationShutdown {
this.redisForPub.disconnect(), this.redisForPub.disconnect(),
this.redisForSub.disconnect(), this.redisForSub.disconnect(),
this.redisForTimelines.disconnect(), this.redisForTimelines.disconnect(),
this.redisForReactions.disconnect(),
]); ]);
} }

View file

@ -49,6 +49,7 @@ type Source = {
redisForPubsub?: RedisOptionsSource; redisForPubsub?: RedisOptionsSource;
redisForJobQueue?: RedisOptionsSource; redisForJobQueue?: RedisOptionsSource;
redisForTimelines?: RedisOptionsSource; redisForTimelines?: RedisOptionsSource;
redisForReactions?: RedisOptionsSource;
meilisearch?: { meilisearch?: {
host: string; host: string;
port: string; port: string;
@ -171,6 +172,7 @@ export type Config = {
redisForPubsub: RedisOptions & RedisOptionsSource; redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource; redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined; sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined; sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined;
perChannelMaxNoteCacheCount: number; perChannelMaxNoteCacheCount: number;
@ -251,6 +253,7 @@ export function loadConfig(): Config {
redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis,
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
sentryForBackend: config.sentryForBackend, sentryForBackend: config.sentryForBackend,
sentryForFrontend: config.sentryForFrontend, sentryForFrontend: config.sentryForFrontend,
id: config.id, id: config.id,

View file

@ -13,6 +13,8 @@ export const REMOTE_USER_MOVE_COOLDOWN = 1000 * 60 * 60 * 24 * 14; // 14days
export const REMOTE_SERVER_CACHE_TTL = 1000 * 60 * 60 * 3; // 3hours export const REMOTE_SERVER_CACHE_TTL = 1000 * 60 * 60 * 3; // 3hours
export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
//#region hard limits //#region hard limits
// If you change DB_* values, you must also change the DB schema. // If you change DB_* values, you must also change the DB schema.

View file

@ -123,11 +123,14 @@ export class AntennaService implements OnApplicationShutdown {
if (antenna.src === 'home') { if (antenna.src === 'home') {
// TODO // TODO
} else if (antenna.src === 'list') { } else if (antenna.src === 'list') {
const listUsers = (await this.userListMembershipsRepository.findBy({ if (antenna.userListId == null) return false;
userListId: antenna.userListId!, const exists = await this.userListMembershipsRepository.exists({
})).map(x => x.userId); where: {
userListId: antenna.userListId,
if (!listUsers.includes(note.userId)) return false; userId: note.userId,
},
});
if (!exists) return false;
} else if (antenna.src === 'users') { } else if (antenna.src === 'users') {
const accts = antenna.users.map(x => { const accts = antenna.users.map(x => {
const { username, host } = Acct.parse(x); const { username, host } = Acct.parse(x);

View file

@ -13,6 +13,7 @@ import {
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js'; import { UserSearchService } from '@/core/UserSearchService.js';
import { WebhookTestService } from '@/core/WebhookTestService.js';
import { AccountMoveService } from './AccountMoveService.js'; import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js'; import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js'; import { AiService } from './AiService.js';
@ -49,6 +50,7 @@ import { PollService } from './PollService.js';
import { PushNotificationService } from './PushNotificationService.js'; import { PushNotificationService } from './PushNotificationService.js';
import { QueryService } from './QueryService.js'; import { QueryService } from './QueryService.js';
import { ReactionService } from './ReactionService.js'; import { ReactionService } from './ReactionService.js';
import { ReactionsBufferingService } from './ReactionsBufferingService.js';
import { RelayService } from './RelayService.js'; import { RelayService } from './RelayService.js';
import { RoleService } from './RoleService.js'; import { RoleService } from './RoleService.js';
import { S3Service } from './S3Service.js'; import { S3Service } from './S3Service.js';
@ -192,6 +194,7 @@ const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExis
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService }; const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService }; const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService };
const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService }; const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService };
const $ReactionsBufferingService: Provider = { provide: 'ReactionsBufferingService', useExisting: ReactionsBufferingService };
const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService }; const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService };
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService }; const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
@ -211,6 +214,7 @@ const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: Us
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
@ -340,6 +344,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
PushNotificationService, PushNotificationService,
QueryService, QueryService,
ReactionService, ReactionService,
ReactionsBufferingService,
RelayService, RelayService,
RoleService, RoleService,
S3Service, S3Service,
@ -359,6 +364,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
VideoProcessingService, VideoProcessingService,
UserWebhookService, UserWebhookService,
SystemWebhookService, SystemWebhookService,
WebhookTestService,
UtilityService, UtilityService,
FileInfoService, FileInfoService,
SearchService, SearchService,
@ -484,6 +490,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$PushNotificationService, $PushNotificationService,
$QueryService, $QueryService,
$ReactionService, $ReactionService,
$ReactionsBufferingService,
$RelayService, $RelayService,
$RoleService, $RoleService,
$S3Service, $S3Service,
@ -503,6 +510,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$VideoProcessingService, $VideoProcessingService,
$UserWebhookService, $UserWebhookService,
$SystemWebhookService, $SystemWebhookService,
$WebhookTestService,
$UtilityService, $UtilityService,
$FileInfoService, $FileInfoService,
$SearchService, $SearchService,
@ -629,6 +637,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
PushNotificationService, PushNotificationService,
QueryService, QueryService,
ReactionService, ReactionService,
ReactionsBufferingService,
RelayService, RelayService,
RoleService, RoleService,
S3Service, S3Service,
@ -648,6 +657,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
VideoProcessingService, VideoProcessingService,
UserWebhookService, UserWebhookService,
SystemWebhookService, SystemWebhookService,
WebhookTestService,
UtilityService, UtilityService,
FileInfoService, FileInfoService,
SearchService, SearchService,
@ -772,6 +782,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$PushNotificationService, $PushNotificationService,
$QueryService, $QueryService,
$ReactionService, $ReactionService,
$ReactionsBufferingService,
$RelayService, $RelayService,
$RoleService, $RoleService,
$S3Service, $S3Service,
@ -791,6 +802,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$VideoProcessingService, $VideoProcessingService,
$UserWebhookService, $UserWebhookService,
$SystemWebhookService, $SystemWebhookService,
$WebhookTestService,
$UtilityService, $UtilityService,
$FileInfoService, $FileInfoService,
$SearchService, $SearchService,

View file

@ -5,6 +5,7 @@
import { URLSearchParams } from 'node:url'; import { URLSearchParams } from 'node:url';
import * as nodemailer from 'nodemailer'; import * as nodemailer from 'nodemailer';
import juice from 'juice';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { validate as validateEmail } from 'deep-email-validator'; import { validate as validateEmail } from 'deep-email-validator';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
@ -61,14 +62,7 @@ export class EmailService {
} : undefined, } : undefined,
} as any); } as any);
try { const htmlContent = `<!doctype html>
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
from: meta.email!,
to: to,
subject: subject,
text: text,
html: `<!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@ -147,7 +141,18 @@ export class EmailService {
<a href="${ this.config.url }">${ this.config.host }</a> <a href="${ this.config.url }">${ this.config.host }</a>
</nav> </nav>
</body> </body>
</html>`, </html>`;
const inlinedHtml = juice(htmlContent);
try {
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
from: meta.email!,
to: to,
subject: subject,
text: text,
html: inlinedHtml,
}); });
this.logger.info(`Message sent: ${info.messageId}`); this.logger.info(`Message sent: ${info.messageId}`);

View file

@ -86,6 +86,12 @@ export class QueueService {
repeat: { pattern: '*/5 * * * *' }, repeat: { pattern: '*/5 * * * *' },
removeOnComplete: true, removeOnComplete: true,
}); });
this.systemQueue.add('bakeBufferedReactions', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
});
} }
@bindThis @bindThis
@ -451,10 +457,15 @@ export class QueueService {
/** /**
* @see UserWebhookDeliverJobData * @see UserWebhookDeliverJobData
* @see WebhookDeliverProcessorService * @see UserWebhookDeliverProcessorService
*/ */
@bindThis @bindThis
public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { public userWebhookDeliver(
webhook: MiWebhook,
type: typeof webhookEventTypes[number],
content: unknown,
opts?: { attempts?: number },
) {
const data: UserWebhookDeliverJobData = { const data: UserWebhookDeliverJobData = {
type, type,
content, content,
@ -467,7 +478,7 @@ export class QueueService {
}; };
return this.userWebhookDeliverQueue.add(webhook.id, data, { return this.userWebhookDeliverQueue.add(webhook.id, data, {
attempts: 4, attempts: opts?.attempts ?? 4,
backoff: { backoff: {
type: 'custom', type: 'custom',
}, },
@ -478,10 +489,15 @@ export class QueueService {
/** /**
* @see SystemWebhookDeliverJobData * @see SystemWebhookDeliverJobData
* @see WebhookDeliverProcessorService * @see SystemWebhookDeliverProcessorService
*/ */
@bindThis @bindThis
public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) { public systemWebhookDeliver(
webhook: MiSystemWebhook,
type: SystemWebhookEventType,
content: unknown,
opts?: { attempts?: number },
) {
const data: SystemWebhookDeliverJobData = { const data: SystemWebhookDeliverJobData = {
type, type,
content, content,
@ -493,7 +509,7 @@ export class QueueService {
}; };
return this.systemWebhookDeliverQueue.add(webhook.id, data, { return this.systemWebhookDeliverQueue.add(webhook.id, data, {
attempts: 4, attempts: opts?.attempts ?? 4,
backoff: { backoff: {
type: 'custom', type: 'custom',
}, },

View file

@ -4,7 +4,6 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
@ -30,9 +29,10 @@ import { RoleService } from '@/core/RoleService.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isQuote, isRenote } from '@/misc/is-renote.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
const FALLBACK = '\u2764'; const FALLBACK = '\u2764';
const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
const legacies: Record<string, string> = { const legacies: Record<string, string> = {
'like': '👍', 'like': '👍',
@ -71,9 +71,6 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
@Injectable() @Injectable()
export class ReactionService { export class ReactionService {
constructor( constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -93,6 +90,7 @@ export class ReactionService {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private userBlockingService: UserBlockingService, private userBlockingService: UserBlockingService,
private reactionsBufferingService: ReactionsBufferingService,
private idService: IdService, private idService: IdService,
private featuredService: FeaturedService, private featuredService: FeaturedService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
@ -174,7 +172,6 @@ export class ReactionService {
reaction, reaction,
}; };
// Create reaction
try { try {
await this.noteReactionsRepository.insert(record); await this.noteReactionsRepository.insert(record);
} catch (e) { } catch (e) {
@ -198,6 +195,9 @@ export class ReactionService {
} }
// Increment reactions count // Increment reactions count
if (meta.enableReactionsBuffering) {
await this.reactionsBufferingService.create(note.id, user.id, reaction, note.reactionAndUserPairCache);
} else {
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
await this.notesRepository.createQueryBuilder().update() await this.notesRepository.createQueryBuilder().update()
.set({ .set({
@ -208,6 +208,7 @@ export class ReactionService {
}) })
.where('id = :id', { id: note.id }) .where('id = :id', { id: note.id })
.execute(); .execute();
}
// 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新 // 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新
if ( if (
@ -304,7 +305,12 @@ export class ReactionService {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
} }
const meta = await this.metaService.fetch();
// Decrement reactions count // Decrement reactions count
if (meta.enableReactionsBuffering) {
await this.reactionsBufferingService.delete(note.id, user.id, exist.reaction);
} else {
const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
await this.notesRepository.createQueryBuilder().update() await this.notesRepository.createQueryBuilder().update()
.set({ .set({
@ -313,6 +319,7 @@ export class ReactionService {
}) })
.where('id = :id', { id: note.id }) .where('id = :id', { id: note.id })
.execute(); .execute();
}
this.globalEventService.publishNoteStream(note.id, 'unreacted', { this.globalEventService.publishNoteStream(note.id, 'unreacted', {
reaction: this.decodeReaction(exist.reaction).reaction, reaction: this.decodeReaction(exist.reaction).reaction,

View file

@ -0,0 +1,162 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import type { MiUser, NotesRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
const REDIS_DELTA_PREFIX = 'reactionsBufferDeltas';
const REDIS_PAIR_PREFIX = 'reactionsBufferPairs';
@Injectable()
export class ReactionsBufferingService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redisForReactions)
private redisForReactions: Redis.Redis, // TODO: 専用のRedisインスタンスにする
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
) {
}
@bindThis
public async create(noteId: MiNote['id'], userId: MiUser['id'], reaction: string, currentPairs: string[]): Promise<void> {
const pipeline = this.redisForReactions.pipeline();
pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, 1);
for (let i = 0; i < currentPairs.length; i++) {
pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, i, currentPairs[i]);
}
pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, Date.now(), `${userId}/${reaction}`);
pipeline.zremrangebyrank(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -(PER_NOTE_REACTION_USER_PAIR_CACHE_MAX + 1));
await pipeline.exec();
}
@bindThis
public async delete(noteId: MiNote['id'], userId: MiUser['id'], reaction: string): Promise<void> {
const pipeline = this.redisForReactions.pipeline();
pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, -1);
pipeline.zrem(`${REDIS_PAIR_PREFIX}:${noteId}`, `${userId}/${reaction}`);
// TODO: 「消した要素一覧」も持っておかないとcreateされた時に上書きされて復活する
await pipeline.exec();
}
@bindThis
public async get(noteId: MiNote['id']): Promise<{
deltas: Record<string, number>;
pairs: ([MiUser['id'], string])[];
}> {
const pipeline = this.redisForReactions.pipeline();
pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`);
pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1);
const results = await pipeline.exec();
const resultDeltas = results![0][1] as Record<string, string>;
const resultPairs = results![1][1] as string[];
const deltas = {} as Record<string, number>;
for (const [name, count] of Object.entries(resultDeltas)) {
deltas[name] = parseInt(count);
}
const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]);
return {
deltas,
pairs,
};
}
@bindThis
public async getMany(noteIds: MiNote['id'][]): Promise<Map<MiNote['id'], {
deltas: Record<string, number>;
pairs: ([MiUser['id'], string])[];
}>> {
const map = new Map<MiNote['id'], {
deltas: Record<string, number>;
pairs: ([MiUser['id'], string])[];
}>();
const pipeline = this.redisForReactions.pipeline();
for (const noteId of noteIds) {
pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`);
pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1);
}
const results = await pipeline.exec();
const opsForEachNotes = 2;
for (let i = 0; i < noteIds.length; i++) {
const noteId = noteIds[i];
const resultDeltas = results![i * opsForEachNotes][1] as Record<string, string>;
const resultPairs = results![i * opsForEachNotes + 1][1] as string[];
const deltas = {} as Record<string, number>;
for (const [name, count] of Object.entries(resultDeltas)) {
deltas[name] = parseInt(count);
}
const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]);
map.set(noteId, {
deltas,
pairs,
});
}
return map;
}
// TODO: scanは重い可能性があるので、別途 bufferedNoteIds を直接Redis上に持っておいてもいいかもしれない
@bindThis
public async bake(): Promise<void> {
const bufferedNoteIds = [];
let cursor = '0';
do {
// https://github.com/redis/ioredis#transparent-key-prefixing
const result = await this.redisForReactions.scan(
cursor,
'MATCH',
`${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:*`,
'COUNT',
'1000');
cursor = result[0];
bufferedNoteIds.push(...result[1].map(x => x.replace(`${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:`, '')));
} while (cursor !== '0');
const bufferedMap = await this.getMany(bufferedNoteIds);
// clear
const pipeline = this.redisForReactions.pipeline();
for (const noteId of bufferedNoteIds) {
pipeline.del(`${REDIS_DELTA_PREFIX}:${noteId}`);
pipeline.del(`${REDIS_PAIR_PREFIX}:${noteId}`);
}
await pipeline.exec();
// TODO: SQL一個にまとめたい
for (const [noteId, buffered] of bufferedMap) {
const sql = Object.entries(buffered.deltas)
.map(([reaction, count]) =>
`jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + ${count})::text::jsonb)`)
.join(' || ');
this.notesRepository.createQueryBuilder().update()
.set({
reactions: () => sql,
reactionAndUserPairCache: buffered.pairs.map(x => x.join('/')),
})
.where('id = :id', { id: noteId })
.execute();
}
}
}

View file

@ -58,6 +58,11 @@ export type RolePolicies = {
userEachUserListsLimit: number; userEachUserListsLimit: number;
rateLimitFactor: number; rateLimitFactor: number;
avatarDecorationLimit: number; avatarDecorationLimit: number;
canImportAntennas: boolean;
canImportBlocking: boolean;
canImportFollowing: boolean;
canImportMuting: boolean;
canImportUserLists: boolean;
}; };
export const DEFAULT_POLICIES: RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = {
@ -87,6 +92,11 @@ export const DEFAULT_POLICIES: RolePolicies = {
userEachUserListsLimit: 50, userEachUserListsLimit: 50,
rateLimitFactor: 1, rateLimitFactor: 1,
avatarDecorationLimit: 1, avatarDecorationLimit: 1,
canImportAntennas: true,
canImportBlocking: true,
canImportFollowing: true,
canImportMuting: true,
canImportUserLists: true,
}; };
@Injectable() @Injectable()
@ -387,6 +397,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)),
rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)),
avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)), avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)),
canImportAntennas: calc('canImportAntennas', vs => vs.some(v => v === true)),
canImportBlocking: calc('canImportBlocking', vs => vs.some(v => v === true)),
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
}; };
} }

View file

@ -54,7 +54,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
* SystemWebhook . * SystemWebhook .
*/ */
@bindThis @bindThis
public async fetchSystemWebhooks(params?: { public fetchSystemWebhooks(params?: {
ids?: MiSystemWebhook['id'][]; ids?: MiSystemWebhook['id'][];
isActive?: MiSystemWebhook['isActive']; isActive?: MiSystemWebhook['isActive'];
on?: MiSystemWebhook['on']; on?: MiSystemWebhook['on'];
@ -165,19 +165,24 @@ export class SystemWebhookService implements OnApplicationShutdown {
/** /**
* SystemWebhook Webhook配送キューに追加する * SystemWebhook Webhook配送キューに追加する
* @see QueueService.systemWebhookDeliver * @see QueueService.systemWebhookDeliver
* // TODO: contentの型を厳格化する
*/ */
@bindThis @bindThis
public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) { public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
webhook: MiSystemWebhook | MiSystemWebhook['id'],
type: T,
content: unknown,
) {
const webhookEntity = typeof webhook === 'string' const webhookEntity = typeof webhook === 'string'
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook) ? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
: webhook; : webhook;
if (!webhookEntity || !webhookEntity.isActive) { if (!webhookEntity || !webhookEntity.isActive) {
this.logger.info(`Webhook is not active or not found : ${webhook}`); this.logger.info(`SystemWebhook is not active or not found : ${webhook}`);
return; return;
} }
if (!webhookEntity.on.includes(type)) { if (!webhookEntity.on.includes(type)) {
this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`); this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`);
return; return;
} }

View file

@ -5,8 +5,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { WebhooksRepository } from '@/models/_.js'; import { type WebhooksRepository } from '@/models/_.js';
import type { MiWebhook } from '@/models/Webhook.js'; import { MiWebhook } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { GlobalEvents } from '@/core/GlobalEventService.js'; import { GlobalEvents } from '@/core/GlobalEventService.js';
@ -38,6 +38,31 @@ export class UserWebhookService implements OnApplicationShutdown {
return this.activeWebhooks; return this.activeWebhooks;
} }
/**
* UserWebhook .
*/
@bindThis
public fetchWebhooks(params?: {
ids?: MiWebhook['id'][];
isActive?: MiWebhook['active'];
on?: MiWebhook['on'];
}): Promise<MiWebhook[]> {
const query = this.webhooksRepository.createQueryBuilder('webhook');
if (params) {
if (params.ids && params.ids.length > 0) {
query.andWhere('webhook.id IN (:...ids)', { ids: params.ids });
}
if (params.isActive !== undefined) {
query.andWhere('webhook.active = :isActive', { isActive: params.isActive });
}
if (params.on && params.on.length > 0) {
query.andWhere(':on <@ webhook.on', { on: params.on });
}
}
return query.getMany();
}
@bindThis @bindThis
private async onMessage(_: string, data: string): Promise<void> { private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data); const obj = JSON.parse(data);

View file

@ -0,0 +1,434 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { Packed } from '@/misc/json-schema.js';
import { type WebhookEventTypes } from '@/models/Webhook.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js';
const oneDayMillis = 24 * 60 * 60 * 1000;
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUserReport {
return {
id: 'dummy-abuse-report1',
targetUserId: 'dummy-target-user',
targetUser: null,
reporterId: 'dummy-reporter-user',
reporter: null,
assigneeId: null,
assignee: null,
resolved: false,
forwarded: false,
comment: 'This is a dummy report for testing purposes.',
targetUserHost: null,
reporterHost: null,
...override,
};
}
function generateDummyUser(override?: Partial<MiUser>): MiUser {
return {
id: 'dummy-user-1',
updatedAt: new Date(Date.now() - oneDayMillis * 7),
lastFetchedAt: new Date(Date.now() - oneDayMillis * 5),
lastActiveDate: new Date(Date.now() - oneDayMillis * 3),
hideOnlineStatus: false,
username: 'dummy1',
usernameLower: 'dummy1',
name: 'DummyUser1',
followersCount: 10,
followingCount: 5,
movedToUri: null,
movedAt: null,
alsoKnownAs: null,
notesCount: 30,
avatarId: null,
avatar: null,
bannerId: null,
banner: null,
avatarUrl: null,
bannerUrl: null,
avatarBlurhash: null,
bannerBlurhash: null,
avatarDecorations: [],
tags: [],
isSuspended: false,
isLocked: false,
isBot: false,
isCat: true,
isRoot: false,
isExplorable: true,
isHibernated: false,
isDeleted: false,
emojis: [],
host: null,
inbox: null,
sharedInbox: null,
featured: null,
uri: null,
followersUri: null,
token: null,
...override,
};
}
function generateDummyNote(override?: Partial<MiNote>): MiNote {
return {
id: 'dummy-note-1',
replyId: null,
reply: null,
renoteId: null,
renote: null,
threadId: null,
text: 'This is a dummy note for testing purposes.',
name: null,
cw: null,
userId: 'dummy-user-1',
user: null,
localOnly: true,
reactionAcceptance: 'likeOnly',
renoteCount: 10,
repliesCount: 5,
clippedCount: 0,
reactions: {},
visibility: 'public',
uri: null,
url: null,
fileIds: [],
attachedFileTypes: [],
visibleUserIds: [],
mentions: [],
mentionedRemoteUsers: '[]',
reactionAndUserPairCache: [],
emojis: [],
tags: [],
hasPoll: false,
channelId: null,
channel: null,
userHost: null,
replyUserId: null,
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
...override,
};
}
function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> {
return {
id: note.id,
createdAt: new Date().toISOString(),
deletedAt: null,
text: note.text,
cw: note.cw,
userId: note.userId,
user: toPackedUserLite(note.user ?? generateDummyUser()),
replyId: note.replyId,
renoteId: note.renoteId,
isHidden: false,
visibility: note.visibility,
mentions: note.mentions,
visibleUserIds: note.visibleUserIds,
fileIds: note.fileIds,
files: [],
tags: note.tags,
poll: null,
emojis: note.emojis,
channelId: note.channelId,
channel: note.channel,
localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,
reactionEmojis: {},
reactions: {},
reactionCount: 0,
renoteCount: note.renoteCount,
repliesCount: note.repliesCount,
uri: note.uri ?? undefined,
url: note.url ?? undefined,
reactionAndUserPairCache: note.reactionAndUserPairCache,
...(detail ? {
clippedCount: note.clippedCount,
reply: note.reply ? toPackedNote(note.reply, false) : null,
renote: note.renote ? toPackedNote(note.renote, true) : null,
myReaction: null,
} : {}),
...override,
};
}
function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> {
return {
id: user.id,
name: user.name,
username: user.username,
host: user.host,
avatarUrl: user.avatarUrl,
avatarBlurhash: user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({
id: it.id,
angle: it.angle,
flipH: it.flipH,
url: 'https://example.com/dummy-image001.png',
offsetX: it.offsetX,
offsetY: it.offsetY,
})),
isBot: user.isBot,
isCat: user.isCat,
emojis: user.emojis,
onlineStatus: 'active',
badgeRoles: [],
...override,
};
}
function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> {
return {
...toPackedUserLite(user),
url: null,
uri: null,
movedTo: null,
alsoKnownAs: [],
createdAt: new Date().toISOString(),
updatedAt: user.updatedAt?.toISOString() ?? null,
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
bannerUrl: user.bannerUrl,
bannerBlurhash: user.bannerBlurhash,
isLocked: user.isLocked,
isSilenced: false,
isSuspended: user.isSuspended,
description: null,
location: null,
birthday: null,
lang: null,
fields: [],
verifiedLinks: [],
followersCount: user.followersCount,
followingCount: user.followingCount,
notesCount: user.notesCount,
pinnedNoteIds: [],
pinnedNotes: [],
pinnedPageId: null,
pinnedPage: null,
publicReactions: true,
followersVisibility: 'public',
followingVisibility: 'public',
twoFactorEnabled: false,
usePasswordLessLogin: false,
securityKeys: false,
roles: [],
memo: null,
moderationNote: undefined,
isFollowing: false,
isFollowed: false,
hasPendingFollowRequestFromYou: false,
hasPendingFollowRequestToYou: false,
isBlocking: false,
isBlocked: false,
isMuted: false,
isRenoteMuted: false,
notify: 'none',
withReplies: true,
...override,
};
}
const dummyUser1 = generateDummyUser();
const dummyUser2 = generateDummyUser({
id: 'dummy-user-2',
updatedAt: new Date(Date.now() - oneDayMillis * 30),
lastFetchedAt: new Date(Date.now() - oneDayMillis),
lastActiveDate: new Date(Date.now() - oneDayMillis),
username: 'dummy2',
usernameLower: 'dummy2',
name: 'DummyUser2',
followersCount: 40,
followingCount: 50,
notesCount: 900,
});
const dummyUser3 = generateDummyUser({
id: 'dummy-user-3',
updatedAt: new Date(Date.now() - oneDayMillis * 15),
lastFetchedAt: new Date(Date.now() - oneDayMillis * 2),
lastActiveDate: new Date(Date.now() - oneDayMillis * 2),
username: 'dummy3',
usernameLower: 'dummy3',
name: 'DummyUser3',
followersCount: 60,
followingCount: 70,
notesCount: 15900,
});
@Injectable()
export class WebhookTestService {
public static NoSuchWebhookError = class extends Error {};
constructor(
private userWebhookService: UserWebhookService,
private systemWebhookService: SystemWebhookService,
private queueService: QueueService,
) {
}
/**
* UserWebhookのテスト送信を行う.
* .
*
* Webhookは以下の設定を無視する.
* - Webhookそのものの有効active
* - on
*/
@bindThis
public async testUserWebhook(
params: {
webhookId: MiWebhook['id'],
type: WebhookEventTypes,
override?: Partial<Omit<MiWebhook, 'id'>>,
},
sender: MiUser | null,
) {
const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] })
.then(it => it.filter(it => it.userId === sender?.id));
if (webhooks.length === 0) {
throw new WebhookTestService.NoSuchWebhookError();
}
const webhook = webhooks[0];
const send = (contents: unknown) => {
const merged = {
...webhook,
...params.override,
};
// テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加するチェック処理などをスキップする意図.
// また、Jobの試行回数も1回だけ.
this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 });
};
const dummyNote1 = generateDummyNote({
userId: dummyUser1.id,
user: dummyUser1,
});
const dummyReply1 = generateDummyNote({
id: 'dummy-reply-1',
replyId: dummyNote1.id,
reply: dummyNote1,
userId: dummyUser1.id,
user: dummyUser1,
});
const dummyRenote1 = generateDummyNote({
id: 'dummy-renote-1',
renoteId: dummyNote1.id,
renote: dummyNote1,
userId: dummyUser2.id,
user: dummyUser2,
text: null,
});
const dummyMention1 = generateDummyNote({
id: 'dummy-mention-1',
userId: dummyUser1.id,
user: dummyUser1,
text: `@${dummyUser2.username} This is a mention to you.`,
mentions: [dummyUser2.id],
});
switch (params.type) {
case 'note': {
send(toPackedNote(dummyNote1));
break;
}
case 'reply': {
send(toPackedNote(dummyReply1));
break;
}
case 'renote': {
send(toPackedNote(dummyRenote1));
break;
}
case 'mention': {
send(toPackedNote(dummyMention1));
break;
}
case 'follow': {
send(toPackedUserDetailedNotMe(dummyUser1));
break;
}
case 'followed': {
send(toPackedUserLite(dummyUser2));
break;
}
case 'unfollow': {
send(toPackedUserDetailedNotMe(dummyUser3));
break;
}
}
}
/**
* SystemWebhookのテスト送信を行う.
* .
*
* Webhookは以下の設定を無視する.
* - Webhookそのものの有効isActive
* - on
*/
@bindThis
public async testSystemWebhook(
params: {
webhookId: MiSystemWebhook['id'],
type: SystemWebhookEventType,
override?: Partial<Omit<MiSystemWebhook, 'id'>>,
},
) {
const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] });
if (webhooks.length === 0) {
throw new WebhookTestService.NoSuchWebhookError();
}
const webhook = webhooks[0];
const send = (contents: unknown) => {
const merged = {
...webhook,
...params.override,
};
// テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加するチェック処理などをスキップする意図.
// また、Jobの試行回数も1回だけ.
this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 });
};
switch (params.type) {
case 'abuseReport': {
send(generateAbuseReport({
targetUserId: dummyUser1.id,
targetUser: dummyUser1,
reporterId: dummyUser2.id,
reporter: dummyUser2,
}));
break;
}
case 'abuseReportResolved': {
send(generateAbuseReport({
targetUserId: dummyUser1.id,
targetUser: dummyUser1,
reporterId: dummyUser2.id,
reporter: dummyUser2,
assigneeId: dummyUser3.id,
assignee: dummyUser3,
resolved: true,
}));
break;
}
case 'userCreated': {
send(toPackedUserLite(dummyUser1));
break;
}
}
}
}

View file

@ -162,8 +162,28 @@ export class ApRequestService {
if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) { if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) {
const html = await res.text(); const html = await res.text();
const window = new Window(); const window = new Window({
settings: {
disableJavaScriptEvaluation: true,
disableJavaScriptFileLoading: true,
disableCSSFileLoading: true,
disableComputedStyleRendering: true,
handleDisabledFileLoadingAsSuccess: true,
navigation: {
disableMainFrameNavigation: true,
disableChildFrameNavigation: true,
disableChildPageNavigation: true,
disableFallbackToSetURL: true,
},
timer: {
maxTimeout: 0,
maxIntervalTime: 0,
maxIntervalIterations: 0,
},
},
});
const document = window.document; const document = window.document;
try {
document.documentElement.innerHTML = html; document.documentElement.innerHTML = html;
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
@ -173,6 +193,11 @@ export class ApRequestService {
return await this.signedGet(href, user, level, false); return await this.signedGet(href, user, level, false);
} }
} }
} catch (e) {
// something went wrong parsing the HTML, ignore the whole thing
} finally {
window.close();
}
} }
//#endregion //#endregion

View file

@ -11,24 +11,39 @@ import type { Packed } from '@/misc/json-schema.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { MiNoteReaction } from '@/models/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js'; import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { MetaService } from '@/core/MetaService.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js'; import type { ReactionService } from '../ReactionService.js';
import type { UserEntityService } from './UserEntityService.js'; import type { UserEntityService } from './UserEntityService.js';
import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js';
function mergeReactions(src: Record<string, number>, delta: Record<string, number>) {
const reactions = { ...src };
for (const [name, count] of Object.entries(delta)) {
if (reactions[name] != null) {
reactions[name] += count;
} else {
reactions[name] = count;
}
}
return reactions;
}
@Injectable() @Injectable()
export class NoteEntityService implements OnModuleInit { export class NoteEntityService implements OnModuleInit {
private userEntityService: UserEntityService; private userEntityService: UserEntityService;
private driveFileEntityService: DriveFileEntityService; private driveFileEntityService: DriveFileEntityService;
private customEmojiService: CustomEmojiService; private customEmojiService: CustomEmojiService;
private reactionService: ReactionService; private reactionService: ReactionService;
private reactionsBufferingService: ReactionsBufferingService;
private idService: IdService; private idService: IdService;
private metaService: MetaService;
private noteLoader = new DebounceLoader(this.findNoteOrFail); private noteLoader = new DebounceLoader(this.findNoteOrFail);
constructor( constructor(
@ -59,6 +74,9 @@ export class NoteEntityService implements OnModuleInit {
//private driveFileEntityService: DriveFileEntityService, //private driveFileEntityService: DriveFileEntityService,
//private customEmojiService: CustomEmojiService, //private customEmojiService: CustomEmojiService,
//private reactionService: ReactionService, //private reactionService: ReactionService,
//private reactionsBufferingService: ReactionsBufferingService,
//private idService: IdService,
//private metaService: MetaService,
) { ) {
} }
@ -67,7 +85,9 @@ export class NoteEntityService implements OnModuleInit {
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.reactionService = this.moduleRef.get('ReactionService'); this.reactionService = this.moduleRef.get('ReactionService');
this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService');
this.idService = this.moduleRef.get('IdService'); this.idService = this.moduleRef.get('IdService');
this.metaService = this.moduleRef.get('MetaService');
} }
@bindThis @bindThis
@ -287,6 +307,7 @@ export class NoteEntityService implements OnModuleInit {
skipHide?: boolean; skipHide?: boolean;
withReactionAndUserPairCache?: boolean; withReactionAndUserPairCache?: boolean;
_hint_?: { _hint_?: {
bufferdReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>; myReactions: Map<MiNote['id'], string | null>;
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>; packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>> packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
@ -303,6 +324,22 @@ export class NoteEntityService implements OnModuleInit {
const note = typeof src === 'object' ? src : await this.noteLoader.load(src); const note = typeof src === 'object' ? src : await this.noteLoader.load(src);
const host = note.userHost; const host = note.userHost;
const meta = await this.metaService.fetch();
const bufferdReactions = opts._hint_?.bufferdReactions != null
? (opts._hint_.bufferdReactions.get(note.id) ?? { deltas: {}, pairs: [] })
: meta.enableReactionsBuffering
? await this.reactionsBufferingService.get(note.id)
: { deltas: {}, pairs: [] };
const reactions = mergeReactions(note.reactions, bufferdReactions.deltas ?? {});
for (const [name, count] of Object.entries(reactions)) {
if (count <= 0) {
delete reactions[name];
}
}
const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferdReactions.pairs.map(x => x.join('/')));
let text = note.text; let text = note.text;
if (note.name && (note.url ?? note.uri)) { if (note.name && (note.url ?? note.uri)) {
@ -315,7 +352,7 @@ export class NoteEntityService implements OnModuleInit {
: await this.channelsRepository.findOneBy({ id: note.channelId }) : await this.channelsRepository.findOneBy({ id: note.channelId })
: null; : null;
const reactionEmojiNames = Object.keys(note.reactions) const reactionEmojiNames = Object.keys(reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
const packedFiles = options?._hint_?.packedFiles; const packedFiles = options?._hint_?.packedFiles;
@ -334,10 +371,10 @@ export class NoteEntityService implements OnModuleInit {
visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined,
renoteCount: note.renoteCount, renoteCount: note.renoteCount,
repliesCount: note.repliesCount, repliesCount: note.repliesCount,
reactionCount: Object.values(note.reactions).reduce((a, b) => a + b, 0), reactionCount: Object.values(reactions).reduce((a, b) => a + b, 0),
reactions: this.reactionService.convertLegacyReactions(note.reactions), reactions: reactions,
reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host),
reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined, reactionAndUserPairCache: opts.withReactionAndUserPairCache ? reactionAndUserPairCache : undefined,
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
tags: note.tags.length > 0 ? note.tags : undefined, tags: note.tags.length > 0 ? note.tags : undefined,
fileIds: note.fileIds, fileIds: note.fileIds,
@ -376,8 +413,12 @@ export class NoteEntityService implements OnModuleInit {
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
...(meId && Object.keys(note.reactions).length > 0 ? { ...(meId && Object.keys(reactions).length > 0 ? {
myReaction: this.populateMyReaction(note, meId, options?._hint_), myReaction: this.populateMyReaction({
id: note.id,
reactions: reactions,
reactionAndUserPairCache: reactionAndUserPairCache,
}, meId, options?._hint_),
} : {}), } : {}),
} : {}), } : {}),
}); });
@ -400,6 +441,10 @@ export class NoteEntityService implements OnModuleInit {
) { ) {
if (notes.length === 0) return []; if (notes.length === 0) return [];
const meta = await this.metaService.fetch();
const bufferdReactions = meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null;
const meId = me ? me.id : null; const meId = me ? me.id : null;
const myReactionsMap = new Map<MiNote['id'], string | null>(); const myReactionsMap = new Map<MiNote['id'], string | null>();
if (meId) { if (meId) {
@ -410,23 +455,33 @@ export class NoteEntityService implements OnModuleInit {
for (const note of notes) { for (const note of notes) {
if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote
const reactionsCount = Object.values(note.renote.reactions).reduce((a, b) => a + b, 0); const reactionsCount = Object.values(mergeReactions(note.renote.reactions, bufferdReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) { if (reactionsCount === 0) {
myReactionsMap.set(note.renote.id, null); myReactionsMap.set(note.renote.id, null);
} else if (reactionsCount <= note.renote.reactionAndUserPairCache.length) { } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferdReactions?.get(note.renote.id)?.pairs.length ?? 0)) {
const pairInBuffer = bufferdReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId);
if (pairInBuffer) {
myReactionsMap.set(note.renote.id, pairInBuffer[1]);
} else {
const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId));
myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null);
}
} else { } else {
idsNeedFetchMyReaction.add(note.renote.id); idsNeedFetchMyReaction.add(note.renote.id);
} }
} else { } else {
if (note.id < oldId) { if (note.id < oldId) {
const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0); const reactionsCount = Object.values(mergeReactions(note.reactions, bufferdReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) { if (reactionsCount === 0) {
myReactionsMap.set(note.id, null); myReactionsMap.set(note.id, null);
} else if (reactionsCount <= note.reactionAndUserPairCache.length) { } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferdReactions?.get(note.id)?.pairs.length ?? 0)) {
const pairInBuffer = bufferdReactions?.get(note.id)?.pairs.find(p => p[0] === meId);
if (pairInBuffer) {
myReactionsMap.set(note.id, pairInBuffer[1]);
} else {
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null);
}
} else { } else {
idsNeedFetchMyReaction.add(note.id); idsNeedFetchMyReaction.add(note.id);
} }
@ -461,6 +516,7 @@ export class NoteEntityService implements OnModuleInit {
return await Promise.all(notes.map(n => this.pack(n, me, { return await Promise.all(notes.map(n => this.pack(n, me, {
...options, ...options,
_hint_: { _hint_: {
bufferdReactions,
myReactions: myReactionsMap, myReactions: myReactionsMap,
packedFiles, packedFiles,
packedUsers, packedUsers,

View file

@ -10,8 +10,9 @@
* The getter will return a .bind version of the function * The getter will return a .bind version of the function
* and memoize the result against a symbol on the instance * and memoize the result against a symbol on the instance
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function bindThis(target: any, key: string, descriptor: any) { export function bindThis(target: any, key: string, descriptor: any) {
let fn = descriptor.value; const fn = descriptor.value;
if (typeof fn !== 'function') { if (typeof fn !== 'function') {
throw new TypeError(`@bindThis decorator can only be applied to methods not: ${typeof fn}`); throw new TypeError(`@bindThis decorator can only be applied to methods not: ${typeof fn}`);
@ -21,26 +22,18 @@ export function bindThis(target: any, key: string, descriptor: any) {
configurable: true, configurable: true,
get() { get() {
// eslint-disable-next-line no-prototype-builtins // eslint-disable-next-line no-prototype-builtins
if (this === target.prototype || this.hasOwnProperty(key) || if (this === target.prototype || this.hasOwnProperty(key)) {
typeof fn !== 'function') {
return fn; return fn;
} }
const boundFn = fn.bind(this); const boundFn = fn.bind(this);
Object.defineProperty(this, key, { Reflect.defineProperty(this, key, {
value: boundFn,
configurable: true, configurable: true,
get() { writable: true,
return boundFn;
},
set(value) {
fn = value;
delete this[key];
},
}); });
return boundFn; return boundFn;
}, },
set(value: any) {
fn = value;
},
}; };
} }

View file

@ -11,6 +11,7 @@ export const DI = {
redisForPub: Symbol('redisForPub'), redisForPub: Symbol('redisForPub'),
redisForSub: Symbol('redisForSub'), redisForSub: Symbol('redisForSub'),
redisForTimelines: Symbol('redisForTimelines'), redisForTimelines: Symbol('redisForTimelines'),
redisForReactions: Symbol('redisForReactions'),
//#region Repositories //#region Repositories
usersRepository: Symbol('usersRepository'), usersRepository: Symbol('usersRepository'),

View file

@ -589,6 +589,11 @@ export class MiMeta {
}) })
public perUserListTimelineCacheMax: number; public perUserListTimelineCacheMax: number;
@Column('boolean', {
default: false,
})
public enableReactionsBuffering: boolean;
@Column('integer', { @Column('integer', {
default: 0, default: 0,
}) })

View file

@ -8,6 +8,7 @@ import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const;
export type WebhookEventTypes = typeof webhookEventTypes[number];
@Entity('webhook') @Entity('webhook')
export class MiWebhook { export class MiWebhook {

View file

@ -272,6 +272,26 @@ export const packedRolePoliciesSchema = {
type: 'integer', type: 'integer',
optional: false, nullable: false, optional: false, nullable: false,
}, },
canImportAntennas: {
type: 'boolean',
optional: false, nullable: false,
},
canImportBlocking: {
type: 'boolean',
optional: false, nullable: false,
},
canImportFollowing: {
type: 'boolean',
optional: false, nullable: false,
},
canImportMuting: {
type: 'boolean',
optional: false, nullable: false,
},
canImportUserLists: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View file

@ -14,6 +14,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
@ -51,6 +52,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
ResyncChartsProcessorService, ResyncChartsProcessorService,
CleanChartsProcessorService, CleanChartsProcessorService,
CheckExpiredMutingsProcessorService, CheckExpiredMutingsProcessorService,
BakeBufferedReactionsProcessorService,
CleanProcessorService, CleanProcessorService,
DeleteDriveFilesProcessorService, DeleteDriveFilesProcessorService,
ExportCustomEmojisProcessorService, ExportCustomEmojisProcessorService,

View file

@ -39,6 +39,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
@ -118,6 +119,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private cleanChartsProcessorService: CleanChartsProcessorService, private cleanChartsProcessorService: CleanChartsProcessorService,
private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private cleanProcessorService: CleanProcessorService, private cleanProcessorService: CleanProcessorService,
) { ) {
this.logger = this.queueLoggerService.logger; this.logger = this.queueLoggerService.logger;
@ -147,6 +149,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'cleanCharts': return this.cleanChartsProcessorService.process(); case 'cleanCharts': return this.cleanChartsProcessorService.process();
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
case 'clean': return this.cleanProcessorService.process(); case 'clean': return this.cleanProcessorService.process();
default: throw new Error(`unrecognized job type ${job.name} for system`); default: throw new Error(`unrecognized job type ${job.name} for system`);
} }

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { MetaService } from '@/core/MetaService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
@Injectable()
export class BakeBufferedReactionsProcessorService {
private logger: Logger;
constructor(
private reactionsBufferingService: ReactionsBufferingService,
private metaService: MetaService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('bake-buffered-reactions');
}
@bindThis
public async process(): Promise<void> {
const meta = await this.metaService.fetch();
if (!meta.enableReactionsBuffering) {
this.logger.info('Reactions buffering is disabled. Skipping...');
return;
}
this.logger.info('Baking buffered reactions...');
await this.reactionsBufferingService.bake();
this.logger.succ('All buffered reactions baked.');
}
}

View file

@ -27,6 +27,9 @@ export class HealthServerService {
@Inject(DI.redisForTimelines) @Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis, private redisForTimelines: Redis.Redis,
@Inject(DI.redisForReactions)
private redisForReactions: Redis.Redis,
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@ -43,6 +46,7 @@ export class HealthServerService {
this.redisForPub.ping(), this.redisForPub.ping(),
this.redisForSub.ping(), this.redisForSub.ping(),
this.redisForTimelines.ping(), this.redisForTimelines.ping(),
this.redisForReactions.ping(),
this.db.query('SELECT 1'), this.db.query('SELECT 1'),
...(this.meilisearch ? [this.meilisearch.health()] : []), ...(this.meilisearch ? [this.meilisearch.health()] : []),
]).then(() => 200, () => 503)); ]).then(() => 200, () => 503));

View file

@ -64,15 +64,6 @@ export class ApiCallService implements OnApplicationShutdown {
let statusCode = err.httpStatusCode; let statusCode = err.httpStatusCode;
if (err.httpStatusCode === 401) { if (err.httpStatusCode === 401) {
reply.header('WWW-Authenticate', 'Bearer realm="Misskey"'); reply.header('WWW-Authenticate', 'Bearer realm="Misskey"');
} else if (err.kind === 'client') {
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`);
statusCode = statusCode ?? 400;
} else if (err.kind === 'permission') {
// (ROLE_PERMISSION_DENIEDは関係ない)
if (err.code === 'PERMISSION_DENIED') {
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
}
statusCode = statusCode ?? 403;
} else if (err.code === 'RATE_LIMIT_EXCEEDED') { } else if (err.code === 'RATE_LIMIT_EXCEEDED') {
const info: unknown = err.info; const info: unknown = err.info;
const unixEpochInSeconds = Date.now(); const unixEpochInSeconds = Date.now();
@ -83,6 +74,15 @@ export class ApiCallService implements OnApplicationShutdown {
} else { } else {
this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`); this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`);
} }
} else if (err.kind === 'client') {
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`);
statusCode = statusCode ?? 400;
} else if (err.kind === 'permission') {
// (ROLE_PERMISSION_DENIEDは関係ない)
if (err.code === 'PERMISSION_DENIED') {
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
}
statusCode = statusCode ?? 403;
} else if (!statusCode) { } else if (!statusCode) {
statusCode = 500; statusCode = 500;
} }

View file

@ -92,6 +92,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho
import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js';
import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js';
import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js';
import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js';
import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements from './endpoints/announcements.js';
import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___announcements_show from './endpoints/announcements/show.js';
import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_create from './endpoints/antennas/create.js';
@ -258,6 +259,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js';
import * as ep___invite_create from './endpoints/invite/create.js'; import * as ep___invite_create from './endpoints/invite/create.js';
import * as ep___invite_delete from './endpoints/invite/delete.js'; import * as ep___invite_delete from './endpoints/invite/delete.js';
import * as ep___invite_list from './endpoints/invite/list.js'; import * as ep___invite_list from './endpoints/invite/list.js';
@ -475,6 +477,7 @@ const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhoo
const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default }; const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default };
const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default }; const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default };
const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default }; const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default };
const $admin_systemWebhook_test: Provider = { provide: 'ep:admin/system-webhook/test', useClass: ep___admin_systemWebhook_test.default };
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default };
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
@ -641,6 +644,7 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default }; const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default };
const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default };
const $i_webhooks_test: Provider = { provide: 'ep:i/webhooks/test', useClass: ep___i_webhooks_test.default };
const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default }; const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default };
const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default }; const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default };
const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default }; const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default };
@ -862,6 +866,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_systemWebhook_list, $admin_systemWebhook_list,
$admin_systemWebhook_show, $admin_systemWebhook_show,
$admin_systemWebhook_update, $admin_systemWebhook_update,
$admin_systemWebhook_test,
$announcements, $announcements,
$announcements_show, $announcements_show,
$antennas_create, $antennas_create,
@ -1028,6 +1033,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$i_webhooks_show, $i_webhooks_show,
$i_webhooks_update, $i_webhooks_update,
$i_webhooks_delete, $i_webhooks_delete,
$i_webhooks_test,
$invite_create, $invite_create,
$invite_delete, $invite_delete,
$invite_list, $invite_list,
@ -1243,6 +1249,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_systemWebhook_list, $admin_systemWebhook_list,
$admin_systemWebhook_show, $admin_systemWebhook_show,
$admin_systemWebhook_update, $admin_systemWebhook_update,
$admin_systemWebhook_test,
$announcements, $announcements,
$announcements_show, $announcements_show,
$antennas_create, $antennas_create,
@ -1409,6 +1416,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$i_webhooks_show, $i_webhooks_show,
$i_webhooks_update, $i_webhooks_update,
$i_webhooks_delete, $i_webhooks_delete,
$i_webhooks_test,
$invite_create, $invite_create,
$invite_delete, $invite_delete,
$invite_list, $invite_list,

View file

@ -98,6 +98,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho
import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js';
import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js';
import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js';
import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js';
import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements from './endpoints/announcements.js';
import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___announcements_show from './endpoints/announcements/show.js';
import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_create from './endpoints/antennas/create.js';
@ -264,6 +265,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js';
import * as ep___invite_create from './endpoints/invite/create.js'; import * as ep___invite_create from './endpoints/invite/create.js';
import * as ep___invite_delete from './endpoints/invite/delete.js'; import * as ep___invite_delete from './endpoints/invite/delete.js';
import * as ep___invite_list from './endpoints/invite/list.js'; import * as ep___invite_list from './endpoints/invite/list.js';
@ -479,6 +481,7 @@ const eps = [
['admin/system-webhook/list', ep___admin_systemWebhook_list], ['admin/system-webhook/list', ep___admin_systemWebhook_list],
['admin/system-webhook/show', ep___admin_systemWebhook_show], ['admin/system-webhook/show', ep___admin_systemWebhook_show],
['admin/system-webhook/update', ep___admin_systemWebhook_update], ['admin/system-webhook/update', ep___admin_systemWebhook_update],
['admin/system-webhook/test', ep___admin_systemWebhook_test],
['announcements', ep___announcements], ['announcements', ep___announcements],
['announcements/show', ep___announcements_show], ['announcements/show', ep___announcements_show],
['antennas/create', ep___antennas_create], ['antennas/create', ep___antennas_create],
@ -645,6 +648,7 @@ const eps = [
['i/webhooks/show', ep___i_webhooks_show], ['i/webhooks/show', ep___i_webhooks_show],
['i/webhooks/update', ep___i_webhooks_update], ['i/webhooks/update', ep___i_webhooks_update],
['i/webhooks/delete', ep___i_webhooks_delete], ['i/webhooks/delete', ep___i_webhooks_delete],
['i/webhooks/test', ep___i_webhooks_test],
['invite/create', ep___invite_create], ['invite/create', ep___invite_create],
['invite/delete', ep___invite_delete], ['invite/delete', ep___invite_delete],
['invite/list', ep___invite_list], ['invite/list', ep___invite_list],

View file

@ -377,6 +377,10 @@ export const meta = {
type: 'number', type: 'number',
optional: false, nullable: false, optional: false, nullable: false,
}, },
enableReactionsBuffering: {
type: 'boolean',
optional: false, nullable: false,
},
notesPerOneAd: { notesPerOneAd: {
type: 'number', type: 'number',
optional: false, nullable: false, optional: false, nullable: false,
@ -617,6 +621,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
enableReactionsBuffering: instance.enableReactionsBuffering,
notesPerOneAd: instance.notesPerOneAd, notesPerOneAd: instance.notesPerOneAd,
summalyProxy: instance.urlPreviewSummaryProxyUrl, summalyProxy: instance.urlPreviewSummaryProxyUrl,
urlPreviewEnabled: instance.urlPreviewEnabled, urlPreviewEnabled: instance.urlPreviewEnabled,

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { WebhookTestService } from '@/core/WebhookTestService.js';
import { ApiError } from '@/server/api/error.js';
import { systemWebhookEventTypes } from '@/models/SystemWebhook.js';
export const meta = {
tags: ['webhooks'],
requireCredential: true,
requireModerator: true,
secure: true,
kind: 'read:admin:system-webhook',
limit: {
duration: ms('15min'),
max: 60,
},
errors: {
noSuchWebhook: {
message: 'No such webhook.',
code: 'NO_SUCH_WEBHOOK',
id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
webhookId: {
type: 'string',
format: 'misskey:id',
},
type: {
type: 'string',
enum: systemWebhookEventTypes,
},
override: {
type: 'object',
properties: {
url: { type: 'string', nullable: false },
secret: { type: 'string', nullable: false },
},
},
},
required: ['webhookId', 'type'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private webhookTestService: WebhookTestService,
) {
super(meta, paramDef, async (ps) => {
try {
await this.webhookTestService.testSystemWebhook({
webhookId: ps.webhookId,
type: ps.type,
override: ps.override,
});
} catch (e) {
if (e instanceof WebhookTestService.NoSuchWebhookError) {
throw new ApiError(meta.errors.noSuchWebhook);
}
throw e;
}
});
}
}

View file

@ -142,6 +142,7 @@ export const paramDef = {
perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perRemoteUserUserTimelineCacheMax: { type: 'integer' },
perUserHomeTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' },
perUserListTimelineCacheMax: { type: 'integer' }, perUserListTimelineCacheMax: { type: 'integer' },
enableReactionsBuffering: { type: 'boolean' },
notesPerOneAd: { type: 'integer' }, notesPerOneAd: { type: 'integer' },
silencedHosts: { silencedHosts: {
type: 'array', type: 'array',
@ -598,6 +599,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax; set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax;
} }
if (ps.enableReactionsBuffering !== undefined) {
set.enableReactionsBuffering = ps.enableReactionsBuffering;
}
if (ps.notesPerOneAd !== undefined) { if (ps.notesPerOneAd !== undefined) {
set.notesPerOneAd = ps.notesPerOneAd; set.notesPerOneAd = ps.notesPerOneAd;
} }

View file

@ -34,6 +34,12 @@ export const meta = {
code: 'TOO_MANY_ANTENNAS', code: 'TOO_MANY_ANTENNAS',
id: 'faf47050-e8b5-438c-913c-db2b1576fde4', id: 'faf47050-e8b5-438c-913c-db2b1576fde4',
}, },
emptyKeyword: {
message: 'Either keywords or excludeKeywords is required.',
code: 'EMPTY_KEYWORD',
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a',
},
}, },
res: { res: {
@ -87,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) {
throw new Error('either keywords or excludeKeywords is required.'); throw new ApiError(meta.errors.emptyKeyword);
} }
const currentAntennasCount = await this.antennasRepository.countBy({ const currentAntennasCount = await this.antennasRepository.countBy({

View file

@ -32,6 +32,12 @@ export const meta = {
code: 'NO_SUCH_USER_LIST', code: 'NO_SUCH_USER_LIST',
id: '1c6b35c9-943e-48c2-81e4-2844989407f7', id: '1c6b35c9-943e-48c2-81e4-2844989407f7',
}, },
emptyKeyword: {
message: 'Either keywords or excludeKeywords is required.',
code: 'EMPTY_KEYWORD',
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4',
},
}, },
res: { res: {
@ -85,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (ps.keywords && ps.excludeKeywords) { if (ps.keywords && ps.excludeKeywords) {
if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) {
throw new Error('either keywords or excludeKeywords is required.'); throw new ApiError(meta.errors.emptyKeyword);
} }
} }
// Fetch the antenna // Fetch the antenna

View file

@ -16,6 +16,7 @@ import { ApiError } from '../../error.js';
export const meta = { export const meta = {
secure: true, secure: true,
requireCredential: true, requireCredential: true,
requireRolePolicy: 'canImportAntennas',
prohibitMoved: true, prohibitMoved: true,
limit: { limit: {

View file

@ -15,6 +15,7 @@ import { ApiError } from '../../error.js';
export const meta = { export const meta = {
secure: true, secure: true,
requireCredential: true, requireCredential: true,
requireRolePolicy: 'canImportBlocking',
prohibitMoved: true, prohibitMoved: true,
limit: { limit: {

View file

@ -15,6 +15,7 @@ import { ApiError } from '../../error.js';
export const meta = { export const meta = {
secure: true, secure: true,
requireCredential: true, requireCredential: true,
requireRolePolicy: 'canImportFollowing',
prohibitMoved: true, prohibitMoved: true,
limit: { limit: {
duration: ms('1hour'), duration: ms('1hour'),

View file

@ -15,6 +15,7 @@ import { ApiError } from '../../error.js';
export const meta = { export const meta = {
secure: true, secure: true,
requireCredential: true, requireCredential: true,
requireRolePolicy: 'canImportMuting',
prohibitMoved: true, prohibitMoved: true,
limit: { limit: {

View file

@ -15,6 +15,7 @@ import { ApiError } from '../../error.js';
export const meta = { export const meta = {
secure: true, secure: true,
requireCredential: true, requireCredential: true,
requireRolePolicy: 'canImportUserLists',
prohibitMoved: true, prohibitMoved: true,
limit: { limit: {
duration: ms('1hour'), duration: ms('1hour'),

View file

@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '@/server/api/error.js'; import { ApiError } from '@/server/api/error.js';
// TODO: UserWebhook schemaの適用
export const meta = { export const meta = {
tags: ['webhooks'], tags: ['webhooks'],

View file

@ -9,6 +9,7 @@ import { webhookEventTypes } from '@/models/Webhook.js';
import type { WebhooksRepository } from '@/models/_.js'; import type { WebhooksRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
// TODO: UserWebhook schemaの適用
export const meta = { export const meta = {
tags: ['webhooks', 'account'], tags: ['webhooks', 'account'],

View file

@ -10,6 +10,7 @@ import type { WebhooksRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
// TODO: UserWebhook schemaの適用
export const meta = { export const meta = {
tags: ['webhooks'], tags: ['webhooks'],

View file

@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { webhookEventTypes } from '@/models/Webhook.js';
import { WebhookTestService } from '@/core/WebhookTestService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['webhooks'],
requireCredential: true,
secure: true,
kind: 'read:account',
limit: {
duration: ms('15min'),
max: 60,
},
errors: {
noSuchWebhook: {
message: 'No such webhook.',
code: 'NO_SUCH_WEBHOOK',
id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
webhookId: {
type: 'string',
format: 'misskey:id',
},
type: {
type: 'string',
enum: webhookEventTypes,
},
override: {
type: 'object',
properties: {
url: { type: 'string' },
secret: { type: 'string' },
},
},
},
required: ['webhookId', 'type'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private webhookTestService: WebhookTestService,
) {
super(meta, paramDef, async (ps, me) => {
try {
await this.webhookTestService.testUserWebhook({
webhookId: ps.webhookId,
type: ps.type,
override: ps.override,
}, me);
} catch (e) {
if (e instanceof WebhookTestService.NoSuchWebhookError) {
throw new ApiError(meta.errors.noSuchWebhook);
}
throw e;
}
});
}
}

View file

@ -228,6 +228,17 @@ describe('アンテナ', () => {
assert.deepStrictEqual(response, expected); assert.deepStrictEqual(response, expected);
}); });
test('を作成する時キーワードが指定されていないとエラーになる', async () => {
await failedApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] },
user: alice
}, {
status: 400,
code: 'EMPTY_KEYWORD',
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a'
})
});
//#endregion //#endregion
//#region 更新(antennas/update) //#region 更新(antennas/update)
@ -255,6 +266,18 @@ describe('アンテナ', () => {
id: '1c6b35c9-943e-48c2-81e4-2844989407f7', id: '1c6b35c9-943e-48c2-81e4-2844989407f7',
}); });
}); });
test('を変更する時キーワードが指定されていないとエラーになる', async () => {
const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
await failedApiCall({
endpoint: 'antennas/update',
parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] },
user: alice
}, {
status: 400,
code: 'EMPTY_KEYWORD',
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4'
})
});
//#endregion //#endregion
//#region 表示(antennas/show) //#region 表示(antennas/show)

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* /*
* SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
@ -6,6 +7,7 @@
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals'; import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { randomString } from '../utils.js';
import { MiUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js';
import { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js'; import { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
import { SystemWebhooksRepository, UsersRepository } from '@/models/_.js'; import { SystemWebhooksRepository, UsersRepository } from '@/models/_.js';
@ -17,7 +19,6 @@ import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { randomString } from '../utils.js';
describe('SystemWebhookService', () => { describe('SystemWebhookService', () => {
let app: TestingModule; let app: TestingModule;
@ -313,7 +314,7 @@ describe('SystemWebhookService', () => {
isActive: true, isActive: true,
on: ['abuseReport'], on: ['abuseReport'],
}); });
await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' }); await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).toHaveBeenCalled(); expect(queueService.systemWebhookDeliver).toHaveBeenCalled();
}); });
@ -323,7 +324,7 @@ describe('SystemWebhookService', () => {
isActive: false, isActive: false,
on: ['abuseReport'], on: ['abuseReport'],
}); });
await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' }); await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled();
}); });
@ -337,8 +338,8 @@ describe('SystemWebhookService', () => {
isActive: true, isActive: true,
on: ['abuseReportResolved'], on: ['abuseReportResolved'],
}); });
await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' }); await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' } as any);
await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' }); await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled();
}); });

View file

@ -0,0 +1,245 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import { randomString } from '../utils.js';
import { MiUser } from '@/models/User.js';
import { MiWebhook, UsersRepository, WebhooksRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
describe('UserWebhookService', () => {
let app: TestingModule;
let service: UserWebhookService;
// --------------------------------------------------------------------------------------
let usersRepository: UsersRepository;
let userWebhooksRepository: WebhooksRepository;
let idService: IdService;
let queueService: jest.Mocked<QueueService>;
// --------------------------------------------------------------------------------------
let root: MiUser;
// --------------------------------------------------------------------------------------
async function createUser(data: Partial<MiUser> = {}) {
return await usersRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
}
async function createWebhook(data: Partial<MiWebhook> = {}) {
return userWebhooksRepository
.insert({
id: idService.gen(),
name: randomString(),
on: ['mention'],
url: 'https://example.com',
secret: randomString(),
userId: root.id,
...data,
})
.then(x => userWebhooksRepository.findOneByOrFail(x.identifiers[0]));
}
// --------------------------------------------------------------------------------------
async function beforeAllImpl() {
app = await Test
.createTestingModule({
imports: [
GlobalModule,
],
providers: [
UserWebhookService,
IdService,
LoggerService,
GlobalEventService,
{
provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn() }),
},
],
})
.compile();
usersRepository = app.get(DI.usersRepository);
userWebhooksRepository = app.get(DI.webhooksRepository);
service = app.get(UserWebhookService);
idService = app.get(IdService);
queueService = app.get(QueueService) as jest.Mocked<QueueService>;
app.enableShutdownHooks();
}
async function afterAllImpl() {
await app.close();
}
async function beforeEachImpl() {
root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' });
}
async function afterEachImpl() {
await usersRepository.delete({});
await userWebhooksRepository.delete({});
}
// --------------------------------------------------------------------------------------
describe('アプリを毎回作り直す必要のないグループ', () => {
beforeAll(beforeAllImpl);
afterAll(afterAllImpl);
beforeEach(beforeEachImpl);
afterEach(afterEachImpl);
describe('fetchSystemWebhooks', () => {
test('フィルタなし', async () => {
const webhook1 = await createWebhook({
active: true,
on: ['mention'],
});
const webhook2 = await createWebhook({
active: false,
on: ['mention'],
});
const webhook3 = await createWebhook({
active: true,
on: ['reply'],
});
const webhook4 = await createWebhook({
active: false,
on: [],
});
const fetchedWebhooks = await service.fetchWebhooks();
expect(fetchedWebhooks).toEqual([webhook1, webhook2, webhook3, webhook4]);
});
test('activeのみ', async () => {
const webhook1 = await createWebhook({
active: true,
on: ['mention'],
});
const webhook2 = await createWebhook({
active: false,
on: ['mention'],
});
const webhook3 = await createWebhook({
active: true,
on: ['reply'],
});
const webhook4 = await createWebhook({
active: false,
on: [],
});
const fetchedWebhooks = await service.fetchWebhooks({ isActive: true });
expect(fetchedWebhooks).toEqual([webhook1, webhook3]);
});
test('特定のイベントのみ', async () => {
const webhook1 = await createWebhook({
active: true,
on: ['mention'],
});
const webhook2 = await createWebhook({
active: false,
on: ['mention'],
});
const webhook3 = await createWebhook({
active: true,
on: ['reply'],
});
const webhook4 = await createWebhook({
active: false,
on: [],
});
const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'] });
expect(fetchedWebhooks).toEqual([webhook1, webhook2]);
});
test('activeな特定のイベントのみ', async () => {
const webhook1 = await createWebhook({
active: true,
on: ['mention'],
});
const webhook2 = await createWebhook({
active: false,
on: ['mention'],
});
const webhook3 = await createWebhook({
active: true,
on: ['reply'],
});
const webhook4 = await createWebhook({
active: false,
on: [],
});
const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'], isActive: true });
expect(fetchedWebhooks).toEqual([webhook1]);
});
test('ID指定', async () => {
const webhook1 = await createWebhook({
active: true,
on: ['mention'],
});
const webhook2 = await createWebhook({
active: false,
on: ['mention'],
});
const webhook3 = await createWebhook({
active: true,
on: ['reply'],
});
const webhook4 = await createWebhook({
active: false,
on: [],
});
const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id] });
expect(fetchedWebhooks).toEqual([webhook1, webhook4]);
});
test('ID指定(他条件とANDになるか見たい)', async () => {
const webhook1 = await createWebhook({
active: true,
on: ['mention'],
});
const webhook2 = await createWebhook({
active: false,
on: ['mention'],
});
const webhook3 = await createWebhook({
active: true,
on: ['reply'],
});
const webhook4 = await createWebhook({
active: false,
on: [],
});
const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id], isActive: false });
expect(fetchedWebhooks).toEqual([webhook4]);
});
});
});
});

View file

@ -0,0 +1,225 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Test, TestingModule } from '@nestjs/testing';
import { beforeAll, describe, jest } from '@jest/globals';
import { WebhookTestService } from '@/core/WebhookTestService.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js';
describe('WebhookTestService', () => {
let app: TestingModule;
let service: WebhookTestService;
// --------------------------------------------------------------------------------------
let usersRepository: UsersRepository;
let userProfilesRepository: UserProfilesRepository;
let queueService: jest.Mocked<QueueService>;
let userWebhookService: jest.Mocked<UserWebhookService>;
let systemWebhookService: jest.Mocked<SystemWebhookService>;
let idService: IdService;
let root: MiUser;
let alice: MiUser;
async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
await userProfilesRepository.insert({
userId: user.id,
});
return user;
}
// --------------------------------------------------------------------------------------
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
],
providers: [
WebhookTestService,
IdService,
{
provide: QueueService, useFactory: () => ({
systemWebhookDeliver: jest.fn(),
userWebhookDeliver: jest.fn(),
}),
},
{
provide: UserWebhookService, useFactory: () => ({
fetchWebhooks: jest.fn(),
}),
},
{
provide: SystemWebhookService, useFactory: () => ({
fetchSystemWebhooks: jest.fn(),
}),
},
],
}).compile();
usersRepository = app.get(DI.usersRepository);
userProfilesRepository = app.get(DI.userProfilesRepository);
service = app.get(WebhookTestService);
idService = app.get(IdService);
queueService = app.get(QueueService) as jest.Mocked<QueueService>;
userWebhookService = app.get(UserWebhookService) as jest.Mocked<UserWebhookService>;
systemWebhookService = app.get(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
app.enableShutdownHooks();
});
beforeEach(async () => {
root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
{ id: 'dummy-webhook', active: true, userId: alice.id } as MiWebhook,
]));
systemWebhookService.fetchSystemWebhooks.mockReturnValue(Promise.resolve([
{ id: 'dummy-webhook', isActive: true } as MiSystemWebhook,
]));
});
afterEach(async () => {
queueService.systemWebhookDeliver.mockClear();
queueService.userWebhookDeliver.mockClear();
userWebhookService.fetchWebhooks.mockClear();
systemWebhookService.fetchSystemWebhooks.mockClear();
await usersRepository.delete({});
await userProfilesRepository.delete({});
});
afterAll(async () => {
await app.close();
});
// --------------------------------------------------------------------------------------
describe('testUserWebhook', () => {
test('note', async () => {
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, alice);
const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('note');
expect((calls[2] as any).id).toBe('dummy-note-1');
});
test('reply', async () => {
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'reply' }, alice);
const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('reply');
expect((calls[2] as any).id).toBe('dummy-reply-1');
});
test('renote', async () => {
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'renote' }, alice);
const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('renote');
expect((calls[2] as any).id).toBe('dummy-renote-1');
});
test('mention', async () => {
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'mention' }, alice);
const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('mention');
expect((calls[2] as any).id).toBe('dummy-mention-1');
});
test('follow', async () => {
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'follow' }, alice);
const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('follow');
expect((calls[2] as any).id).toBe('dummy-user-1');
});
test('followed', async () => {
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'followed' }, alice);
const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('followed');
expect((calls[2] as any).id).toBe('dummy-user-2');
});
test('unfollow', async () => {
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'unfollow' }, alice);
const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('unfollow');
expect((calls[2] as any).id).toBe('dummy-user-3');
});
describe('NoSuchWebhookError', () => {
test('user not match', async () => {
userWebhookService.fetchWebhooks.mockClear();
userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
{ id: 'dummy-webhook', active: true } as MiWebhook,
]));
await expect(service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, root))
.rejects.toThrow(WebhookTestService.NoSuchWebhookError);
});
});
});
describe('testSystemWebhook', () => {
test('abuseReport', async () => {
await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReport' });
const calls = queueService.systemWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('abuseReport');
expect((calls[2] as any).id).toBe('dummy-abuse-report1');
expect((calls[2] as any).resolved).toBe(false);
});
test('abuseReportResolved', async () => {
await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReportResolved' });
const calls = queueService.systemWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('abuseReportResolved');
expect((calls[2] as any).id).toBe('dummy-abuse-report1');
expect((calls[2] as any).resolved).toBe(true);
});
test('userCreated', async () => {
await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'userCreated' });
const calls = queueService.systemWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('userCreated');
expect((calls[2] as any).id).toBe('dummy-user-1');
});
});
});

View file

@ -4,10 +4,10 @@
*/ */
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import type { MiUser } from '@/models/User.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import type { MiUser } from '@/models/User.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { genAidx } from '@/misc/id/aidx.js'; import { genAidx } from '@/misc/id/aidx.js';
import { import {
@ -49,6 +49,7 @@ import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js'; import { AccountMoveService } from '@/core/AccountMoveService.js';
import { ReactionService } from '@/core/ReactionService.js'; import { ReactionService } from '@/core/ReactionService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
@ -169,6 +170,7 @@ describe('UserEntityService', () => {
ApLoggerService, ApLoggerService,
AccountMoveService, AccountMoveService,
ReactionService, ReactionService,
ReactionsBufferingService,
NotificationService, NotificationService,
]; ];

View file

@ -10,23 +10,42 @@ import '@tabler/icons-webfont/dist/tabler-icons.scss';
import '@/style.scss'; import '@/style.scss';
import { createApp, defineAsyncComponent } from 'vue'; import { createApp, defineAsyncComponent } from 'vue';
import lightTheme from '@@/themes/l-light.json5'; import defaultLightTheme from '@@/themes/l-light.json5';
import darkTheme from '@@/themes/d-dark.json5'; import defaultDarkTheme from '@@/themes/d-dark.json5';
import { MediaProxy } from '@@/js/media-proxy.js'; import { MediaProxy } from '@@/js/media-proxy.js';
import { applyTheme } from './theme.js'; import { applyTheme, assertIsTheme } from '@/theme.js';
import { fetchCustomEmojis } from './custom-emojis.js'; import { fetchCustomEmojis } from '@/custom-emojis.js';
import { DI } from './di.js'; import { DI } from '@/di.js';
import { serverMetadata } from './server-metadata.js'; import { serverMetadata } from '@/server-metadata.js';
import { url } from './config.js'; import { url } from '@@/js/config.js';
import { parseEmbedParams } from '@@/js/embed-page.js'; import { parseEmbedParams } from '@@/js/embed-page.js';
import { postMessageToParentWindow, setIframeId } from '@/post-message.js'; import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
console.info('Misskey Embed'); import type { Theme } from '@/theme.js';
console.log('Misskey Embed');
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const embedParams = parseEmbedParams(params); const embedParams = parseEmbedParams(params);
console.info(embedParams); if (_DEV_) console.log(embedParams);
function parseThemeOrNull(theme: string | null): Theme | null {
if (theme == null) return null;
try {
const parsed = JSON.parse(theme);
if (assertIsTheme(parsed)) {
return parsed;
} else {
return null;
}
} catch (err) {
return null;
}
}
const lightTheme = parseThemeOrNull(serverMetadata.defaultLightTheme) ?? defaultLightTheme;
const darkTheme = parseThemeOrNull(serverMetadata.defaultDarkTheme) ?? defaultDarkTheme;
if (embedParams.colorMode === 'dark') { if (embedParams.colorMode === 'dark') {
applyTheme(darkTheme); applyTheme(darkTheme);

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { toUnicode } from 'punycode/'; import { toUnicode } from 'punycode/';
import { host as hostRaw } from '@/config.js'; import { host as hostRaw } from '@@/js/config.js';
defineProps<{ defineProps<{
user: Misskey.entities.UserLite; user: Misskey.entities.UserLite;

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts"> <script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker'; import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker'; import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@/to-be-shared/worker-multi-dispatch.js'; import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {

View file

@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import EmA from './EmA.vue'; import EmA from './EmA.vue';
import { url as local } from '@/config.js'; import { url as local } from '@@/js/config.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
url: string; url: string;

View file

@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { toUnicode } from 'punycode'; import { toUnicode } from 'punycode';
import { } from 'vue'; import { } from 'vue';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { host as localHost } from '@/config.js'; import { host as localHost } from '@@/js/config.js';
const props = defineProps<{ const props = defineProps<{
username: string; username: string;

View file

@ -13,7 +13,7 @@ import EmMention from '@/components/EmMention.vue';
import EmEmoji from '@/components/EmEmoji.vue'; import EmEmoji from '@/components/EmEmoji.vue';
import EmCustomEmoji from '@/components/EmCustomEmoji.vue'; import EmCustomEmoji from '@/components/EmCustomEmoji.vue';
import EmA from '@/components/EmA.vue'; import EmA from '@/components/EmA.vue';
import { host } from '@/config.js'; import { host } from '@@/js/config.js';
function safeParseFloat(str: unknown): number | null { function safeParseFloat(str: unknown): number | null {
if (typeof str !== 'string' || str === '') return null; if (typeof str !== 'string' || str === '') return null;

View file

@ -121,8 +121,8 @@ import EmUserName from '@/components/EmUserName.vue';
import EmTime from '@/components/EmTime.vue'; import EmTime from '@/components/EmTime.vue';
import { userPage } from '@/utils.js'; import { userPage } from '@/utils.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { shouldCollapsed } from '@/to-be-shared/collapsed.js'; import { shouldCollapsed } from '@@/js/collapsed.js';
import { url } from '@/config.js'; import { url } from '@@/js/config.js';
function getAppearNote(note: Misskey.entities.Note) { function getAppearNote(note: Misskey.entities.Note) {
return Misskey.note.isPureRenote(note) ? note.renote : note; return Misskey.note.isPureRenote(note) ? note.renote : note;

View file

@ -142,9 +142,9 @@ import EmAcct from '@/components/EmAcct.vue';
import { userPage } from '@/utils.js'; import { userPage } from '@/utils.js';
import { notePage } from '@/utils.js'; import { notePage } from '@/utils.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { shouldCollapsed } from '@/to-be-shared/collapsed.js'; import { shouldCollapsed } from '@@/js/collapsed.js';
import { serverMetadata } from '@/server-metadata.js'; import { serverMetadata } from '@/server-metadata.js';
import { url } from '@/config.js'; import { url } from '@@/js/config.js';
import EmMfm from '@/components/EmMfm.js'; import EmMfm from '@/components/EmMfm.js';
const props = defineProps<{ const props = defineProps<{

View file

@ -35,8 +35,8 @@ import * as Misskey from 'misskey-js';
import EmMediaList from '@/components/EmMediaList.vue'; import EmMediaList from '@/components/EmMediaList.vue';
import EmPoll from '@/components/EmPoll.vue'; import EmPoll from '@/components/EmPoll.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { url } from '@/config.js'; import { url } from '@@/js/config.js';
import { shouldCollapsed } from '@/to-be-shared/collapsed.js'; import { shouldCollapsed } from '@@/js/collapsed.js';
import EmA from '@/components/EmA.vue'; import EmA from '@/components/EmA.vue';
import EmMfm from '@/components/EmMfm.js'; import EmMfm from '@/components/EmMfm.js';

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref, computed } from 'vue'; import { onMounted, onUnmounted, ref, computed } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { dateTimeFormat } from '@/to-be-shared/intl-const.js'; import { dateTimeFormat } from '@@/js/intl-const.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
time: Date | string | number | null; time: Date | string | number | null;

View file

@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue'; import { ref } from 'vue';
import { toUnicode as decodePunycode } from 'punycode/'; import { toUnicode as decodePunycode } from 'punycode/';
import EmA from './EmA.vue'; import EmA from './EmA.vue';
import { url as local } from '@/config.js'; import { url as local } from '@@/js/config.js';
function safeURIDecode(str: string): string { function safeURIDecode(str: string): string {
try { try {

View file

@ -6,7 +6,7 @@
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { I18n } from '@@/js/i18n.js'; import { I18n } from '@@/js/i18n.js';
import type { Locale } from '../../../locales/index.js'; import type { Locale } from '../../../locales/index.js';
import { locale } from '@/config.js'; import { locale } from '@@/js/config.js';
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_)); export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));

View file

@ -5,7 +5,7 @@
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { ref } from 'vue'; import { ref } from 'vue';
import { apiUrl } from '@/config.js'; import { apiUrl } from '@@/js/config.js';
export const pendingApiRequestsCount = ref(0); export const pendingApiRequestsCount = ref(0);

View file

@ -50,8 +50,8 @@ import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
import { misskeyApi } from '@/misskey-api.js'; import { misskeyApi } from '@/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { serverMetadata } from '@/server-metadata.js'; import { serverMetadata } from '@/server-metadata.js';
import { url, instanceName } from '@/config.js'; import { url, instanceName } from '@@/js/config.js';
import { isLink } from '@/to-be-shared/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { defaultEmbedParams } from '@@/js/embed-page.js'; import { defaultEmbedParams } from '@@/js/embed-page.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
@ -135,7 +135,7 @@ misskeyApi('clips/show', {
.instanceIcon { .instanceIcon {
height: 24px; height: 24px;
border-radius: 4px; border-radius: 3px;
} }
} }
</style> </style>

View file

@ -46,8 +46,8 @@ import XNotFound from '@/pages/not-found.vue';
import EmTimelineContainer from '@/components/EmTimelineContainer.vue'; import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { serverMetadata } from '@/server-metadata.js'; import { serverMetadata } from '@/server-metadata.js';
import { url, instanceName } from '@/config.js'; import { url, instanceName } from '@@/js/config.js';
import { isLink } from '@/to-be-shared/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { defaultEmbedParams } from '@@/js/embed-page.js'; import { defaultEmbedParams } from '@@/js/embed-page.js';
@ -119,7 +119,7 @@ function top(ev: MouseEvent) {
.instanceIcon { .instanceIcon {
height: 24px; height: 24px;
border-radius: 4px; border-radius: 3px;
} }
} }
</style> </style>

View file

@ -59,7 +59,7 @@ import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
import { misskeyApi } from '@/misskey-api.js'; import { misskeyApi } from '@/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { serverMetadata } from '@/server-metadata.js'; import { serverMetadata } from '@/server-metadata.js';
import { url, instanceName } from '@/config.js'; import { url, instanceName } from '@@/js/config.js';
import { defaultEmbedParams } from '@@/js/embed-page.js'; import { defaultEmbedParams } from '@@/js/embed-page.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
@ -132,7 +132,7 @@ misskeyApi('users/show', {
.instanceIcon { .instanceIcon {
height: 24px; height: 24px;
border-radius: 4px; border-radius: 3px;
} }
} }
</style> </style>

View file

@ -3,13 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/misskey-api.js'; import { misskeyApi } from '@/misskey-api.js';
const providedMetaEl = document.getElementById('misskey_meta'); const providedMetaEl = document.getElementById('misskey_meta');
const _serverMetadata = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null; const _serverMetadata: Misskey.entities.MetaDetailed | null = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null;
// NOTE: devモードのときしか _serverMetadata が null になることは無い // NOTE: devモードのときしか _serverMetadata が null になることは無い
export const serverMetadata = _serverMetadata ?? await misskeyApi('meta', { export const serverMetadata: Misskey.entities.MetaDetailed = _serverMetadata ?? await misskeyApi('meta', {
detail: true, detail: true,
}); });

View file

@ -26,6 +26,10 @@ export type Theme = {
let timeout: number | null = null; let timeout: number | null = null;
export function assertIsTheme(theme: Record<string, unknown>): theme is Theme {
return typeof theme === 'object' && theme !== null && 'id' in theme && 'name' in theme && 'author' in theme && 'props' in theme;
}
export function applyTheme(theme: Theme, persist = true) { export function applyTheme(theme: Theme, persist = true) {
if (timeout) window.clearTimeout(timeout); if (timeout) window.clearTimeout(timeout);
@ -35,8 +39,6 @@ export function applyTheme(theme: Theme, persist = true) {
document.documentElement.classList.remove('_themeChanging_'); document.documentElement.classList.remove('_themeChanging_');
}, 1000); }, 1000);
const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
// Deep copy // Deep copy
const _theme = JSON.parse(JSON.stringify(theme)); const _theme = JSON.parse(JSON.stringify(theme));
@ -58,7 +60,7 @@ export function applyTheme(theme: Theme, persist = true) {
document.documentElement.style.setProperty(`--${k}`, v.toString()); document.documentElement.style.setProperty(`--${k}`, v.toString());
} }
document.documentElement.style.setProperty('color-scheme', colorScheme); // iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照
} }
function compile(theme: Theme): Record<string, string> { function compile(theme: Theme): Record<string, string> {

View file

@ -1,82 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
function defaultUseWorkerNumber(prev: number, totalWorkers: number) {
return prev + 1;
}
export class WorkerMultiDispatch<POST = any, RETURN = any> {
private symbol = Symbol('WorkerMultiDispatch');
private workers: Worker[] = [];
private terminated = false;
private prevWorkerNumber = 0;
private getUseWorkerNumber = defaultUseWorkerNumber;
private finalizationRegistry: FinalizationRegistry<symbol>;
constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) {
this.getUseWorkerNumber = getUseWorkerNumber;
for (let i = 0; i < concurrency; i++) {
this.workers.push(workerConstructor());
}
this.finalizationRegistry = new FinalizationRegistry(() => {
this.terminate();
});
this.finalizationRegistry.register(this, this.symbol);
if (_DEV_) console.log('WorkerMultiDispatch: Created', this);
}
public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: typeof defaultUseWorkerNumber = this.getUseWorkerNumber) {
let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length);
workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length;
if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber);
this.prevWorkerNumber = workerNumber;
// 不毛だがunionをoverloadに突っ込めない
// https://stackoverflow.com/questions/66507585/overload-signatures-union-types-and-no-overload-matches-this-call-error
// https://github.com/microsoft/TypeScript/issues/14107
if (Array.isArray(options)) {
this.workers[workerNumber].postMessage(message, options);
} else {
this.workers[workerNumber].postMessage(message, options);
}
return workerNumber;
}
public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
this.workers.forEach(worker => {
worker.addEventListener('message', callback, options);
});
}
public removeListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
this.workers.forEach(worker => {
worker.removeEventListener('message', callback, options);
});
}
public terminate() {
this.terminated = true;
if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this);
this.workers.forEach(worker => {
worker.terminate();
});
this.workers = [];
this.finalizationRegistry.unregister(this);
}
public isTerminated() {
return this.terminated;
}
public getWorkers() {
return this.workers;
}
public getSymbol() {
return this.symbol;
}
}

View file

@ -40,7 +40,7 @@ import XNotFound from '@/pages/not-found.vue';
const page = location.pathname.split('/')[2]; const page = location.pathname.split('/')[2];
const contentId = location.pathname.split('/')[3]; const contentId = location.pathname.split('/')[3];
console.log(page, contentId); if (_DEV_) console.log(page, contentId);
const embedParams = inject(DI.embedParams, defaultEmbedParams); const embedParams = inject(DI.embedParams, defaultEmbedParams);

View file

@ -4,7 +4,7 @@
*/ */
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { url } from '@/config.js'; import { url } from '@@/js/config.js';
export const acct = (user: Misskey.Acct) => { export const acct = (user: Misskey.Acct) => {
return Misskey.acct.toString(user); return Misskey.acct.toString(user);

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FIXME = any;
declare const _LANGS_: string[][];
declare const _VERSION_: string;
declare const _ENV_: string;
declare const _DEV_: boolean;
declare const _PERF_PREFIX_: string;
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TagCanvas: any;
}

View file

@ -14,7 +14,11 @@ export default [
}, },
...pluginVue.configs['flat/recommended'], ...pluginVue.configs['flat/recommended'],
{ {
files: ['js/**/*.{ts,vue}', '**/*.vue'], files: [
'@types/**/*.ts',
'js/**/*.ts',
'**/*.vue',
],
languageOptions: { languageOptions: {
globals: { globals: {
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),

View file

@ -7,7 +7,7 @@ import * as Misskey from 'misskey-js';
export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean { export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean {
const collapsed = note.cw == null && ( const collapsed = note.cw == null && (
note.text != null && ( (note.text != null && (
(note.text.includes('$[x2')) || (note.text.includes('$[x2')) ||
(note.text.includes('$[x3')) || (note.text.includes('$[x3')) ||
(note.text.includes('$[x4')) || (note.text.includes('$[x4')) ||
@ -15,7 +15,7 @@ export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): bo
(note.text.split('\n').length > 9) || (note.text.split('\n').length > 9) ||
(note.text.length > 500) || (note.text.length > 500) ||
(urls.length >= 4) (urls.length >= 4)
) || note.files.length >= 5 )) || (note.files != null && note.files.length >= 5)
); );
return collapsed; return collapsed;

View file

@ -3,6 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { Locale } from '../../../locales/index.js';
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href); const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href);
const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content; const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
@ -10,9 +13,16 @@ export const host = address.host;
export const hostname = address.hostname; export const hostname = address.hostname;
export const url = address.origin; export const url = address.origin;
export const apiUrl = location.origin + '/api'; export const apiUrl = location.origin + '/api';
export const wsOrigin = location.origin;
export const lang = localStorage.getItem('lang') ?? 'en-US'; export const lang = localStorage.getItem('lang') ?? 'en-US';
export const langs = _LANGS_; export const langs = _LANGS_;
const preParseLocale = localStorage.getItem('locale'); const preParseLocale = localStorage.getItem('locale');
export const locale = preParseLocale ? JSON.parse(preParseLocale) : null; export let locale: Locale = preParseLocale ? JSON.parse(preParseLocale) : null;
export const instanceName = siteName === 'Misskey' || siteName == null ? host : siteName; export const version = _VERSION_;
export const instanceName = (siteName === 'Misskey' || siteName == null) ? host : siteName;
export const ui = localStorage.getItem('ui');
export const debug = localStorage.getItem('debug') === 'true'; export const debug = localStorage.getItem('debug') === 'true';
export function updateLocale(newLocale: Locale): void {
locale = newLocale;
}

View file

@ -98,6 +98,11 @@ export const ROLE_POLICIES = [
'userEachUserListsLimit', 'userEachUserListsLimit',
'rateLimitFactor', 'rateLimitFactor',
'avatarDecorationLimit', 'avatarDecorationLimit',
'canImportAntennas',
'canImportBlocking',
'canImportFollowing',
'canImportMuting',
'canImportUserLists',
] as const; ] as const;
// なんか動かない // なんか動かない

View file

@ -19,7 +19,7 @@ export function char2fluentEmojiFilePath(char: string): string {
// Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25 // Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25
if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char); if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char);
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
codes = codes.filter(x => x && x.length); codes = codes.filter(x => x != null && x.length > 0);
const fileName = codes.map(x => x!.padStart(4, '0')).join('-'); const fileName = (codes as string[]).map(x => x.padStart(4, '0')).join('-');
return `${fluentEmojiPngBase}/${fileName}.png`; return `${fluentEmojiPngBase}/${fileName}.png`;
} }

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