Merge branch 'develop' into feat-1714

This commit is contained in:
かっこかり 2024-07-18 18:58:36 +09:00 committed by GitHub
commit ca7992b1ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 1920 additions and 1128 deletions

View file

@ -164,12 +164,12 @@ id: 'aidx'
#clusterLimit: 1 #clusterLimit: 1
# Job concurrency per worker # Job concurrency per worker
# deliverJobConcurrency: 128 # deliverJobConcurrency: 16
# inboxJobConcurrency: 16 # inboxJobConcurrency: 4
# Job rate limiter # Job rate limiter
# deliverJobPerSec: 128 # deliverJobPerSec: 128
# inboxJobPerSec: 32 # inboxJobPerSec: 64
# Job attempts # Job attempts
# deliverJobMaxAttempts: 12 # deliverJobMaxAttempts: 12

View file

@ -230,15 +230,15 @@ id: 'aidx'
#clusterLimit: 1 #clusterLimit: 1
# Job concurrency per worker # Job concurrency per worker
#deliverJobConcurrency: 128 #deliverJobConcurrency: 16
#inboxJobConcurrency: 16 #inboxJobConcurrency: 4
#relationshipJobConcurrency: 16 #relationshipJobConcurrency: 16
# What's relationshipJob?: # What's relationshipJob?:
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
# Job rate limiter # Job rate limiter
#deliverJobPerSec: 128 #deliverJobPerSec: 1024
#inboxJobPerSec: 32 #inboxJobPerSec: 64
#relationshipJobPerSec: 64 #relationshipJobPerSec: 64
# Job attempts # Job attempts

View file

@ -157,12 +157,12 @@ id: 'aidx'
#clusterLimit: 1 #clusterLimit: 1
# Job concurrency per worker # Job concurrency per worker
# deliverJobConcurrency: 128 # deliverJobConcurrency: 16
# inboxJobConcurrency: 16 # inboxJobConcurrency: 4
# Job rate limiter # Job rate limiter
# deliverJobPerSec: 128 # deliverJobPerSec: 1024
# inboxJobPerSec: 32 # inboxJobPerSec: 64
# Job attempts # Job attempts
# deliverJobMaxAttempts: 12 # deliverJobMaxAttempts: 12

View file

@ -12,7 +12,6 @@ node_modules/
packages/*/node_modules packages/*/node_modules
redis/ redis/
files/ files/
misskey-assets/
fluent-emojis/ fluent-emojis/
.pnp.* .pnp.*

1
.gitignore vendored
View file

@ -59,6 +59,7 @@ ormconfig.json
temp temp
/packages/frontend/src/**/*.stories.ts /packages/frontend/src/**/*.stories.ts
tsdoc-metadata.json tsdoc-metadata.json
misskey-assets
# blender backups # blender backups
*.blend1 *.blend1

3
.gitmodules vendored
View file

@ -1,6 +1,3 @@
[submodule "misskey-assets"]
path = misskey-assets
url = https://github.com/misskey-dev/assets.git
[submodule "fluent-emojis"] [submodule "fluent-emojis"]
path = fluent-emojis path = fluent-emojis
url = https://github.com/misskey-dev/emojis.git url = https://github.com/misskey-dev/emojis.git

View file

@ -1,4 +1,4 @@
## Unreleased ## 2024.7.0
### Note ### Note
- デッキUIの新着ートをサウンドで通知する機能の追加v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。 - デッキUIの新着ートをサウンドで通知する機能の追加v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。
@ -7,10 +7,17 @@
- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に - Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に
- 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます
- Feat: 連合に使うHTTP SignaturesがEd25519鍵に対応するように #13464
- Ed25519署名に対応するサーバーが増えると、deliverで要求されるサーバーリソースが削減されます
- ジョブキューのconfig設定のデフォルト値を変更しました。
default.ymlでジョブキューの並列度を設定している場合は、従前よりもconcurrencyの値をより下げるとパフォーマンスが改善する可能性があります。
* deliverJobConcurrency: 16 (←128)
* deliverJobPerSec: 1024 (←128)
* inboxJobConcurrency: 4 (←16)
* inboxJobPerSec: 64 (←32)
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題
- Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正
- Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`)
### Client ### Client
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
@ -22,6 +29,8 @@
(Based on https://github.com/taiyme/misskey/pull/226) (Based on https://github.com/taiyme/misskey/pull/226)
- Enhance: サーバー情報ページ・お問い合わせページを改善 - Enhance: サーバー情報ページ・お問い合わせページを改善
(Cherry-picked from https://github.com/taiyme/misskey/pull/238) (Cherry-picked from https://github.com/taiyme/misskey/pull/238)
- Enhance: AiScriptを0.19.0にアップデート
- Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`)
- Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正 - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正
- Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968) - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968)
- Fix: リバーシの対局を正しく共有できないことがある問題を修正 - Fix: リバーシの対局を正しく共有できないことがある問題を修正
@ -30,6 +39,11 @@
- Fix: テーマプレビューが見れない問題を修正 - Fix: テーマプレビューが見れない問題を修正
- Fix: ショートカットキーが連打できる問題を修正 - Fix: ショートカットキーが連打できる問題を修正
(Cherry-picked from https://github.com/taiyme/misskey/pull/234) (Cherry-picked from https://github.com/taiyme/misskey/pull/234)
- Fix: MkSignin.vueのcredentialRequestからReactivityを削除ProxyがPasskey認証処理に渡ることを避けるため
- Fix: 「アニメーション画像を再生しない」がオンのときでもサーバーのバナー画像・背景画像がアニメーションしてしまう問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/574)
- Fix: Twitchの埋め込みが開けない問題を修正
- Fix: 子メニューの高さがウィンドウからはみ出ることがある問題を修正
### Server ### Server
- Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949)
@ -40,7 +54,7 @@
- Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに - Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに
- Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに - Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに
- Enhance: `default.yml`内の`url`, `db.db`, `db.user`, `db.pass`を環境変数から読み込めるように - Enhance: `default.yml`内の`url`, `db.db`, `db.user`, `db.pass`を環境変数から読み込めるように
- Fix: チャート生成時にinstance.suspentionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正 - Fix: チャート生成時にinstance.suspensionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正
- Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006) - Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006)
- Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036) - Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036)
- Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) - Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059)
@ -55,10 +69,15 @@
2. フォロー中かつ非アクティブなユーザ 2. フォロー中かつ非アクティブなユーザ
3. フォローしていないアクティブなユーザ 3. フォローしていないアクティブなユーザ
4. フォローしていない非アクティブなユーザ 4. フォローしていない非アクティブなユーザ
また、自分自身のアカウントもサジェストされるようになりました。
- Fix: 一般ユーザーから見たユーザーのバッジの一覧に公開されていないものが含まれることがある問題を修正 - Fix: 一般ユーザーから見たユーザーのバッジの一覧に公開されていないものが含まれることがある問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/652) (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/652)
- Fix: ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正 - Fix: ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正
- Fix: エラーメッセージの誤字を修正 (#14213) - Fix: エラーメッセージの誤字を修正 (#14213)
- Fix: ソーシャルタイムラインにローカルタイムラインに表示される自分へのリプライが表示されない問題を修正
- Fix: リノートのミュートが適用されるまでに時間がかかることがある問題を修正
(Cherry-picked from https://github.com/Type4ny-Project/Type4ny/commit/e9601029b52e0ad43d9131b555b614e56c84ebc1)
### Misskey.js ### Misskey.js
- Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) - Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応)

View file

@ -106,6 +106,38 @@ If your language is not listed in Crowdin, please open an issue.
![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg) ![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg)
## Development ## Development
### Setup
Before developing, you have to set up environment. Misskey requires Redis, PostgreSQL, and FFmpeg.
You would want to install Meilisearch to experiment related features. Technically, meilisearch is not strict requirement, but some features and tests require it.
There are a few ways to proceed.
#### Use system-wide software
You could install them in system-wide (such as from package manager).
#### Use `docker compose`
You could obtain middleware container by typing `docker compose -f $PROJECT_ROOT/compose.local-db.yml up -d`.
#### Use Devcontainer
Devcontainer also has necessary setting. This method can be done by connecting from VSCode.
Instead of running `pnpm` locally, you can use Dev Container to set up your development environment.
To use Dev Container, open the project directory on VSCode with Dev Containers installed.
**Note:** If you are using Windows, please clone the repository with WSL. Using Git for Windows will result in broken files due to the difference in how newlines are handled.
It will run the following command automatically inside the container.
``` bash
git submodule update --init
pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate
```
After finishing the migration, you can proceed.
### Start developing
During development, it is useful to use the During development, it is useful to use the
``` ```
@ -135,26 +167,6 @@ MK_DEV_PREFER=backend pnpm dev
- To change the port of Vite, specify with `VITE_PORT` environment variable. - To change the port of Vite, specify with `VITE_PORT` environment variable.
- HMR may not work in some environments such as Windows. - HMR may not work in some environments such as Windows.
### Dev Container
Instead of running `pnpm` locally, you can use Dev Container to set up your development environment.
To use Dev Container, open the project directory on VSCode with Dev Containers installed.
**Note:** If you are using Windows, please clone the repository with WSL. Using Git for Windows will result in broken files due to the difference in how newlines are handled.
It will run the following command automatically inside the container.
``` bash
git submodule update --init
pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate
```
After finishing the migration, run the `pnpm dev` command to start the development server.
``` bash
pnpm dev
```
## Testing ## Testing
- Test codes are located in [`/packages/backend/test`](/packages/backend/test). - Test codes are located in [`/packages/backend/test`](/packages/backend/test).
@ -185,7 +197,7 @@ TODO
## Environment Variable ## Environment Variable
- `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`). - `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`).
- `MISSKEY_WEBFINGER_USE_HTTP`: If it's set true, WebFinger requests will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. - `MISSKEY_USE_HTTP`: If it's set true, federation requests (like nodeinfo and webfinger) will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. (was `MISSKEY_WEBFINGER_USE_HTTP`)
## Continuous integration ## Continuous integration
Misskey uses GitHub Actions for executing automated tests. Misskey uses GitHub Actions for executing automated tests.

View file

@ -178,12 +178,12 @@ id: "aidx"
#clusterLimit: 1 #clusterLimit: 1
# Job concurrency per worker # Job concurrency per worker
# deliverJobConcurrency: 128 # deliverJobConcurrency: 16
# inboxJobConcurrency: 16 # inboxJobConcurrency: 4
# Job rate limiter # Job rate limiter
# deliverJobPerSec: 128 # deliverJobPerSec: 1024
# inboxJobPerSec: 32 # inboxJobPerSec: 64
# Job attempts # Job attempts
# deliverJobMaxAttempts: 12 # deliverJobMaxAttempts: 12

@ -1 +0,0 @@
Subproject commit 0179793ec891856d6f37a3be16ba4c22f67a81b5

View file

@ -1,12 +1,12 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.5.0", "version": "2024.7.0-beta.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/misskey-dev/misskey.git" "url": "https://github.com/misskey-dev/misskey.git"
}, },
"packageManager": "pnpm@9.0.6", "packageManager": "pnpm@9.5.0",
"workspaces": [ "workspaces": [
"packages/frontend", "packages/frontend",
"packages/backend", "packages/backend",

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class APMultipleKeys1708980134301 {
name = 'APMultipleKeys1708980134301'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_171e64971c780ebd23fae140bb"`);
await queryRunner.query(`ALTER TABLE "user_keypair" ADD "ed25519PublicKey" character varying(128)`);
await queryRunner.query(`ALTER TABLE "user_keypair" ADD "ed25519PrivateKey" character varying(128)`);
await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`);
await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_10c146e4b39b443ede016f6736d"`);
await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_0db6a5fdb992323449edc8ee421" PRIMARY KEY ("userId", "keyId")`);
await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_0db6a5fdb992323449edc8ee421"`);
await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_171e64971c780ebd23fae140bba" PRIMARY KEY ("keyId")`);
await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`);
await queryRunner.query(`CREATE INDEX "IDX_10c146e4b39b443ede016f6736" ON "user_publickey" ("userId") `);
await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`);
await queryRunner.query(`DROP INDEX "public"."IDX_10c146e4b39b443ede016f6736"`);
await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "UQ_10c146e4b39b443ede016f6736d"`);
await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_171e64971c780ebd23fae140bba"`);
await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_0db6a5fdb992323449edc8ee421" PRIMARY KEY ("userId", "keyId")`);
await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_0db6a5fdb992323449edc8ee421"`);
await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_10c146e4b39b443ede016f6736d" PRIMARY KEY ("userId")`);
await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" TYPE "public"."user_profile_followersVisibility_enum_old" USING "followersVisibility"::"text"::"public"."user_profile_followersVisibility_enum_old"`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" SET DEFAULT 'public'`);
await queryRunner.query(`ALTER TABLE "user_keypair" DROP COLUMN "ed25519PrivateKey"`);
await queryRunner.query(`ALTER TABLE "user_keypair" DROP COLUMN "ed25519PublicKey"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_171e64971c780ebd23fae140bb" ON "user_publickey" ("keyId") `);
}
}

View file

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

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class APMultipleKeys1709269211718 {
name = 'APMultipleKeys1709269211718'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "UQ_10c146e4b39b443ede016f6736d"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`);
}
}

View file

@ -79,13 +79,13 @@
"@fastify/multipart": "8.3.0", "@fastify/multipart": "8.3.0",
"@fastify/static": "7.0.4", "@fastify/static": "7.0.4",
"@fastify/view": "9.1.0", "@fastify/view": "9.1.0",
"@misskey-dev/node-http-message-signatures": "0.0.10",
"@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.1.0", "@misskey-dev/summaly": "5.1.0",
"@napi-rs/canvas": "^0.1.53", "@napi-rs/canvas": "^0.1.53",
"@nestjs/common": "10.3.10", "@nestjs/common": "10.3.10",
"@nestjs/core": "10.3.10", "@nestjs/core": "10.3.10",
"@nestjs/testing": "10.3.10", "@nestjs/testing": "10.3.10",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "8.13.0", "@sentry/node": "8.13.0",
"@sentry/profiling-node": "8.13.0", "@sentry/profiling-node": "8.13.0",
"@simplewebauthn/server": "10.0.0", "@simplewebauthn/server": "10.0.0",

View file

@ -1,82 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare module '@peertube/http-signature' {
import type { IncomingMessage, ClientRequest } from 'node:http';
interface ISignature {
keyId: string;
algorithm: string;
headers: string[];
signature: string;
}
interface IOptions {
headers?: string[];
algorithm?: string;
strict?: boolean;
authorizationHeaderName?: string;
}
interface IParseRequestOptions extends IOptions {
clockSkew?: number;
}
interface IParsedSignature {
scheme: string;
params: ISignature;
signingString: string;
algorithm: string;
keyId: string;
}
type RequestSignerConstructorOptions =
IRequestSignerConstructorOptionsFromProperties |
IRequestSignerConstructorOptionsFromFunction;
interface IRequestSignerConstructorOptionsFromProperties {
keyId: string;
key: string | Buffer;
algorithm?: string;
}
interface IRequestSignerConstructorOptionsFromFunction {
sign?: (data: string, cb: (err: any, sig: ISignature) => void) => void;
}
class RequestSigner {
constructor(options: RequestSignerConstructorOptions);
public writeHeader(header: string, value: string): string;
public writeDateHeader(): string;
public writeTarget(method: string, path: string): void;
public sign(cb: (err: any, authz: string) => void): void;
}
interface ISignRequestOptions extends IOptions {
keyId: string;
key: string;
httpVersion?: string;
}
export function parse(request: IncomingMessage, options?: IParseRequestOptions): IParsedSignature;
export function parseRequest(request: IncomingMessage, options?: IParseRequestOptions): IParsedSignature;
export function sign(request: ClientRequest, options: ISignRequestOptions): boolean;
export function signRequest(request: ClientRequest, options: ISignRequestOptions): boolean;
export function createSigner(): RequestSigner;
export function isSigner(obj: any): obj is RequestSigner;
export function sshKeyToPEM(key: string): string;
export function sshKeyFingerprint(key: string): string;
export function pemToRsaSSHKey(pem: string, comment: string): string;
export function verify(parsedSignature: IParsedSignature, pubkey: string | Buffer): boolean;
export function verifySignature(parsedSignature: IParsedSignature, pubkey: string | Buffer): boolean;
export function verifyHMAC(parsedSignature: IParsedSignature, secret: string): boolean;
}

View file

@ -9,6 +9,11 @@ export const MAX_NOTE_TEXT_LENGTH = 3000;
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days
export const REMOTE_USER_CACHE_TTL = 1000 * 60 * 60 * 3; // 3hours
export const REMOTE_USER_MOVE_COOLDOWN = 1000 * 60 * 60 * 24 * 14; // 14days
export const REMOTE_SERVER_CACHE_TTL = 1000 * 60 * 60 * 3; // 3hours
//#region hard limits //#region hard limits
// If you change DB_* values, you must also change the DB schema. // If you change DB_* values, you must also change the DB schema.

View file

@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/_.js'; import type { UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
@ -12,30 +13,44 @@ import { RelayService } from '@/core/RelayService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
@Injectable() @Injectable()
export class AccountUpdateService { export class AccountUpdateService implements OnModuleInit {
private apDeliverManagerService: ApDeliverManagerService;
constructor( constructor(
private moduleRef: ModuleRef,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private relayService: RelayService, private relayService: RelayService,
) { ) {
} }
async onModuleInit() {
this.apDeliverManagerService = this.moduleRef.get(ApDeliverManagerService.name);
}
@bindThis @bindThis
public async publishToFollowers(userId: MiUser['id']) { /**
* Deliver account update to followers
* @param userId user id
* @param deliverKey optional. Private key to sign the deliver.
*/
public async publishToFollowers(userId: MiUser['id'], deliverKey?: PrivateKeyWithPem) {
const user = await this.usersRepository.findOneBy({ id: userId }); const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) throw new Error('user not found'); if (user == null) throw new Error('user not found');
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
this.apDeliverManagerService.deliverToFollowers(user, content); await Promise.allSettled([
this.relayService.deliverToRelays(user, content); this.apDeliverManagerService.deliverToFollowers(user, content, deliverKey),
this.relayService.deliverToRelays(user, content, deliverKey),
]);
} }
} }
} }

View file

@ -61,6 +61,7 @@ import { UserFollowingService } from './UserFollowingService.js';
import { UserKeypairService } from './UserKeypairService.js'; import { UserKeypairService } from './UserKeypairService.js';
import { UserListService } from './UserListService.js'; import { UserListService } from './UserListService.js';
import { UserMutingService } from './UserMutingService.js'; import { UserMutingService } from './UserMutingService.js';
import { UserRenoteMutingService } from './UserRenoteMutingService.js';
import { UserSuspendService } from './UserSuspendService.js'; import { UserSuspendService } from './UserSuspendService.js';
import { UserAuthService } from './UserAuthService.js'; import { UserAuthService } from './UserAuthService.js';
import { VideoProcessingService } from './VideoProcessingService.js'; import { VideoProcessingService } from './VideoProcessingService.js';
@ -203,6 +204,7 @@ const $UserFollowingService: Provider = { provide: 'UserFollowingService', useEx
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService };
const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService', useExisting: UserRenoteMutingService };
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
@ -350,6 +352,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserKeypairService, UserKeypairService,
UserListService, UserListService,
UserMutingService, UserMutingService,
UserRenoteMutingService,
UserSearchService, UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
@ -493,6 +496,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserKeypairService, $UserKeypairService,
$UserListService, $UserListService,
$UserMutingService, $UserMutingService,
$UserRenoteMutingService,
$UserSearchService, $UserSearchService,
$UserSuspendService, $UserSuspendService,
$UserAuthService, $UserAuthService,
@ -637,6 +641,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserKeypairService, UserKeypairService,
UserListService, UserListService,
UserMutingService, UserMutingService,
UserRenoteMutingService,
UserSearchService, UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
@ -779,6 +784,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserKeypairService, $UserKeypairService,
$UserListService, $UserListService,
$UserMutingService, $UserMutingService,
$UserRenoteMutingService,
$UserSearchService, $UserSearchService,
$UserSuspendService, $UserSuspendService,
$UserAuthService, $UserAuthService,

View file

@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { IsNull, DataSource } from 'typeorm'; import { IsNull, DataSource } from 'typeorm';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js';
import { MiUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js';
import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserProfile } from '@/models/UserProfile.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@ -38,7 +38,7 @@ export class CreateSystemUserService {
// Generate secret // Generate secret
const secret = generateNativeUserToken(); const secret = generateNativeUserToken();
const keyPair = await genRsaKeyPair(); const keyPair = await genRSAAndEd25519KeyPair();
let account!: MiUser; let account!: MiUser;
@ -64,9 +64,8 @@ export class CreateSystemUserService {
}).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
await transactionalEntityManager.insert(MiUserKeypair, { await transactionalEntityManager.insert(MiUserKeypair, {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
userId: account.id, userId: account.id,
...keyPair,
}); });
await transactionalEntityManager.insert(MiUserProfile, { await transactionalEntityManager.insert(MiUserProfile, {

View file

@ -15,6 +15,7 @@ import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { REMOTE_SERVER_CACHE_TTL } from '@/const.js';
import type { DOMWindow } from 'jsdom'; import type { DOMWindow } from 'jsdom';
type NodeInfo = { type NodeInfo = {
@ -24,6 +25,7 @@ type NodeInfo = {
version?: unknown; version?: unknown;
}; };
metadata?: { metadata?: {
httpMessageSignaturesImplementationLevel?: unknown,
name?: unknown; name?: unknown;
nodeName?: unknown; nodeName?: unknown;
nodeDescription?: unknown; nodeDescription?: unknown;
@ -39,6 +41,7 @@ type NodeInfo = {
@Injectable() @Injectable()
export class FetchInstanceMetadataService { export class FetchInstanceMetadataService {
private logger: Logger; private logger: Logger;
private httpColon = 'https://';
constructor( constructor(
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
@ -48,6 +51,7 @@ export class FetchInstanceMetadataService {
private redisClient: Redis.Redis, private redisClient: Redis.Redis,
) { ) {
this.logger = this.loggerService.getLogger('metadata', 'cyan'); this.logger = this.loggerService.getLogger('metadata', 'cyan');
this.httpColon = process.env.MISSKEY_USE_HTTP?.toLowerCase() === 'true' ? 'http://' : 'https://';
} }
@bindThis @bindThis
@ -59,7 +63,7 @@ export class FetchInstanceMetadataService {
return await this.redisClient.set( return await this.redisClient.set(
`fetchInstanceMetadata:mutex:v2:${host}`, '1', `fetchInstanceMetadata:mutex:v2:${host}`, '1',
'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395
'GET' // 古い値を返すなかったらnull 'GET', // 古い値を返すなかったらnull
); );
} }
@ -73,23 +77,24 @@ export class FetchInstanceMetadataService {
public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> { public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
const host = instance.host; const host = instance.host;
// finallyでunlockされてしまうのでtry内でロックチェックをしない if (!force) {
// returnであってもfinallyは実行される // キャッシュ有効チェックはロック取得前に行う
if (!force && await this.tryLock(host) === '1') { const _instance = await this.federatedInstanceService.fetch(host);
// 1が返ってきていたらロックされているという意味なので、何もしない const now = Date.now();
return; if (_instance && _instance.infoUpdatedAt != null && (now - _instance.infoUpdatedAt.getTime() < REMOTE_SERVER_CACHE_TTL)) {
this.logger.debug(`Skip because updated recently ${_instance.infoUpdatedAt.toJSON()}`);
return;
}
// finallyでunlockされてしまうのでtry内でロックチェックをしない
// returnであってもfinallyは実行される
if (await this.tryLock(host) === '1') {
// 1が返ってきていたら他にロックされているという意味なので、何もしない
return;
}
} }
try { try {
if (!force) {
const _instance = await this.federatedInstanceService.fetch(host);
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
// unlock at the finally caluse
return;
}
}
this.logger.info(`Fetching metadata of ${instance.host} ...`); this.logger.info(`Fetching metadata of ${instance.host} ...`);
const [info, dom, manifest] = await Promise.all([ const [info, dom, manifest] = await Promise.all([
@ -118,6 +123,14 @@ export class FetchInstanceMetadataService {
updates.openRegistrations = info.openRegistrations; updates.openRegistrations = info.openRegistrations;
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null; updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
if (info.metadata && info.metadata.httpMessageSignaturesImplementationLevel && (
info.metadata.httpMessageSignaturesImplementationLevel === '01' ||
info.metadata.httpMessageSignaturesImplementationLevel === '11'
)) {
updates.httpMessageSignaturesImplementationLevel = info.metadata.httpMessageSignaturesImplementationLevel;
} else {
updates.httpMessageSignaturesImplementationLevel = '00';
}
} }
if (name) updates.name = name; if (name) updates.name = name;
@ -129,6 +142,12 @@ export class FetchInstanceMetadataService {
await this.federatedInstanceService.update(instance.id, updates); await this.federatedInstanceService.update(instance.id, updates);
this.logger.succ(`Successfuly updated metadata of ${instance.host}`); this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
this.logger.debug('Updated metadata:', {
info: !!info,
dom: !!dom,
manifest: !!manifest,
updates,
});
} catch (e) { } catch (e) {
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
} finally { } finally {
@ -141,7 +160,7 @@ export class FetchInstanceMetadataService {
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`); this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try { try {
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') const wellknown = await this.httpRequestService.getJson(this.httpColon + instance.host + '/.well-known/nodeinfo')
.catch(err => { .catch(err => {
if (err.statusCode === 404) { if (err.statusCode === 404) {
throw new Error('No nodeinfo provided'); throw new Error('No nodeinfo provided');
@ -184,7 +203,7 @@ export class FetchInstanceMetadataService {
private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> { private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> {
this.logger.info(`Fetching HTML of ${instance.host} ...`); this.logger.info(`Fetching HTML of ${instance.host} ...`);
const url = 'https://' + instance.host; const url = this.httpColon + instance.host;
const html = await this.httpRequestService.getHtml(url); const html = await this.httpRequestService.getHtml(url);
@ -196,7 +215,7 @@ export class FetchInstanceMetadataService {
@bindThis @bindThis
private async fetchManifest(instance: MiInstance): Promise<Record<string, unknown> | null> { private async fetchManifest(instance: MiInstance): Promise<Record<string, unknown> | null> {
const url = 'https://' + instance.host; const url = this.httpColon + instance.host;
const manifestUrl = url + '/manifest.json'; const manifestUrl = url + '/manifest.json';
@ -207,7 +226,7 @@ export class FetchInstanceMetadataService {
@bindThis @bindThis
private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> { private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> {
const url = 'https://' + instance.host; const url = this.httpColon + instance.host;
if (doc) { if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
@ -234,12 +253,12 @@ export class FetchInstanceMetadataService {
@bindThis @bindThis
private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
const url = 'https://' + instance.host; const url = this.httpColon + instance.host;
return (new URL(manifest.icons[0].src, url)).href; return (new URL(manifest.icons[0].src, url)).href;
} }
if (doc) { if (doc) {
const url = 'https://' + instance.host; const url = this.httpColon + instance.host;
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const links = Array.from(doc.getElementsByTagName('link')).reverse(); const links = Array.from(doc.getElementsByTagName('link')).reverse();

View file

@ -245,6 +245,7 @@ export interface InternalEventTypes {
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userKeypairUpdated: { userId: MiUser['id']; };
} }
// name/messages(spec) pairs dictionary // name/messages(spec) pairs dictionary

View file

@ -70,7 +70,7 @@ export class HttpRequestService {
localAddress: config.outgoingAddress, localAddress: config.outgoingAddress,
}); });
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 16);
this.httpAgent = config.proxy this.httpAgent = config.proxy
? new HttpProxyAgent({ ? new HttpProxyAgent({

View file

@ -13,7 +13,6 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import type { import type {
DbJobData, DbJobData,
DeliverJobData, DeliverJobData,
@ -33,7 +32,7 @@ import type {
UserWebhookDeliverQueue, UserWebhookDeliverQueue,
SystemWebhookDeliverQueue, SystemWebhookDeliverQueue,
} from './QueueModule.js'; } from './QueueModule.js';
import type httpSignature from '@peertube/http-signature'; import { genRFC3230DigestHeader, type PrivateKeyWithPem, type ParsedSignature } from '@misskey-dev/node-http-message-signatures';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
@Injectable() @Injectable()
@ -90,21 +89,21 @@ export class QueueService {
} }
@bindThis @bindThis
public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { public async deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean, privateKey?: PrivateKeyWithPem) {
if (content == null) return null; if (content == null) return null;
if (to == null) return null; if (to == null) return null;
const contentBody = JSON.stringify(content); const contentBody = JSON.stringify(content);
const digest = ApRequestCreator.createDigest(contentBody);
const data: DeliverJobData = { const data: DeliverJobData = {
user: { user: {
id: user.id, id: user.id,
}, },
content: contentBody, content: contentBody,
digest, digest: await genRFC3230DigestHeader(contentBody, 'SHA-256'),
to, to,
isSharedInbox, isSharedInbox,
privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem },
}; };
return this.deliverQueue.add(to, data, { return this.deliverQueue.add(to, data, {
@ -122,13 +121,13 @@ export class QueueService {
* @param user `{ id: string; }` ThinUserに変換しないので前もって変換してください * @param user `{ id: string; }` ThinUserに変換しないので前もって変換してください
* @param content IActivity | null * @param content IActivity | null
* @param inboxes `Map<string, boolean>` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox) * @param inboxes `Map<string, boolean>` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox)
* @param forceMainKey boolean | undefined, force to use main (rsa) key
* @returns void * @returns void
*/ */
@bindThis @bindThis
public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) { public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>, privateKey?: PrivateKeyWithPem) {
if (content == null) return null; if (content == null) return null;
const contentBody = JSON.stringify(content); const contentBody = JSON.stringify(content);
const digest = ApRequestCreator.createDigest(contentBody);
const opts = { const opts = {
attempts: this.config.deliverJobMaxAttempts ?? 12, attempts: this.config.deliverJobMaxAttempts ?? 12,
@ -144,9 +143,9 @@ export class QueueService {
data: { data: {
user, user,
content: contentBody, content: contentBody,
digest,
to: d[0], to: d[0],
isSharedInbox: d[1], isSharedInbox: d[1],
privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem },
} as DeliverJobData, } as DeliverJobData,
opts, opts,
}))); })));
@ -155,7 +154,7 @@ export class QueueService {
} }
@bindThis @bindThis
public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { public inbox(activity: IActivity, signature: ParsedSignature | null) {
const data = { const data = {
activity: activity, activity: activity,
signature, signature,

View file

@ -16,6 +16,8 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { deepClone } from '@/misc/clone.js'; import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UserKeypairService } from './UserKeypairService.js';
import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
const ACTOR_USERNAME = 'relay.actor' as const; const ACTOR_USERNAME = 'relay.actor' as const;
@ -34,6 +36,7 @@ export class RelayService {
private queueService: QueueService, private queueService: QueueService,
private createSystemUserService: CreateSystemUserService, private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private userKeypairService: UserKeypairService,
) { ) {
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10);
} }
@ -111,7 +114,7 @@ export class RelayService {
} }
@bindThis @bindThis
public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any): Promise<void> { public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any, privateKey?: PrivateKeyWithPem): Promise<void> {
if (activity == null) return; if (activity == null) return;
const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({
@ -121,11 +124,9 @@ export class RelayService {
const copy = deepClone(activity); const copy = deepClone(activity);
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
privateKey = privateKey ?? await this.userKeypairService.getLocalUserPrivateKeyPem(user.id);
const signed = await this.apRendererService.attachLdSignature(copy, privateKey);
const signed = await this.apRendererService.attachLdSignature(copy, user); this.queueService.deliverMany(user, signed, new Map(relays.map(({ inbox }) => [inbox, false])), privateKey);
for (const relay of relays) {
this.queueService.deliver(user, signed, relay.inbox, false);
}
} }
} }

View file

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { generateKeyPair } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { DataSource, IsNull } from 'typeorm'; import { DataSource, IsNull } from 'typeorm';
@ -21,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import UsersChart from '@/core/chart/charts/users.js'; import UsersChart from '@/core/chart/charts/users.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js';
@Injectable() @Injectable()
export class SignupService { export class SignupService {
@ -93,22 +93,7 @@ export class SignupService {
} }
} }
const keyPair = await new Promise<string[]>((res, rej) => const keyPair = await genRSAAndEd25519KeyPair();
generateKeyPair('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: undefined,
passphrase: undefined,
},
}, (err, publicKey, privateKey) =>
err ? rej(err) : res([publicKey, privateKey]),
));
let account!: MiUser; let account!: MiUser;
@ -131,9 +116,8 @@ export class SignupService {
})); }));
await transactionalEntityManager.save(new MiUserKeypair({ await transactionalEntityManager.save(new MiUserKeypair({
publicKey: keyPair[0],
privateKey: keyPair[1],
userId: account.id, userId: account.id,
...keyPair,
})); }));
await transactionalEntityManager.save(new MiUserProfile({ await transactionalEntityManager.save(new MiUserProfile({

View file

@ -5,41 +5,184 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { genEd25519KeyPair, importPrivateKey, PrivateKey, PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { UserKeypairsRepository } from '@/models/_.js'; import type { UserKeypairsRepository } from '@/models/_.js';
import { RedisKVCache } from '@/misc/cache.js'; import { RedisKVCache, MemoryKVCache } from '@/misc/cache.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { GlobalEventService, GlobalEvents } from '@/core/GlobalEventService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { webcrypto } from 'node:crypto';
@Injectable() @Injectable()
export class UserKeypairService implements OnApplicationShutdown { export class UserKeypairService implements OnApplicationShutdown {
private cache: RedisKVCache<MiUserKeypair>; private keypairEntityCache: RedisKVCache<MiUserKeypair>;
private privateKeyObjectCache: MemoryKVCache<webcrypto.CryptoKey>;
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redis)
private redisClient: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.userKeypairsRepository) @Inject(DI.userKeypairsRepository)
private userKeypairsRepository: UserKeypairsRepository, private userKeypairsRepository: UserKeypairsRepository,
private globalEventService: GlobalEventService,
private userEntityService: UserEntityService,
) { ) {
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', { this.keypairEntityCache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
lifetime: 1000 * 60 * 60 * 24, // 24h lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: Infinity, memoryCacheLifetime: Infinity,
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
toRedisConverter: (value) => JSON.stringify(value), toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value), fromRedisConverter: (value) => JSON.parse(value),
}); });
this.privateKeyObjectCache = new MemoryKVCache<webcrypto.CryptoKey>(1000 * 60 * 60 * 1);
this.redisForSub.on('message', this.onMessage);
} }
@bindThis @bindThis
public async getUserKeypair(userId: MiUser['id']): Promise<MiUserKeypair> { public async getUserKeypair(userId: MiUser['id']): Promise<MiUserKeypair> {
return await this.cache.fetch(userId); return await this.keypairEntityCache.fetch(userId);
}
/**
* Get private key [Only PrivateKeyWithPem for queue data etc.]
* @param userIdOrHint user id or MiUserKeypair
* @param preferType
* If ed25519-like(`ed25519`, `01`, `11`) is specified, ed25519 keypair will be returned if exists.
* Otherwise, main keypair will be returned.
* @returns
*/
@bindThis
public async getLocalUserPrivateKeyPem(
userIdOrHint: MiUser['id'] | MiUserKeypair,
preferType?: string,
): Promise<PrivateKeyWithPem> {
const keypair = typeof userIdOrHint === 'string' ? await this.getUserKeypair(userIdOrHint) : userIdOrHint;
if (
preferType && ['01', '11', 'ed25519'].includes(preferType.toLowerCase()) &&
keypair.ed25519PublicKey != null && keypair.ed25519PrivateKey != null
) {
return {
keyId: `${this.userEntityService.genLocalUserUri(keypair.userId)}#ed25519-key`,
privateKeyPem: keypair.ed25519PrivateKey,
};
}
return {
keyId: `${this.userEntityService.genLocalUserUri(keypair.userId)}#main-key`,
privateKeyPem: keypair.privateKey,
};
}
/**
* Get private key [Only PrivateKey for ap request]
* Using cache due to performance reasons of `crypto.subtle.importKey`
* @param userIdOrHint user id, MiUserKeypair, or PrivateKeyWithPem
* @param preferType
* If ed25519-like(`ed25519`, `01`, `11`) is specified, ed25519 keypair will be returned if exists.
* Otherwise, main keypair will be returned. (ignored if userIdOrHint is PrivateKeyWithPem)
* @returns
*/
@bindThis
public async getLocalUserPrivateKey(
userIdOrHint: MiUser['id'] | MiUserKeypair | PrivateKeyWithPem,
preferType?: string,
): Promise<PrivateKey> {
if (typeof userIdOrHint === 'object' && 'privateKeyPem' in userIdOrHint) {
// userIdOrHint is PrivateKeyWithPem
return {
keyId: userIdOrHint.keyId,
privateKey: await this.privateKeyObjectCache.fetch(userIdOrHint.keyId, async () => {
return await importPrivateKey(userIdOrHint.privateKeyPem);
}),
};
}
const userId = typeof userIdOrHint === 'string' ? userIdOrHint : userIdOrHint.userId;
const getKeypair = () => typeof userIdOrHint === 'string' ? this.getUserKeypair(userId) : userIdOrHint;
if (preferType && ['01', '11', 'ed25519'].includes(preferType.toLowerCase())) {
const keyId = `${this.userEntityService.genLocalUserUri(userId)}#ed25519-key`;
const fetched = await this.privateKeyObjectCache.fetchMaybe(keyId, async () => {
const keypair = await getKeypair();
if (keypair.ed25519PublicKey != null && keypair.ed25519PrivateKey != null) {
return await importPrivateKey(keypair.ed25519PrivateKey);
}
return;
});
if (fetched) {
return {
keyId,
privateKey: fetched,
};
}
}
const keyId = `${this.userEntityService.genLocalUserUri(userId)}#main-key`;
return {
keyId,
privateKey: await this.privateKeyObjectCache.fetch(keyId, async () => {
const keypair = await getKeypair();
return await importPrivateKey(keypair.privateKey);
}),
};
} }
@bindThis
public async refresh(userId: MiUser['id']): Promise<void> {
return await this.keypairEntityCache.refresh(userId);
}
/**
* If DB has ed25519 keypair, refresh cache and return it.
* If not, create, save and return ed25519 keypair.
* @param userId user id
* @returns MiUserKeypair if keypair is created, void if keypair is already exists
*/
@bindThis
public async refreshAndPrepareEd25519KeyPair(userId: MiUser['id']): Promise<MiUserKeypair | void> {
await this.refresh(userId);
const keypair = await this.keypairEntityCache.fetch(userId);
if (keypair.ed25519PublicKey != null) {
return;
}
const ed25519 = await genEd25519KeyPair();
await this.userKeypairsRepository.update({ userId }, {
ed25519PublicKey: ed25519.publicKey,
ed25519PrivateKey: ed25519.privateKey,
});
this.globalEventService.publishInternalEvent('userKeypairUpdated', { userId });
const result = {
...keypair,
ed25519PublicKey: ed25519.publicKey,
ed25519PrivateKey: ed25519.privateKey,
};
this.keypairEntityCache.set(userId, result);
return result;
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'userKeypairUpdated': {
this.refresh(body.userId);
break;
}
}
}
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.cache.dispose(); this.keypairEntityCache.dispose();
} }
@bindThis @bindThis

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import type { RenoteMutingsRepository } from '@/models/_.js';
import type { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
@Injectable()
export class UserRenoteMutingService {
constructor(
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
private idService: IdService,
private cacheService: CacheService,
) {
}
@bindThis
public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise<void> {
await this.renoteMutingsRepository.insert({
id: this.idService.gen(),
muterId: user.id,
muteeId: target.id,
});
await this.cacheService.renoteMutingsCache.refresh(user.id);
}
@bindThis
public async unmute(mutings: MiRenoteMuting[]): Promise<void> {
if (mutings.length === 0) return;
await this.renoteMutingsRepository.delete({
id: In(mutings.map(m => m.id)),
});
const muterIds = [...new Set(mutings.map(m => m.muterId))];
for (const muterId of muterIds) {
await this.cacheService.renoteMutingsCache.refresh(muterId);
}
}
}

View file

@ -3,27 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UserKeypairService } from './UserKeypairService.js';
import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
@Injectable() @Injectable()
export class UserSuspendService { export class UserSuspendService {
constructor( constructor(
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private queueService: QueueService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private userKeypairService: UserKeypairService,
private apDeliverManagerService: ApDeliverManagerService,
) { ) {
} }
@ -32,28 +28,12 @@ export class UserSuspendService {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
const queue: string[] = []; manager.addAllKnowingSharedInboxRecipe();
// process deliver時にはキーペアが消去されているはずなので、ここで挿入する
const followings = await this.followingsRepository.find({ const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main');
where: [ manager.execute({ privateKey });
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user, content, inbox, true);
}
} }
} }
@ -62,28 +42,12 @@ export class UserSuspendService {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
const queue: string[] = []; manager.addAllKnowingSharedInboxRecipe();
// process deliver時にはキーペアが消去されているはずなので、ここで挿入する
const followings = await this.followingsRepository.find({ const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main');
where: [ manager.execute({ privateKey });
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user as any, content, inbox, true);
}
} }
} }
} }

View file

@ -46,7 +46,7 @@ export class WebfingerService {
const m = query.match(mRegex); const m = query.match(mRegex);
if (m) { if (m) {
const hostname = m[2]; const hostname = m[2];
const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; const useHttp = process.env.MISSKEY_USE_HTTP && process.env.MISSKEY_USE_HTTP.toLowerCase() === 'true';
return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`; return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`;
} }

View file

@ -5,7 +5,7 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; import type { MiUser, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { MemoryKVCache } from '@/misc/cache.js'; import { MemoryKVCache } from '@/misc/cache.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js';
@ -13,9 +13,12 @@ import { CacheService } from '@/core/CacheService.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import Logger from '@/logger.js';
import { getApId } from './type.js'; import { getApId } from './type.js';
import { ApPersonService } from './models/ApPersonService.js'; import { ApPersonService } from './models/ApPersonService.js';
import { ApLoggerService } from './ApLoggerService.js';
import type { IObject } from './type.js'; import type { IObject } from './type.js';
import { UtilityService } from '../UtilityService.js';
export type UriParseResult = { export type UriParseResult = {
/** wether the URI was generated by us */ /** wether the URI was generated by us */
@ -35,8 +38,8 @@ export type UriParseResult = {
@Injectable() @Injectable()
export class ApDbResolverService implements OnApplicationShutdown { export class ApDbResolverService implements OnApplicationShutdown {
private publicKeyCache: MemoryKVCache<MiUserPublickey | null>; private publicKeyByUserIdCache: MemoryKVCache<MiUserPublickey[] | null>;
private publicKeyByUserIdCache: MemoryKVCache<MiUserPublickey | null>; private logger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -53,9 +56,17 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
private apLoggerService: ApLoggerService,
private utilityService: UtilityService,
) { ) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey[] | null>(Infinity);
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.logger = this.apLoggerService.logger.createSubLogger('db-resolver');
}
private punyHost(url: string): string {
const urlObj = new URL(url);
const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
return host;
} }
@bindThis @bindThis
@ -116,62 +127,141 @@ export class ApDbResolverService implements OnApplicationShutdown {
} }
} }
/**
* AP KeyId => Misskey User and Key
*/
@bindThis @bindThis
public async getAuthUserFromKeyId(keyId: string): Promise<{ private async refreshAndFindKey(userId: MiUser['id'], keyId: string): Promise<MiUserPublickey | null> {
user: MiRemoteUser; this.refreshCacheByUserId(userId);
key: MiUserPublickey; const keys = await this.getPublicKeyByUserId(userId);
} | null> { if (keys == null || !Array.isArray(keys) || keys.length === 0) {
const key = await this.publicKeyCache.fetch(keyId, async () => { this.logger.warn(`No key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`);
const key = await this.userPublickeysRepository.findOneBy({ return null;
keyId, }
}); const exactKey = keys.find(x => x.keyId === keyId);
if (exactKey) return exactKey;
if (key == null) return null; this.logger.warn(`No exact key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`);
return null;
return key;
}, key => key != null);
if (key == null) return null;
const user = await this.cacheService.findUserById(key.userId).catch(() => null) as MiRemoteUser | null;
if (user == null) return null;
if (user.isDeleted) return null;
return {
user,
key,
};
} }
/** /**
* AP Actor id => Misskey User and Key * AP Actor id => Misskey User and Key
* @param uri AP Actor id
* @param keyId Key id to find. If not specified, main key will be selected.
* @returns
* 1. `null` if the user and key host do not match
* 2. `{ user: null, key: null }` if the user is not found
* 3. `{ user: MiRemoteUser, key: null }` if key is not found
* 4. `{ user: MiRemoteUser, key: MiUserPublickey }` if both are found
*/ */
@bindThis @bindThis
public async getAuthUserFromApId(uri: string): Promise<{ public async getAuthUserFromApId(uri: string, keyId?: string): Promise<{
user: MiRemoteUser; user: MiRemoteUser;
key: MiUserPublickey | null; key: MiUserPublickey | null;
} | null> { } | {
const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser; user: null;
if (user.isDeleted) return null; key: null;
} |
null> {
if (keyId) {
if (this.punyHost(uri) !== this.punyHost(keyId)) {
/**
* keyIdはURL形式かつkeyIdのホストはuriのホストと一致するはず
* ApPersonService.validateActorに由来
*
* Mastodonはリプライ関連で他人のトゥートをHTTP Signature署名して送ってくることがある
*
* uriとkeyIdのホストが一致しない場合は無視する
* keyIdとuriの同一性を比べてみてもいいが`uri#*-key`keyIdを設定するのが
*
*
*
* The keyId should be in URL format and its host should match the host of the uri
* (derived from ApPersonService.validateActor)
*
* However, Mastodon sometimes sends toots from other users with HTTP Signature signing for reply-related purposes
* Such signatures are of questionable validity, so we choose to ignore them
* Here, we ignore cases where the hosts of uri and keyId do not match
* We could also compare the equality of keyId without the hash and uri, but since setting a keyId like `uri#*-key`
* is not a strict rule, we decide to allow for some flexibility
*/
this.logger.warn(`actor uri and keyId are not matched uri=${uri} keyId=${keyId}`);
return null;
}
}
const key = await this.publicKeyByUserIdCache.fetch( const user = await this.apPersonService.resolvePerson(uri, undefined, true) as MiRemoteUser;
user.id, if (user.isDeleted) return { user: null, key: null };
() => this.userPublickeysRepository.findOneBy({ userId: user.id }),
const keys = await this.getPublicKeyByUserId(user.id);
if (keys == null || !Array.isArray(keys) || keys.length === 0) {
this.logger.warn(`No key found uri=${uri} userId=${user.id} keys=${JSON.stringify(keys)}`);
return { user, key: null };
}
if (!keyId) {
// Choose the main-like
const mainKey = keys.find(x => {
try {
const url = new URL(x.keyId);
const path = url.pathname.split('/').pop()?.toLowerCase();
if (url.hash) {
if (url.hash.toLowerCase().includes('main')) {
return true;
}
} else if (path?.includes('main') || path === 'publickey') {
return true;
}
} catch { /* noop */ }
return false;
});
return { user, key: mainKey ?? keys[0] };
}
const exactKey = keys.find(x => x.keyId === keyId);
if (exactKey) return { user, key: exactKey };
/**
* keyIdで見つからない場合
* If not found with keyId, update cache and reacquire
*/
const cacheRaw = this.publicKeyByUserIdCache.cache.get(user.id);
if (cacheRaw && cacheRaw.date > Date.now() - 1000 * 60 * 12) {
const exactKey = await this.refreshAndFindKey(user.id, keyId);
if (exactKey) return { user, key: exactKey };
}
/**
* lastFetchedAtでの更新制限を弱めて再取得
* Reacquisition with weakened update limit at lastFetchedAt
*/
if (user.lastFetchedAt == null || user.lastFetchedAt < new Date(Date.now() - 1000 * 60 * 12)) {
this.logger.info(`Fetching user to find public key uri=${uri} userId=${user.id} keyId=${keyId}`);
const renewed = await this.apPersonService.fetchPersonWithRenewal(uri, 0);
if (renewed == null || renewed.isDeleted) return null;
return { user, key: await this.refreshAndFindKey(user.id, keyId) };
}
this.logger.warn(`No key found uri=${uri} userId=${user.id} keyId=${keyId}`);
return { user, key: null };
}
@bindThis
public async getPublicKeyByUserId(userId: MiUser['id']): Promise<MiUserPublickey[] | null> {
return await this.publicKeyByUserIdCache.fetch(
userId,
() => this.userPublickeysRepository.find({ where: { userId } }),
v => v != null, v => v != null,
); );
}
return { @bindThis
user, public refreshCacheByUserId(userId: MiUser['id']): void {
key, this.publicKeyByUserIdCache.delete(userId);
};
} }
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.publicKeyCache.dispose();
this.publicKeyByUserIdCache.dispose(); this.publicKeyByUserIdCache.dispose();
} }

View file

@ -9,10 +9,14 @@ import { DI } from '@/di-symbols.js';
import type { FollowingsRepository } from '@/models/_.js'; import type { FollowingsRepository } from '@/models/_.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { IActivity } from '@/core/activitypub/type.js'; import type { IActivity } from '@/core/activitypub/type.js';
import { ThinUser } from '@/queue/types.js'; import { ThinUser } from '@/queue/types.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import type Logger from '@/logger.js';
import { UserKeypairService } from '../UserKeypairService.js';
import { ApLoggerService } from './ApLoggerService.js';
import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
interface IRecipe { interface IRecipe {
type: string; type: string;
@ -27,12 +31,19 @@ interface IDirectRecipe extends IRecipe {
to: MiRemoteUser; to: MiRemoteUser;
} }
interface IAllKnowingSharedInboxRecipe extends IRecipe {
type: 'AllKnowingSharedInbox';
}
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe => const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
recipe.type === 'Followers'; recipe.type === 'Followers';
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe => const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
recipe.type === 'Direct'; recipe.type === 'Direct';
const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe =>
recipe.type === 'AllKnowingSharedInbox';
class DeliverManager { class DeliverManager {
private actor: ThinUser; private actor: ThinUser;
private activity: IActivity | null; private activity: IActivity | null;
@ -40,16 +51,18 @@ class DeliverManager {
/** /**
* Constructor * Constructor
* @param userEntityService * @param userKeypairService
* @param followingsRepository * @param followingsRepository
* @param queueService * @param queueService
* @param actor Actor * @param actor Actor
* @param activity Activity to deliver * @param activity Activity to deliver
*/ */
constructor( constructor(
private userEntityService: UserEntityService, private userKeypairService: UserKeypairService,
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private queueService: QueueService, private queueService: QueueService,
private accountUpdateService: AccountUpdateService,
private logger: Logger,
actor: { id: MiUser['id']; host: null; }, actor: { id: MiUser['id']; host: null; },
activity: IActivity | null, activity: IActivity | null,
@ -91,6 +104,18 @@ class DeliverManager {
this.addRecipe(recipe); this.addRecipe(recipe);
} }
/**
* Add recipe for all-knowing shared inbox deliver
*/
@bindThis
public addAllKnowingSharedInboxRecipe(): void {
const deliver: IAllKnowingSharedInboxRecipe = {
type: 'AllKnowingSharedInbox',
};
this.addRecipe(deliver);
}
/** /**
* Add recipe * Add recipe
* @param recipe Recipe * @param recipe Recipe
@ -104,11 +129,44 @@ class DeliverManager {
* Execute delivers * Execute delivers
*/ */
@bindThis @bindThis
public async execute(): Promise<void> { public async execute(opts?: { privateKey?: PrivateKeyWithPem }): Promise<void> {
//#region MIGRATION
if (!opts?.privateKey) {
/**
* ed25519の署名がなければ追加する
*/
const created = await this.userKeypairService.refreshAndPrepareEd25519KeyPair(this.actor.id);
if (created) {
// createdが存在するということは新規作成されたということなので、フォロワーに配信する
this.logger.info(`ed25519 key pair created for user ${this.actor.id} and publishing to followers`);
// リモートに配信
const keyPair = await this.userKeypairService.getLocalUserPrivateKeyPem(created, 'main');
await this.accountUpdateService.publishToFollowers(this.actor.id, keyPair);
}
}
//#endregion
//#region collect inboxes by recipes
// The value flags whether it is shared or not. // The value flags whether it is shared or not.
// key: inbox URL, value: whether it is sharedInbox // key: inbox URL, value: whether it is sharedInbox
const inboxes = new Map<string, boolean>(); const inboxes = new Map<string, boolean>();
if (this.recipes.some(r => isAllKnowingSharedInbox(r))) {
// all-knowing shared inbox
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
for (const following of followings) {
if (following.followeeSharedInbox) inboxes.set(following.followeeSharedInbox, true);
if (following.followerSharedInbox) inboxes.set(following.followerSharedInbox, true);
}
}
// build inbox list // build inbox list
// Process follower recipes first to avoid duplication when processing direct recipes later. // Process follower recipes first to avoid duplication when processing direct recipes later.
if (this.recipes.some(r => isFollowers(r))) { if (this.recipes.some(r => isFollowers(r))) {
@ -142,39 +200,49 @@ class DeliverManager {
inboxes.set(recipe.to.inbox, false); inboxes.set(recipe.to.inbox, false);
} }
//#endregion
// deliver // deliver
await this.queueService.deliverMany(this.actor, this.activity, inboxes); await this.queueService.deliverMany(this.actor, this.activity, inboxes, opts?.privateKey);
this.logger.info(`Deliver queues dispatched: inboxes=${inboxes.size} actorId=${this.actor.id} activityId=${this.activity?.id}`);
} }
} }
@Injectable() @Injectable()
export class ApDeliverManagerService { export class ApDeliverManagerService {
private logger: Logger;
constructor( constructor(
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService, private userKeypairService: UserKeypairService,
private queueService: QueueService, private queueService: QueueService,
private accountUpdateService: AccountUpdateService,
private apLoggerService: ApLoggerService,
) { ) {
this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager');
} }
/** /**
* Deliver activity to followers * Deliver activity to followers
* @param actor * @param actor
* @param activity Activity * @param activity Activity
* @param forceMainKey Force to use main (rsa) key
*/ */
@bindThis @bindThis
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> { public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, privateKey?: PrivateKeyWithPem): Promise<void> {
const manager = new DeliverManager( const manager = new DeliverManager(
this.userEntityService, this.userKeypairService,
this.followingsRepository, this.followingsRepository,
this.queueService, this.queueService,
this.accountUpdateService,
this.logger,
actor, actor,
activity, activity,
); );
manager.addFollowersRecipe(); manager.addFollowersRecipe();
await manager.execute(); await manager.execute({ privateKey });
} }
/** /**
@ -186,9 +254,11 @@ export class ApDeliverManagerService {
@bindThis @bindThis
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> { public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
const manager = new DeliverManager( const manager = new DeliverManager(
this.userEntityService, this.userKeypairService,
this.followingsRepository, this.followingsRepository,
this.queueService, this.queueService,
this.accountUpdateService,
this.logger,
actor, actor,
activity, activity,
); );
@ -199,10 +269,11 @@ export class ApDeliverManagerService {
@bindThis @bindThis
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
return new DeliverManager( return new DeliverManager(
this.userEntityService, this.userKeypairService,
this.followingsRepository, this.followingsRepository,
this.queueService, this.queueService,
this.accountUpdateService,
this.logger,
actor, actor,
activity, activity,
); );

View file

@ -114,15 +114,8 @@ export class ApInboxService {
result = await this.performOneActivity(actor, activity); result = await this.performOneActivity(actor, activity);
} }
// ついでにリモートユーザーの情報が古かったら更新しておく // ついでにリモートユーザーの情報が古かったら更新しておく?
if (actor.uri) { // → No, この関数が呼び出される前に署名検証で更新されているはず
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => {
this.apPersonService.updatePerson(actor.uri);
});
}
}
return result;
} }
@bindThis @bindThis

View file

@ -22,7 +22,6 @@ import { UserKeypairService } from '@/core/UserKeypairService.js';
import { MfmService } from '@/core/MfmService.js'; import { MfmService } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js';
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js'; import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
@ -31,6 +30,7 @@ import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js'; import { CONTEXT } from './misc/contexts.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
@Injectable() @Injectable()
export class ApRendererService { export class ApRendererService {
@ -251,15 +251,15 @@ export class ApRendererService {
} }
@bindThis @bindThis
public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey { public renderKey(user: MiLocalUser, publicKey: string, postfix?: string): IKey {
return { return {
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, id: `${this.userEntityService.genLocalUserUri(user.id)}${postfix ?? '/publickey'}`,
type: 'Key', type: 'Key',
owner: this.userEntityService.genLocalUserUri(user.id), owner: this.userEntityService.genLocalUserUri(user.id),
publicKeyPem: createPublicKey(key.publicKey).export({ publicKeyPem: createPublicKey(publicKey).export({
type: 'spki', type: 'spki',
format: 'pem', format: 'pem',
}), }) as string,
}; };
} }
@ -499,7 +499,10 @@ export class ApRendererService {
tag, tag,
manuallyApprovesFollowers: user.isLocked, manuallyApprovesFollowers: user.isLocked,
discoverable: user.isExplorable, discoverable: user.isExplorable,
publicKey: this.renderKey(user, keypair, '#main-key'), publicKey: this.renderKey(user, keypair.publicKey, '#main-key'),
additionalPublicKeys: [
...(keypair.ed25519PublicKey ? [this.renderKey(user, keypair.ed25519PublicKey, '#ed25519-key')] : []),
],
isCat: user.isCat, isCat: user.isCat,
attachment: attachment.length ? attachment : undefined, attachment: attachment.length ? attachment : undefined,
}; };
@ -622,12 +625,10 @@ export class ApRendererService {
} }
@bindThis @bindThis
public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise<IActivity> { public async attachLdSignature(activity: any, key: PrivateKeyWithPem): Promise<IActivity> {
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const jsonLd = this.jsonLdService.use(); const jsonLd = this.jsonLdService.use();
jsonLd.debug = false; jsonLd.debug = false;
activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); activity = await jsonLd.signRsaSignature2017(activity, key.privateKeyPem, key.keyId);
return activity; return activity;
} }

View file

@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as crypto from 'node:crypto';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { genRFC3230DigestHeader, signAsDraftToRequest } from '@misskey-dev/node-http-message-signatures';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
@ -15,122 +15,61 @@ import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import type { PrivateKeyWithPem, PrivateKey } from '@misskey-dev/node-http-message-signatures';
type Request = { export async function createSignedPost(args: { level: string; key: PrivateKey; url: string; body: string; digest?: string, additionalHeaders: Record<string, string> }) {
url: string; const u = new URL(args.url);
method: string; const request = {
headers: Record<string, string>; url: u.href,
}; method: 'POST',
headers: {
'Date': new Date().toUTCString(),
'Host': u.host,
'Content-Type': 'application/activity+json',
...args.additionalHeaders,
} as Record<string, string>,
};
type Signed = { // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする
request: Request; const digestHeader = args.digest ?? await genRFC3230DigestHeader(args.body, 'SHA-256');
signingString: string; request.headers['Digest'] = digestHeader;
signature: string;
signatureHeader: string;
};
type PrivateKey = { const result = await signAsDraftToRequest(
privateKeyPem: string; request,
keyId: string; args.key,
}; ['(request-target)', 'date', 'host', 'digest'],
);
export class ApRequestCreator { return {
static createSignedPost(args: { key: PrivateKey, url: string, body: string, digest?: string, additionalHeaders: Record<string, string> }): Signed { request,
const u = new URL(args.url); ...result,
const digestHeader = args.digest ?? this.createDigest(args.body); };
}
const request: Request = { export async function createSignedGet(args: { level: string; key: PrivateKey; url: string; additionalHeaders: Record<string, string> }) {
url: u.href, const u = new URL(args.url);
method: 'POST', const request = {
headers: this.#objectAssignWithLcKey({ url: u.href,
'Date': new Date().toUTCString(), method: 'GET',
'Host': u.host, headers: {
'Content-Type': 'application/activity+json', 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'Digest': digestHeader, 'Date': new Date().toUTCString(),
}, args.additionalHeaders), 'Host': new URL(args.url).host,
}; ...args.additionalHeaders,
} as Record<string, string>,
};
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする
const result = await signAsDraftToRequest(
request,
args.key,
['(request-target)', 'date', 'host', 'accept'],
);
return { return {
request, request,
signingString: result.signingString, ...result,
signature: result.signature, };
signatureHeader: result.signatureHeader,
};
}
static createDigest(body: string) {
return `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}`;
}
static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const request: Request = {
url: u.href,
method: 'GET',
headers: this.#objectAssignWithLcKey({
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'Date': new Date().toUTCString(),
'Host': new URL(args.url).host,
}, args.additionalHeaders),
};
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
const signingString = this.#genSigningString(request, includeHeaders);
const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
request.headers = this.#objectAssignWithLcKey(request.headers, {
Signature: signatureHeader,
});
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete request.headers['host'];
return {
request,
signingString,
signature,
signatureHeader,
};
}
static #genSigningString(request: Request, includeHeaders: string[]): string {
request.headers = this.#lcObjectKey(request.headers);
const results: string[] = [];
for (const key of includeHeaders.map(x => x.toLowerCase())) {
if (key === '(request-target)') {
results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
} else {
results.push(`${key}: ${request.headers[key]}`);
}
}
return results.join('\n');
}
static #lcObjectKey(src: Record<string, string>): Record<string, string> {
const dst: Record<string, string> = {};
for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
return dst;
}
static #objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b));
}
} }
@Injectable() @Injectable()
@ -150,21 +89,28 @@ export class ApRequestService {
} }
@bindThis @bindThis
public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> { public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, level: string, digest?: string, key?: PrivateKeyWithPem): Promise<void> {
const body = typeof object === 'string' ? object : JSON.stringify(object); const body = typeof object === 'string' ? object : JSON.stringify(object);
const keyFetched = await this.userKeypairService.getLocalUserPrivateKey(key ?? user.id, level);
const keypair = await this.userKeypairService.getUserKeypair(user.id); const req = await createSignedPost({
level,
const req = ApRequestCreator.createSignedPost({ key: keyFetched,
key: {
privateKeyPem: keypair.privateKey,
keyId: `${this.config.url}/users/${user.id}#main-key`,
},
url, url,
body, body,
digest,
additionalHeaders: { additionalHeaders: {
'User-Agent': this.config.userAgent,
}, },
digest,
});
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete req.request.headers['Host'];
this.logger.debug('create signed post', {
version: 'draft',
level,
url,
keyId: keyFetched.keyId,
}); });
await this.httpRequestService.send(url, { await this.httpRequestService.send(url, {
@ -180,19 +126,27 @@ export class ApRequestService {
* @param url URL to fetch * @param url URL to fetch
*/ */
@bindThis @bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> { public async signedGet(url: string, user: { id: MiUser['id'] }, level: string): Promise<unknown> {
const keypair = await this.userKeypairService.getUserKeypair(user.id); const key = await this.userKeypairService.getLocalUserPrivateKey(user.id, level);
const req = await createSignedGet({
const req = ApRequestCreator.createSignedGet({ level,
key: { key,
privateKeyPem: keypair.privateKey,
keyId: `${this.config.url}/users/${user.id}#main-key`,
},
url, url,
additionalHeaders: { additionalHeaders: {
'User-Agent': this.config.userAgent,
}, },
}); });
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete req.request.headers['Host'];
this.logger.debug('create signed get', {
version: 'draft',
level,
url,
keyId: key.keyId,
});
const res = await this.httpRequestService.send(url, { const res = await this.httpRequestService.send(url, {
method: req.request.method, method: req.request.method,
headers: req.request.headers, headers: req.request.headers,

View file

@ -16,6 +16,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { isCollectionOrOrderedCollection } from './type.js'; import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js'; import { ApRendererService } from './ApRendererService.js';
@ -41,6 +42,7 @@ export class Resolver {
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private federatedInstanceService: FederatedInstanceService,
private loggerService: LoggerService, private loggerService: LoggerService,
private recursionLimit = 100, private recursionLimit = 100,
) { ) {
@ -103,8 +105,10 @@ export class Resolver {
this.user = await this.instanceActorService.getInstanceActor(); this.user = await this.instanceActorService.getInstanceActor();
} }
const server = await this.federatedInstanceService.fetch(host);
const object = (this.user const object = (this.user
? await this.apRequestService.signedGet(value, this.user) as IObject ? await this.apRequestService.signedGet(value, this.user, server.httpMessageSignaturesImplementationLevel) as IObject
: await this.httpRequestService.getActivityJson(value)) as IObject; : await this.httpRequestService.getActivityJson(value)) as IObject;
if ( if (
@ -200,6 +204,7 @@ export class ApResolverService {
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private federatedInstanceService: FederatedInstanceService,
private loggerService: LoggerService, private loggerService: LoggerService,
) { ) {
} }
@ -220,6 +225,7 @@ export class ApResolverService {
this.httpRequestService, this.httpRequestService,
this.apRendererService, this.apRendererService,
this.apDbResolverService, this.apDbResolverService,
this.federatedInstanceService,
this.loggerService, this.loggerService,
); );
} }

View file

@ -134,6 +134,7 @@ const security_v1 = {
'privateKey': { '@id': 'sec:privateKey', '@type': '@id' }, 'privateKey': { '@id': 'sec:privateKey', '@type': '@id' },
'privateKeyPem': 'sec:privateKeyPem', 'privateKeyPem': 'sec:privateKeyPem',
'publicKey': { '@id': 'sec:publicKey', '@type': '@id' }, 'publicKey': { '@id': 'sec:publicKey', '@type': '@id' },
'additionalPublicKeys': { '@id': 'sec:publicKey', '@type': '@id' },
'publicKeyBase58': 'sec:publicKeyBase58', 'publicKeyBase58': 'sec:publicKeyBase58',
'publicKeyPem': 'sec:publicKeyPem', 'publicKeyPem': 'sec:publicKeyPem',
'publicKeyWif': 'sec:publicKeyWif', 'publicKeyWif': 'sec:publicKeyWif',

View file

@ -3,9 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { verify } from 'crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import promiseLimit from 'promise-limit'; import promiseLimit from 'promise-limit';
import { DataSource } from 'typeorm'; import { DataSource, In, Not } from 'typeorm';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
@ -39,6 +40,7 @@ import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js'; import { checkHttps } from '@/misc/check-https.js';
import { REMOTE_USER_CACHE_TTL, REMOTE_USER_MOVE_COOLDOWN } from '@/const.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
@ -48,7 +50,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js';
import type { ApLoggerService } from '../ApLoggerService.js'; import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js'; import type { ApImageService } from './ApImageService.js';
import type { IActor, IObject } from '../type.js'; import type { IActor, IKey, IObject } from '../type.js';
const nameLength = 128; const nameLength = 128;
const summaryLength = 2048; const summaryLength = 2048;
@ -185,13 +187,38 @@ export class ApPersonService implements OnModuleInit {
} }
if (x.publicKey) { if (x.publicKey) {
if (typeof x.publicKey.id !== 'string') { const publicKeys = Array.isArray(x.publicKey) ? x.publicKey : [x.publicKey];
throw new Error('invalid Actor: publicKey.id is not a string');
for (const publicKey of publicKeys) {
if (typeof publicKey.id !== 'string') {
throw new Error('invalid Actor: publicKey.id is not a string');
}
const publicKeyIdHost = this.punyHost(publicKey.id);
if (publicKeyIdHost !== expectHost) {
throw new Error('invalid Actor: publicKey.id has different host');
}
}
}
if (x.additionalPublicKeys) {
if (!x.publicKey) {
throw new Error('invalid Actor: additionalPublicKeys is set but publicKey is not');
} }
const publicKeyIdHost = this.punyHost(x.publicKey.id); if (!Array.isArray(x.additionalPublicKeys)) {
if (publicKeyIdHost !== expectHost) { throw new Error('invalid Actor: additionalPublicKeys is not an array');
throw new Error('invalid Actor: publicKey.id has different host'); }
for (const key of x.additionalPublicKeys) {
if (typeof key.id !== 'string') {
throw new Error('invalid Actor: additionalPublicKeys.id is not a string');
}
const keyIdHost = this.punyHost(key.id);
if (keyIdHost !== expectHost) {
throw new Error('invalid Actor: additionalPublicKeys.id has different host');
}
} }
} }
@ -228,6 +255,33 @@ export class ApPersonService implements OnModuleInit {
return null; return null;
} }
/**
* uriからUser(Person)
*
* Misskeyに対象のPersonが登録されていればそれを返しnullを返します
* TTLが0でない場合TTLを過ぎていた場合はupdatePersonを実行します
*/
@bindThis
async fetchPersonWithRenewal(uri: string, TTL = REMOTE_USER_CACHE_TTL): Promise<MiLocalUser | MiRemoteUser | null> {
const exist = await this.fetchPerson(uri);
if (exist == null) return null;
if (this.userEntityService.isRemoteUser(exist)) {
if (TTL === 0 || exist.lastFetchedAt == null || Date.now() - exist.lastFetchedAt.getTime() > TTL) {
this.logger.debug('fetchPersonWithRenewal: renew', { uri, TTL, lastFetchedAt: exist.lastFetchedAt });
try {
await this.updatePerson(exist.uri);
return await this.fetchPerson(uri);
} catch (err) {
this.logger.error('error occurred while renewing user', { err });
}
}
this.logger.debug('fetchPersonWithRenewal: use cache', { uri, TTL, lastFetchedAt: exist.lastFetchedAt });
}
return exist;
}
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>>> { private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>>> {
if (user == null) throw new Error('failed to create user: user is null'); if (user == null) throw new Error('failed to create user: user is null');
@ -363,11 +417,15 @@ export class ApPersonService implements OnModuleInit {
})); }));
if (person.publicKey) { if (person.publicKey) {
await transactionalEntityManager.save(new MiUserPublickey({ const publicKeys = new Map<string, IKey>();
userId: user.id, (person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key));
keyId: person.publicKey.id, (Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key));
keyPem: person.publicKey.publicKeyPem,
})); await transactionalEntityManager.save(Array.from(publicKeys.values(), key => new MiUserPublickey({
keyId: key.id,
userId: user!.id,
keyPem: key.publicKeyPem,
})));
} }
}); });
} catch (e) { } catch (e) {
@ -513,11 +571,29 @@ export class ApPersonService implements OnModuleInit {
// Update user // Update user
await this.usersRepository.update(exist.id, updates); await this.usersRepository.update(exist.id, updates);
if (person.publicKey) { try {
await this.userPublickeysRepository.update({ userId: exist.id }, { // Deleteアクティビティ受信時にもここが走ってsaveがuserforeign key制約エラーを吐くことがある
keyId: person.publicKey.id, // とりあえずtry-catchで囲っておく
keyPem: person.publicKey.publicKeyPem, const publicKeys = new Map<string, IKey>();
if (person.publicKey) {
(person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key));
(Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key));
await this.userPublickeysRepository.save(Array.from(publicKeys.values(), key => ({
keyId: key.id,
userId: exist.id,
keyPem: key.publicKeyPem,
})));
}
this.userPublickeysRepository.delete({
keyId: Not(In(Array.from(publicKeys.keys()))),
userId: exist.id,
}).catch(err => {
this.logger.error('something happened while deleting remote user public keys:', { userId: exist.id, err });
}); });
} catch (err) {
this.logger.error('something happened while updating remote user public keys:', { userId: exist.id, err });
} }
let _description: string | null = null; let _description: string | null = null;
@ -559,7 +635,7 @@ export class ApPersonService implements OnModuleInit {
exist.movedAt == null || exist.movedAt == null ||
// 以前のmovingから14日以上経過した場合のみ移行処理を許可 // 以前のmovingから14日以上経過した場合のみ移行処理を許可
// Mastodonのクールダウン期間は30日だが若干緩めに設定しておく // Mastodonのクールダウン期間は30日だが若干緩めに設定しておく
exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() exist.movedAt.getTime() + REMOTE_USER_MOVE_COOLDOWN < updated.movedAt.getTime()
)) { )) {
this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`);
return this.processRemoteMove(updated, movePreventUris) return this.processRemoteMove(updated, movePreventUris)
@ -582,9 +658,9 @@ export class ApPersonService implements OnModuleInit {
* Misskeyに登録しそれを返します * Misskeyに登録しそれを返します
*/ */
@bindThis @bindThis
public async resolvePerson(uri: string, resolver?: Resolver): Promise<MiLocalUser | MiRemoteUser> { public async resolvePerson(uri: string, resolver?: Resolver, withRenewal = false): Promise<MiLocalUser | MiRemoteUser> {
//#region このサーバーに既に登録されていたらそれを返す //#region このサーバーに既に登録されていたらそれを返す
const exist = await this.fetchPerson(uri); const exist = withRenewal ? await this.fetchPersonWithRenewal(uri) : await this.fetchPerson(uri);
if (exist) return exist; if (exist) return exist;
//#endregion //#endregion

View file

@ -55,7 +55,7 @@ export function getOneApId(value: ApObject): string {
export function getApId(value: string | IObject): string { export function getApId(value: string | IObject): string {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id; if (typeof value.id === 'string') return value.id;
throw new Error('cannot detemine id'); throw new Error('cannot determine id');
} }
/** /**
@ -169,10 +169,8 @@ export interface IActor extends IObject {
discoverable?: boolean; discoverable?: boolean;
inbox: string; inbox: string;
sharedInbox?: string; // 後方互換性のため sharedInbox?: string; // 後方互換性のため
publicKey?: { publicKey?: IKey | IKey[];
id: string; additionalPublicKeys?: IKey[];
publicKeyPem: string;
};
followers?: string | ICollection | IOrderedCollection; followers?: string | ICollection | IOrderedCollection;
following?: string | ICollection | IOrderedCollection; following?: string | ICollection | IOrderedCollection;
featured?: string | IOrderedCollection; featured?: string | IOrderedCollection;
@ -236,8 +234,9 @@ export const isEmoji = (object: IObject): object is IApEmoji =>
export interface IKey extends IObject { export interface IKey extends IObject {
type: 'Key'; type: 'Key';
id: string;
owner: string; owner: string;
publicKeyPem: string | Buffer; publicKeyPem: string;
} }
export interface IApDocument extends IObject { export interface IApDocument extends IObject {

View file

@ -56,6 +56,7 @@ export class InstanceEntityService {
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
moderationNote: iAmModerator ? instance.moderationNote : null, moderationNote: iAmModerator ? instance.moderationNote : null,
httpMessageSignaturesImplementationLevel: instance.httpMessageSignaturesImplementationLevel,
}; };
} }

View file

@ -195,6 +195,9 @@ export class MemoryKVCache<T> {
private lifetime: number; private lifetime: number;
private gcIntervalHandle: NodeJS.Timeout; private gcIntervalHandle: NodeJS.Timeout;
/**
* @param lifetime (ms)
*/
constructor(lifetime: MemoryKVCache<never>['lifetime']) { constructor(lifetime: MemoryKVCache<never>['lifetime']) {
this.cache = new Map(); this.cache = new Map();
this.lifetime = lifetime; this.lifetime = lifetime;

View file

@ -3,39 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as crypto from 'node:crypto'; import { genEd25519KeyPair, genRsaKeyPair } from '@misskey-dev/node-http-message-signatures';
import * as util from 'node:util';
const generateKeyPair = util.promisify(crypto.generateKeyPair); export async function genRSAAndEd25519KeyPair(rsaModulusLength = 4096) {
const [rsa, ed25519] = await Promise.all([genRsaKeyPair(rsaModulusLength), genEd25519KeyPair()]);
export async function genRsaKeyPair(modulusLength = 2048) { return {
return await generateKeyPair('rsa', { publicKey: rsa.publicKey,
modulusLength, privateKey: rsa.privateKey,
publicKeyEncoding: { ed25519PublicKey: ed25519.publicKey,
type: 'spki', ed25519PrivateKey: ed25519.privateKey,
format: 'pem', };
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: undefined,
passphrase: undefined,
},
});
}
export async function genEcKeyPair(namedCurve: 'prime256v1' | 'secp384r1' | 'secp521r1' | 'curve25519' = 'prime256v1') {
return await generateKeyPair('ec', {
namedCurve,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: undefined,
passphrase: undefined,
},
});
} }

View file

@ -158,4 +158,9 @@ export class MiInstance {
length: 16384, default: '', length: 16384, default: '',
}) })
public moderationNote: string; public moderationNote: string;
@Column('varchar', {
length: 16, default: '00', nullable: false,
})
public httpMessageSignaturesImplementationLevel: string;
} }

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { PrimaryColumn, Entity, JoinColumn, Column, OneToOne } from 'typeorm'; import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
@ -12,22 +12,42 @@ export class MiUserKeypair {
@PrimaryColumn(id()) @PrimaryColumn(id())
public userId: MiUser['id']; public userId: MiUser['id'];
@OneToOne(type => MiUser, { @ManyToOne(type => MiUser, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn()
public user: MiUser | null; public user: MiUser | null;
/**
* RSA public key
*/
@Column('varchar', { @Column('varchar', {
length: 4096, length: 4096,
}) })
public publicKey: string; public publicKey: string;
/**
* RSA private key
*/
@Column('varchar', { @Column('varchar', {
length: 4096, length: 4096,
}) })
public privateKey: string; public privateKey: string;
@Column('varchar', {
length: 128,
nullable: true,
default: null,
})
public ed25519PublicKey: string | null;
@Column('varchar', {
length: 128,
nullable: true,
default: null,
})
public ed25519PrivateKey: string | null;
constructor(data: Partial<MiUserKeypair>) { constructor(data: Partial<MiUserKeypair>) {
if (data == null) return; if (data == null) return;

View file

@ -9,7 +9,13 @@ import { MiUser } from './User.js';
@Entity('user_publickey') @Entity('user_publickey')
export class MiUserPublickey { export class MiUserPublickey {
@PrimaryColumn(id()) @PrimaryColumn('varchar', {
length: 256,
})
public keyId: string;
@Index()
@Column(id())
public userId: MiUser['id']; public userId: MiUser['id'];
@OneToOne(type => MiUser, { @OneToOne(type => MiUser, {
@ -18,12 +24,6 @@ export class MiUserPublickey {
@JoinColumn() @JoinColumn()
public user: MiUser | null; public user: MiUser | null;
@Index({ unique: true })
@Column('varchar', {
length: 256,
})
public keyId: string;
@Column('varchar', { @Column('varchar', {
length: 4096, length: 4096,
}) })

View file

@ -116,5 +116,9 @@ export const packedFederationInstanceSchema = {
type: 'string', type: 'string',
optional: true, nullable: true, optional: true, nullable: true,
}, },
httpMessageSignaturesImplementationLevel: {
type: 'string',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View file

@ -250,9 +250,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
}, { }, {
...baseQueueOptions(this.config, QUEUE.DELIVER), ...baseQueueOptions(this.config, QUEUE.DELIVER),
autorun: false, autorun: false,
concurrency: this.config.deliverJobConcurrency ?? 128, concurrency: this.config.deliverJobConcurrency ?? 16,
limiter: { limiter: {
max: this.config.deliverJobPerSec ?? 128, max: this.config.deliverJobPerSec ?? 1024,
duration: 1000, duration: 1000,
}, },
settings: { settings: {
@ -290,9 +290,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
}, { }, {
...baseQueueOptions(this.config, QUEUE.INBOX), ...baseQueueOptions(this.config, QUEUE.INBOX),
autorun: false, autorun: false,
concurrency: this.config.inboxJobConcurrency ?? 16, concurrency: this.config.inboxJobConcurrency ?? 4,
limiter: { limiter: {
max: this.config.inboxJobPerSec ?? 32, max: this.config.inboxJobPerSec ?? 64,
duration: 1000, duration: 1000,
}, },
settings: { settings: {

View file

@ -73,25 +73,33 @@ export class DeliverProcessorService {
} }
try { try {
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest); const _server = await this.federatedInstanceService.fetch(host);
await this.fetchInstanceMetadataService.fetchInstanceMetadata(_server).then(() => {});
const server = await this.federatedInstanceService.fetch(host);
await this.apRequestService.signedPost(
job.data.user,
job.data.to,
job.data.content,
server.httpMessageSignaturesImplementationLevel,
job.data.digest,
job.data.privateKey,
);
// Update stats // Update stats
this.federatedInstanceService.fetch(host).then(i => { if (server.isNotResponding) {
if (i.isNotResponding) { this.federatedInstanceService.update(server.id, {
this.federatedInstanceService.update(i.id, { isNotResponding: false,
isNotResponding: false, notRespondingSince: null,
notRespondingSince: null, });
}); }
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i); this.apRequestChart.deliverSucc();
this.apRequestChart.deliverSucc(); this.federationChart.deliverd(server.host, true);
this.federationChart.deliverd(i.host, true);
if (meta.enableChartsForFederatedInstances) { if (meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, true); this.instanceChart.requestSent(server.host, true);
} }
});
return 'Success'; return 'Success';
} catch (res) { } catch (res) {

View file

@ -5,8 +5,8 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import httpSignature from '@peertube/http-signature';
import * as Bull from 'bullmq'; import * as Bull from 'bullmq';
import { verifyDraftSignature } from '@misskey-dev/node-http-message-signatures';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -20,6 +20,7 @@ import type { MiRemoteUser } from '@/models/User.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import * as Acct from '@/misc/acct.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { JsonLdService } from '@/core/activitypub/JsonLdService.js';
@ -52,8 +53,15 @@ export class InboxProcessorService {
@bindThis @bindThis
public async process(job: Bull.Job<InboxJobData>): Promise<string> { public async process(job: Bull.Job<InboxJobData>): Promise<string> {
const signature = job.data.signature; // HTTP-signature const signature = job.data.signature ?
'version' in job.data.signature ? job.data.signature.value : job.data.signature
: null;
if (Array.isArray(signature)) {
// RFC 9401はsignatureが配列になるが、とりあえずエラーにする
throw new Error('signature is array');
}
let activity = job.data.activity; let activity = job.data.activity;
let actorUri = getApId(activity.actor);
//#region Log //#region Log
const info = Object.assign({}, activity); const info = Object.assign({}, activity);
@ -61,7 +69,7 @@ export class InboxProcessorService {
this.logger.debug(JSON.stringify(info, null, 2)); this.logger.debug(JSON.stringify(info, null, 2));
//#endregion //#endregion
const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); const host = this.utilityService.toPuny(new URL(actorUri).hostname);
// ブロックしてたら中断 // ブロックしてたら中断
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
@ -69,69 +77,76 @@ export class InboxProcessorService {
return `Blocked request: ${host}`; return `Blocked request: ${host}`;
} }
const keyIdLower = signature.keyId.toLowerCase();
if (keyIdLower.startsWith('acct:')) {
return `Old keyId is no longer supported. ${keyIdLower}`;
}
// HTTP-Signature keyIdを元にDBから取得 // HTTP-Signature keyIdを元にDBから取得
let authUser: { let authUser: Awaited<ReturnType<typeof this.apDbResolverService.getAuthUserFromApId>> = null;
user: MiRemoteUser; let httpSignatureIsValid = null as boolean | null;
key: MiUserPublickey | null;
} | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId);
// keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 try {
if (authUser == null) { authUser = await this.apDbResolverService.getAuthUserFromApId(actorUri, signature?.keyId);
try { } catch (err) {
authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); // 対象が4xxならスキップ
} catch (err) { if (err instanceof StatusError) {
// 対象が4xxならスキップ if (!err.isRetryable) {
if (err instanceof StatusError) { throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`);
if (!err.isRetryable) {
throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`);
}
throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`);
} }
throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`);
} }
} }
// それでもわからなければ終了 // authUser.userがnullならスキップ
if (authUser == null) { if (authUser != null && authUser.user == null) {
throw new Bull.UnrecoverableError('skip: failed to resolve user'); throw new Bull.UnrecoverableError('skip: failed to resolve user');
} }
// publicKey がなくても終了 if (signature != null && authUser != null) {
if (authUser.key == null) { if (signature.keyId.toLowerCase().startsWith('acct:')) {
throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey'); this.logger.warn(`Old keyId is no longer supported. lowerKeyId=${signature.keyId.toLowerCase()}`);
} else if (authUser.key != null) {
// keyがなかったらLD Signatureで検証するべき
// HTTP-Signatureの検証
const errorLogger = (ms: any) => this.logger.error(ms);
httpSignatureIsValid = await verifyDraftSignature(signature, authUser.key.keyPem, errorLogger);
this.logger.debug('Inbox message validation: ', {
userId: authUser.user.id,
userAcct: Acct.toString(authUser.user),
parsedKeyId: signature.keyId,
foundKeyId: authUser.key.keyId,
httpSignatureValid: httpSignatureIsValid,
});
}
} }
// HTTP-Signatureの検証 if (
const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); authUser == null ||
httpSignatureIsValid !== true ||
// また、signatureのsignerは、activity.actorと一致する必要がある authUser.user.uri !== actorUri // 一応チェック
if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { ) {
// 一致しなくても、でもLD-Signatureがありそうならそっちも見る // 一致しなくても、でもLD-Signatureがありそうならそっちも見る
const ldSignature = activity.signature; const ldSignature = activity.signature;
if (ldSignature) {
if (ldSignature && ldSignature.creator) {
if (ldSignature.type !== 'RsaSignature2017') { if (ldSignature.type !== 'RsaSignature2017') {
throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`); throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`);
} }
// ldSignature.creator: https://example.oom/users/user#main-key if (ldSignature.creator.toLowerCase().startsWith('acct:')) {
// みたいになっててUserを引っ張れば公開キーも入ることを期待する throw new Bull.UnrecoverableError(`old key not supported ${ldSignature.creator}`);
if (ldSignature.creator) {
const candicate = ldSignature.creator.replace(/#.*/, '');
await this.apPersonService.resolvePerson(candicate).catch(() => null);
} }
// keyIdからLD-Signatureのユーザーを取得 authUser = await this.apDbResolverService.getAuthUserFromApId(actorUri, ldSignature.creator);
authUser = await this.apDbResolverService.getAuthUserFromKeyId(ldSignature.creator);
if (authUser == null) { if (authUser == null) {
throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした'); throw new Bull.UnrecoverableError(`skip: LD-Signatureのactorとcreatorが一致しませんでした uri=${actorUri} creator=${ldSignature.creator}`);
}
if (authUser.user == null) {
throw new Bull.UnrecoverableError(`skip: LD-Signatureのユーザーが取得できませんでした uri=${actorUri} creator=${ldSignature.creator}`);
}
// 一応actorチェック
if (authUser.user.uri !== actorUri) {
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${actorUri})`);
} }
if (authUser.key == null) { if (authUser.key == null) {
throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'); throw new Bull.UnrecoverableError(`skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした uri=${actorUri} creator=${ldSignature.creator}`);
} }
const jsonLd = this.jsonLdService.use(); const jsonLd = this.jsonLdService.use();
@ -142,13 +157,27 @@ export class InboxProcessorService {
throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました');
} }
// ブロックしてたら中断
const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) {
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
}
// アクティビティを正規化 // アクティビティを正規化
// GHSA-2vxv-pv3m-3wvj
delete activity.signature; delete activity.signature;
try { try {
activity = await jsonLd.compact(activity) as IActivity; activity = await jsonLd.compact(activity) as IActivity;
} catch (e) { } catch (e) {
throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${e}`); throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${e}`);
} }
// actorが正規化前後で一致しているか確認
actorUri = getApId(activity.actor);
if (authUser.user.uri !== actorUri) {
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity(after normalization).actor(${actorUri})`);
}
// TODO: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする // TODO: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする
// https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29 // https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29
activity.signature = ldSignature; activity.signature = ldSignature;
@ -158,19 +187,8 @@ export class InboxProcessorService {
delete compactedInfo['@context']; delete compactedInfo['@context'];
this.logger.debug(`compacted: ${JSON.stringify(compactedInfo, null, 2)}`); this.logger.debug(`compacted: ${JSON.stringify(compactedInfo, null, 2)}`);
//#endregion //#endregion
// もう一度actorチェック
if (authUser.user.uri !== activity.actor) {
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`);
}
// ブロックしてたら中断
const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) {
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
}
} else { } else {
throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`); throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. http_signature_keyId=${signature?.keyId}`);
} }
} }

View file

@ -9,7 +9,24 @@ import type { MiNote } from '@/models/Note.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiWebhook } from '@/models/Webhook.js'; import type { MiWebhook } from '@/models/Webhook.js';
import type { IActivity } from '@/core/activitypub/type.js'; import type { IActivity } from '@/core/activitypub/type.js';
import type httpSignature from '@peertube/http-signature'; import type { ParsedSignature, PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
/**
* @peertube/http-signature
* TODO: 2026年ぐらいには消す
*/
export interface OldParsedSignature {
scheme: 'Signature';
params: {
keyId: string;
algorithm: string;
headers: string[];
signature: string;
};
signingString: string;
algorithm: string;
keyId: string;
}
export type DeliverJobData = { export type DeliverJobData = {
/** Actor */ /** Actor */
@ -22,11 +39,13 @@ export type DeliverJobData = {
to: string; to: string;
/** whether it is sharedInbox */ /** whether it is sharedInbox */
isSharedInbox: boolean; isSharedInbox: boolean;
/** force to use main (rsa) key */
privateKey?: PrivateKeyWithPem;
}; };
export type InboxJobData = { export type InboxJobData = {
activity: IActivity; activity: IActivity;
signature: httpSignature.IParsedSignature; signature: ParsedSignature | OldParsedSignature | null;
}; };
export type RelationshipJobData = { export type RelationshipJobData = {

View file

@ -3,11 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as crypto from 'node:crypto';
import { IncomingMessage } from 'node:http'; import { IncomingMessage } from 'node:http';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import fastifyAccepts from '@fastify/accepts'; import fastifyAccepts from '@fastify/accepts';
import httpSignature from '@peertube/http-signature'; import { verifyDigestHeader, parseRequestSignature } from '@misskey-dev/node-http-message-signatures';
import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; import { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
import accepts from 'accepts'; import accepts from 'accepts';
import vary from 'vary'; import vary from 'vary';
@ -31,12 +30,17 @@ import { IActivity } from '@/core/activitypub/type.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isQuote, isRenote } from '@/misc/is-renote.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
import type { FindOptionsWhere } from 'typeorm'; import type { FindOptionsWhere } from 'typeorm';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
@Injectable() @Injectable()
export class ActivityPubServerService { export class ActivityPubServerService {
private logger: Logger;
private inboxLogger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -71,8 +75,11 @@ export class ActivityPubServerService {
private queueService: QueueService, private queueService: QueueService,
private userKeypairService: UserKeypairService, private userKeypairService: UserKeypairService,
private queryService: QueryService, private queryService: QueryService,
private loggerService: LoggerService,
) { ) {
//this.createServer = this.createServer.bind(this); //this.createServer = this.createServer.bind(this);
this.logger = this.loggerService.getLogger('server-ap', 'gray');
this.inboxLogger = this.logger.createSubLogger('inbox', 'gray');
} }
@bindThis @bindThis
@ -100,70 +107,44 @@ export class ActivityPubServerService {
} }
@bindThis @bindThis
private inbox(request: FastifyRequest, reply: FastifyReply) { private async inbox(request: FastifyRequest, reply: FastifyReply) {
let signature; if (request.body == null) {
this.inboxLogger.warn('request body is empty');
reply.code(400);
return;
}
let signature: ReturnType<typeof parseRequestSignature>;
const verifyDigest = await verifyDigestHeader(request.raw, request.rawBody || '', true);
if (verifyDigest !== true) {
this.inboxLogger.warn('digest verification failed');
reply.code(401);
return;
}
try { try {
signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); signature = parseRequestSignature(request.raw, {
} catch (e) { requiredInputs: {
draft: ['(request-target)', 'digest', 'host', 'date'],
},
});
} catch (err) {
this.inboxLogger.warn('signature header parsing failed', { err });
if (typeof request.body === 'object' && 'signature' in request.body) {
// LD SignatureがあればOK
this.queueService.inbox(request.body as IActivity, null);
reply.code(202);
return;
}
this.inboxLogger.warn('signature header parsing failed and LD signature not found');
reply.code(401); reply.code(401);
return; return;
} }
if (signature.params.headers.indexOf('host') === -1
|| request.headers.host !== this.config.host) {
// Host not specified or not match.
reply.code(401);
return;
}
if (signature.params.headers.indexOf('digest') === -1) {
// Digest not found.
reply.code(401);
} else {
const digest = request.headers.digest;
if (typeof digest !== 'string') {
// Huh?
reply.code(401);
return;
}
const re = /^([a-zA-Z0-9\-]+)=(.+)$/;
const match = digest.match(re);
if (match == null) {
// Invalid digest
reply.code(401);
return;
}
const algo = match[1].toUpperCase();
const digestValue = match[2];
if (algo !== 'SHA-256') {
// Unsupported digest algorithm
reply.code(401);
return;
}
if (request.rawBody == null) {
// Bad request
reply.code(400);
return;
}
const hash = crypto.createHash('sha256').update(request.rawBody).digest('base64');
if (hash !== digestValue) {
// Invalid digest
reply.code(401);
return;
}
}
this.queueService.inbox(request.body as IActivity, signature); this.queueService.inbox(request.body as IActivity, signature);
reply.code(202); reply.code(202);
} }
@ -640,7 +621,7 @@ export class ActivityPubServerService {
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
reply.header('Cache-Control', 'public, max-age=180'); reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply); this.setResponseType(request, reply);
return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair.publicKey)));
} else { } else {
reply.code(400); reply.code(400);
return; return;

View file

@ -94,6 +94,13 @@ export class NodeinfoServerService {
localComments: 0, localComments: 0,
}, },
metadata: { metadata: {
/**
* '00': Draft, RSA only
* '01': Draft, Ed25519 suported
* '11': RFC 9421, Ed25519 supported
*/
httpMessageSignaturesImplementationLevel: '01',
nodeName: meta.name, nodeName: meta.name,
nodeDescription: meta.description, nodeDescription: meta.description,
nodeAdmins: [{ nodeAdmins: [{

View file

@ -56,7 +56,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const res = [] as [string, number][]; const res = [] as [string, number][];
for (const job of jobs) { for (const job of jobs) {
const host = new URL(job.data.signature.keyId).host; const signature = job.data.signature ? 'version' in job.data.signature ? job.data.signature.value : job.data.signature : null;
const host = signature ? Array.isArray(signature) ? 'TODO' : new URL(signature.keyId).host : new URL(job.data.activity.actor).host;
if (res.find(x => x[0] === host)) { if (res.find(x => x[0] === host)) {
res.find(x => x[0] === host)![1]++; res.find(x => x[0] === host)![1]++;
} else { } else {

View file

@ -139,6 +139,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
timelineConfig = [ timelineConfig = [
`homeTimeline:${me.id}`, `homeTimeline:${me.id}`,
'localTimeline', 'localTimeline',
`localTimelineWithReplyTo:${me.id}`,
]; ];
} }

View file

@ -6,12 +6,11 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms'; import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { IdService } from '@/core/IdService.js';
import type { RenoteMutingsRepository } from '@/models/_.js';
import type { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { UserRenoteMutingService } from "@/core/UserRenoteMutingService.js";
import type { RenoteMutingsRepository } from '@/models/_.js';
export const meta = { export const meta = {
tags: ['account'], tags: ['account'],
@ -62,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private renoteMutingsRepository: RenoteMutingsRepository, private renoteMutingsRepository: RenoteMutingsRepository,
private getterService: GetterService, private getterService: GetterService,
private idService: IdService, private userRenoteMutingService: UserRenoteMutingService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const muter = me; const muter = me;
@ -79,21 +78,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
// Check if already muting // Check if already muting
const exist = await this.renoteMutingsRepository.findOneBy({ const exist = await this.renoteMutingsRepository.exists({
muterId: muter.id, where: {
muteeId: mutee.id, muterId: muter.id,
muteeId: mutee.id,
},
}); });
if (exist != null) { if (exist === true) {
throw new ApiError(meta.errors.alreadyMuting); throw new ApiError(meta.errors.alreadyMuting);
} }
// Create mute // Create mute
await this.renoteMutingsRepository.insert({ await this.userRenoteMutingService.mute(muter, mutee);
id: this.idService.gen(),
muterId: muter.id,
muteeId: mutee.id,
} as MiRenoteMuting);
}); });
} }
} }

View file

@ -5,10 +5,11 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RenoteMutingsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { UserRenoteMutingService } from "@/core/UserRenoteMutingService.js";
import type { RenoteMutingsRepository } from '@/models/_.js';
export const meta = { export const meta = {
tags: ['account'], tags: ['account'],
@ -53,6 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private renoteMutingsRepository: RenoteMutingsRepository, private renoteMutingsRepository: RenoteMutingsRepository,
private getterService: GetterService, private getterService: GetterService,
private userRenoteMutingService: UserRenoteMutingService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const muter = me; const muter = me;
@ -79,9 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// Delete mute // Delete mute
await this.renoteMutingsRepository.delete({ await this.userRenoteMutingService.unmute([exist]);
id: exist.id,
});
}); });
} }
} }

View file

@ -378,7 +378,7 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false);
}, 1000 * 10); });
test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);
@ -672,7 +672,7 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10); });
}); });
describe('Social TL', () => { describe('Social TL', () => {
@ -812,7 +812,7 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10); });
}); });
describe('User List TL', () => { describe('User List TL', () => {
@ -1025,7 +1025,7 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10); });
test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);
@ -1184,7 +1184,7 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10); });
test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);

View file

@ -14,6 +14,7 @@ import type { InstanceActorService } from '@/core/InstanceActorService.js';
import type { LoggerService } from '@/core/LoggerService.js'; import type { LoggerService } from '@/core/LoggerService.js';
import type { MetaService } from '@/core/MetaService.js'; import type { MetaService } from '@/core/MetaService.js';
import type { UtilityService } from '@/core/UtilityService.js'; import type { UtilityService } from '@/core/UtilityService.js';
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { import type {
FollowRequestsRepository, FollowRequestsRepository,
@ -47,6 +48,7 @@ export class MockResolver extends Resolver {
{} as HttpRequestService, {} as HttpRequestService,
{} as ApRendererService, {} as ApRendererService,
{} as ApDbResolverService, {} as ApDbResolverService,
{} as FederatedInstanceService,
loggerService, loggerService,
); );
} }

View file

@ -75,62 +75,61 @@ describe('FetchInstanceMetadataService', () => {
test('Lock and update', async () => { test('Lock and update', async () => {
redisClient.set = mockRedis(); redisClient.set = mockRedis();
const now = Date.now(); const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any); federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); }); httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalled(); expect(httpRequestService.getJson).toHaveBeenCalled();
}); });
test('Lock and don\'t update', async () => { test('Don\'t lock and update if recently updated', async () => {
redisClient.set = mockRedis(); redisClient.set = mockRedis();
const now = Date.now(); federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date() } as any);
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); }); httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(tryLockSpy).toHaveBeenCalledTimes(0);
expect(unlockSpy).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
}); });
test('Do nothing when lock not acquired', async () => { test('Do nothing when lock not acquired', async () => {
redisClient.set = mockRedis(); redisClient.set = mockRedis();
const now = Date.now(); const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); }); httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com'); await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(0); expect(unlockSpy).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
}); });
test('Do when lock not acquired but forced', async () => { test('Do when forced', async () => {
redisClient.set = mockRedis(); redisClient.set = mockRedis();
const now = Date.now(); const now = Date.now();
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); }); httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com'); await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(tryLockSpy).toHaveBeenCalledTimes(0); expect(tryLockSpy).toHaveBeenCalledTimes(0);
expect(unlockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalled(); expect(httpRequestService.getJson).toHaveBeenCalled();
}); });
}); });

View file

@ -4,10 +4,8 @@
*/ */
import * as assert from 'assert'; import * as assert from 'assert';
import httpSignature from '@peertube/http-signature'; import { verifyDraftSignature, parseRequestSignature, genEd25519KeyPair, genRsaKeyPair, importPrivateKey } from '@misskey-dev/node-http-message-signatures';
import { createSignedGet, createSignedPost } from '@/core/activitypub/ApRequestService.js';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
return { return {
@ -24,38 +22,68 @@ export const buildParsedSignature = (signingString: string, signature: string, a
}; };
}; };
describe('ap-request', () => { async function getKeyPair(level: string) {
test('createSignedPost with verify', async () => { if (level === '00') {
const keypair = await genRsaKeyPair(); return await genRsaKeyPair();
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; } else if (level === '01') {
const url = 'https://example.com/inbox'; return await genEd25519KeyPair();
const activity = { a: 1 }; }
const body = JSON.stringify(activity); throw new Error('Invalid level');
const headers = { }
'User-Agent': 'UA',
};
const req = ApRequestCreator.createSignedPost({ key, url, body, additionalHeaders: headers }); describe('ap-request post', () => {
const url = 'https://example.com/inbox';
const activity = { a: 1 };
const body = JSON.stringify(activity);
const headers = {
'User-Agent': 'UA',
};
const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); describe.each(['00', '01'])('createSignedPost with verify', (level) => {
test('pem', async () => {
const keypair = await getKeyPair(level);
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey };
const result = httpSignature.verifySignature(parsed, keypair.publicKey); const req = await createSignedPost({ level, key, url, body, additionalHeaders: headers });
assert.deepStrictEqual(result, true);
});
test('createSignedGet with verify', async () => { const parsed = parseRequestSignature(req.request);
const keypair = await genRsaKeyPair(); expect(parsed.version).toBe('draft');
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; expect(Array.isArray(parsed.value)).toBe(false);
const url = 'https://example.com/outbox'; const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey);
const headers = { assert.deepStrictEqual(verify, true);
'User-Agent': 'UA', });
}; test('imported', async () => {
const keypair = await getKeyPair(level);
const key = { keyId: 'x', 'privateKey': await importPrivateKey(keypair.privateKey) };
const req = ApRequestCreator.createSignedGet({ key, url, additionalHeaders: headers }); const req = await createSignedPost({ level, key, url, body, additionalHeaders: headers });
const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); const parsed = parseRequestSignature(req.request);
expect(parsed.version).toBe('draft');
const result = httpSignature.verifySignature(parsed, keypair.publicKey); expect(Array.isArray(parsed.value)).toBe(false);
assert.deepStrictEqual(result, true); const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey);
assert.deepStrictEqual(verify, true);
});
});
});
describe('ap-request get', () => {
describe.each(['00', '01'])('createSignedGet with verify', (level) => {
test('pass', async () => {
const keypair = await getKeyPair(level);
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey };
const url = 'https://example.com/outbox';
const headers = {
'User-Agent': 'UA',
};
const req = await createSignedGet({ level, key, url, additionalHeaders: headers });
const parsed = parseRequestSignature(req.request);
expect(parsed.version).toBe('draft');
expect(Array.isArray(parsed.value)).toBe(false);
const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey);
assert.deepStrictEqual(verify, true);
});
}); });
}); });

View file

@ -53,7 +53,6 @@ await fs.readFile(
'../../assets/**', '../../assets/**',
'../../fluent-emojis/**', '../../fluent-emojis/**',
'../../locales/ja-JP.yml', '../../locales/ja-JP.yml',
'../../misskey-assets/**',
'assets/**', 'assets/**',
'public/**', 'public/**',
'../../pnpm-lock.yaml', '../../pnpm-lock.yaml',

View file

@ -24,7 +24,7 @@
"@rollup/plugin-json": "6.1.0", "@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.7", "@rollup/plugin-replace": "5.0.7",
"@rollup/pluginutils": "5.1.0", "@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.18.0", "@syuilo/aiscript": "0.19.0",
"@tabler/icons-webfont": "3.3.0", "@tabler/icons-webfont": "3.3.0",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.0.5", "@vitejs/plugin-vue": "5.0.5",

View file

@ -30,7 +30,7 @@ import * as os from '@/os.js';
import MkLoading from '@/components/global/MkLoading.vue'; import MkLoading from '@/components/global/MkLoading.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
code: string; code: string;

View file

@ -39,7 +39,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{

View file

@ -62,7 +62,7 @@ import { computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';

View file

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View file

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
}" }"
:style="{ :style="{
width: (width && !asDrawer) ? `${width}px` : '', width: (width && !asDrawer) ? `${width}px` : '',
maxHeight: maxHeight ? `${maxHeight}px` : '', maxHeight: maxHeight ? `min(${maxHeight}px, calc(100dvh - 32px))` : 'calc(100dvh - 32px)',
}" }"
@keydown.stop="() => {}" @keydown.stop="() => {}"
@contextmenu.self.prevent="() => {}" @contextmenu.self.prevent="() => {}"

View file

@ -33,7 +33,7 @@ import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'
import RouterView from '@/components/global/RouterView.vue'; import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue'; import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout.js'; import { popout as _popout } from '@/scripts/popout.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { url } from '@/config.js'; import { url } from '@/config.js';
import { useScrollPositionManager } from '@/nirax.js'; import { useScrollPositionManager } from '@/nirax.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View file

@ -87,7 +87,7 @@ const host = ref(toUnicode(configHost));
const totpLogin = ref(false); const totpLogin = ref(false);
const isBackupCode = ref(false); const isBackupCode = ref(false);
const queryingKey = ref(false); const queryingKey = ref(false);
const credentialRequest = ref<CredentialRequestOptions | null>(null); let credentialRequest: CredentialRequestOptions | null = null;
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'login', v: any): void; (ev: 'login', v: any): void;
@ -122,14 +122,14 @@ function onLogin(res: any): Promise<void> | void {
} }
async function queryKey(): Promise<void> { async function queryKey(): Promise<void> {
if (credentialRequest.value == null) return; if (credentialRequest == null) return;
queryingKey.value = true; queryingKey.value = true;
await webAuthnRequest(credentialRequest.value) await webAuthnRequest(credentialRequest)
.catch(() => { .catch(() => {
queryingKey.value = false; queryingKey.value = false;
return Promise.reject(null); return Promise.reject(null);
}).then(credential => { }).then(credential => {
credentialRequest.value = null; credentialRequest = null;
queryingKey.value = false; queryingKey.value = false;
signing.value = true; signing.value = true;
return misskeyApi('signin', { return misskeyApi('signin', {
@ -160,7 +160,7 @@ function onSubmit(): void {
}).then(res => { }).then(res => {
totpLogin.value = true; totpLogin.value = true;
signing.value = false; signing.value = false;
credentialRequest.value = parseRequestOptionsFromJSON({ credentialRequest = parseRequestOptionsFromJSON({
publicKey: res, publicKey: res,
}); });
}) })

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
scrolling="no" scrolling="no"
:allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')" :allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')"
:class="$style.playerIframe" :class="$style.playerIframe"
:src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :src="transformPlayerUrl(player.url)"
:style="{ border: 0 }" :style="{ border: 0 }"
></iframe> ></iframe>
<span v-else>invalid url</span> <span v-else>invalid url</span>
@ -92,6 +92,7 @@ import * as os from '@/os.js';
import { deviceKind } from '@/scripts/device-kind.js'; import { deviceKind } from '@/scripts/device-kind.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { versatileLang } from '@/scripts/intl-const.js'; import { versatileLang } from '@/scripts/intl-const.js';
import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
type SummalyResult = Awaited<ReturnType<typeof summaly>>; type SummalyResult = Awaited<ReturnType<typeof summaly>>;

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_panel" :class="$style.root"> <div class="_panel" :class="$style.root">
<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"></div>
<MkAvatar :class="$style.avatar" :user="user" indicator/> <MkAvatar :class="$style.avatar" :user="user" indicator/>
<div :class="$style.title"> <div :class="$style.title">
<MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> <MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
@ -41,6 +41,8 @@ import { userPage } from '@/filters/user.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import { defaultStore } from '@/store.js';
defineProps<{ defineProps<{
user: Misskey.entities.UserDetailed; user: Misskey.entities.UserDetailed;

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
> >
<div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }"> <div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
<div v-if="user != null"> <div v-if="user != null">
<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"> <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''">
<span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span> <span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
</div> </div>
<svg viewBox="0 0 128 128" :class="$style.avatarBack"> <svg viewBox="0 0 128 128" :class="$style.avatarBack">
@ -67,6 +67,7 @@ import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
const props = defineProps<{ const props = defineProps<{
showing: boolean; showing: boolean;

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="poamfof"> <div class="poamfof">
<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player"> <div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player">
<iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> <iframe v-if="!fetching" :src="transformPlayerUrl(player.url)" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div> </div>
<span v-else>invalid url</span> <span v-else>invalid url</span>
</Transition> </Transition>
@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue'; import { ref } from 'vue';
import MkWindow from '@/components/MkWindow.vue'; import MkWindow from '@/components/MkWindow.vue';
import { versatileLang } from '@/scripts/intl-const.js'; import { versatileLang } from '@/scripts/intl-const.js';
import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
const props = defineProps<{ const props = defineProps<{

View file

@ -16,7 +16,7 @@ export type MkABehavior = 'window' | 'browser' | null;
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, shallowRef } from 'vue'; import { computed, inject, shallowRef } from 'vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { url } from '@/config.js'; import { url } from '@/config.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';

View file

@ -31,7 +31,7 @@ import { defaultStore } from '@/store.js';
import { customEmojisMap } from '@/custom-emojis.js'; import { customEmojisMap } from '@/custom-emojis.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { misskeyApiGet } from '@/scripts/misskey-api.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';

View file

@ -14,7 +14,7 @@ import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@/scripts/emoji-
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js'; import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View file

@ -22,7 +22,7 @@ import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue'; import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js'; import { pleaseLogin } from '@/scripts/please-login.js';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { embedPage } from '@/config.js'; import { embedPage } from '@/config.js';

View file

@ -93,7 +93,7 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { PageHeaderItem } from '@/types/page-header.js'; import { PageHeaderItem } from '@/types/page-header.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';

View file

@ -43,7 +43,7 @@ import { url } from '@/config.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { clipsCache } from '@/cache.js'; import { clipsCache } from '@/cache.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { copyEmbedCode } from '@/scripts/get-embed-code.js'; import { copyEmbedCode } from '@/scripts/get-embed-code.js';
const props = defineProps<{ const props = defineProps<{

View file

@ -210,7 +210,7 @@ import { apiUrl } from '@/config.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import MkRange from '@/components/MkRange.vue'; import MkRange from '@/components/MkRange.vue';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
type FrontendMonoDefinition = { type FrontendMonoDefinition = {
id: string; id: string;

View file

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { misskeyApiGet } from '@/scripts/misskey-api.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';

View file

@ -37,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { AISCRIPT_VERSION } from '@syuilo/aiscript';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
@ -48,7 +49,7 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
const PRESET_DEFAULT = `/// @ 0.18.0 const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION}
var name = "" var name = ""
@ -66,7 +67,7 @@ Ui:render([
]) ])
`; `;
const PRESET_OMIKUJI = `/// @ 0.18.0 const PRESET_OMIKUJI = `/// @ ${AISCRIPT_VERSION}
// //
// //
@ -109,7 +110,7 @@ Ui:render([
]) ])
`; `;
const PRESET_SHUFFLE = `/// @ 0.18.0 const PRESET_SHUFFLE = `/// @ ${AISCRIPT_VERSION}
// //
let string = "ペペロンチーノ" let string = "ペペロンチーノ"
@ -188,7 +189,7 @@ var cursor = 0
do() do()
`; `;
const PRESET_QUIZ = `/// @ 0.18.0 const PRESET_QUIZ = `/// @ ${AISCRIPT_VERSION}
let title = '地理クイズ' let title = '地理クイズ'
let qas = [{ let qas = [{
@ -301,7 +302,7 @@ qaEls.push(Ui:C:container({
Ui:render(qaEls) Ui:render(qaEls)
`; `;
const PRESET_TIMELINE = `/// @ 0.18.0 const PRESET_TIMELINE = `/// @ ${AISCRIPT_VERSION}
// API // API
@fetch() { @fetch() {

View file

@ -78,7 +78,7 @@ import MkCode from '@/components/MkCode.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js'; import { pleaseLogin } from '@/scripts/please-login.js';
const props = defineProps<{ const props = defineProps<{

View file

@ -77,7 +77,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
const router = useRouter(); const router = useRouter();

View file

@ -125,7 +125,7 @@ import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{ const props = defineProps<{
pageName: string; pageName: string;

View file

@ -82,7 +82,7 @@ import MkCode from '@/components/MkCode.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue'; import MkKeyValue from '@/components/MkKeyValue.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { ColdDeviceStorage } from '@/store.js'; import { ColdDeviceStorage } from '@/store.js';
import { unisonReload } from '@/scripts/unison-reload.js'; import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View file

@ -38,7 +38,7 @@ import MkSelect from '@/components/MkSelect.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { Theme, getBuiltinThemesRef } from '@/scripts/theme.js'; import { Theme, getBuiltinThemesRef } from '@/scripts/theme.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { getThemes, removeTheme } from '@/theme-store.js'; import { getThemes, removeTheme } from '@/theme-store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View file

@ -167,12 +167,14 @@ import number from '@/filters/number.js';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { $i, iAmModerator } from '@/account.js'; import { $i, iAmModerator } from '@/account.js';
import { dateString } from '@/filters/date.js'; import { dateString } from '@/filters/date.js';
import { confetti } from '@/scripts/confetti.js'; import { confetti } from '@/scripts/confetti.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
function calcAge(birthdate: string): number { function calcAge(birthdate: string): number {
const date = new Date(birthdate); const date = new Date(birthdate);
@ -220,8 +222,14 @@ watch(moderationNote, async () => {
const style = computed(() => { const style = computed(() => {
if (props.user.bannerUrl == null) return {}; if (props.user.bannerUrl == null) return {};
return { if (defaultStore.state.disableShowingAnimatedImages) {
backgroundImage: `url(${ props.user.bannerUrl })`, return {
backgroundImage: `url(${ getStaticImageUrl(props.user.bannerUrl) })`,
};
} else {
return {
backgroundImage: `url(${ props.user.bannerUrl })`,
};
}; };
}); });

View file

@ -6,33 +6,6 @@
/** /**
* Clipboardに値をコピー(TODO: 文字列以外も対応) * Clipboardに値をコピー(TODO: 文字列以外も対応)
*/ */
export default val => { export function copyToClipboard(input: string | null) {
// 空div 生成 if (input) navigator.clipboard.writeText(input);
const tmp = document.createElement('div');
// 選択用のタグ生成
const pre = document.createElement('pre');
// 親要素のCSSで user-select: none だとコピーできないので書き換える
pre.style.webkitUserSelect = 'auto';
pre.style.userSelect = 'auto';
tmp.appendChild(pre).textContent = val;
// 要素を画面外へ
const s = tmp.style;
s.position = 'fixed';
s.right = '200%';
// body に追加
document.body.appendChild(tmp);
// 要素を選択
document.getSelection().selectAllChildren(tmp);
// クリップボードにコピー
const result = document.execCommand('copy');
// 要素削除
document.body.removeChild(tmp);
return result;
}; };

View file

@ -6,7 +6,7 @@
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';

View file

@ -11,7 +11,7 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { url } from '@/config.js'; import { url } from '@/config.js';
import { defaultStore, noteActions } from '@/store.js'; import { defaultStore, noteActions } from '@/store.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';

View file

@ -7,7 +7,7 @@ import { toUnicode } from 'punycode';
import { defineAsyncComponent, ref, watch } from 'vue'; import { defineAsyncComponent, ref, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { host, url } from '@/config.js'; import { host, url } from '@/config.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';

View file

@ -7,29 +7,24 @@ import { Ref, nextTick } from 'vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { MFM_TAGS } from '@/const.js'; import { MFM_TAGS } from '@/const.js';
import type { MenuItem } from '@/types/menu.js';
/** /**
* MFMの装飾のリストを表示する * MFMの装飾のリストを表示する
*/ */
export function mfmFunctionPicker(src: any, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) { export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
return new Promise((res, rej) => { os.popupMenu([{
os.popupMenu([{ text: i18n.ts.addMfmFunction,
text: i18n.ts.addMfmFunction, type: 'label',
type: 'label', }, ...getFunctionList(textArea, textRef)], src);
}, ...getFunctionList(textArea, textRef)], src);
});
} }
function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) : object[] { function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>): MenuItem[] {
const ret: object[] = []; return MFM_TAGS.map(tag => ({
MFM_TAGS.forEach(tag => { text: tag,
ret.push({ icon: 'ti ti-icons',
text: tag, action: () => add(textArea, textRef, tag),
icon: 'ti ti-icons', }));
action: () => add(textArea, textRef, tag),
});
});
return ret;
} }
function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, type: string) { function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, type: string) {

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { hostname } from '@/config.js';
export function transformPlayerUrl(url: string): string {
const urlObj = new URL(url);
if (!['https:', 'http:'].includes(urlObj.protocol)) throw new Error('Invalid protocol');
const urlParams = new URLSearchParams(urlObj.search);
if (urlObj.hostname === 'player.twitch.tv') {
// TwitchはCSPの制約あり
// https://dev.twitch.tv/docs/embed/video-and-clips/
urlParams.set('parent', hostname);
urlParams.set('allowfullscreen', '');
urlParams.set('autoplay', 'true');
} else {
urlParams.set('autoplay', '1');
urlParams.set('auto_play', '1');
}
urlObj.search = urlParams.toString();
return urlObj.toString();
}

View file

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2024.5.0", "version": "2024.7.0-beta.1",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",

View file

@ -4608,6 +4608,7 @@ export type components = {
/** Format: date-time */ /** Format: date-time */
latestRequestReceivedAt: string | null; latestRequestReceivedAt: string | null;
moderationNote?: string | null; moderationNote?: string | null;
httpMessageSignaturesImplementationLevel: string;
}; };
GalleryPost: { GalleryPost: {
/** /**

File diff suppressed because it is too large Load diff