1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-10 03:15:35 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
syuilo
c9b5e66cdb wip 2025-12-16 16:50:20 +09:00
215 changed files with 4970 additions and 6307 deletions

View File

@@ -107,51 +107,13 @@ port: 3000
# Proxy trust settings
#
# Specifies the IP addresses that Misskey will use as trusted
# reverse proxies (e.g., nginx, Cloudflare). This affects how
# Misskey determines the source IP for each request and is used
# for important rate limiting and security features. If the value
# is not set correctly, Misskey may use the IP address of the
# reverse proxy instead of the actual source IP, which may lead to
# unintended rate limiting or security vulnerabilities.
# By default, the loopback network and private network address
# ranges shown below are trusted.
# If you are using a single reverse proxy and it is on the same
# machine or the same private network as Misskey, it is unlikely you
# need to change this setting, and the default setting is fine.
# Also, if you are using multiple reverse proxy servers and they are
# all on the same private network as Misskey, the default setting
# is fine.
# However, if you are using a reverse proxy server that accesses
# Misskey web servers and streaming servers via public IP addresses
# (for example, Cloudflare), you must set this variable.
# When changing this setting, you can use one of the following values:
# Changes how the server interpret the origin IP of the request.
#
# - true: Trust all proxies
# - false: Do not trust any proxies
# - IP address, IP address range, or array of them: Trust hops that
# match the specified criteria.
# - Integer: Trust the nth hop from the front-facing proxy server as
# the client.
# For more information on how to configure this setting, please refer
# to the Fastify documentation:
# https://fastify.dev/docs/latest/Reference/Server/#trustproxy
# Any format supported by Fastify is accepted.
# Default: do not trust any proxies (i.e. trustProxy: false)
# See: https://fastify.dev/docs/latest/reference/server/#trustproxy
#
# Note that if this variable is set, it overrides the default range,
# so if you have both an external reverse proxy and a proxy on the
# local host, you must include both IPs (or IP ranges).
#
#trustProxy:
# - '10.0.0.0/8'
# - '172.16.0.0/12'
# - '192.168.0.0/16'
# - '127.0.0.1/32'
# - '::1/128'
# - 'fc00::/7'
# # Example: If you are using some external reverse proxies like CDNs,
# # you may need to add the CDN IP ranges here.
# # If you're using Cloudflare, you can find IP Ranges at:
# # https://www.cloudflare.com/ips/
# trustProxy: false
# ┌──────────────────────────┐
#───┘ PostgreSQL configuration └────────────────────────────────
@@ -321,10 +283,6 @@ id: 'aidx'
# Whether disable HSTS
#disableHsts: true
# Enable internal IP-based rate limiting (default: true)
# To configure them in reverse proxy instead, set this to false.
#enableIpRateLimit: true
# Number of worker processes
#clusterLimit: 1

View File

@@ -6,7 +6,6 @@
Dockerfile
build/
built/
src-js/
db/
.devcontainer/compose.yml
node_modules/

View File

@@ -54,7 +54,7 @@ body:
* Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4
* Browser: Chrome 113.0.5672.126
* Server URL: misskey.example.com
* Misskey: 2026.x.x
* Misskey: 2025.x.x
value: |
* Model and OS of the device(s):
* Browser:
@@ -74,7 +74,7 @@ body:
Examples:
* Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment
* Misskey: 2026.x.x
* Misskey: 2025.x.x
* Node: 20.x.x
* PostgreSQL: 18.x.x
* Redis: 7.x.x

View File

@@ -40,3 +40,6 @@ updates:
typescript-eslint:
patterns:
- "@typescript-eslint/*"
tensorflow:
patterns:
- "@tensorflow/*"

View File

@@ -11,7 +11,6 @@ on:
jobs:
dockle:
runs-on: ubuntu-latest
env:
DOCKER_CONTENT_TRUST: 1
DOCKLE_VERSION: 0.4.15
@@ -21,33 +20,29 @@ jobs:
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
run: |
set -eux
curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v${DOCKLE_VERSION}/dockle_${DOCKLE_VERSION}_Linux-64bit.deb"
sudo dpkg -i dockle.deb
- name: Build web image (docker build)
run: |
set -eux
docker build -t "misskey-web:ci" .
docker image ls
- run: |
cp .config/docker_example.env .config/docker.env
cp ./compose_example.yml ./compose.yml
- name: Mount tmpfs for Dockle tar
env:
TMPFS_SIZE: 8G
run: |
set -eux
sudo mkdir -p /mnt/dockle-tmp
sudo mount -t tmpfs -o size=${{ env.TMPFS_SIZE }} tmpfs /mnt/dockle-tmp
free -h
df -h
- run: |
docker compose up -d web
IMAGE_ID=$(docker compose images --format json web | jq -r '.[0].ID')
docker tag "${IMAGE_ID}" misskey-web:latest
- name: Save image tar into tmpfs
- name: Prune docker junk (optional but recommended)
run: |
set -eux
docker save misskey-web:ci -o /mnt/dockle-tmp/misskey-web.tar
ls -lh /mnt/dockle-tmp/misskey-web.tar
docker system prune -af
docker volume prune -f
- name: Run Dockle Scan (tar input)
- name: Save image for Dockle
run: |
set -eux
dockle --exit-code 1 --input /mnt/dockle-tmp/misskey-web.tar
docker save misskey-web:latest -o ./misskey-web.tar
ls -lh ./misskey-web.tar
- name: Run Dockle with tar input
run: |
dockle --exit-code 1 --input ./misskey-web.tar

View File

@@ -48,13 +48,6 @@ jobs:
image: redis:7
ports:
- 56312:6379
meilisearch:
image: getmeili/meilisearch:v1.3.4
ports:
- 57712:7700
env:
MEILI_NO_ANALYTICS: true
MEILI_ENV: development
steps:
- uses: actions/checkout@v6.0.1

1
.gitignore vendored
View File

@@ -46,7 +46,6 @@ docker-compose.yml
built
built-test
js-built
src-js
/data
/.cache-loader
/db

View File

@@ -1,57 +1,14 @@
## 2026.1.0
### Note
- `users/following``birthday` プロパティは非推奨になりました。代わりに `users/get-following-birthday-users` をご利用ください。
## Unreleased
### General
- Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように
(Cherry-picked from https://github.com/MisskeyIO/misskey)
- 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました
- 依存関係の更新
-
### Client
- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
- Enhance: ウィジェットの設定項目のラベルの多言語対応
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
- Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正
- Fix: ドライブのソートが「登録日(昇順)」の場合に正しく動作しない問題を修正
- Fix: 管理画面でアーカイブ済のお知らせを表示した際にアクティブなお知らせが多い旨の警告が出る問題を修正
- Fix: ファイルタブのセンシティブメディアを開く際に確認ダイアログを出す設定が適用されない問題を修正
-
### Server
- Enhance: OAuthのクライアント情報取得Client Information Discoveryにおいて、IndieWeb Living Standard 11 July 2024で定義されているJSONドキュメント形式に対応しました
- JSONによるClient Information Discoveryを行うには、レスポンスの`Content-Type`ヘッダーが`application/json`である必要があります
- 従来の実装12 February 2022版・HTML Microformat形式も引き続きサポートされます
- Enhance: メモリ使用量を削減
-
## 2025.12.2
### Note
v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`に変更」について、正しく環境に応じた設定を行わないとサインインが困難になるといった状態を緩和するために、以下の対応を行いました。
**正しく設定しないと、上記のような不具合の原因となったり、セキュリティリスクが高まったりする可能性があります。必ず現在のconfigをご確認の上、必要に応じて値を変更してください。**
- `trustProxy`について、デフォルトconfigに値が設定されていない状態ではループバックアドレスとローカルIPアドレス空間を信頼するようにしました。
- `trustProxy`の設定方法について、より詳細に記述しました。
- リバースプロキシやCDNなどのより上流のレイヤでレートリミットを設定したい場合や、緊急時の一時的な緩和策として、Misskey内部でのIPアドレスペースでのレートリミットを無効化できるようにしました。
### General
- 依存関係の更新
### Client
- Enhance: デッキのUI説明を追加
- Enhance: 設定がブラウザによって消去されないようにするオプションを追加
- Fix: バージョン表記のないPlayが正しく動作しない問題を修正
バージョン表記のないものは v0.x 系として実行されます。v1.x 系で動作させたい場合は必ずバージョン表記を含めてください。
- Fix: デッキUIでメニュー位置を下にしているとプロファイル削除ボタンが表示されないのを修正
- Fix: 一部のUnicode絵文字のリアクションがボタンにならない問題を修正
### Server
- Enhance: Misskey内部でのIPアドレスペースでのレートリミットを無効化できるように
- リバースプロキシやCDNなど別のレイヤで別途レートリミットを設定する場合や、ローカルでのテスト用途等として利用することを想定しています。
- デフォルトは `enableIpRateLimit: true`Misskey内部でのIPアドレスペースでのレートリミットは有効です。
- Fix: コントロールパネルのジョブキューページで使用される一部APIの応答速度を改善
## 2025.12.1

View File

@@ -1,5 +1,5 @@
Unless otherwise stated this repository is
Copyright © 2014-2026 syuilo and contributors
Copyright © 2014-2025 syuilo and contributors
And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE.

View File

@@ -102,7 +102,6 @@ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/src-js ./packages/backend/src-js
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis
COPY --chown=misskey:misskey . ./

View File

@@ -26,8 +26,6 @@
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/misskey-dev/misskey)
<a href="https://flatt.tech/oss/gmo/trampoline" target="_blank"><img src="https://flatt.tech/assets/images/badges/gmo-oss.svg" height="24px"/></a>
</div>
## Thanks

View File

@@ -1406,7 +1406,6 @@ youAreAdmin: "あなたは管理者です"
frame: "フレーム"
presets: "プリセット"
zeroPadding: "ゼロ埋め"
nothingToConfigure: "設定項目はありません"
_imageEditing:
_vars:
@@ -1558,9 +1557,6 @@ _settings:
showPageTabBarBottom: "ページのタブバーを下部に表示"
emojiPaletteBanner: "絵文字ピッカーに固定表示するプリセットをパレットとして登録したり、ピッカーの表示方法をカスタマイズしたりできます。"
enableAnimatedImages: "アニメーション画像を有効にする"
settingsPersistence_title: "設定の永続化"
settingsPersistence_description1: "設定の永続化を有効にすると、設定情報が失われるのを防止できます。"
settingsPersistence_description2: "環境によっては有効化できない場合があります。"
_chat:
showSenderName: "送信者の名前を表示"
@@ -2600,48 +2596,9 @@ _widgets:
_userList:
chooseList: "リストを選択"
clicker: "クリッカー"
birthdayFollowings: "もうすぐ誕生日のユーザー"
birthdayFollowings: "今日誕生日のユーザー"
chat: "ダイレクトメッセージ"
_widgetOptions:
showHeader: "ヘッダーを表示"
transparent: "背景を透明にする"
height: "高さ"
_button:
colored: "色付き"
_clock:
size: "サイズ"
thickness: "針の太さ"
thicknessThin: "細い"
thicknessMedium: "普通"
thicknessThick: "太い"
graduations: "文字盤の目盛り"
graduationDots: "ドット"
graduationArabic: "アラビア数字"
fadeGraduations: "目盛りをフェード"
sAnimation: "秒針のアニメーション"
sAnimationElastic: "リアル"
sAnimationEaseOut: "滑らか"
twentyFour: "24時間表示"
labelTime: "時刻"
labelTz: "タイムゾーン"
labelTimeAndTz: "時刻とタイムゾーン"
timezone: "タイムゾーン"
showMs: "ミリ秒を表示"
showLabel: "ラベルを表示"
_jobQueue:
sound: "音を鳴らす"
_rss:
url: "RSSフィードのURL"
refreshIntervalSec: "更新間隔(秒)"
maxEntries: "最大表示件数"
_rssTicker:
shuffle: "表示順をシャッフル"
duration: "ティッカーのスクロール速度(秒)"
reverse: "逆方向にスクロール"
_birthdayFollowings:
period: "期間"
_cw:
hide: "隠す"
show: "もっと見る"
@@ -2933,15 +2890,6 @@ _deck:
usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となります"
flexible: "幅を自動調整"
enableSyncBetweenDevicesForProfiles: "プロファイル情報のデバイス間同期を有効にする"
showHowToUse: "UIの説明を見る"
_howToUse:
addColumn_title: "カラム追加"
addColumn_description: "カラムの種類を選んで追加できます。"
settings_title: "UI設定"
settings_description: "デッキUIの詳細設定を行えます。"
switchProfile_title: "プロファイル切り替え"
switchProfile_description: "UIのレイアウトをプロファイルとして保存し、いつでも切り替えられるようにできます。"
_columns:
main: "メイン"
@@ -3458,6 +3406,7 @@ _imageEffector:
title: "エフェクト"
addEffect: "エフェクトを追加"
discardChangesConfirm: "変更を破棄して終了しますか?"
nothingToConfigure: "設定項目はありません"
failedToLoadImage: "画像の読み込みに失敗しました"
_fxs:

View File

@@ -1,12 +1,12 @@
{
"name": "misskey",
"version": "2026.1.0-alpha.0",
"version": "2025.12.1",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@10.26.2",
"packageManager": "pnpm@10.25.0",
"workspaces": [
"packages/misskey-js",
"packages/i18n",
@@ -28,7 +28,7 @@
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
"build-storybook": "pnpm --filter frontend build-storybook",
"build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api",
"start": "cd packages/backend && pnpm compile-config && node ./built/boot/entry.js",
"start": "pnpm check:connect && cd packages/backend && pnpm compile-config && node ./built/boot/entry.js",
"start:inspect": "cd packages/backend && pnpm compile-config && node --inspect ./built/boot/entry.js",
"start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"cli": "cd packages/backend && pnpm cli",
@@ -58,31 +58,33 @@
},
"dependencies": {
"cssnano": "7.1.2",
"esbuild": "0.27.2",
"esbuild": "0.27.1",
"execa": "9.6.1",
"ignore-walk": "8.0.0",
"js-yaml": "4.1.1",
"postcss": "8.5.6",
"tar": "7.5.2",
"terser": "5.44.1"
"terser": "5.44.1",
"typescript": "5.9.3"
},
"devDependencies": {
"@eslint/js": "9.39.2",
"@eslint/js": "9.39.1",
"@misskey-dev/eslint-plugin": "2.2.0",
"@types/js-yaml": "4.0.9",
"@types/node": "24.10.4",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@typescript/native-preview": "7.0.0-dev.20251226.1",
"@types/node": "24.10.2",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"cross-env": "10.1.0",
"cypress": "15.8.1",
"eslint": "9.39.2",
"cypress": "15.7.1",
"eslint": "9.39.1",
"globals": "16.5.0",
"ncp": "2.0.0",
"pnpm": "10.26.2",
"typescript": "5.9.3",
"pnpm": "10.25.0",
"start-server-and-test": "2.1.3"
},
"optionalDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
},
"pnpm": {
"overrides": {
"@aiscript-dev/aiscript-languageserver": "-"

View File

@@ -1,122 +0,0 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { build } from 'esbuild';
import { swcPlugin } from 'esbuild-plugin-swc';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const resolveTsPathsPlugin = {
name: 'resolve-ts-paths',
setup(build) {
build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, (args) => {
if (args.importer) {
const absPath = join(args.resolveDir, args.path);
const tsPath = absPath.slice(0, -3) + '.ts';
if (fs.existsSync(tsPath)) return { path: tsPath };
const tsxPath = absPath.slice(0, -3) + '.tsx';
if (fs.existsSync(tsxPath)) return { path: tsxPath };
}
});
},
};
const externalIpaddrPlugin = {
name: 'external-ipaddr',
setup(build) {
build.onResolve({ filter: /^ipaddr\.js$/ }, (args) => {
return { path: args.path, external: true };
});
},
};
/** @type {import('esbuild').BuildOptions} */
const options = {
entryPoints: ['./src/boot/entry.ts'],
minify: true,
keepNames: true,
bundle: true,
outdir: './built/boot',
target: 'node22',
platform: 'node',
format: 'esm',
sourcemap: false,
packages: 'bundle',
banner: {
js: 'import { createRequire as topLevelCreateRequire } from "module";' +
'import ___url___ from "url";' +
'const require = topLevelCreateRequire(import.meta.url);' +
'const __filename = ___url___.fileURLToPath(import.meta.url);' +
'const __dirname = ___url___.fileURLToPath(new URL(".", import.meta.url));',
},
plugins: [
externalIpaddrPlugin,
resolveTsPathsPlugin,
swcPlugin({
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
dynamicImport: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
},
experimental: {
keepImportAssertions: true,
},
baseUrl: join(_dirname, 'src'),
paths: {
'@/*': ['*'],
},
target: 'esnext',
keepClassNames: true,
},
}),
//externalIpaddrPlugin,
],
external: [
'slacc-*',
'class-transformer',
'class-validator',
'@sentry/*',
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'@nestjs/microservices',
'@napi-rs/canvas-win32-x64-msvc',
'mock-aws-s3',
'aws-sdk',
'nock',
'sharp',
're2',
'@napi-rs/canvas',
'oauth2orize',
'oauth2orize-pkce',
],
};
const args = process.argv.slice(2).map(arg => arg.toLowerCase());
if (!args.includes('--no-clean')) {
fs.rmSync('./built', { recursive: true, force: true });
}
await buildSrc();
async function buildSrc() {
console.log(`[${_package.name}] start building...`);
await build(options)
.then(() => {
console.log(`[${_package.name}] build succeeded.`);
})
.catch((err) => {
process.stderr.write(err.stderr || err.message || err);
process.exit(1);
});
console.log(`[${_package.name}] finish building.`);
}

View File

@@ -1,20 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class BirthdayIndex1767169026317 {
name = 'BirthdayIndex1767169026317'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`);
await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`);
}
async down(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`);
await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`);
await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`);
}
}

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
import { DataSource } from 'typeorm';
import { loadConfig } from './src-js/config.js';
import { entities } from './src-js/postgres.js';
import { loadConfig } from './built/config.js';
import { entities } from './built/postgres.js';
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';

View File

@@ -12,17 +12,17 @@
"start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js",
"cli": "pnpm compile-config && node ./src-js/boot/cli.js",
"cli": "pnpm compile-config && node ./built/boot/cli.js",
"check:connect": "pnpm compile-config && node ./scripts/check_connect.js",
"compile-config": "node ./scripts/compile_config.js",
"build": "swc src -d src-js -D --strip-leading-paths && node ./build.js",
"build": "swc src -d built -D --strip-leading-paths",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
"build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "pnpm compile-config && node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "pnpm compile-config && node ./scripts/dev.mjs",
"typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit",
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
@@ -41,18 +41,20 @@
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.15.7",
"@swc/core-darwin-x64": "1.15.7",
"@swc/core-darwin-arm64": "1.15.3",
"@swc/core-darwin-x64": "1.15.3",
"@swc/core-freebsd-x64": "1.3.11",
"@swc/core-linux-arm-gnueabihf": "1.15.7",
"@swc/core-linux-arm64-gnu": "1.15.7",
"@swc/core-linux-arm64-musl": "1.15.7",
"@swc/core-linux-x64-gnu": "1.15.7",
"@swc/core-linux-x64-musl": "1.15.7",
"@swc/core-win32-arm64-msvc": "1.15.7",
"@swc/core-win32-ia32-msvc": "1.15.7",
"@swc/core-win32-x64-msvc": "1.15.7",
"bufferutil": "4.1.0",
"@swc/core-linux-arm-gnueabihf": "1.15.3",
"@swc/core-linux-arm64-gnu": "1.15.3",
"@swc/core-linux-arm64-musl": "1.15.3",
"@swc/core-linux-x64-gnu": "1.15.3",
"@swc/core-linux-x64-musl": "1.15.3",
"@swc/core-win32-arm64-msvc": "1.15.3",
"@swc/core-win32-ia32-msvc": "1.15.3",
"@swc/core-win32-x64-msvc": "1.15.3",
"@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.0.9",
"slacc-android-arm-eabi": "0.0.10",
"slacc-android-arm64": "0.0.10",
"slacc-darwin-arm64": "0.0.10",
@@ -66,14 +68,14 @@
"slacc-linux-x64-musl": "0.0.10",
"slacc-win32-arm64-msvc": "0.0.10",
"slacc-win32-x64-msvc": "0.0.10",
"utf-8-validate": "6.0.6"
"utf-8-validate": "6.0.5"
},
"dependencies": {
"@aws-sdk/client-s3": "3.958.0",
"@aws-sdk/lib-storage": "3.958.0",
"@aws-sdk/client-s3": "3.947.0",
"@aws-sdk/lib-storage": "3.947.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/cors": "11.1.0",
"@fastify/express": "4.0.2",
"@fastify/http-proxy": "11.4.1",
"@fastify/multipart": "9.3.0",
@@ -81,18 +83,18 @@
"@kitajs/html": "4.2.11",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.5",
"@napi-rs/canvas": "0.1.87",
"@nestjs/common": "11.1.10",
"@nestjs/core": "11.1.10",
"@nestjs/testing": "11.1.10",
"@napi-rs/canvas": "0.1.84",
"@nestjs/common": "11.1.9",
"@nestjs/core": "11.1.9",
"@nestjs/testing": "11.1.9",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.32.1",
"@sentry/profiling-node": "10.32.1",
"@sentry/node": "10.29.0",
"@sentry/profiling-node": "10.29.0",
"@simplewebauthn/server": "13.2.2",
"@sinonjs/fake-timers": "15.1.0",
"@smithy/node-http-handler": "4.4.7",
"@sinonjs/fake-timers": "15.0.0",
"@smithy/node-http-handler": "4.4.5",
"@swc/cli": "0.7.9",
"@swc/core": "1.15.7",
"@swc/core": "1.15.3",
"@twemoji/parser": "16.0.0",
"@types/redis-info": "3.0.3",
"accepts": "1.3.8",
@@ -102,7 +104,7 @@
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"body-parser": "2.2.1",
"bullmq": "5.66.3",
"bullmq": "5.65.1",
"cacheable-lookup": "7.0.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
@@ -114,7 +116,7 @@
"fastify": "5.6.2",
"fastify-raw-body": "5.0.0",
"feed": "5.1.0",
"file-type": "21.2.0",
"file-type": "21.1.1",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.5",
"got": "14.6.5",
@@ -138,7 +140,8 @@
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.0.1",
"nodemailer": "7.0.12",
"nodemailer": "7.0.11",
"nsfwjs": "4.2.0",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
@@ -150,7 +153,7 @@
"qrcode": "1.5.4",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.23.0",
"re2": "1.22.3",
"redis-info": "3.1.0",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
@@ -163,11 +166,12 @@
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.28.1",
"systeminformation": "5.27.12",
"tinycolor2": "1.6.0",
"tmp": "0.2.5",
"tsc-alias": "1.8.16",
"typeorm": "0.3.28",
"typescript": "5.9.3",
"ulid": "3.0.2",
"vary": "1.1.2",
"web-push": "3.6.7",
@@ -177,8 +181,8 @@
"devDependencies": {
"@jest/globals": "29.7.0",
"@kitajs/ts-html-plugin": "4.1.3",
"@nestjs/platform-express": "11.1.10",
"@sentry/vue": "10.32.1",
"@nestjs/platform-express": "11.1.9",
"@sentry/vue": "10.29.0",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.39",
"@types/accepts": "1.3.7",
@@ -192,18 +196,18 @@
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "24.10.4",
"@types/node": "24.10.2",
"@types/nodemailer": "7.0.4",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.16.0",
"@types/pg": "8.15.6",
"@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.16.0",
"@types/semver": "7.7.1",
"@types/simple-oauth2": "5.0.8",
"@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/supertest": "6.0.3",
"@types/tinycolor2": "1.4.6",
@@ -211,12 +215,11 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.11",
"cross-env": "10.1.0",
"esbuild-plugin-swc": "1.0.1",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.1",
@@ -227,6 +230,6 @@
"pid-port": "2.0.0",
"simple-oauth2": "5.1.0",
"supertest": "7.1.4",
"vite": "7.3.0"
"vite": "7.2.7"
}
}

View File

@@ -4,8 +4,8 @@
*/
import Redis from 'ioredis';
import { loadConfig } from '../src-js/config.js';
import { createPostgresDataSource } from '../src-js/postgres.js';
import { loadConfig } from '../built/config.js';
import { createPostgresDataSource } from '../built/postgres.js';
const config = loadConfig();
@@ -28,8 +28,10 @@ async function connectToRedis(redisOptions) {
try {
await redis.connect();
resolve();
} catch (e) {
reject(e);
} finally {
redis.disconnect(false);
}
@@ -48,7 +50,7 @@ const promises = Array
]))
.map(connectToRedis)
.concat([
connectToPostgres(),
connectToPostgres()
]);
await Promise.all(promises);

View File

@@ -3,8 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { writeFileSync, existsSync } from 'node:fs';
import { execa } from 'execa';
import { writeFileSync, existsSync } from "node:fs";
async function main() {
if (!process.argv.includes('--no-build')) {
@@ -19,10 +19,10 @@ async function main() {
}
/** @type {import('../src/config.js')} */
const { loadConfig } = await import('../src-js/config.js');
const { loadConfig } = await import('../built/config.js');
/** @type {import('../src/server/api/openapi/gen-spec.js')} */
const { genOpenapiSpec } = await import('../src-js/server/api/openapi/gen-spec.js');
const { genOpenapiSpec } = await import('../built/server/api/openapi/gen-spec.js');
const config = loadConfig();
const spec = genOpenapiSpec(config, true);

View File

@@ -21,7 +21,7 @@ import { execa } from 'execa';
});
}, 3000);
execa('tsgo', ['-w', '-p', 'tsconfig.json'], {
execa('tsc', ['-w', '-p', 'tsconfig.json'], {
stdout: process.stdout,
stderr: process.stderr,
});

View File

@@ -4,6 +4,8 @@
*/
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as os from 'node:os';
import cluster from 'node:cluster';
import chalk from 'chalk';
@@ -15,15 +17,20 @@ import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { jobQueue, server } from './common.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta');
const themeColor = chalk.hex('#86b300');
function greet(props: { version: string }) {
function greet() {
if (!envOption.quiet) {
//#region Misskey logo
const v = `v${props.version}`;
const v = `v${meta.version}`;
console.log(themeColor(' _____ _ _ '));
console.log(themeColor(' | |_|___ ___| |_ ___ _ _ '));
console.log(themeColor(' | | | | |_ -|_ -| \'_| -_| | |'));
@@ -39,7 +46,7 @@ function greet(props: { version: string }) {
}
bootLogger.info('Welcome to Misskey!');
bootLogger.info(`Misskey v${props.version}`, null, true);
bootLogger.info(`Misskey v${meta.version}`, null, true);
}
/**
@@ -50,11 +57,11 @@ export async function masterMain() {
// initialize app
try {
config = loadConfigBoot();
greet({ version: config.version });
greet();
showEnvironment();
await showMachineInfo(bootLogger);
showNodejsVersion();
config = loadConfigBoot();
//await connectDb();
if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString());
} catch (e) {

View File

@@ -30,7 +30,6 @@ type Source = {
socket?: string;
trustProxy?: FastifyServerOptions['trustProxy'];
chmodSocket?: string;
enableIpRateLimit?: boolean;
disableHsts?: boolean;
db: {
host: string;
@@ -121,9 +120,8 @@ export type Config = {
url: string;
port: number;
socket: string | undefined;
trustProxy: NonNullable<FastifyServerOptions['trustProxy']>;
trustProxy: FastifyServerOptions['trustProxy'];
chmodSocket: string | undefined;
enableIpRateLimit: boolean;
disableHsts: boolean | undefined;
db: {
host: string;
@@ -219,42 +217,24 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
/** Path of repository root directory */
let rootDir = _dirname;
// 見つかるまで上に遡る
while (!fs.existsSync(resolve(rootDir, 'packages'))) {
const parentDir = dirname(rootDir);
if (parentDir === rootDir) {
throw new Error('Cannot find root directory');
}
rootDir = parentDir;
}
const compiledConfigFilePathForTest = resolve(_dirname, '../../../built/._config_.json');
/** Path of configuration directory */
const configDir = resolve(rootDir, '.config');
/** Path of built directory */
const projectBuiltDir = resolve(rootDir, 'built');
const compiledConfigFilePathForTest = resolve(projectBuiltDir, '._config_.json');
export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest)
? compiledConfigFilePathForTest
: resolve(projectBuiltDir, '.config.json');
export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) ? compiledConfigFilePathForTest : resolve(_dirname, '../../../built/.config.json');
export function loadConfig(): Config {
if (!fs.existsSync(compiledConfigFilePath)) {
throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.');
}
const meta = JSON.parse(fs.readFileSync(resolve(projectBuiltDir, 'meta.json'), 'utf-8'));
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
const frontendManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'));
const frontendEmbedManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'));
const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json');
const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json');
const frontendManifest = frontendManifestExists ?
JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'), 'utf-8'))
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
: { 'src/_boot_.ts': { file: null } };
const frontendEmbedManifest = frontendEmbedManifestExists ?
JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'), 'utf-8'))
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
: { 'src/boot.ts': { file: null } };
const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source;
@@ -283,17 +263,9 @@ export function loadConfig(): Config {
url: url.origin,
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
socket: config.socket,
trustProxy: config.trustProxy ?? [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.1/32',
'::1/128',
'fc00::/7',
],
trustProxy: config.trustProxy,
chmodSocket: config.chmodSocket,
disableHsts: config.disableHsts,
enableIpRateLimit: config.enableIpRateLimit ?? true,
host,
hostname,
scheme,

View File

@@ -3,17 +3,88 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Injectable } from '@nestjs/common';
import si from 'systeminformation';
import { Mutex } from 'async-mutex';
import fetch from 'node-fetch';
import { bindThis } from '@/decorators.js';
import type { NSFWJS, PredictionType } from 'nsfwjs';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const REQUIRED_CPU_FLAGS_X64 = ['avx2', 'fma'];
let isSupportedCpu: undefined | boolean = undefined;
@Injectable()
export class AiService {
private model: NSFWJS;
private modelLoadMutex: Mutex = new Mutex();
constructor(
) {
}
@bindThis
public async detectSensitive(source: string | Buffer): Promise<null> {
return null;
public async detectSensitive(source: string | Buffer): Promise<PredictionType[] | null> {
try {
if (isSupportedCpu === undefined) {
isSupportedCpu = await this.computeIsSupportedCpu();
}
if (!isSupportedCpu) {
console.error('This CPU cannot use TensorFlow.');
return null;
}
const tf = await import('@tensorflow/tfjs-node');
tf.env().global.fetch = fetch;
if (this.model == null) {
const nsfw = await import('nsfwjs');
await this.modelLoadMutex.runExclusive(async () => {
if (this.model == null) {
this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
}
});
}
const buffer = source instanceof Buffer ? source : await fs.promises.readFile(source);
const image = await tf.node.decodeImage(buffer, 3) as any;
try {
const predictions = await this.model.classify(image);
return predictions;
} finally {
image.dispose();
}
} catch (err) {
console.error(err);
return null;
}
}
private async computeIsSupportedCpu(): Promise<boolean> {
switch (process.arch) {
case 'x64': {
const cpuFlags = await this.getCpuFlags();
return REQUIRED_CPU_FLAGS_X64.every(required => cpuFlags.includes(required));
}
case 'arm64': {
// As far as I know, no required CPU flags for ARM64.
return true;
}
default: {
return false;
}
}
}
@bindThis
private async getCpuFlags(): Promise<string[]> {
const str = await si.cpuFlags();
return str.split(/\s+/);
}
}

View File

@@ -141,7 +141,7 @@ import { ApLoggerService } from './activitypub/ApLoggerService.js';
import { ApMfmService } from './activitypub/ApMfmService.js';
import { ApRendererService } from './activitypub/ApRendererService.js';
import { ApRequestService } from './activitypub/ApRequestService.js';
import { ApResolverService, Resolver } from './activitypub/ApResolverService.js';
import { ApResolverService } from './activitypub/ApResolverService.js';
import { JsonLdService } from './activitypub/JsonLdService.js';
import { RemoteLoggerService } from './RemoteLoggerService.js';
import { RemoteUserResolveService } from './RemoteUserResolveService.js';
@@ -447,7 +447,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRendererService,
ApRequestService,
ApResolverService,
Resolver,
JsonLdService,
RemoteLoggerService,
RemoteUserResolveService,
@@ -746,7 +745,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRendererService,
ApRequestService,
ApResolverService,
Resolver,
JsonLdService,
RemoteLoggerService,
RemoteUserResolveService,

View File

@@ -95,7 +95,7 @@ export class ApInboxService {
if (isCollectionOrOrderedCollection(activity)) {
const results = [] as [string, string | void][];
// eslint-disable-next-line no-param-reassign
resolver ??= await this.apResolverService.createResolver();
resolver ??= this.apResolverService.createResolver();
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
if (items.length >= resolver.getRecursionLimit()) {
@@ -221,7 +221,7 @@ export class ApInboxService {
this.logger.info(`Accept: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= await this.apResolverService.createResolver();
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`);
@@ -284,7 +284,7 @@ export class ApInboxService {
this.logger.info(`Announce: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= await this.apResolverService.createResolver();
resolver ??= this.apResolverService.createResolver();
if (!activity.object) return 'skip: activity has no object property';
const targetUri = getApId(activity.object);
@@ -406,7 +406,7 @@ export class ApInboxService {
}
// eslint-disable-next-line no-param-reassign
resolver ??= await this.apResolverService.createResolver();
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -575,7 +575,7 @@ export class ApInboxService {
this.logger.info(`Reject: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= await this.apResolverService.createResolver();
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -642,7 +642,7 @@ export class ApInboxService {
this.logger.info(`Undo: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= await this.apResolverService.createResolver();
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -774,7 +774,7 @@ export class ApInboxService {
this.logger.debug('Update');
// eslint-disable-next-line no-param-reassign
resolver ??= await this.apResolverService.createResolver();
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);

View File

@@ -3,17 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type {
FollowRequestsRepository,
MiMeta,
NoteReactionsRepository,
NotesRepository,
PollsRepository,
UsersRepository
} from '@/models/_.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { DI } from '@/di-symbols.js';
@@ -23,43 +16,26 @@ import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { ICollection, IObject, IOrderedCollection } from './type.js';
import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
import { ModuleRef } from '@nestjs/core';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
@Injectable({ scope: Scope.TRANSIENT })
export class Resolver {
private history: Set<string>;
private user?: MiLocalUser;
private logger: Logger;
private recursionLimit = 256;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService,
private systemAccountService: SystemAccountService,
private apRequestService: ApRequestService,
@@ -67,6 +43,7 @@ export class Resolver {
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
private recursionLimit = 256,
) {
this.history = new Set();
this.logger = this.loggerService.getLogger('ap-resolve');
@@ -203,12 +180,54 @@ export class Resolver {
@Injectable()
export class ApResolverService {
constructor(
private moduleRef: ModuleRef,
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService,
private systemAccountService: SystemAccountService,
private apRequestService: ApRequestService,
private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
) {
}
@bindThis
public async createResolver(): Promise<Resolver> {
return await this.moduleRef.create(Resolver);
public createResolver(): Resolver {
return new Resolver(
this.config,
this.meta,
this.usersRepository,
this.notesRepository,
this.pollsRepository,
this.noteReactionsRepository,
this.followRequestsRepository,
this.utilityService,
this.systemAccountService,
this.apRequestService,
this.httpRequestService,
this.apRendererService,
this.apDbResolverService,
this.loggerService,
);
}
}

View File

@@ -46,7 +46,7 @@ export class ApImageService {
throw new Error('actor has been suspended');
}
const image = await (await this.apResolverService.createResolver()).resolve(value);
const image = await this.apResolverService.createResolver().resolve(value);
if (!isDocument(image)) return null;

View File

@@ -128,7 +128,7 @@ export class ApNoteService {
@bindThis
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = await this.apResolverService.createResolver();
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value);

View File

@@ -310,7 +310,7 @@ export class ApPersonService implements OnModuleInit {
}
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = await this.apResolverService.createResolver();
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
@@ -500,7 +500,7 @@ export class ApPersonService implements OnModuleInit {
//#endregion
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = await this.apResolverService.createResolver();
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = hint ?? await resolver.resolve(uri);
@@ -678,7 +678,7 @@ export class ApPersonService implements OnModuleInit {
// リモートサーバーからフェッチしてきて登録
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = await this.apResolverService.createResolver();
if (resolver == null) resolver = this.apResolverService.createResolver();
return await this.createPerson(uri, resolver);
}
@@ -707,7 +707,7 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Updating the featured: ${user.uri}`);
const _resolver = resolver ?? await this.apResolverService.createResolver();
const _resolver = resolver ?? this.apResolverService.createResolver();
// Resolve to (Ordered)Collection Object
const collection = await _resolver.resolveCollection(user.featured);

View File

@@ -45,7 +45,7 @@ export class ApQuestionService {
@bindThis
public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = await this.apResolverService.createResolver();
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(source);
if (!isQuestion(question)) throw new Error('invalid type');
@@ -91,7 +91,7 @@ export class ApQuestionService {
// resolve new Question object
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = await this.apResolverService.createResolver();
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(value);
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);

View File

@@ -720,7 +720,7 @@ export class UserEntityService implements OnModuleInit {
me,
{
...options,
userProfile: profilesMap?.get(u.id),
userProfile: profilesMap.get(u.id),
userRelations: userRelations,
userMemos: userMemos,
pinNotes: pinNotes,

View File

@@ -4,12 +4,13 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import si from 'systeminformation';
import Xev from 'xev';
import * as osUtils from 'os-utils';
import { bindThis } from '@/decorators.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import type { OnApplicationShutdown } from '@nestjs/common';
const ev = new Xev();
@@ -96,14 +97,12 @@ function cpuUsage(): Promise<number> {
// MEMORY STAT
async function mem() {
const si = await import('systeminformation');
const data = await si.mem();
return data;
}
// NETWORK STAT
async function net() {
const si = await import('systeminformation');
const iface = await si.networkInterfaceDefault();
const data = await si.networkStats(iface);
return data[0];
@@ -111,6 +110,5 @@ async function net() {
// FS STAT
async function fs() {
const si = await import('systeminformation');
return await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 }));
}

View File

@@ -4,11 +4,15 @@
*/
import * as os from 'node:os';
import sysUtils from 'systeminformation';
import type Logger from '@/logger.js';
export async function showMachineInfo(parentLogger: Logger) {
const logger = parentLogger.createSubLogger('machine');
logger.debug(`Hostname: ${os.hostname()}`);
logger.debug(`Platform: ${process.platform} Arch: ${process.arch}`);
logger.debug(`CPU: ${os.cpus().length} core MEM: ${(os.totalmem() / 1024 / 1024 / 1024).toFixed(1)}GB (available: ${(os.freemem() / 1024 / 1024 / 1024).toFixed(1)}GB)`);
const mem = await sysUtils.mem();
const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1);
const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1);
logger.debug(`CPU: ${os.cpus().length} core MEM: ${totalmem}GB (available: ${availmem}GB)`);
}

View File

@@ -13,6 +13,7 @@ import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ServerService } from './ServerService.js';
import { WellKnownServerService } from './WellKnownServerService.js';
import { GetterService } from './api/GetterService.js';
import { ChannelsService } from './api/stream/ChannelsService.js';
import { ActivityPubServerService } from './ActivityPubServerService.js';
import { ApiLoggerService } from './api/ApiLoggerService.js';
import { ApiServerService } from './api/ApiServerService.js';
@@ -30,25 +31,24 @@ import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { MainChannel } from './api/stream/channels/main.js';
import { AdminChannel } from './api/stream/channels/admin.js';
import { AntennaChannel } from './api/stream/channels/antenna.js';
import { ChannelChannel } from './api/stream/channels/channel.js';
import { DriveChannel } from './api/stream/channels/drive.js';
import { GlobalTimelineChannel } from './api/stream/channels/global-timeline.js';
import { HashtagChannel } from './api/stream/channels/hashtag.js';
import { HomeTimelineChannel } from './api/stream/channels/home-timeline.js';
import { HybridTimelineChannel } from './api/stream/channels/hybrid-timeline.js';
import { LocalTimelineChannel } from './api/stream/channels/local-timeline.js';
import { QueueStatsChannel } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannel } from './api/stream/channels/server-stats.js';
import { UserListChannel } from './api/stream/channels/user-list.js';
import { RoleTimelineChannel } from './api/stream/channels/role-timeline.js';
import { ChatUserChannel } from './api/stream/channels/chat-user.js';
import { ChatRoomChannel } from './api/stream/channels/chat-room.js';
import { ReversiChannel } from './api/stream/channels/reversi.js';
import { ReversiGameChannel } from './api/stream/channels/reversi-game.js';
import { MainChannelService } from './api/stream/channels/main.js';
import { AdminChannelService } from './api/stream/channels/admin.js';
import { AntennaChannelService } from './api/stream/channels/antenna.js';
import { ChannelChannelService } from './api/stream/channels/channel.js';
import { DriveChannelService } from './api/stream/channels/drive.js';
import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js';
import { HashtagChannelService } from './api/stream/channels/hashtag.js';
import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js';
import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js';
import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js';
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { ChatUserChannelService } from './api/stream/channels/chat-user.js';
import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@Module({
@@ -69,7 +69,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
ServerService,
WellKnownServerService,
GetterService,
MainStreamConnection,
ChannelsService,
ApiCallService,
ApiLoggerService,
ApiServerService,
@@ -80,24 +80,24 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
SigninService,
SignupApiService,
StreamingApiServerService,
MainChannel,
AdminChannel,
AntennaChannel,
ChannelChannel,
DriveChannel,
GlobalTimelineChannel,
HashtagChannel,
RoleTimelineChannel,
ChatUserChannel,
ChatRoomChannel,
ReversiChannel,
ReversiGameChannel,
HomeTimelineChannel,
HybridTimelineChannel,
LocalTimelineChannel,
QueueStatsChannel,
ServerStatsChannel,
UserListChannel,
MainChannelService,
AdminChannelService,
AntennaChannelService,
ChannelChannelService,
DriveChannelService,
GlobalTimelineChannelService,
HashtagChannelService,
RoleTimelineChannelService,
ChatUserChannelService,
ChatRoomChannelService,
ReversiChannelService,
ReversiGameChannelService,
HomeTimelineChannelService,
HybridTimelineChannelService,
LocalTimelineChannelService,
QueueStatsChannelService,
ServerStatsChannelService,
UserListChannelService,
OpenApiServerService,
OAuth2ProviderService,
],

View File

@@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown {
@bindThis
public async launch(): Promise<void> {
const fastify = Fastify({
trustProxy: this.config.trustProxy,
trustProxy: this.config.trustProxy ?? false,
logger: false,
});
this.#fastify = fastify;

View File

@@ -313,14 +313,11 @@ export class ApiCallService implements OnApplicationShutdown {
}
if (ep.meta.limit) {
let limitActor: string | null = null;
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor: string;
if (user) {
limitActor = user.id;
} else if (this.config.enableIpRateLimit) {
if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) {
this.logger.warn('Recieved API request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.');
}
} else {
limitActor = getIpHash(request.ip);
}
@@ -333,7 +330,7 @@ export class ApiCallService implements OnApplicationShutdown {
// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
if (limitActor != null && factor > 0) {
if (factor > 0) {
// Rate limit
const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor);
if (rateLimit != null) {

View File

@@ -15,7 +15,6 @@ import type {
UserSecurityKeysRepository,
UsersRepository,
} from '@/models/_.js';
import type Logger from '@/logger.js';
import type { Config } from '@/config.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser } from '@/models/User.js';
@@ -24,7 +23,6 @@ import { bindThis } from '@/decorators.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { UserAuthService } from '@/core/UserAuthService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
@@ -33,8 +31,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
@Injectable()
export class SigninApiService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@@ -54,7 +50,6 @@ export class SigninApiService {
@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,
private loggerService: LoggerService,
private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
@@ -62,7 +57,6 @@ export class SigninApiService {
private webAuthnService: WebAuthnService,
private captchaService: CaptchaService,
) {
this.logger = this.loggerService.getLogger('Signin');
}
@bindThis
@@ -96,21 +90,16 @@ export class SigninApiService {
}
// not more than 1 attempt per second and not more than 10 attempts per hour
if (this.config.enableIpRateLimit) {
if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) {
this.logger.warn('Recieved signin request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.');
}
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
if (rateLimit != null) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
if (rateLimit != null) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}
if (typeof username !== 'string') {

View File

@@ -84,25 +84,19 @@ export class SigninWithPasskeyApiService {
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
};
if (this.config.enableIpRateLimit) {
if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) {
this.logger.warn('Recieved signin with passkey request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.');
}
try {
try {
// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
// NOTE: 1 Sign-in require 2 API calls
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
} catch (err) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
} catch (err) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}
// Initiate Passkey Auth challenge with context

View File

@@ -8,14 +8,18 @@ import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as WebSocket from 'ws';
import { DI } from '@/di-symbols.js';
import type { MiAccessToken } from '@/models/_.js';
import type { UsersRepository, MiAccessToken } from '@/models/_.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiLocalUser } from '@/models/User.js';
import { UserService } from '@/core/UserService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection, { ConnectionRequest } from './stream/Connection.js';
import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js';
import type * as http from 'node:http';
import { ContextIdFactory, ModuleRef } from '@nestjs/core';
@Injectable()
export class StreamingApiServerService {
@@ -27,9 +31,16 @@ export class StreamingApiServerService {
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
private moduleRef: ModuleRef,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private cacheService: CacheService,
private authenticateService: AuthenticateService,
private channelsService: ChannelsService,
private notificationService: NotificationService,
private usersService: UserService,
private channelFollowingService: ChannelFollowingService,
private channelMutingService: ChannelMutingService,
) {
}
@@ -83,12 +94,14 @@ export class StreamingApiServerService {
return;
}
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId<ConnectionRequest>({
user,
token: app,
}, contextId);
const stream = await this.moduleRef.create(MainStreamConnection, contextId);
const stream = new MainStreamConnection(
this.channelsService,
this.notificationService,
this.cacheService,
this.channelFollowingService,
this.channelMutingService,
user, app,
);
await stream.init();

View File

@@ -391,7 +391,6 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js';
export * as 'users/flashs' from './endpoints/users/flashs.js';
export * as 'users/followers' from './endpoints/users/followers.js';
export * as 'users/following' from './endpoints/users/following.js';
export * as 'users/get-following-birthday-users' from './endpoints/users/get-following-birthday-users.js';
export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js';
export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js';
export * as 'users/lists/create' from './endpoints/users/lists/create.js';

View File

@@ -52,14 +52,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const jobs = await this.deliverQueue.getJobs(['delayed']);
const counts = new Map<string, number>();
const res = [] as [string, number][];
for (const job of jobs) {
const host = new URL(job.data.to).host;
counts.set(host, (counts.get(host) ?? 0) + 1);
if (res.find(x => x[0] === host)) {
res.find(x => x[0] === host)![1]++;
} else {
res.push([host, 1]);
}
}
const res = [...counts.entries()].sort((a, b) => b[1] - a[1]);
res.sort((a, b) => b[1] - a[1]);
return res;
});

View File

@@ -52,14 +52,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const jobs = await this.inboxQueue.getJobs(['delayed']);
const counts = new Map<string, number>();
const res = [] as [string, number][];
for (const job of jobs) {
const host = new URL(job.data.signature.keyId).host;
counts.set(host, (counts.get(host) ?? 0) + 1);
if (res.find(x => x[0] === host)) {
res.find(x => x[0] === host)![1]++;
} else {
res.push([host, 1]);
}
}
const res = [...counts.entries()].sort((a, b) => b[1] - a[1]);
res.sort((a, b) => b[1] - a[1]);
return res;
});

View File

@@ -4,6 +4,7 @@
*/
import * as os from 'node:os';
import si from 'systeminformation';
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
@@ -111,8 +112,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async () => {
const si = await import('systeminformation');
const memStats = await si.mem();
const fsStats = await si.fsSize();
const netInterface = await si.networkInterfaceDefault();

View File

@@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apResolverService: ApResolverService,
) {
super(meta, paramDef, async (ps, me) => {
const resolver = await this.apResolverService.createResolver();
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(ps.uri);
return object;
});

View File

@@ -148,7 +148,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (this.utilityService.isSelfHost(host)) return null;
// リモートから一旦オブジェクトフェッチ
const resolver = await this.apResolverService.createResolver();
const resolver = this.apResolverService.createResolver();
// allow ap/show exclusively to lookup URLs that are cross-origin or non-canonical (like https://alice.example.com/@bob@bob.example.com -> https://bob.example.com/@bob)
const object = await resolver.resolve(uri, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId).catch((err) => {
if (err instanceof IdentifiableError) {

View File

@@ -4,6 +4,7 @@
*/
import * as os from 'node:os';
import si from 'systeminformation';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { MiMeta } from '@/models/_.js';
@@ -92,8 +93,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
},
};
const si = await import('systeminformation');
const memStats = await si.mem();
const fsStats = await si.fsSize();

View File

@@ -86,7 +86,7 @@ export const paramDef = {
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-birthday-users instead.' },
birthday: { ...birthdaySchema, nullable: true },
},
},
],
@@ -146,15 +146,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere('following.followerId = :userId', { userId: user.id })
.innerJoinAndSelect('following.followee', 'followee');
// @deprecated use get-following-birthday-users instead.
if (ps.birthday) {
query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
try {
const birthday = ps.birthday.split('-');
birthday.shift(); // 年の部分を削除
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) });
const birthday = ps.birthday.substring(5, 10);
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
birthdayUserQuery.select('user_profile.userId')
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
} catch (err) {
throw new ApiError(meta.errors.birthdayInvalid);
}

View File

@@ -1,167 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type {
FollowingsRepository,
UserProfilesRepository,
} from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { Packed } from '@/misc/json-schema.js';
export const meta = {
tags: ['users'],
requireCredential: true,
kind: 'read:account',
description: 'Find users who have a birthday on the specified range.',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'misskey:id',
},
birthday: {
type: 'string',
optional: false, nullable: false,
},
user: {
type: 'object',
optional: false, nullable: false,
ref: 'UserLite',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
birthday: {
oneOf: [{
type: 'object',
properties: {
month: { type: 'integer', minimum: 1, maximum: 12 },
day: { type: 'integer', minimum: 1, maximum: 31 },
},
required: ['month', 'day'],
}, {
type: 'object',
properties: {
begin: {
type: 'object',
properties: {
month: { type: 'integer', minimum: 1, maximum: 12 },
day: { type: 'integer', minimum: 1, maximum: 31 },
},
required: ['month', 'day'],
},
end: {
type: 'object',
properties: {
month: { type: 'integer', minimum: 1, maximum: 12 },
day: { type: 'integer', minimum: 1, maximum: 31 },
},
required: ['month', 'day'],
},
},
required: ['begin', 'end'],
}],
},
},
required: ['birthday'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.followingsRepository
.createQueryBuilder('following')
.andWhere('following.followerId = :userId', { userId: me.id })
.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) {
const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; };
// 誕生日は mmdd の形式の最大4桁の数字例: 8月30日 → 830でインデックスが効くようになっているので、その形式に変換
const begin = range.begin.month * 100 + range.begin.day;
const end = range.end.month * 100 + range.end.day;
if (begin <= end) {
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end });
} else {
// 12/31 から 1/1 の範囲を取得するために OR で対応
query.andWhere(new Brackets(qb => {
qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin });
qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end });
}));
}
} else {
const { month, day } = ps.birthday as { month: number; day: number };
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day });
}
query.select('following.followeeId', 'user_id');
query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date');
query.orderBy('birthday_date', 'ASC');
const birthdayUsers = await query
.offset(ps.offset).limit(ps.limit)
.getRawMany<{ birthday_date: number; user_id: string }>();
const users = new Map<string, Packed<'UserLite'>>((
await this.userEntityService.packMany(
birthdayUsers.map(u => u.user_id),
me,
{ schema: 'UserLite' },
)
).map(u => [u.id, u]));
return birthdayUsers
.map(item => {
const birthday = new Date();
birthday.setHours(0, 0, 0, 0);
// item.birthday_date は mmdd の形式の最大4桁の数字例: 8月30日 → 830で出力されるので、日付に戻してDateオブジェクトに設定
birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100);
if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) {
birthday.setFullYear(new Date().getFullYear() + 1);
}
const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`;
return {
id: item.user_id,
birthday: birthdayStr,
user: users.get(item.user_id),
};
})
.filter(item => item.user != null)
.map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> });
});
}
}

View File

@@ -4,54 +4,72 @@
*/
import { Injectable } from '@nestjs/common';
import { HybridTimelineChannel } from './channels/hybrid-timeline.js';
import { LocalTimelineChannel } from './channels/local-timeline.js';
import { HomeTimelineChannel } from './channels/home-timeline.js';
import { GlobalTimelineChannel } from './channels/global-timeline.js';
import { MainChannel } from './channels/main.js';
import { ChannelChannel } from './channels/channel.js';
import { AdminChannel } from './channels/admin.js';
import { ServerStatsChannel } from './channels/server-stats.js';
import { QueueStatsChannel } from './channels/queue-stats.js';
import { UserListChannel } from './channels/user-list.js';
import { AntennaChannel } from './channels/antenna.js';
import { DriveChannel } from './channels/drive.js';
import { HashtagChannel } from './channels/hashtag.js';
import { RoleTimelineChannel } from './channels/role-timeline.js';
import { ChatUserChannel } from './channels/chat-user.js';
import { ChatRoomChannel } from './channels/chat-room.js';
import { ReversiChannel } from './channels/reversi.js';
import { ReversiGameChannel } from './channels/reversi-game.js';
import type { ChannelConstructor } from './channel.js';
import { bindThis } from '@/decorators.js';
import { HybridTimelineChannelService } from './channels/hybrid-timeline.js';
import { LocalTimelineChannelService } from './channels/local-timeline.js';
import { HomeTimelineChannelService } from './channels/home-timeline.js';
import { GlobalTimelineChannelService } from './channels/global-timeline.js';
import { MainChannelService } from './channels/main.js';
import { ChannelChannelService } from './channels/channel.js';
import { AdminChannelService } from './channels/admin.js';
import { ServerStatsChannelService } from './channels/server-stats.js';
import { QueueStatsChannelService } from './channels/queue-stats.js';
import { UserListChannelService } from './channels/user-list.js';
import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
import { ChatUserChannelService } from './channels/chat-user.js';
import { ChatRoomChannelService } from './channels/chat-room.js';
import { ReversiChannelService } from './channels/reversi.js';
import { ReversiGameChannelService } from './channels/reversi-game.js';
import { type MiChannelService } from './channel.js';
@Injectable()
export class ChannelsService {
constructor(
private mainChannelService: MainChannelService,
private homeTimelineChannelService: HomeTimelineChannelService,
private localTimelineChannelService: LocalTimelineChannelService,
private hybridTimelineChannelService: HybridTimelineChannelService,
private globalTimelineChannelService: GlobalTimelineChannelService,
private userListChannelService: UserListChannelService,
private hashtagChannelService: HashtagChannelService,
private roleTimelineChannelService: RoleTimelineChannelService,
private antennaChannelService: AntennaChannelService,
private channelChannelService: ChannelChannelService,
private driveChannelService: DriveChannelService,
private serverStatsChannelService: ServerStatsChannelService,
private queueStatsChannelService: QueueStatsChannelService,
private adminChannelService: AdminChannelService,
private chatUserChannelService: ChatUserChannelService,
private chatRoomChannelService: ChatRoomChannelService,
private reversiChannelService: ReversiChannelService,
private reversiGameChannelService: ReversiGameChannelService,
) {
}
@bindThis
public getChannelConstructor(name: string): ChannelConstructor<boolean> {
public getChannelService(name: string): MiChannelService<boolean> {
switch (name) {
case 'main': return MainChannel;
case 'homeTimeline': return HomeTimelineChannel;
case 'localTimeline': return LocalTimelineChannel;
case 'hybridTimeline': return HybridTimelineChannel;
case 'globalTimeline': return GlobalTimelineChannel;
case 'userList': return UserListChannel;
case 'hashtag': return HashtagChannel;
case 'roleTimeline': return RoleTimelineChannel;
case 'antenna': return AntennaChannel;
case 'channel': return ChannelChannel;
case 'drive': return DriveChannel;
case 'serverStats': return ServerStatsChannel;
case 'queueStats': return QueueStatsChannel;
case 'admin': return AdminChannel;
case 'chatUser': return ChatUserChannel;
case 'chatRoom': return ChatRoomChannel;
case 'reversi': return ReversiChannel;
case 'reversiGame': return ReversiGameChannel;
case 'main': return this.mainChannelService;
case 'homeTimeline': return this.homeTimelineChannelService;
case 'localTimeline': return this.localTimelineChannelService;
case 'hybridTimeline': return this.hybridTimelineChannelService;
case 'globalTimeline': return this.globalTimelineChannelService;
case 'userList': return this.userListChannelService;
case 'hashtag': return this.hashtagChannelService;
case 'roleTimeline': return this.roleTimelineChannelService;
case 'antenna': return this.antennaChannelService;
case 'channel': return this.channelChannelService;
case 'drive': return this.driveChannelService;
case 'serverStats': return this.serverStatsChannelService;
case 'queueStats': return this.queueStatsChannelService;
case 'admin': return this.adminChannelService;
case 'chatUser': return this.chatUserChannelService;
case 'chatRoom': return this.chatRoomChannelService;
case 'reversi': return this.reversiChannelService;
case 'reversiGame': return this.reversiGameChannelService;
default:
throw new Error(`no such channel: ${name}`);

View File

@@ -6,39 +6,19 @@
import * as WebSocket from 'ws';
import type { MiUser } from '@/models/User.js';
import type { MiAccessToken } from '@/models/AccessToken.js';
import { NotificationService } from '@/core/NotificationService.js';
import type { Packed } from '@/misc/json-schema.js';
import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiFollowing, MiUserProfile } from '@/models/_.js';
import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events';
import type Channel from './channel.js';
import type { ChannelConstructor } from './channel.js';
import type { ChannelRequest } from './channel.js';
import { ContextIdFactory, ModuleRef, REQUEST } from '@nestjs/core';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { MainChannel } from '@/server/api/stream/channels/main.js';
import { HomeTimelineChannel } from '@/server/api/stream/channels/home-timeline.js';
import { LocalTimelineChannel } from '@/server/api/stream/channels/local-timeline.js';
import { HybridTimelineChannel } from '@/server/api/stream/channels/hybrid-timeline.js';
import { GlobalTimelineChannel } from '@/server/api/stream/channels/global-timeline.js';
import { UserListChannel } from '@/server/api/stream/channels/user-list.js';
import { HashtagChannel } from '@/server/api/stream/channels/hashtag.js';
import { RoleTimelineChannel } from '@/server/api/stream/channels/role-timeline.js';
import { AntennaChannel } from '@/server/api/stream/channels/antenna.js';
import { ChannelChannel } from '@/server/api/stream/channels/channel.js';
import { DriveChannel } from '@/server/api/stream/channels/drive.js';
import { ServerStatsChannel } from '@/server/api/stream/channels/server-stats.js';
import { QueueStatsChannel } from '@/server/api/stream/channels/queue-stats.js';
import { AdminChannel } from '@/server/api/stream/channels/admin.js';
import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js';
import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js';
import { ReversiChannel } from '@/server/api/stream/channels/reversi.js';
import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js';
const MAX_CHANNELS_PER_CONNECTION = 32;
@@ -46,7 +26,6 @@ const MAX_CHANNELS_PER_CONNECTION = 32;
* Main stream connection
*/
// eslint-disable-next-line import/no-default-export
@Injectable({ scope: Scope.TRANSIENT })
export default class Connection {
public user?: MiUser;
public token?: MiAccessToken;
@@ -65,16 +44,16 @@ export default class Connection {
private fetchIntervalId: NodeJS.Timeout | null = null;
constructor(
private moduleRef: ModuleRef,
private channelsService: ChannelsService,
private notificationService: NotificationService,
private cacheService: CacheService,
private channelFollowingService: ChannelFollowingService,
private channelMutingService: ChannelMutingService,
@Inject(REQUEST)
request: ConnectionRequest,
user: MiUser | null | undefined,
token: MiAccessToken | null | undefined,
) {
if (request.user) this.user = request.user;
if (request.token) this.token = request.token;
if (user) this.user = user;
if (token) this.token = token;
}
@bindThis
@@ -253,34 +232,28 @@ export default class Connection {
* チャンネルに接続
*/
@bindThis
public async connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) {
public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) {
if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) {
return;
}
const channelConstructor = this.getChannelConstructor(channel);
const channelService = this.channelsService.getChannelService(channel);
if (channelConstructor.requireCredential && this.user == null) {
if (channelService.requireCredential && this.user == null) {
return;
}
if (this.token && ((channelConstructor.kind && !this.token.permission.some(p => p === channelConstructor.kind))
|| (!channelConstructor.kind && channelConstructor.requireCredential))) {
if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind))
|| (!channelService.kind && channelService.requireCredential))) {
return;
}
// 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視
if (channelConstructor.shouldShare && this.channels.some(c => c.chName === channel)) {
if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) {
return;
}
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId<ChannelRequest>({
id: id,
connection: this,
}, contextId);
const ch: Channel = await this.moduleRef.create<Channel>(channelConstructor, contextId);
const ch: Channel = channelService.create(id, this);
this.channels.push(ch);
ch.init(params ?? {});
@@ -291,33 +264,6 @@ export default class Connection {
}
}
@bindThis
public getChannelConstructor(name: string): ChannelConstructor<boolean> {
switch (name) {
case 'main': return MainChannel;
case 'homeTimeline': return HomeTimelineChannel;
case 'localTimeline': return LocalTimelineChannel;
case 'hybridTimeline': return HybridTimelineChannel;
case 'globalTimeline': return GlobalTimelineChannel;
case 'userList': return UserListChannel;
case 'hashtag': return HashtagChannel;
case 'roleTimeline': return RoleTimelineChannel;
case 'antenna': return AntennaChannel;
case 'channel': return ChannelChannel;
case 'drive': return DriveChannel;
case 'serverStats': return ServerStatsChannel;
case 'queueStats': return QueueStatsChannel;
case 'admin': return AdminChannel;
case 'chatUser': return ChatUserChannel;
case 'chatRoom': return ChatRoomChannel;
case 'reversi': return ReversiChannel;
case 'reversiGame': return ReversiGameChannel;
default:
throw new Error(`no such channel: ${name}`);
}
}
/**
* チャンネルから切断
* @param id チャンネルコネクションID
@@ -360,8 +306,3 @@ export default class Connection {
}
}
}
export interface ConnectionRequest {
user: MiUser | null | undefined,
token: MiAccessToken | null | undefined,
}

View File

@@ -22,7 +22,7 @@ export default abstract class Channel {
public abstract readonly chName: string;
public static readonly shouldShare: boolean;
public static readonly requireCredential: boolean;
public static readonly kind: string | null;
public static readonly kind?: string | null;
protected get user() {
return this.connection.user;
@@ -85,9 +85,9 @@ export default abstract class Channel {
return false;
}
constructor(request: ChannelRequest) {
this.id = request.id;
this.connection = request.connection;
constructor(id: string, connection: Connection) {
this.id = id;
this.connection = connection;
}
public send(payload: { type: string, body: JsonValue }): void;
@@ -111,14 +111,9 @@ export default abstract class Channel {
public onMessage?(type: string, body: JsonValue): void;
}
export interface ChannelRequest {
id: string,
connection: Connection,
}
export interface ChannelConstructor<T extends boolean> {
new(...args: any[]): Channel;
export type MiChannelService<T extends boolean> = {
shouldShare: boolean;
requireCredential: T;
kind: T extends true ? string : string | null | undefined;
}
create: (id: string, connection: Connection) => Channel;
};

View File

@@ -3,26 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class AdminChannel extends Channel {
class AdminChannel extends Channel {
public readonly chName = 'admin';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:admin:stream';
constructor(
@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
}
@bindThis
public async init(params: JsonObject) {
// Subscribe admin stream
@@ -31,3 +22,22 @@ export class AdminChannel extends Channel {
});
}
}
@Injectable()
export class AdminChannelService implements MiChannelService<true> {
public readonly shouldShare = AdminChannel.shouldShare;
public readonly requireCredential = AdminChannel.requireCredential;
public readonly kind = AdminChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): AdminChannel {
return new AdminChannel(
id,
connection,
);
}
}

View File

@@ -3,16 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class AntennaChannel extends Channel {
class AntennaChannel extends Channel {
public readonly chName = 'antenna';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -20,12 +18,12 @@ export class AntennaChannel extends Channel {
private antennaId: string;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.onEvent = this.onEvent.bind(this);
}
@@ -57,3 +55,24 @@ export class AntennaChannel extends Channel {
this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent);
}
}
@Injectable()
export class AntennaChannelService implements MiChannelService<true> {
public readonly shouldShare = AntennaChannel.shouldShare;
public readonly requireCredential = AntennaChannel.requireCredential;
public readonly kind = AntennaChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): AntennaChannel {
return new AntennaChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
@@ -11,23 +11,20 @@ import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class ChannelChannel extends Channel {
class ChannelChannel extends Channel {
public readonly chName = 'channel';
public static shouldShare = false;
public static requireCredential = false as const;
private channelId: string;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.onNote = this.onNote.bind(this);
}
@@ -95,3 +92,24 @@ export class ChannelChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class ChannelChannelService implements MiChannelService<false> {
public readonly shouldShare = ChannelChannel.shouldShare;
public readonly requireCredential = ChannelChannel.requireCredential;
public readonly kind = ChannelChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ChannelChannel {
return new ChannelChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,16 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { ChatService } from '@/core/ChatService.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class ChatRoomChannel extends Channel {
class ChatRoomChannel extends Channel {
public readonly chName = 'chatRoom';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -20,12 +18,12 @@ export class ChatRoomChannel extends Channel {
private roomId: string;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private chatService: ChatService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
}
@bindThis
@@ -57,3 +55,24 @@ export class ChatRoomChannel extends Channel {
this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent);
}
}
@Injectable()
export class ChatRoomChannelService implements MiChannelService<true> {
public readonly shouldShare = ChatRoomChannel.shouldShare;
public readonly requireCredential = ChatRoomChannel.requireCredential;
public readonly kind = ChatRoomChannel.kind;
constructor(
private chatService: ChatService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ChatRoomChannel {
return new ChatRoomChannel(
this.chatService,
id,
connection,
);
}
}

View File

@@ -3,16 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { ChatService } from '@/core/ChatService.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class ChatUserChannel extends Channel {
class ChatUserChannel extends Channel {
public readonly chName = 'chatUser';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -20,12 +18,12 @@ export class ChatUserChannel extends Channel {
private otherId: string;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private chatService: ChatService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
}
@bindThis
@@ -57,3 +55,24 @@ export class ChatUserChannel extends Channel {
this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
}
}
@Injectable()
export class ChatUserChannelService implements MiChannelService<true> {
public readonly shouldShare = ChatUserChannel.shouldShare;
public readonly requireCredential = ChatUserChannel.requireCredential;
public readonly kind = ChatUserChannel.kind;
constructor(
private chatService: ChatService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ChatUserChannel {
return new ChatUserChannel(
this.chatService,
id,
connection,
);
}
}

View File

@@ -3,26 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class DriveChannel extends Channel {
class DriveChannel extends Channel {
public readonly chName = 'drive';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:account';
constructor(
@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
}
@bindThis
public async init(params: JsonObject) {
// Subscribe drive stream
@@ -31,3 +22,22 @@ export class DriveChannel extends Channel {
});
}
}
@Injectable()
export class DriveChannelService implements MiChannelService<true> {
public readonly shouldShare = DriveChannel.shouldShare;
public readonly requireCredential = DriveChannel.requireCredential;
public readonly kind = DriveChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): DriveChannel {
return new DriveChannel(
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -11,11 +11,9 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class GlobalTimelineChannel extends Channel {
class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline';
public static shouldShare = false;
public static requireCredential = false as const;
@@ -23,14 +21,14 @@ export class GlobalTimelineChannel extends Channel {
private withFiles: boolean;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.onNote = this.onNote.bind(this);
}
@@ -76,3 +74,28 @@ export class GlobalTimelineChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class GlobalTimelineChannelService implements MiChannelService<false> {
public readonly shouldShare = GlobalTimelineChannel.shouldShare;
public readonly requireCredential = GlobalTimelineChannel.requireCredential;
public readonly kind = GlobalTimelineChannel.kind;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): GlobalTimelineChannel {
return new GlobalTimelineChannel(
this.metaService,
this.roleService,
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,30 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class HashtagChannel extends Channel {
class HashtagChannel extends Channel {
public readonly chName = 'hashtag';
public static shouldShare = false;
public static requireCredential = false as const;
private q: string[][];
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.onNote = this.onNote.bind(this);
}
@@ -64,3 +62,24 @@ export class HashtagChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class HashtagChannelService implements MiChannelService<false> {
public readonly shouldShare = HashtagChannel.shouldShare;
public readonly requireCredential = HashtagChannel.requireCredential;
public readonly kind = HashtagChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): HashtagChannel {
return new HashtagChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,17 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class HomeTimelineChannel extends Channel {
class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -22,12 +20,12 @@ export class HomeTimelineChannel extends Channel {
private withFiles: boolean;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.onNote = this.onNote.bind(this);
}
@@ -100,3 +98,24 @@ export class HomeTimelineChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class HomeTimelineChannelService implements MiChannelService<true> {
public readonly shouldShare = HomeTimelineChannel.shouldShare;
public readonly requireCredential = HomeTimelineChannel.requireCredential;
public readonly kind = HomeTimelineChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): HomeTimelineChannel {
return new HomeTimelineChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -11,11 +11,9 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class HybridTimelineChannel extends Channel {
class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline';
public static shouldShare = false;
public static requireCredential = true as const;
@@ -25,14 +23,14 @@ export class HybridTimelineChannel extends Channel {
private withFiles: boolean;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.onNote = this.onNote.bind(this);
}
@@ -120,3 +118,28 @@ export class HybridTimelineChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class HybridTimelineChannelService implements MiChannelService<true> {
public readonly shouldShare = HybridTimelineChannel.shouldShare;
public readonly requireCredential = HybridTimelineChannel.requireCredential;
public readonly kind = HybridTimelineChannel.kind;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): HybridTimelineChannel {
return new HybridTimelineChannel(
this.metaService,
this.roleService,
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -11,27 +11,25 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class LocalTimelineChannel extends Channel {
class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline';
public static shouldShare = false as const;
public static shouldShare = false;
public static requireCredential = false as const;
private withRenotes: boolean;
private withReplies: boolean;
private withFiles: boolean;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.onNote = this.onNote.bind(this);
}
@@ -86,3 +84,28 @@ export class LocalTimelineChannel extends Channel {
this.subscriber.off('notesStream', this.onNote);
}
}
@Injectable()
export class LocalTimelineChannelService implements MiChannelService<false> {
public readonly shouldShare = LocalTimelineChannel.shouldShare;
public readonly requireCredential = LocalTimelineChannel.requireCredential;
public readonly kind = LocalTimelineChannel.kind;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): LocalTimelineChannel {
return new LocalTimelineChannel(
this.metaService,
this.roleService,
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -3,28 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class MainChannel extends Channel {
class MainChannel extends Channel {
public readonly chName = 'main';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:account';
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
}
@bindThis
@@ -63,3 +61,24 @@ export class MainChannel extends Channel {
});
}
}
@Injectable()
export class MainChannelService implements MiChannelService<true> {
public readonly shouldShare = MainChannel.shouldShare;
public readonly requireCredential = MainChannel.requireCredential;
public readonly kind = MainChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): MainChannel {
return new MainChannel(
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -4,26 +4,21 @@
*/
import Xev from 'xev';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
const ev = new Xev();
@Injectable({ scope: Scope.TRANSIENT })
export class QueueStatsChannel extends Channel {
class QueueStatsChannel extends Channel {
public readonly chName = 'queueStats';
public static shouldShare = true;
public static requireCredential = false as const;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
//this.onStats = this.onStats.bind(this);
//this.onMessage = this.onMessage.bind(this);
}
@@ -61,3 +56,22 @@ export class QueueStatsChannel extends Channel {
ev.removeListener('queueStats', this.onStats);
}
}
@Injectable()
export class QueueStatsChannelService implements MiChannelService<false> {
public readonly shouldShare = QueueStatsChannel.shouldShare;
public readonly requireCredential = QueueStatsChannel.requireCredential;
public readonly kind = QueueStatsChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): QueueStatsChannel {
return new QueueStatsChannel(
id,
connection,
);
}
}

View File

@@ -3,32 +3,31 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import type { MiReversiGame } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { ReversiService } from '@/core/ReversiService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import Channel, { type MiChannelService } from '../channel.js';
import { reversiUpdateKeys } from 'misskey-js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
export class ReversiGameChannel extends Channel {
class ReversiGameChannel extends Channel {
public readonly chName = 'reversiGame';
public static shouldShare = false;
public static requireCredential = false as const;
private gameId: MiReversiGame['id'] | null = null;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private reversiService: ReversiService,
private reversiGameEntityService: ReversiGameEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
}
@bindThis
@@ -108,3 +107,25 @@ export class ReversiGameChannel extends Channel {
}
}
@Injectable()
export class ReversiGameChannelService implements MiChannelService<false> {
public readonly shouldShare = ReversiGameChannel.shouldShare;
public readonly requireCredential = ReversiGameChannel.requireCredential;
public readonly kind = ReversiGameChannel.kind;
constructor(
private reversiService: ReversiService,
private reversiGameEntityService: ReversiGameEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ReversiGameChannel {
return new ReversiGameChannel(
this.reversiService,
this.reversiGameEntityService,
id,
connection,
);
}
}

View File

@@ -3,24 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class ReversiChannel extends Channel {
class ReversiChannel extends Channel {
public readonly chName = 'reversi';
public static shouldShare = true;
public static requireCredential = true as const;
public static kind = 'read:account';
constructor(
@Inject(REQUEST)
request: ChannelRequest,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
}
@bindThis
@@ -34,3 +32,22 @@ export class ReversiChannel extends Channel {
this.subscriber.off(`reversiStream:${this.user!.id}`, this.send);
}
}
@Injectable()
export class ReversiChannelService implements MiChannelService<true> {
public readonly shouldShare = ReversiChannel.shouldShare;
public readonly requireCredential = ReversiChannel.requireCredential;
public readonly kind = ReversiChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ReversiChannel {
return new ReversiChannel(
id,
connection,
);
}
}

View File

@@ -3,30 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class RoleTimelineChannel extends Channel {
class RoleTimelineChannel extends Channel {
public readonly chName = 'roleTimeline';
public static shouldShare = false;
public static requireCredential = false as const;
private roleId: string;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
private roleservice: RoleService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.onNote = this.onNote.bind(this);
}
@@ -62,3 +60,26 @@ export class RoleTimelineChannel extends Channel {
this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent);
}
}
@Injectable()
export class RoleTimelineChannelService implements MiChannelService<false> {
public readonly shouldShare = RoleTimelineChannel.shouldShare;
public readonly requireCredential = RoleTimelineChannel.requireCredential;
public readonly kind = RoleTimelineChannel.kind;
constructor(
private noteEntityService: NoteEntityService,
private roleservice: RoleService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
return new RoleTimelineChannel(
this.noteEntityService,
this.roleservice,
id,
connection,
);
}
}

View File

@@ -4,26 +4,21 @@
*/
import Xev from 'xev';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
const ev = new Xev();
@Injectable({ scope: Scope.TRANSIENT })
export class ServerStatsChannel extends Channel {
class ServerStatsChannel extends Channel {
public readonly chName = 'serverStats';
public static shouldShare = true;
public static requireCredential = false as const;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
//this.onStats = this.onStats.bind(this);
//this.onMessage = this.onMessage.bind(this);
}
@@ -59,3 +54,22 @@ export class ServerStatsChannel extends Channel {
ev.removeListener('serverStats', this.onStats);
}
}
@Injectable()
export class ServerStatsChannelService implements MiChannelService<false> {
public readonly shouldShare = ServerStatsChannel.shouldShare;
public readonly requireCredential = ServerStatsChannel.requireCredential;
public readonly kind = ServerStatsChannel.kind;
constructor(
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ServerStatsChannel {
return new ServerStatsChannel(
id,
connection,
);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -11,11 +11,9 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';
import Channel, { type MiChannelService } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class UserListChannel extends Channel {
class UserListChannel extends Channel {
public readonly chName = 'userList';
public static shouldShare = false;
public static requireCredential = false as const;
@@ -26,18 +24,14 @@ export class UserListChannel extends Channel {
private withRenotes: boolean;
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(REQUEST)
request: ChannelRequest,
private noteEntityService: NoteEntityService,
id: string,
connection: Channel['connection'],
) {
super(request);
super(id, connection);
//this.updateListUsers = this.updateListUsers.bind(this);
//this.onNote = this.onNote.bind(this);
}
@@ -136,3 +130,32 @@ export class UserListChannel extends Channel {
clearInterval(this.listUsersClock);
}
}
@Injectable()
export class UserListChannelService implements MiChannelService<false> {
public readonly shouldShare = UserListChannel.shouldShare;
public readonly requireCredential = UserListChannel.requireCredential;
public readonly kind = UserListChannel.kind;
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private noteEntityService: NoteEntityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): UserListChannel {
return new UserListChannel(
this.userListsRepository,
this.userListMembershipsRepository,
this.noteEntityService,
id,
connection,
);
}
}

View File

@@ -123,84 +123,41 @@ function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: str
return { name, logo };
}
// https://indieauth.spec.indieweb.org/#client-information-discovery
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
// and if there is an [h-app] with a url property matching the client_id URL,
// then it should use the name and icon and display them on the authorization prompt."
// (But we don't display any icon for now)
// https://indieauth.spec.indieweb.org/#redirect-url
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
// of redirect_uri at the client_id URL.
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
// look for an exact match of the given redirect_uri in the request against the list of
// redirect_uris discovered after resolving any relative URLs."
async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> {
try {
const res = await httpRequestService.send(id);
const redirectUris: string[] = [];
let name = id;
let logo: string | null = null;
// https://indieauth.spec.indieweb.org/#redirect-url
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
// of redirect_uri at the client_id URL.
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
// look for an exact match of the given redirect_uri in the request against the list of
// redirect_uris discovered after resolving any relative URLs."
const linkHeader = res.headers.get('link');
if (linkHeader) {
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
}
if (res.headers.get('content-type')?.includes('application/json')) {
// Client discovery via JSON document (11 July 2024 spec)
// https://indieauth.spec.indieweb.org/#client-metadata
// "Clients SHOULD have a JSON [RFC7159] document at their client_id URL containing
// client metadata defined in [RFC7591], the minimum properties for an IndieAuth
// client defined below."
const text = await res.text();
const doc = htmlParser.parse(`<div>${text}</div>`);
const json = await res.json() as {
client_id: string;
client_name?: string;
client_uri: string;
logo_uri?: string;
redirect_uris?: string[];
};
redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
// https://indieauth.spec.indieweb.org/#client-metadata-li-1
// "The authorization server MUST verify that the client_id in the document matches the
// client_id of the URL where the document was retrieved."
if (json.client_id !== id) {
throw new AuthorizationError('client_id in the document does not match the client_id URL', 'invalid_request');
let name = id;
let logo: string | null = null;
if (text) {
const microformats = parseMicroformats(doc, res.url, id);
if (typeof microformats.name === 'string') {
name = microformats.name;
}
// https://indieauth.spec.indieweb.org/#client-metadata-li-1
// "The client_uri MUST be a prefix of the client_id."
if (!json.client_uri || !id.startsWith(json.client_uri)) {
throw new AuthorizationError('client_uri is not a prefix of client_id', 'invalid_request');
}
if (typeof json.client_name === 'string') {
name = json.client_name;
}
if (typeof json.logo_uri === 'string') {
// Since uri can be relative, resolve it against the document URL
logo = new URL(json.logo_uri, res.url).toString();
}
if (Array.isArray(json.redirect_uris)) {
redirectUris.push(...json.redirect_uris.filter((uri): uri is string => typeof uri === 'string'));
}
} else {
// Client discovery via HTML microformats (12 February 2022 spec)
// https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
// and if there is an [h-app] with a url property matching the client_id URL,
// then it should use the name and icon and display them on the authorization prompt."
const text = await res.text();
const doc = htmlParser.parse(`<div>${text}</div>`);
redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
if (text) {
const microformats = parseMicroformats(doc, res.url, id);
if (typeof microformats.name === 'string') {
name = microformats.name;
}
if (typeof microformats.logo === 'string') {
logo = microformats.logo;
}
if (typeof microformats.logo === 'string') {
logo = microformats.logo;
}
}
@@ -215,8 +172,6 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
logger.error('Error while fetching client information', { err });
if (err instanceof StatusError) {
throw new AuthorizationError('Failed to fetch client information', 'invalid_request');
} else if (err instanceof AuthorizationError) {
throw err;
} else {
throw new AuthorizationError('Failed to parse client information', 'server_error');
}

View File

@@ -4,9 +4,8 @@
*/
import { randomUUID } from 'node:crypto';
import { dirname, resolve } from 'node:path';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import sharp from 'sharp';
@@ -70,28 +69,13 @@ import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
let rootDir = _dirname;
// 見つかるまで上に遡る
while (!fs.existsSync(resolve(rootDir, 'packages'))) {
const parentDir = dirname(rootDir);
if (parentDir === rootDir) {
throw new Error('Cannot find root directory');
}
rootDir = parentDir;
}
const backendRootDir = resolve(rootDir, 'packages/backend');
const frontendRootDir = resolve(rootDir, 'packages/frontend');
const staticAssets = resolve(backendRootDir, 'assets');
const clientAssets = resolve(frontendRootDir, 'assets');
const assets = resolve(rootDir, 'built/_frontend_dist_');
const swAssets = resolve(rootDir, 'built/_sw_dist_');
const fluentEmojisDir = resolve(rootDir, 'fluent-emojis/dist');
const twemojiDir = resolve(backendRootDir, 'node_modules/@discordapp/twemoji/dist/svg');
const frontendViteOut = resolve(rootDir, 'built/_frontend_vite_');
const frontendEmbedViteOut = resolve(rootDir, 'built/_frontend_embed_vite_');
const tarball = resolve(rootDir, 'built/tarball');
const staticAssets = `${_dirname}/../../../assets/`;
const clientAssets = `${_dirname}/../../../../frontend/assets/`;
const assets = `${_dirname}/../../../../../built/_frontend_dist_/`;
const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`;
const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`;
const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`;
const tarball = `${_dirname}/../../../../../built/tarball/`;
@Injectable()
export class ClientServerService {
@@ -223,7 +207,6 @@ export class ClientServerService {
//#region vite assets
if (this.config.frontendEmbedManifestExists) {
console.log(`[ClientServerService] Using built frontend vite assets. ${frontendViteOut}`);
fastify.register((fastify, options, done) => {
fastify.register(fastifyStatic, {
root: frontendViteOut,
@@ -243,7 +226,6 @@ export class ClientServerService {
done();
});
} else {
console.log('[ClientServerService] Proxying to Vite dev server.');
const urlOriginWithoutPort = configUrl.origin.replace(/:\d+$/, '');
const port = (process.env.VITE_PORT ?? '5173');
@@ -315,7 +297,7 @@ export class ClientServerService {
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
return reply.sendFile(path, fluentEmojisDir, {
return await reply.sendFile(path, `${_dirname}/../../../../../fluent-emojis/dist/`, {
maxAge: ms('30 days'),
});
});
@@ -330,7 +312,7 @@ export class ClientServerService {
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
return reply.sendFile(path, twemojiDir, {
return await reply.sendFile(path, `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, {
maxAge: ms('30 days'),
});
});
@@ -344,7 +326,7 @@ export class ClientServerService {
}
const mask = await sharp(
`${twemojiDir}/${path.replace('.png', '')}.svg`,
`${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`,
{ density: 1000 },
)
.resize(488, 488)

View File

@@ -34,10 +34,6 @@ services:
source: ../built
target: /misskey/packages/backend/built
read_only: true
- type: bind
source: ../src-js
target: /misskey/packages/backend/src-js
read_only: true
- type: bind
source: ../migration
target: /misskey/packages/backend/migration

View File

@@ -143,7 +143,7 @@ services:
bash -c "
npm install -g pnpm
pnpm -F backend i --frozen-lockfile
pnpm exec tsgo -p ./packages/backend/test-federation
pnpm exec tsc -p ./packages/backend/test-federation
node ./packages/backend/test-federation/built/daemon.js
"

View File

@@ -13,7 +13,7 @@
"experimental": {
"keepImportAssertions": true
},
"baseUrl": "../src-js",
"baseUrl": "../built",
"paths": {
"@/*": ["*"]
},

View File

@@ -11,11 +11,3 @@ services:
environment:
POSTGRES_DB: "test-misskey"
POSTGRES_HOST_AUTH_METHOD: trust
meilisearchtest:
image: getmeili/meilisearch:v1.3.4
ports:
- "127.0.0.1:57712:7700"
environment:
- MEILI_NO_ANALYTICS=true
- MEILI_ENV=development

View File

@@ -28,7 +28,6 @@ const host = `http://127.0.0.1:${port}`;
const clientPort = port + 1;
const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`;
const redirect_uri2 = `http://127.0.0.1:${clientPort}/redirect2`;
const basicAuthParams: AuthorizationParamsExtended = {
redirect_uri,
@@ -808,193 +807,45 @@ describe('OAuth', () => {
});
});
// https://indieauth.spec.indieweb.org/#client-information-discovery
describe('Client Information Discovery', () => {
// https://indieauth.spec.indieweb.org/#client-information-discovery
describe('JSON client metadata (11 July 2024)', () => {
test('Read JSON document', async () => {
sender = (reply): void => {
reply.header('content-type', 'application/json');
reply.send({
client_id: `http://127.0.0.1:${clientPort}/`,
client_uri: `http://127.0.0.1:${clientPort}/`,
client_name: 'Misklient JSON',
logo_uri: '/logo.png',
redirect_uris: ['/redirect'],
});
};
describe('Redirection', () => {
const tests: Record<string, (reply: FastifyReply) => void> = {
'Read HTTP header': reply => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Mixed links': reply => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<link rel="redirect_uri" href="/redirect2" />
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Multiple items in Link header': reply => {
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Multiple items in HTML': reply => {
reply.send(`
<!DOCTYPE html>
<link rel="redirect_uri" href="/redirect2" />
<link rel="redirect_uri" href="/redirect" />
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
const meta = getMeta(await response.text());
assert.strictEqual(meta.clientName, 'Misklient JSON');
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
});
test('Merge Link header redirect_uri with JSON redirect_uris', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect2>; rel="redirect_uri"');
reply.header('content-type', 'application/json');
reply.send({
client_id: `http://127.0.0.1:${clientPort}/`,
client_uri: `http://127.0.0.1:${clientPort}/`,
client_name: 'Misklient JSON',
redirect_uris: ['/redirect'],
});
};
const client = new AuthorizationCode(clientConfig);
const ok1 = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(ok1.status, 200);
const ok2 = await fetch(client.authorizeURL({
redirect_uri: redirect_uri2,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(ok2.status, 200);
});
test('Reject when client_id does not match retrieved URL', async () => {
sender = (reply): void => {
reply.header('content-type', 'application/json');
reply.send({
client_id: `http://127.0.0.1:${clientPort}/mismatch`,
client_uri: `http://127.0.0.1:${clientPort}/`,
redirect_uris: ['/redirect'],
});
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
await assertDirectError(response, 400, 'invalid_request');
});
test('Reject when client_uri is not a prefix of client_id', async () => {
sender = (reply): void => {
reply.header('content-type', 'application/json');
reply.send({
client_id: `http://127.0.0.1:${clientPort}/`,
client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`,
redirect_uris: ['/redirect'],
});
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
await assertDirectError(response, 400, 'invalid_request');
});
test('Reject when JSON metadata has no redirect_uris and no Link header', async () => {
sender = (reply): void => {
reply.header('content-type', 'application/json');
reply.send({
client_id: `http://127.0.0.1:${clientPort}/`,
client_uri: `http://127.0.0.1:${clientPort}/`,
client_name: 'Misklient JSON',
});
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
await assertDirectError(response, 400, 'invalid_request');
});
});
// https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
describe('HTML link client metadata (12 Feb 2022)', () => {
describe('Redirection', () => {
const tests: Record<string, (reply: FastifyReply) => void> = {
'Read HTTP header': reply => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Mixed links': reply => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<link rel="redirect_uri" href="/redirect2" />
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Multiple items in Link header': reply => {
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Multiple items in HTML': reply => {
reply.send(`
<!DOCTYPE html>
<link rel="redirect_uri" href="/redirect2" />
<link rel="redirect_uri" href="/redirect" />
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
};
for (const [title, replyFunc] of Object.entries(tests)) {
test(title, async () => {
sender = replyFunc;
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
});
}
test('No item', async () => {
sender = (reply): void => {
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
};
for (const [title, replyFunc] of Object.entries(tests)) {
test(title, async () => {
sender = replyFunc;
const client = new AuthorizationCode(clientConfig);
@@ -1005,82 +856,16 @@ describe('OAuth', () => {
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
// direct error because there's no redirect URI to ping
await assertDirectError(response, 400, 'invalid_request');
assert.strictEqual(response.status, 200);
});
});
}
test('Disallow loopback', async () => {
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
await assertDirectError(response, 400, 'invalid_request');
});
test('Missing name', async () => {
test('No item', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send();
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
});
test('With Logo', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app">
<a href="/" class="u-url p-name">Misklient</a>
<img src="/logo.png" class="u-logo" />
</div>
`);
reply.send();
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
const meta = getMeta(await response.text());
assert.strictEqual(meta.clientName, 'Misklient');
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
});
test('Missing Logo', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
reply.send();
};
const client = new AuthorizationCode(clientConfig);
@@ -1092,34 +877,119 @@ describe('OAuth', () => {
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
const meta = getMeta(await response.text());
assert.strictEqual(meta.clientName, 'Misklient');
assert.strictEqual(meta.clientLogo, undefined);
// direct error because there's no redirect URI to ping
await assertDirectError(response, 400, 'invalid_request');
});
});
test('Mismatching URL in h-app', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/foo" class="u-url p-name">Misklient
`);
reply.send();
};
test('Disallow loopback', async () => {
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
const client = new AuthorizationCode(clientConfig);
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
await assertDirectError(response, 400, 'invalid_request');
});
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
});
test('Missing name', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send();
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
});
test('With Logo', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app">
<a href="/" class="u-url p-name">Misklient</a>
<img src="/logo.png" class="u-logo" />
</div>
`);
reply.send();
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
const meta = getMeta(await response.text());
assert.strictEqual(meta.clientName, 'Misklient');
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
});
test('Missing Logo', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
reply.send();
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
const meta = getMeta(await response.text());
assert.strictEqual(meta.clientName, 'Misklient');
assert.strictEqual(meta.clientLogo, undefined);
});
test('Mismatching URL in h-app', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
<!DOCTYPE html>
<div class="h-app"><a href="/foo" class="u-url p-name">Misklient
`);
reply.send();
};
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
});
});

View File

@@ -25,6 +25,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"baseUrl": "./",
"paths": {
"@/*": ["../src/*"]
},

View File

@@ -1,483 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import type { Index, MeiliSearch } from 'meilisearch';
import { type Config, loadConfig } from '@/config.js';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { SearchService } from '@/core/SearchService.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import {
type BlockingsRepository,
type ChannelsRepository,
type FollowingsRepository,
type MutingsRepository,
type NotesRepository,
type UserProfilesRepository,
type UsersRepository,
type MiChannel,
type MiNote,
type MiUser,
} from '@/models/_.js';
describe('SearchService', () => {
type TestContext = {
app: TestingModule;
service: SearchService;
cacheService: CacheService;
idService: IdService;
mutingsRepository: MutingsRepository;
blockingsRepository: BlockingsRepository;
usersRepository: UsersRepository;
userProfilesRepository: UserProfilesRepository;
notesRepository: NotesRepository;
channelsRepository: ChannelsRepository;
followingsRepository: FollowingsRepository;
indexer?: (note: MiNote) => Promise<void>;
};
const meilisearchSettings = {
searchableAttributes: [
'text',
'cw',
],
sortableAttributes: [
'createdAt',
],
filterableAttributes: [
'createdAt',
'userId',
'userHost',
'channelId',
'tags',
],
typoTolerance: {
enabled: false,
},
pagination: {
maxTotalHits: 10000,
},
};
async function buildContext(configOverride?: Config): Promise<TestContext> {
const builder = Test.createTestingModule({
imports: [
GlobalModule,
CoreModule,
],
});
if (configOverride) {
builder.overrideProvider(DI.config).useValue(configOverride);
}
const app = await builder.compile();
app.enableShutdownHooks();
return {
app,
service: app.get(SearchService),
cacheService: app.get(CacheService),
idService: app.get(IdService),
mutingsRepository: app.get(DI.mutingsRepository),
blockingsRepository: app.get(DI.blockingsRepository),
usersRepository: app.get(DI.usersRepository),
userProfilesRepository: app.get(DI.userProfilesRepository),
notesRepository: app.get(DI.notesRepository),
channelsRepository: app.get(DI.channelsRepository),
followingsRepository: app.get(DI.followingsRepository),
};
}
async function cleanupContext(ctx: TestContext) {
await ctx.notesRepository.createQueryBuilder().delete().execute();
await ctx.mutingsRepository.createQueryBuilder().delete().execute();
await ctx.blockingsRepository.createQueryBuilder().delete().execute();
await ctx.followingsRepository.createQueryBuilder().delete().execute();
await ctx.channelsRepository.createQueryBuilder().delete().execute();
await ctx.userProfilesRepository.createQueryBuilder().delete().execute();
await ctx.usersRepository.createQueryBuilder().delete().execute();
}
async function createUser(ctx: TestContext, data: Partial<MiUser> = {}) {
const id = ctx.idService.gen();
const username = data.username ?? `user_${id}`;
const usernameLower = data.usernameLower ?? username.toLowerCase();
const user = await ctx.usersRepository
.insert({
id,
username,
usernameLower,
...data,
})
.then(x => ctx.usersRepository.findOneByOrFail(x.identifiers[0]));
await ctx.userProfilesRepository.insert({
userId: id,
});
return user;
}
async function createChannel(ctx: TestContext, user: MiUser, data: Partial<MiChannel> = {}) {
const id = ctx.idService.gen();
const channel = await ctx.channelsRepository
.insert({
id,
userId: user.id,
name: data.name ?? `channel_${id}`,
...data,
})
.then(x => ctx.channelsRepository.findOneByOrFail(x.identifiers[0]));
return channel;
}
async function createNote(ctx: TestContext, user: MiUser, data: Partial<MiNote> = {}, time?: number) {
const id = time == null ? ctx.idService.gen() : ctx.idService.gen(time);
const note = await ctx.notesRepository
.insert({
id,
text: 'hello',
userId: user.id,
userHost: user.host,
visibility: 'public',
tags: [],
...data,
})
.then(x => ctx.notesRepository.findOneByOrFail(x.identifiers[0]));
if (ctx.indexer) {
await ctx.indexer(note);
}
return note;
}
async function createFollowing(ctx: TestContext, follower: MiUser, followee: MiUser) {
await ctx.followingsRepository.insert({
id: ctx.idService.gen(),
followerId: follower.id,
followeeId: followee.id,
followerHost: follower.host,
followeeHost: followee.host,
});
}
function clearUserCaches(ctx: TestContext, userId: MiUser['id']) {
ctx.cacheService.userMutingsCache.delete(userId);
ctx.cacheService.userBlockedCache.delete(userId);
ctx.cacheService.userBlockingCache.delete(userId);
}
async function createMuting(ctx: TestContext, muter: MiUser, mutee: MiUser) {
await ctx.mutingsRepository.insert({
id: ctx.idService.gen(),
muterId: muter.id,
muteeId: mutee.id,
});
clearUserCaches(ctx, muter.id);
}
async function createBlocking(ctx: TestContext, blocker: MiUser, blockee: MiUser) {
await ctx.blockingsRepository.insert({
id: ctx.idService.gen(),
blockerId: blocker.id,
blockeeId: blockee.id,
});
clearUserCaches(ctx, blocker.id);
clearUserCaches(ctx, blockee.id);
}
function defineSearchNoteTests(
getCtx: () => TestContext,
{
supportsFollowersVisibility,
sinceIdOrder,
}: {
supportsFollowersVisibility: boolean;
sinceIdOrder: 'asc' | 'desc';
},
) {
describe('searchNote', () => {
test('filters notes by visibility (followers only visible to followers)', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const publicNote = await createNote(ctx, author, { text: 'hello public', visibility: 'public' });
const followersNote = await createNote(ctx, author, { text: 'hello followers', visibility: 'followers' });
const beforeFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(beforeFollow.map(note => note.id)).toEqual([publicNote.id]);
await createFollowing(ctx, me, author);
const afterFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
const expectedIds = supportsFollowersVisibility
? [followersNote.id, publicNote.id]
: [publicNote.id];
expect(afterFollow.map(note => note.id).sort()).toEqual(expectedIds.sort());
});
test('filters out suspended users via base note filtering', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const active = await createUser(ctx, { username: 'active', usernameLower: 'active', host: null });
const suspended = await createUser(ctx, { username: 'suspended', usernameLower: 'suspended', host: null, isSuspended: true });
const activeNote = await createNote(ctx, active, { text: 'hello active', visibility: 'public' });
await createNote(ctx, suspended, { text: 'hello suspended', visibility: 'public' });
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(result.map(note => note.id)).toEqual([activeNote.id]);
});
test('filters by userId', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const alice = await createUser(ctx, { username: 'alice', usernameLower: 'alice', host: null });
const bob = await createUser(ctx, { username: 'bob', usernameLower: 'bob', host: null });
const aliceNote = await createNote(ctx, alice, { text: 'hello alice', visibility: 'public' });
await createNote(ctx, bob, { text: 'hello bob', visibility: 'public' });
const result = await ctx.service.searchNote('hello', me, { userId: alice.id }, { limit: 10 });
expect(result.map(note => note.id)).toEqual([aliceNote.id]);
});
test('filters by channelId', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const channelA = await createChannel(ctx, author, { name: 'channel-a' });
const channelB = await createChannel(ctx, author, { name: 'channel-b' });
const channelNote = await createNote(ctx, author, { text: 'hello channel', channelId: channelA.id, visibility: 'public' });
await createNote(ctx, author, { text: 'hello other', channelId: channelB.id, visibility: 'public' });
const result = await ctx.service.searchNote('hello', me, { channelId: channelA.id }, { limit: 10 });
expect(result.map(note => note.id)).toEqual([channelNote.id]);
});
test('filters by host', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const local = await createUser(ctx, { username: 'local', usernameLower: 'local', host: null });
const remote = await createUser(ctx, { username: 'remote', usernameLower: 'remote', host: 'example.com' });
const localNote = await createNote(ctx, local, { text: 'hello local', visibility: 'public' });
const remoteNote = await createNote(ctx, remote, { text: 'hello remote', visibility: 'public', userHost: 'example.com' });
const localResult = await ctx.service.searchNote('hello', me, { host: '.' }, { limit: 10 });
expect(localResult.map(note => note.id)).toEqual([localNote.id]);
const remoteResult = await ctx.service.searchNote('hello', me, { host: 'example.com' }, { limit: 10 });
expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]);
});
describe('muting and blocking', () => {
test('filters out muted users', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const muted = await createUser(ctx, { username: 'muted', usernameLower: 'muted', host: null });
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
await createNote(ctx, muted, { text: 'hello muted', visibility: 'public' });
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
await createMuting(ctx, me, muted);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(result.map(note => note.id)).toEqual([otherNote.id]);
});
test('filters out users who block me', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const blocker = await createUser(ctx, { username: 'blocker', usernameLower: 'blocker', host: null });
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
await createNote(ctx, blocker, { text: 'hello blocker', visibility: 'public' });
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
await createBlocking(ctx, blocker, me);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(result.map(note => note.id)).toEqual([otherNote.id]);
});
test('filters no out users I block', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const blocked = await createUser(ctx, { username: 'blocked', usernameLower: 'blocked', host: null });
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
const blockedNote = await createNote(ctx, blocked, { text: 'hello blocked', visibility: 'public' });
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
await createBlocking(ctx, me, blocked);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(result.map(note => note.id).sort()).toEqual([otherNote.id, blockedNote.id].sort());
});
});
describe('pagination', () => {
test('paginates with sinceId', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const t1 = Date.now() - 3000;
const t2 = Date.now() - 2000;
const t3 = Date.now() - 1000;
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id });
const expected = sinceIdOrder === 'asc'
? [note2.id, note3.id]
: [note3.id, note2.id];
expect(result.map(note => note.id)).toEqual(expected);
});
test('paginates with untilId', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const t1 = Date.now() - 3000;
const t2 = Date.now() - 2000;
const t3 = Date.now() - 1000;
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, untilId: note3.id });
expect(result.map(note => note.id)).toEqual([note2.id, note1.id]);
});
test('paginates with sinceId and untilId together', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const t1 = Date.now() - 4000;
const t2 = Date.now() - 3000;
const t3 = Date.now() - 2000;
const t4 = Date.now() - 1000;
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
const note4 = await createNote(ctx, author, { text: 'hello' }, t4);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id, untilId: note4.id });
expect(result.map(note => note.id)).toEqual([note3.id, note2.id]);
});
});
});
}
describe('sqlLike', () => {
let ctx: TestContext;
beforeAll(async () => {
ctx = await buildContext();
});
afterAll(async () => {
await ctx.app.close();
});
afterEach(async () => {
await cleanupContext(ctx);
});
defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: true, sinceIdOrder: 'asc' });
});
describe('meilisearch', () => {
let ctx: TestContext;
let meilisearch: MeiliSearch;
let meilisearchIndex: Index;
let meiliConfig: Config;
beforeAll(async () => {
const baseConfig = loadConfig();
meiliConfig = {
...baseConfig,
fulltextSearch: {
provider: 'meilisearch',
},
meilisearch: {
host: '127.0.0.1',
port: '57712',
apiKey: '',
index: 'test-search-service',
scope: 'global',
ssl: false,
},
};
ctx = await buildContext(meiliConfig);
meilisearch = ctx.app.get(DI.meilisearch) as MeiliSearch;
meilisearchIndex = meilisearch.index(`${meiliConfig.meilisearch!.index}---notes`);
const settingsTask = await meilisearchIndex.updateSettings(meilisearchSettings);
await meilisearch.tasks.waitForTask(settingsTask.taskUid);
const clearTask = await meilisearchIndex.deleteAllDocuments();
await meilisearch.tasks.waitForTask(clearTask.taskUid);
ctx.indexer = async (note: MiNote) => {
if (note.text == null && note.cw == null) return;
if (!['home', 'public'].includes(note.visibility)) return;
if (meiliConfig.meilisearch?.scope === 'local' && note.userHost != null) return;
const task = await meilisearchIndex.addDocuments([{
id: note.id,
createdAt: ctx.idService.parse(note.id).date.getTime(),
userId: note.userId,
userHost: note.userHost,
channelId: note.channelId,
cw: note.cw,
text: note.text,
tags: note.tags,
}], {
primaryKey: 'id',
});
await meilisearch.tasks.waitForTask(task.taskUid);
};
});
afterAll(async () => {
await ctx.app.close();
});
afterEach(async () => {
await cleanupContext(ctx);
const clearTask = await meilisearchIndex.deleteAllDocuments();
await meilisearch.tasks.waitForTask(clearTask.taskUid);
});
defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: false, sinceIdOrder: 'desc' });
});
});

View File

@@ -26,6 +26,7 @@
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"rootDir": "./src",
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
},

View File

@@ -3,7 +3,7 @@
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsgo --noEmit",
"typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"exports": {
@@ -11,15 +11,16 @@
},
"devDependencies": {
"@types/estree": "1.0.8",
"@types/node": "24.10.4",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"rollup": "4.54.0"
"@types/node": "24.10.2",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"rollup": "4.53.3",
"typescript": "5.9.3"
},
"dependencies": {
"i18n": "workspace:*",
"estree-walker": "3.0.3",
"magic-string": "0.30.21",
"vite": "7.3.0"
"vite": "7.2.7"
}
}

View File

@@ -17,6 +17,7 @@
"noImplicitReturns": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"typeRoots": [
"./@types",
"./node_modules/@types"

View File

@@ -16,7 +16,7 @@
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.3",
"@vitejs/plugin-vue": "6.0.2",
"buraha": "0.0.1",
"estree-walker": "3.0.3",
"frontend-shared": "workspace:*",
@@ -25,13 +25,13 @@
"mfm-js": "0.25.0",
"misskey-js": "workspace:*",
"punycode.js": "2.3.1",
"rollup": "4.54.0",
"sass": "1.97.1",
"shiki": "3.20.0",
"rollup": "4.53.3",
"sass": "1.95.0",
"shiki": "3.19.0",
"tinycolor2": "1.6.0",
"uuid": "13.0.0",
"vite": "7.3.0",
"vue": "3.5.26"
"vite": "7.2.7",
"vue": "3.5.25"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.5",
@@ -39,14 +39,14 @@
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.8",
"@types/micromatch": "4.0.10",
"@types/node": "24.10.4",
"@types/node": "24.10.2",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@vitest/coverage-v8": "4.0.16",
"@vue/runtime-core": "3.5.26",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"@vitest/coverage-v8": "4.0.15",
"@vue/runtime-core": "3.5.25",
"acorn": "8.15.0",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
@@ -54,14 +54,15 @@
"happy-dom": "20.0.11",
"intersection-observer": "0.12.2",
"micromatch": "4.0.8",
"msw": "2.12.6",
"msw": "2.12.4",
"nodemon": "3.1.11",
"prettier": "3.7.4",
"start-server-and-test": "2.1.3",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "3.2.1",
"vue-component-type-helpers": "3.1.7",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.8"
"vue-tsc": "3.1.7"
}
}

View File

@@ -22,6 +22,7 @@
"isolatedModules": true,
"useDefineForClassFields": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@@/*": ["../frontend-shared/*"]

View File

@@ -60,7 +60,7 @@ async function buildSrc() {
function buildDts() {
return execa(
'tsgo',
'tsc',
[
'--project', 'tsconfig.json',
'--outDir', 'js-built',

View File

@@ -41,7 +41,7 @@ export const emojiCharByCategory = _charGroupByCategory;
export function getUnicodeEmojiOrNull(char: string): UnicodeEmojiDef | null {
// Colorize it because emojilist.json assumes that
return unicodeEmojisMap.get(forceColorizeEmoji(char))
return unicodeEmojisMap.get(colorizeEmoji(char))
// カラースタイル絵文字がjsonに無い場合はテキストスタイル絵文字にフォールバックする
?? unicodeEmojisMap.get(char)
// それでも見つからない場合はnullを返す
@@ -54,12 +54,12 @@ export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string {
}
export function isSupportedEmoji(char: string): boolean {
return unicodeEmojisMap.has(forceColorizeEmoji(char)) || unicodeEmojisMap.has(char);
return unicodeEmojisMap.has(colorizeEmoji(char)) || unicodeEmojisMap.has(char);
}
export function getEmojiName(char: string): string {
// Colorize it because emojilist.json assumes that
const idx = _indexByChar.get(forceColorizeEmoji(char)) ?? _indexByChar.get(char);
const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char);
if (idx === undefined) {
// 絵文字情報がjsonに無い場合は名前の取得が出来ないのでそのまま返すしか無い
return char;
@@ -72,24 +72,7 @@ export function getEmojiName(char: string): string {
* テキストスタイル絵文字U+260Eなどの1文字で表現される絵文字をカラースタイル絵文字に変換しますVS16:U+FE0Fを付与
*/
export function colorizeEmoji(char: string) {
// <文字列>.length はコードポイント数ではなくUTF-16コードユニット数を返すため、サロゲートペアを含む絵文字で誤動作する。
// そのため、配列に変換してコードポイント数を数える方法を取る。
return Array.from(char).length === 1 ? `${char}\uFE0F` : char;
}
/**
* 文字種にかかわらず、カラースタイル絵文字への変換を試みます(本ファイルにある検索プログラム用・フォールバックが必須)。
*/
function forceColorizeEmoji(char: string) {
// <文字列>.length はコードポイント数ではなくUTF-16コードユニット数を返すため、サロゲートペアを含む絵文字で誤動作する。
// そのため、配列に変換してコードポイント数を数える方法を取る。
const chars = Array.from(char);
if (chars.includes('\uFE0F')) {
return char;
} else {
chars.splice(1, 0, '\uFE0F');
return chars.join('');
}
return char.length === 1 ? `${char}\uFE0F` : char;
}
export interface CustomEmojiFolderTree {

View File

@@ -17,16 +17,17 @@
"build": "node ./build.js",
"watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"",
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsgo --noEmit",
"typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.10.4",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"esbuild": "0.27.2",
"@types/node": "24.10.2",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"esbuild": "0.27.1",
"eslint-plugin-vue": "10.6.2",
"nodemon": "3.1.11",
"typescript": "5.9.3",
"vue-eslint-parser": "10.2.0"
},
"files": [
@@ -35,6 +36,6 @@
"dependencies": {
"i18n": "workspace:*",
"misskey-js": "workspace:*",
"vue": "3.5.26"
"vue": "3.5.25"
}
}

View File

@@ -17,6 +17,7 @@
"noImplicitReturns": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@@/*": ["./*"]

View File

@@ -6,7 +6,7 @@
"watch": "vite",
"build": "tsx build.ts",
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
"chromatic": "chromatic",
"test": "vitest --run --globals",
@@ -25,11 +25,11 @@
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@sentry/vue": "10.32.1",
"@syuilo/aiscript": "1.2.1",
"@sentry/vue": "10.29.0",
"@syuilo/aiscript": "1.2.0",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.3",
"@vitejs/plugin-vue": "6.0.2",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16",
"analytics": "0.8.19",
"broadcast-channel": "7.2.0",
@@ -55,7 +55,7 @@
"is-file-animated": "1.0.2",
"json5": "2.2.3",
"matter-js": "0.20.0",
"mediabunny": "1.27.2",
"mediabunny": "1.25.7",
"mfm-js": "0.25.0",
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
@@ -64,59 +64,59 @@
"punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",
"rollup": "4.54.0",
"rollup": "4.53.3",
"sanitize-html": "2.17.0",
"sass": "1.97.1",
"shiki": "3.20.0",
"sass": "1.95.0",
"shiki": "3.19.0",
"textarea-caret": "3.1.0",
"three": "0.182.0",
"three": "0.181.2",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"v-code-diff": "1.13.1",
"vite": "7.3.0",
"vue": "3.5.26",
"vite": "7.2.7",
"vue": "3.5.25",
"vuedraggable": "next",
"wanakana": "5.3.1"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.5",
"@storybook/addon-essentials": "8.6.15",
"@storybook/addon-interactions": "8.6.15",
"@storybook/addon-links": "10.1.10",
"@storybook/addon-mdx-gfm": "8.6.15",
"@storybook/addon-storysource": "8.6.15",
"@storybook/blocks": "8.6.15",
"@storybook/components": "8.6.15",
"@storybook/core-events": "8.6.15",
"@storybook/manager-api": "8.6.15",
"@storybook/preview-api": "8.6.15",
"@storybook/react": "10.1.10",
"@storybook/react-vite": "10.1.10",
"@storybook/test": "8.6.15",
"@storybook/theming": "8.6.15",
"@storybook/types": "8.6.15",
"@storybook/vue3": "10.1.10",
"@storybook/vue3-vite": "10.1.10",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "10.1.4",
"@storybook/addon-mdx-gfm": "8.6.14",
"@storybook/addon-storysource": "8.6.14",
"@storybook/blocks": "8.6.14",
"@storybook/components": "8.6.14",
"@storybook/core-events": "8.6.14",
"@storybook/manager-api": "8.6.14",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "10.1.4",
"@storybook/react-vite": "10.1.4",
"@storybook/test": "8.6.14",
"@storybook/theming": "8.6.14",
"@storybook/types": "8.6.14",
"@storybook/vue3": "10.1.4",
"@storybook/vue3-vite": "10.1.4",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.8",
"@types/matter-js": "0.20.2",
"@types/micromatch": "4.0.10",
"@types/node": "24.10.4",
"@types/node": "24.10.2",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.16.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@vitest/coverage-v8": "4.0.16",
"@vue/compiler-core": "3.5.26",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"@vitest/coverage-v8": "4.0.15",
"@vue/compiler-core": "3.5.25",
"acorn": "8.15.0",
"astring": "1.9.0",
"cross-env": "10.1.0",
"cypress": "15.8.1",
"cypress": "15.7.1",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.6.2",
"estree-walker": "3.0.3",
@@ -125,22 +125,23 @@
"magic-string": "0.30.21",
"micromatch": "4.0.8",
"minimatch": "10.1.1",
"msw": "2.12.6",
"msw": "2.12.4",
"msw-storybook-addon": "2.0.6",
"nodemon": "3.1.11",
"prettier": "3.7.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react": "19.2.1",
"react-dom": "19.2.1",
"seedrandom": "3.0.5",
"start-server-and-test": "2.1.3",
"storybook": "10.1.10",
"storybook": "10.1.4",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vite-plugin-glsl": "1.5.5",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "4.0.16",
"vitest": "4.0.15",
"vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "3.2.1",
"vue-component-type-helpers": "3.1.7",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.8"
}

View File

@@ -69,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="(f, i) in foldersPaginator.items.value"
:key="f.id"
v-anim="i"
:class="$style.folder"
:folder="f"
:selectMode="select === 'folder'"
:isSelected="selectedFolders.some(x => x.id === f.id)"
@@ -101,6 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<XFile
v-for="file in item.items" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@@ -123,6 +125,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<XFile
v-for="file in filesPaginator.items.value" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@@ -132,14 +135,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
</TransitionGroup>
<MkButton
v-show="canFetchFiles"
v-appear="shouldEnableInfiniteScroll ? fetchMoreFiles : null"
:class="$style.loadMore"
primary
rounded
@click="fetchMoreFiles"
>{{ i18n.ts.loadMore }}</MkButton>
<MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton>
<div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty">
<div v-if="draghover">{{ i18n.ts.dropHereToUpload }}</div>
@@ -186,12 +182,10 @@ const props = withDefaults(defineProps<{
type?: string;
multiple?: boolean;
select?: 'file' | 'folder' | null;
forceDisableInfiniteScroll?: boolean;
}>(), {
initialFolder: null,
multiple: false,
select: null,
forceDisableInfiniteScroll: false,
});
const emit = defineEmits<{
@@ -200,10 +194,6 @@ const emit = defineEmits<{
(ev: 'cd', v: Misskey.entities.DriveFolder | null): void;
}>();
const shouldEnableInfiniteScroll = computed(() => {
return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll;
});
const folder = ref<Misskey.entities.DriveFolder | null>(null);
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
@@ -238,9 +228,10 @@ const filesPaginator = markRaw(new Paginator('drive/files', {
params: () => ({ // 自動でリロードしたくないためcomputedParamsは使わない
folderId: folder.value ? folder.value.id : null,
type: props.type,
sort: ['-createdAt', '+createdAt'].includes(sortModeSelect.value) ? null : sortModeSelect.value,
sort: sortModeSelect.value,
}),
}));
const foldersPaginator = markRaw(new Paginator('drive/folders', {
limit: 30,
canFetchDetection: 'limit',
@@ -249,16 +240,6 @@ const foldersPaginator = markRaw(new Paginator('drive/folders', {
}),
}));
const canFetchFiles = computed(() => !fetching.value && (filesPaginator.order.value === 'oldest' ? filesPaginator.canFetchNewer.value : filesPaginator.canFetchOlder.value));
async function fetchMoreFiles() {
if (filesPaginator.order.value === 'oldest') {
filesPaginator.fetchNewer();
} else {
filesPaginator.fetchOlder();
}
}
const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month');
const shouldBeGroupedByDate = computed(() => ['+createdAt', '-createdAt'].includes(sortModeSelect.value));
@@ -269,10 +250,10 @@ watch(sortModeSelect, () => {
async function initialize() {
fetching.value = true;
await foldersPaginator.reload();
filesPaginator.initialDirection = sortModeSelect.value === '-createdAt' ? 'newer' : 'older';
filesPaginator.order.value = sortModeSelect.value === '-createdAt' ? 'oldest' : 'newest';
await filesPaginator.reload();
await Promise.all([
foldersPaginator.init(),
filesPaginator.init(),
]);
fetching.value = false;
}

View File

@@ -23,8 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
>
<MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading">
<template #preview>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<div :class="$style.embedCodeGenPreviewWrapper">
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
@@ -42,29 +43,27 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
</template>
<template #controls>
<div class="_spacer _gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput>
<MkSelect v-model="colorMode" :items="colorModeDef">
<template #label>{{ i18n.ts.theme }}</template>
</MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
<div class="_buttons">
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
<div :class="$style.embedCodeGenSettings" class="_gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput>
<MkSelect v-model="colorMode" :items="colorModeDef">
<template #label>{{ i18n.ts.theme }}</template>
</MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
<div class="_buttons">
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</template>
</MkPreviewWithControls>
</div>
</div>
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
<div class="_gaps_s">
@@ -90,17 +89,18 @@ import { url } from '@@/js/config.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkCode from '@/components/MkCode.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
import { prefer } from '@/preferences.js';
const emit = defineEmits<{
(ev: 'ok'): void;
@@ -302,6 +302,29 @@ onUnmounted(() => {
height: 100%;
}
.embedCodeGenInputRoot {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.embedCodeGenPreviewRoot {
position: relative;
cursor: not-allowed;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.embedCodeGenPreviewWrapper {
display: flex;
flex-direction: column;
@@ -349,6 +372,11 @@ onUnmounted(() => {
color-scheme: light dark;
}
.embedCodeGenSettings {
padding: 24px;
overflow-y: scroll;
}
.embedCodeGenResultRoot {
box-sizing: border-box;
padding: 24px;
@@ -389,4 +417,11 @@ onUnmounted(() => {
.embedCodeGenResultButtons {
margin: 0 auto;
}
@container (max-width: 800px) {
.embedCodeGenInputRoot {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<MkPagination v-slot="{ items }" :paginator="paginator">
<div
:class="{
[$style.grid]: viewMode === 'grid',
[$style.list]: viewMode === 'list',
'_gaps_s': viewMode === 'list',
}"
>
<div :class="[$style.fileList, { [$style.grid]: viewMode === 'grid', [$style.list]: viewMode === 'list', '_gaps_s': viewMode === 'list' }]">
<MkA
v-for="file in items"
:key="file.id"

View File

@@ -81,13 +81,7 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
}
async function onClick() {
const isLoggedIn = await pleaseLogin({
openOnRemote: {
type: 'web',
path: `/@${props.user.username}@${props.user.host ?? host}`,
},
});
if (!isLoggedIn) return;
pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } });
wait.value = true;

View File

@@ -1,84 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
<template v-for="v, k in form">
<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1" :manualSave="v.manualSave">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm" :manualSave="v.manualSave">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm" :manualSave="v.manualSave">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea>
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="v.label || k"></span>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch>
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
</MkSelect>
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option>
</MkRadios>
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange>
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span>
</MkButton>
<XFile
v-else-if="v.type === 'drive-file'"
:fileId="v.defaultFileId"
:validate="async f => !v.validate || await v.validate(f)"
@update="f => values[k] = f"
/>
</template>
</div>
<MkResult v-else type="empty" :text="i18n.ts.nothingToConfigure"/>
</template>
<script lang="ts" setup>
import XFile from '@/components/MkForm.file.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRange from '@/components/MkRange.vue';
import MkButton from '@/components/MkButton.vue';
import MkRadios from '@/components/MkRadios.vue';
import { i18n } from '@/i18n.js';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
const props = defineProps<{
form: Form;
}>();
// TODO: ジェネリックにしたい
const values = defineModel<Record<string, any>>({ required: true });
function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
return def.enum.map((v) => {
if (typeof v === 'string') {
return { value: v, label: v };
} else {
return { value: v.value, label: v.label };
}
});
}
function getRadioKey(e: RadioFormItem['options'][number]) {
return typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
}
</script>

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