From 5982a8a9a93689ea720ac24449af189a8003c1ff Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:55:29 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20FEP-5feb=E6=BA=96=E6=8B=A0=E3=81=AE?= =?UTF-8?q?=E3=82=A2=E3=83=8A=E3=82=A6=E3=83=B3=E3=82=B9=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +- app/api/DB/host.ts | 184 ++++++---- app/api/DB/local.ts | 179 ++++++---- app/api/models/takos/relay.ts | 12 - app/api/models/takos/service_actor_key.ts | 14 + .../takos_host/fasp_backfill_request.ts | 18 + app/api/models/takos_host/fasp_config.ts | 23 ++ .../takos_host/fasp_event_subscription.ts | 24 ++ .../models/takos_host/fasp_registration.ts | 23 ++ app/api/models/takos_host/relay.ts | 16 - app/api/routes/fasp_discovery.ts | 316 ++++++++++++++++++ app/api/routes/fasp_registration.ts | 177 ++++++++++ app/api/routes/nodeinfo.ts | 9 +- app/api/routes/relays.ts | 84 ----- app/api/routes/service_actor.ts | 140 ++++++++ app/api/server.ts | 9 +- app/api/services/service_actor.ts | 16 + app/api/utils/activitypub.ts | 24 +- .../src/components/Setting/FaspSettings.tsx | 106 ++++++ .../src/components/Setting/RelaySettings.tsx | 64 ---- app/client/src/components/Setting/faspApi.ts | 82 +++++ app/client/src/components/Setting/index.tsx | 4 +- .../src/components/Setting/relaysApi.ts | 43 --- app/shared/config.ts | 10 + app/shared/db.ts | 64 +++- app/shared/fasp.ts | 204 +++++++++++ app/shared/types.ts | 56 +++- app/takos_host/README.md | 14 +- app/takos_host/consumer.ts | 15 - docs/FASP.md | 46 ++- docs/openapi.yaml | 188 ++++++++++- scripts/host_cli.ts | 122 ------- 32 files changed, 1755 insertions(+), 559 deletions(-) delete mode 100644 app/api/models/takos/relay.ts create mode 100644 app/api/models/takos/service_actor_key.ts create mode 100644 app/api/models/takos_host/fasp_backfill_request.ts create mode 100644 app/api/models/takos_host/fasp_config.ts create mode 100644 app/api/models/takos_host/fasp_event_subscription.ts create mode 100644 app/api/models/takos_host/fasp_registration.ts delete mode 100644 app/api/models/takos_host/relay.ts create mode 100644 app/api/routes/fasp_discovery.ts create mode 100644 app/api/routes/fasp_registration.ts delete mode 100644 app/api/routes/relays.ts create mode 100644 app/api/routes/service_actor.ts create mode 100644 app/api/services/service_actor.ts create mode 100644 app/client/src/components/Setting/FaspSettings.tsx delete mode 100644 app/client/src/components/Setting/RelaySettings.tsx create mode 100644 app/client/src/components/Setting/faspApi.ts delete mode 100644 app/client/src/components/Setting/relaysApi.ts create mode 100644 app/shared/fasp.ts diff --git a/README.md b/README.md index 547319f46..1f58d6c8d 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ MongoDB にはセッション自動削除用の TTL インデックスが必要 特に `ACTIVITYPUB_DOMAIN` は Mastodon など外部からアクセスされる公開ドメインを 設定してください。未設定の場合はリクエストされたホスト名が利用されます。 以前の `TENANT_ID` 変数は廃止され、ドメイン名そのものがテナント ID として扱われます。 -リレーサーバーの設定は UI から追加・削除でき、データベースに保存されます。 -登録したリレーとは `Follow` を送っておくことで投稿が inbox に届きます。 -`getEnv(c)` で取得した環境変数を `fetchJson` や `deliverActivityPubObject` -へ渡すことで マルチテナント環境でも正しいドメインが利用されます。 +FASP の設定はデータベースで管理され、管理UI `/admin/fasps` から登録や capability +切替を行います。`getEnv(c)` で取得した環境変数を `fetchJson` や +`deliverActivityPubObject` へ渡すことでマルチテナント環境でも正しいドメインが +利用されます。 ### 初期設定 @@ -130,7 +130,7 @@ ActivityPub 形式の一覧が必要な場合は、`/ap/users/:username/follower できます。外部との連携には ActivityPub の `/users/:username/outbox` を利用して ください。 -- `GET /api/posts` – 公開タイムラインを取得(登録済みリレーからの投稿も含む) +- `GET /api/posts` – 公開タイムラインを取得 - `GET /api/posts?timeline=following&actor=URI` – フォロー中アクターの投稿のみ取得 - `POST /api/posts` – 投稿を作成 (`{ "author": "user", "content": "hello" }`) @@ -175,17 +175,17 @@ ActivityPub の `Video` オブジェクトを利用して動画を投稿でき WebSocket では Base64 文字列を、HTTP POST では multipart/form-data の `thumbnail` パートに画像ファイルを送信してください。 -## リレー API +## FASP連携とService Actor配信 -ほかのインスタンスと連携するためのリレーサーバーを管理します。 +takos host は Fediverse Auxiliary Service Provider (FASP) と連携するための API +を `/fasp` に提供し、登録・プロバイダ情報取得・capability 切替および Discovery +機能 +(`data_sharing`、`trends`、`account_search`)を利用できます。設定はデータベースで +管理され、管理UI `/admin/fasps` から操作します。 -- `GET /api/relays` – 登録済みリレー一覧を取得 -- `POST /api/relays` – `{ "inboxUrl": "https://relay.example/inbox" }` - を送信して追加 -- `DELETE /api/relays/:id` – リレーを削除 - -各インスタンスのリストは `relays` コレクションに基づきます。 takos host -のデフォルトリレーは自動登録されますが、一覧には表示されません。 +また ActivityPub の Service Actor を `/actor` で公開し、外部サービスはこの Actor +を フォローすることで投稿やアナウンスを受信できます。詳細は +[docs/FASP.md](docs/FASP.md) を参照してください。 ## アカウント管理 API diff --git a/app/api/DB/host.ts b/app/api/DB/host.ts index 57d6ce161..32af3b238 100644 --- a/app/api/DB/host.ts +++ b/app/api/DB/host.ts @@ -10,8 +10,12 @@ import HostNote from "../models/takos_host/note.ts"; import HostVideo from "../models/takos_host/video.ts"; import HostMessage from "../models/takos_host/message.ts"; import HostAttachment from "../models/takos_host/attachment.ts"; +import HostFaspConfig from "../models/takos_host/fasp_config.ts"; +import HostFaspRegistration from "../models/takos_host/fasp_registration.ts"; +import HostFaspEventSubscription from "../models/takos_host/fasp_event_subscription.ts"; +import HostFaspBackfillRequest from "../models/takos_host/fasp_backfill_request.ts"; import SystemKey from "../models/takos/system_key.ts"; -import HostRelay from "../models/takos_host/relay.ts"; +import ServiceActorKey from "../models/takos/service_actor_key.ts"; import HostRemoteActor from "../models/takos_host/remote_actor.ts"; import HostSession from "../models/takos_host/session.ts"; import HostFcmToken from "../models/takos_host/fcm_token.ts"; @@ -22,7 +26,14 @@ import OAuthClient from "../../takos_host/models/oauth_client.ts"; import HostDomain from "../../takos_host/models/domain.ts"; import mongoose from "mongoose"; import type { DB, ListOpts } from "../../shared/db.ts"; -import type { AccountDoc, RelayDoc, SessionDoc } from "../../shared/types.ts"; +import type { + AccountDoc, + FaspBackfillRequestDoc, + FaspConfigDoc, + FaspEventSubscriptionDoc, + FaspRegistrationDoc, + SessionDoc, +} from "../../shared/types.ts"; import type { SortOrder } from "mongoose"; import type { Db } from "mongodb"; import { connectDatabase } from "../../shared/db.ts"; @@ -39,13 +50,8 @@ export class MongoDBHost implements DB { return this.env["ROOT_DOMAIN"] ?? ""; } - private async useLocalObjects() { - if (!this.rootDomain) return false; - const count = await HostRelay.countDocuments({ - tenant_id: this.tenantId, - host: this.rootDomain, - }); - return count > 0; + private useLocalObjects() { + return false; } private async searchObjects( @@ -59,7 +65,7 @@ export class MongoDBHost implements DB { const conds: Record[] = [ { ...baseFilter, tenant_id: this.tenantId }, ]; - if (await this.useLocalObjects()) { + if (this.useLocalObjects()) { conds.push({ ...baseFilter, tenant_id: this.rootDomain }); } const exec = async ( @@ -532,27 +538,6 @@ export class MongoDBHost implements DB { } return { deletedCount: 0 }; } - - async listRelays() { - const docs = await HostRelay.find({ tenant_id: this.tenantId }).lean< - { host: string }[] - >(); - return docs.map((d) => d.host); - } - - async addRelay(relay: string, inboxUrl?: string) { - const url = inboxUrl ?? `https://${relay}/inbox`; - await HostRelay.updateOne( - { tenant_id: this.tenantId, host: relay }, - { $set: { inboxUrl: url }, $setOnInsert: { since: new Date() } }, - { upsert: true }, - ); - } - - async removeRelay(relay: string) { - await HostRelay.deleteOne({ tenant_id: this.tenantId, host: relay }); - } - async addFollowerByName(username: string, follower: string) { await HostAccount.updateOne({ userName: username, @@ -778,43 +763,6 @@ export class MongoDBHost implements DB { return !!res; } - async findRelaysByHosts(hosts: string[]): Promise { - const docs = await HostRelay.find({ host: { $in: hosts } }).lean< - { _id: mongoose.Types.ObjectId; host: string; inboxUrl: string }[] - >(); - return docs.map((d) => ({ - _id: String(d._id), - host: d.host, - inboxUrl: d.inboxUrl, - })); - } - - async findRelayByHost(host: string): Promise { - const doc = await HostRelay.findOne({ host }).lean< - { _id: mongoose.Types.ObjectId; host: string; inboxUrl: string } | null - >(); - return doc - ? { _id: String(doc._id), host: doc.host, inboxUrl: doc.inboxUrl } - : null; - } - - async createRelay( - data: { host: string; inboxUrl: string }, - ): Promise { - const doc = new HostRelay({ host: data.host, inboxUrl: data.inboxUrl }); - await doc.save(); - return { _id: String(doc._id), host: doc.host, inboxUrl: doc.inboxUrl }; - } - - async deleteRelayById(id: string): Promise { - const doc = await HostRelay.findByIdAndDelete(id).lean< - { _id: mongoose.Types.ObjectId; host: string; inboxUrl: string } | null - >(); - return doc - ? { _id: String(doc._id), host: doc.host, inboxUrl: doc.inboxUrl } - : null; - } - async findRemoteActorByUrl(url: string) { return await HostRemoteActor.findOne({ actorUrl: url }).lean(); } @@ -857,6 +805,106 @@ export class MongoDBHost implements DB { await SystemKey.create({ domain, privateKey, publicKey }); } + async findServiceActorKey(domain: string) { + return await ServiceActorKey.findOne({ domain }).lean< + { domain: string; privateKey: string; publicKey: string } | null + >(); + } + + async saveServiceActorKey( + domain: string, + privateKey: string, + publicKey: string, + ) { + await ServiceActorKey.create({ domain, privateKey, publicKey }); + } + + /** FASP設定取得 */ + async findFaspConfig(): Promise { + return await HostFaspConfig.findOne({ tenant_id: this.tenantId }).lean< + FaspConfigDoc | null + >(); + } + + /** FASP設定保存 */ + async saveFaspConfig(config: FaspConfigDoc) { + await HostFaspConfig.updateOne({ tenant_id: this.tenantId }, { + $set: config, + }, { upsert: true }); + } + + /** FASP設定削除 */ + async deleteFaspConfig() { + await HostFaspConfig.deleteOne({ tenant_id: this.tenantId }); + } + + /** FASP 登録情報作成 */ + async createFaspRegistration(reg: FaspRegistrationDoc) { + const doc = new HostFaspRegistration({ ...reg, tenant_id: this.tenantId }); + await doc.save(); + return doc.toObject(); + } + + /** server_id から登録情報検索 */ + async findFaspRegistrationByServerId(serverId: string) { + return await HostFaspRegistration.findOne({ + server_id: serverId, + tenant_id: this.tenantId, + }).lean(); + } + + /** capability 選択の更新 */ + async updateFaspCapability( + serverId: string, + capability: { id: string; version: string }, + enabled: boolean, + ) { + const update = enabled + ? { $addToSet: { capabilities: capability } } + : { $pull: { capabilities: capability } }; + await HostFaspRegistration.updateOne({ + server_id: serverId, + tenant_id: this.tenantId, + }, update); + } + + async createFaspEventSubscription(sub: FaspEventSubscriptionDoc) { + const doc = new HostFaspEventSubscription({ + ...sub, + tenant_id: this.tenantId, + }); + await doc.save(); + return doc.toObject(); + } + + async deleteFaspEventSubscription(id: string) { + await HostFaspEventSubscription.deleteOne({ + _id: id, + tenant_id: this.tenantId, + }); + } + + async createFaspBackfillRequest(req: FaspBackfillRequestDoc) { + const doc = new HostFaspBackfillRequest({ + ...req, + tenant_id: this.tenantId, + }); + await doc.save(); + return doc.toObject(); + } + + async findFaspBackfillRequestById(id: string) { + return await HostFaspBackfillRequest.findOne({ + _id: id, + tenant_id: this.tenantId, + }).lean(); + } + + async findFaspRegistration() { + return await HostFaspRegistration.findOne({ tenant_id: this.tenantId }) + .lean(); + } + async registerFcmToken(token: string, userName: string) { if (this.env["DB_MODE"] === "host") { await HostFcmToken.updateOne( diff --git a/app/api/DB/local.ts b/app/api/DB/local.ts index 6b65db635..c0e50c2ca 100644 --- a/app/api/DB/local.ts +++ b/app/api/DB/local.ts @@ -11,18 +11,29 @@ import KeyPackage from "../models/takos/key_package.ts"; import Notification from "../models/takos/notification.ts"; import PublicMessage from "../models/takos/public_message.ts"; import SystemKey from "../models/takos/system_key.ts"; -import Relay from "../models/takos/relay.ts"; +import ServiceActorKey from "../models/takos/service_actor_key.ts"; import RemoteActor from "../models/takos/remote_actor.ts"; import Session from "../models/takos/session.ts"; import FcmToken from "../models/takos/fcm_token.ts"; import HostFcmToken from "../models/takos_host/fcm_token.ts"; +import HostFaspConfig from "../models/takos_host/fasp_config.ts"; +import HostFaspRegistration from "../models/takos_host/fasp_registration.ts"; +import HostFaspEventSubscription from "../models/takos_host/fasp_event_subscription.ts"; +import HostFaspBackfillRequest from "../models/takos_host/fasp_backfill_request.ts"; import Instance from "../../takos_host/models/instance.ts"; import OAuthClient from "../../takos_host/models/oauth_client.ts"; import HostDomain from "../../takos_host/models/domain.ts"; import Tenant from "../models/takos/tenant.ts"; import mongoose from "mongoose"; import type { DB, ListOpts } from "../../shared/db.ts"; -import type { AccountDoc, RelayDoc, SessionDoc } from "../../shared/types.ts"; +import type { + AccountDoc, + FaspBackfillRequestDoc, + FaspConfigDoc, + FaspEventSubscriptionDoc, + FaspRegistrationDoc, + SessionDoc, +} from "../../shared/types.ts"; import type { SortOrder } from "mongoose"; import type { Db } from "mongodb"; import { connectDatabase } from "../../shared/db.ts"; @@ -446,25 +457,6 @@ export class MongoDBLocal implements DB { } return { deletedCount: 0 }; } - - async listRelays() { - const docs = await Relay.find({}).lean<{ host: string }[]>(); - return docs.map((d) => d.host); - } - - async addRelay(relay: string, inboxUrl?: string) { - const url = inboxUrl ?? `https://${relay}/inbox`; - await Relay.updateOne( - { host: relay }, - { $set: { inboxUrl: url }, $setOnInsert: { since: new Date() } }, - { upsert: true }, - ); - } - - async removeRelay(relay: string) { - await Relay.deleteOne({ host: relay }); - } - async addFollowerByName(username: string, follower: string) { await Account.updateOne({ userName: username }, { $addToSet: { followers: follower }, @@ -675,43 +667,6 @@ export class MongoDBLocal implements DB { return !!res; } - async findRelaysByHosts(hosts: string[]): Promise { - const docs = await Relay.find({ host: { $in: hosts } }).lean< - { _id: mongoose.Types.ObjectId; host: string; inboxUrl: string }[] - >(); - return docs.map((d) => ({ - _id: String(d._id), - host: d.host, - inboxUrl: d.inboxUrl, - })); - } - - async findRelayByHost(host: string): Promise { - const doc = await Relay.findOne({ host }).lean< - { _id: mongoose.Types.ObjectId; host: string; inboxUrl: string } | null - >(); - return doc - ? { _id: String(doc._id), host: doc.host, inboxUrl: doc.inboxUrl } - : null; - } - - async createRelay( - data: { host: string; inboxUrl: string }, - ): Promise { - const doc = new Relay({ host: data.host, inboxUrl: data.inboxUrl }); - await doc.save(); - return { _id: String(doc._id), host: doc.host, inboxUrl: doc.inboxUrl }; - } - - async deleteRelayById(id: string): Promise { - const doc = await Relay.findByIdAndDelete(id).lean< - { _id: mongoose.Types.ObjectId; host: string; inboxUrl: string } | null - >(); - return doc - ? { _id: String(doc._id), host: doc.host, inboxUrl: doc.inboxUrl } - : null; - } - async findRemoteActorByUrl(url: string) { return await RemoteActor.findOne({ actorUrl: url }).lean(); } @@ -754,6 +709,114 @@ export class MongoDBLocal implements DB { await SystemKey.create({ domain, privateKey, publicKey }); } + async findServiceActorKey(domain: string) { + return await ServiceActorKey.findOne({ domain }).lean< + { domain: string; privateKey: string; publicKey: string } | null + >(); + } + + async saveServiceActorKey( + domain: string, + privateKey: string, + publicKey: string, + ) { + await ServiceActorKey.create({ domain, privateKey, publicKey }); + } + + /** FASP設定取得 */ + async findFaspConfig(): Promise { + return await HostFaspConfig.findOne({ + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }).lean(); + } + + /** FASP設定保存 */ + async saveFaspConfig(config: FaspConfigDoc) { + await HostFaspConfig.updateOne( + { tenant_id: this.env["ACTIVITYPUB_DOMAIN"] }, + { $set: config }, + { upsert: true }, + ); + } + + /** FASP設定削除 */ + async deleteFaspConfig() { + await HostFaspConfig.deleteOne({ + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }); + } + + /** FASP 登録情報作成 */ + async createFaspRegistration(reg: FaspRegistrationDoc) { + const doc = new HostFaspRegistration({ + ...reg, + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }); + await doc.save(); + return doc.toObject(); + } + + /** server_id で登録情報検索 */ + async findFaspRegistrationByServerId(serverId: string) { + return await HostFaspRegistration.findOne({ + server_id: serverId, + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }).lean(); + } + + /** capability 選択の更新 */ + async updateFaspCapability( + serverId: string, + capability: { id: string; version: string }, + enabled: boolean, + ) { + const update = enabled + ? { $addToSet: { capabilities: capability } } + : { $pull: { capabilities: capability } }; + await HostFaspRegistration.updateOne({ + server_id: serverId, + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }, update); + } + + async createFaspEventSubscription(sub: FaspEventSubscriptionDoc) { + const doc = new HostFaspEventSubscription({ + ...sub, + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }); + await doc.save(); + return doc.toObject(); + } + + async deleteFaspEventSubscription(id: string) { + await HostFaspEventSubscription.deleteOne({ + _id: id, + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }); + } + + async createFaspBackfillRequest(req: FaspBackfillRequestDoc) { + const doc = new HostFaspBackfillRequest({ + ...req, + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }); + await doc.save(); + return doc.toObject(); + } + + async findFaspBackfillRequestById(id: string) { + return await HostFaspBackfillRequest.findOne({ + _id: id, + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }).lean(); + } + + async findFaspRegistration() { + return await HostFaspRegistration.findOne({ + tenant_id: this.env["ACTIVITYPUB_DOMAIN"], + }).lean(); + } + async registerFcmToken(token: string, userName: string) { if (this.env["DB_MODE"] === "host") { await HostFcmToken.updateOne( diff --git a/app/api/models/takos/relay.ts b/app/api/models/takos/relay.ts deleted file mode 100644 index 2a6ed50a8..000000000 --- a/app/api/models/takos/relay.ts +++ /dev/null @@ -1,12 +0,0 @@ -import mongoose from "mongoose"; - -const relaySchema = new mongoose.Schema({ - host: { type: String, required: true, unique: true }, - inboxUrl: { type: String, required: true }, - since: { type: Date, default: Date.now }, -}); - -const Relay = mongoose.models.Relay ?? mongoose.model("Relay", relaySchema); - -export default Relay; -export { relaySchema }; diff --git a/app/api/models/takos/service_actor_key.ts b/app/api/models/takos/service_actor_key.ts new file mode 100644 index 000000000..78616754f --- /dev/null +++ b/app/api/models/takos/service_actor_key.ts @@ -0,0 +1,14 @@ +import mongoose from "mongoose"; + +// Service Actor 用の鍵を保存するスキーマ。 +const serviceActorKeySchema = new mongoose.Schema({ + domain: { type: String, required: true, unique: true }, + privateKey: { type: String, required: true }, + publicKey: { type: String, required: true }, +}); + +const ServiceActorKey = mongoose.models.ServiceActorKey ?? + mongoose.model("ServiceActorKey", serviceActorKeySchema, "service_actor_key"); + +export default ServiceActorKey; +export { serviceActorKeySchema }; diff --git a/app/api/models/takos_host/fasp_backfill_request.ts b/app/api/models/takos_host/fasp_backfill_request.ts new file mode 100644 index 000000000..9ee4a1298 --- /dev/null +++ b/app/api/models/takos_host/fasp_backfill_request.ts @@ -0,0 +1,18 @@ +import mongoose from "mongoose"; + +/** + * FASP data sharing の backfill request スキーマ。 + * docs/FASP.md 3章および docs/fasp/discovery/data_sharing/v0.1 に基づき保存。 + */ +const faspBackfillRequestSchema = new mongoose.Schema({ + server_id: { type: String, index: true }, + category: { type: String }, + max_count: { type: Number }, + more_objects_available: { type: Boolean, default: true }, + tenant_id: { type: String, index: true }, +}); + +const HostFaspBackfillRequest = mongoose.models.HostFaspBackfillRequest ?? + mongoose.model("HostFaspBackfillRequest", faspBackfillRequestSchema); + +export default HostFaspBackfillRequest; diff --git a/app/api/models/takos_host/fasp_config.ts b/app/api/models/takos_host/fasp_config.ts new file mode 100644 index 000000000..31edab65b --- /dev/null +++ b/app/api/models/takos_host/fasp_config.ts @@ -0,0 +1,23 @@ +import mongoose from "mongoose"; + +/** + * FASP 設定を保持するスキーマ。 + * docs/FASP.md 7.1 の設定例に基づき base_url や capability を保存する。 + * capabilities の仕様は FASP General provider_info に準拠する。 + */ +const faspConfigSchema = new mongoose.Schema({ + enabled: { type: Boolean, default: false }, + base_url: { type: String, default: "" }, + capabilities: { + data_sharing: { type: String, default: "" }, + trends: { type: String, default: "" }, + account_search: { type: String, default: "" }, + }, + tenant_id: { type: String, index: true }, +}); + +const HostFaspConfig = mongoose.models.HostFaspConfig ?? + mongoose.model("HostFaspConfig", faspConfigSchema); + +export default HostFaspConfig; +export { faspConfigSchema }; diff --git a/app/api/models/takos_host/fasp_event_subscription.ts b/app/api/models/takos_host/fasp_event_subscription.ts new file mode 100644 index 000000000..d72dd78f7 --- /dev/null +++ b/app/api/models/takos_host/fasp_event_subscription.ts @@ -0,0 +1,24 @@ +import mongoose from "mongoose"; + +/** + * FASP data sharing の event subscription スキーマ。 + * docs/FASP.md 3章および docs/fasp/discovery/data_sharing/v0.1 に基づき保存。 + */ +const faspEventSubscriptionSchema = new mongoose.Schema({ + server_id: { type: String, index: true }, + category: { type: String }, + subscription_type: { type: String }, + max_batch_size: { type: Number }, + threshold: { + timeframe: Number, + shares: Number, + likes: Number, + replies: Number, + }, + tenant_id: { type: String, index: true }, +}); + +const HostFaspEventSubscription = mongoose.models.HostFaspEventSubscription ?? + mongoose.model("HostFaspEventSubscription", faspEventSubscriptionSchema); + +export default HostFaspEventSubscription; diff --git a/app/api/models/takos_host/fasp_registration.ts b/app/api/models/takos_host/fasp_registration.ts new file mode 100644 index 000000000..d2eee45e1 --- /dev/null +++ b/app/api/models/takos_host/fasp_registration.ts @@ -0,0 +1,23 @@ +import mongoose from "mongoose"; + +/** + * FASP 登録情報を保持するスキーマ。 + * docs/FASP.md 4.1 の登録フローで交換する情報を保存する。 + */ +const faspRegistrationSchema = new mongoose.Schema({ + fasp_id: { type: String, index: true }, + name: { type: String }, + base_url: { type: String }, + server_id: { type: String, index: true }, + public_key: { type: String }, + private_key: { type: String }, + our_public_key: { type: String }, + capabilities: [{ id: String, version: String }], + tenant_id: { type: String, index: true }, +}); + +const HostFaspRegistration = mongoose.models.HostFaspRegistration ?? + mongoose.model("HostFaspRegistration", faspRegistrationSchema); + +export default HostFaspRegistration; +export { faspRegistrationSchema }; diff --git a/app/api/models/takos_host/relay.ts b/app/api/models/takos_host/relay.ts deleted file mode 100644 index 7a3301ed0..000000000 --- a/app/api/models/takos_host/relay.ts +++ /dev/null @@ -1,16 +0,0 @@ -import mongoose from "mongoose"; - -const relaySchema = new mongoose.Schema({ - host: { type: String, required: true }, - inboxUrl: { type: String, required: true }, - tenant_id: { type: String, index: true }, - since: { type: Date, default: Date.now }, -}); - -relaySchema.index({ host: 1, tenant_id: 1 }, { unique: true }); - -const HostRelay = mongoose.models.HostRelay ?? - mongoose.model("HostRelay", relaySchema); - -export default HostRelay; -export { relaySchema }; diff --git a/app/api/routes/fasp_discovery.ts b/app/api/routes/fasp_discovery.ts new file mode 100644 index 000000000..c725a9b5b --- /dev/null +++ b/app/api/routes/fasp_discovery.ts @@ -0,0 +1,316 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { createDB } from "../DB/mod.ts"; +import { getEnv } from "../../shared/config.ts"; +import { + parseKeyId, + signedFetch, + verifyContentDigest, + verifyRequest, +} from "../../shared/fasp.ts"; +import { b64ToBuf } from "../../shared/buffer.ts"; +import { rateLimit } from "../utils/rate_limit.ts"; + +/** + * FASP discovery の data_sharing / trends / account_search エンドポイント。 + * docs/fasp/discovery/data_sharing/v0.1/data_sharing.md などを参照。 + */ +const app = new Hono(); +app.use("/*", rateLimit({ windowMs: 60_000, limit: 30 })); + +const subSchema = z.object({ + category: z.enum(["content", "account"]), + subscriptionType: z.enum(["lifecycle", "trends"]), + maxBatchSize: z.number().int().positive().optional(), + threshold: z.object({ + timeframe: z.number().int().positive().optional(), + shares: z.number().int().positive().optional(), + likes: z.number().int().positive().optional(), + replies: z.number().int().positive().optional(), + }).partial().optional(), +}); + +// FASP -> takos: イベント購読 +app.post("/data_sharing/v0/event_subscriptions", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const raw = new Uint8Array(await c.req.arrayBuffer()); + const digest = c.req.header("content-digest") ?? ""; + if (!await verifyContentDigest(raw, digest)) { + return c.json({ error: "invalid digest" }, 401); + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const serverId = parseKeyId(sigInput); + if (!serverId) return c.json({ error: "missing key" }, 401); + const reg = await db.findFaspRegistrationByServerId(serverId); + if (!reg) return c.json({ error: "unknown server" }, 401); + const key = await crypto.subtle.importKey( + "raw", + b64ToBuf(reg.public_key), + { name: "Ed25519" }, + true, + ["verify"], + ); + const ok = await verifyRequest({ + method: c.req.method, + url: c.req.url, + body: raw, + key, + signatureInput: sigInput, + signature, + }); + if (!ok) return c.json({ error: "invalid signature" }, 401); + const body = JSON.parse(new TextDecoder().decode(raw)); + const parsed = subSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid body" }, 422); + const doc = await db.createFaspEventSubscription({ + server_id: serverId, + category: parsed.data.category, + subscription_type: parsed.data.subscriptionType, + max_batch_size: parsed.data.maxBatchSize, + threshold: parsed.data.threshold, + }); + return c.json({ subscription: { id: doc._id } }, 201); +}); + +// イベント購読解除 +app.delete("/data_sharing/v0/event_subscriptions/:id", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const raw = new Uint8Array(await c.req.arrayBuffer()); + const digest = c.req.header("content-digest") ?? ""; + if (!await verifyContentDigest(raw, digest)) { + return c.json({ error: "invalid digest" }, 401); + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const serverId = parseKeyId(sigInput); + if (!serverId) return c.json({ error: "missing key" }, 401); + const reg = await db.findFaspRegistrationByServerId(serverId); + if (!reg) return c.json({ error: "unknown server" }, 401); + const key = await crypto.subtle.importKey( + "raw", + b64ToBuf(reg.public_key), + { name: "Ed25519" }, + true, + ["verify"], + ); + const ok = await verifyRequest({ + method: c.req.method, + url: c.req.url, + body: raw, + key, + signatureInput: sigInput, + signature, + }); + if (!ok) return c.json({ error: "invalid signature" }, 401); + await db.deleteFaspEventSubscription(c.req.param("id")); + return c.body(null, 204); +}); + +const backfillSchema = z.object({ + category: z.enum(["content", "account"]), + maxCount: z.number().int().positive(), +}); + +// バックフィル要求 +app.post("/data_sharing/v0/backfill_requests", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const raw = new Uint8Array(await c.req.arrayBuffer()); + const digest = c.req.header("content-digest") ?? ""; + if (!await verifyContentDigest(raw, digest)) { + return c.json({ error: "invalid digest" }, 401); + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const serverId = parseKeyId(sigInput); + if (!serverId) return c.json({ error: "missing key" }, 401); + const reg = await db.findFaspRegistrationByServerId(serverId); + if (!reg) return c.json({ error: "unknown server" }, 401); + const key = await crypto.subtle.importKey( + "raw", + b64ToBuf(reg.public_key), + { name: "Ed25519" }, + true, + ["verify"], + ); + const ok = await verifyRequest({ + method: c.req.method, + url: c.req.url, + body: raw, + key, + signatureInput: sigInput, + signature, + }); + if (!ok) return c.json({ error: "invalid signature" }, 401); + const body = JSON.parse(new TextDecoder().decode(raw)); + const parsed = backfillSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid body" }, 422); + const doc = await db.createFaspBackfillRequest({ + server_id: serverId, + category: parsed.data.category, + max_count: parsed.data.maxCount, + }); + return c.json({ backfillRequest: { id: doc._id } }, 201); +}); + +// バックフィル継続要求 +app.post("/data_sharing/v0/backfill_requests/:id/continuation", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const raw = new Uint8Array(await c.req.arrayBuffer()); + const digest = c.req.header("content-digest") ?? ""; + if (!await verifyContentDigest(raw, digest)) { + return c.json({ error: "invalid digest" }, 401); + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const serverId = parseKeyId(sigInput); + if (!serverId) return c.json({ error: "missing key" }, 401); + const reg = await db.findFaspRegistrationByServerId(serverId); + if (!reg) return c.json({ error: "unknown server" }, 401); + const key = await crypto.subtle.importKey( + "raw", + b64ToBuf(reg.public_key), + { name: "Ed25519" }, + true, + ["verify"], + ); + const ok = await verifyRequest({ + method: c.req.method, + url: c.req.url, + body: raw, + key, + signatureInput: sigInput, + signature, + }); + if (!ok) return c.json({ error: "invalid signature" }, 401); + const doc = await db.findFaspBackfillRequestById(c.req.param("id")); + if (!doc) return c.json({ error: "not_found" }, 404); + return c.body(null, 204); +}); + +const announcementSchema = z.object({ + source: z.object({ + subscription: z.object({ id: z.string() }).optional(), + backfillRequest: z.object({ id: z.string() }).optional(), + }), + category: z.enum(["content", "account"]), + eventType: z.enum(["new", "update", "delete", "trending"]).optional(), + objectUris: z.array(z.string()), + moreObjectsAvailable: z.boolean().optional(), +}); + +// FASP からのアナウンス受信 +app.post("/data_sharing/v0/announcements", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const raw = new Uint8Array(await c.req.arrayBuffer()); + const digest = c.req.header("content-digest") ?? ""; + if (!await verifyContentDigest(raw, digest)) { + return c.json({ error: "invalid digest" }, 401); + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const serverId = parseKeyId(sigInput); + if (!serverId) return c.json({ error: "missing key" }, 401); + const reg = await db.findFaspRegistrationByServerId(serverId); + if (!reg) return c.json({ error: "unknown server" }, 401); + const key = await crypto.subtle.importKey( + "raw", + b64ToBuf(reg.public_key), + { name: "Ed25519" }, + true, + ["verify"], + ); + const ok = await verifyRequest({ + method: c.req.method, + url: c.req.url, + body: raw, + key, + signatureInput: sigInput, + signature, + }); + if (!ok) return c.json({ error: "invalid signature" }, 401); + const body = JSON.parse(new TextDecoder().decode(raw)); + const parsed = announcementSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid body" }, 422); + return c.body(null, 204); +}); + +// 以下は takos -> FASP: trends / account_search 呼び出し + +// トレンドコンテンツ取得 +app.get("/trends/v0/content", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const config = await db.findFaspConfig(); + if (!config || !config.capabilities.trends) { + return c.json({ error: "not_configured" }, 404); + } + const reg = await db.findFaspRegistration(); + if (!reg) return c.json({ error: "not_registered" }, 404); + const url = new URL("/trends/v0/content", config.base_url); + const within = c.req.query("withinLastHours"); + const max = c.req.query("maxCount"); + if (within) url.searchParams.set("withinLastHours", within); + if (max) url.searchParams.set("maxCount", max); + const key = await crypto.subtle.importKey( + "pkcs8", + b64ToBuf(reg.private_key), + { name: "Ed25519" }, + false, + ["sign"], + ); + const body = new Uint8Array(); + const res = await signedFetch({ + method: "GET", + url: url.href, + body, + key, + keyId: reg.fasp_id, + headers: { accept: "application/json" }, + }); + const json = await res.json(); + return c.json(json, res.status); +}); + +// アカウント検索 +app.get("/account_search/v0/search", async (c) => { + const term = c.req.query("term"); + if (!term) return c.json({ error: "missing term" }, 422); + const env = getEnv(c); + const db = createDB(env); + const config = await db.findFaspConfig(); + if (!config || !config.capabilities.account_search) { + return c.json({ error: "not_configured" }, 404); + } + const reg = await db.findFaspRegistration(); + if (!reg) return c.json({ error: "not_registered" }, 404); + const url = new URL("/account_search/v0/search", config.base_url); + url.searchParams.set("term", term); + const limit = c.req.query("limit"); + if (limit) url.searchParams.set("limit", limit); + const key = await crypto.subtle.importKey( + "pkcs8", + b64ToBuf(reg.private_key), + { name: "Ed25519" }, + false, + ["sign"], + ); + const body = new Uint8Array(); + const res = await signedFetch({ + method: "GET", + url: url.href, + body, + key, + keyId: reg.fasp_id, + headers: { accept: "application/json" }, + }); + const json = await res.json(); + return c.json(json, res.status); +}); + +export default app; diff --git a/app/api/routes/fasp_registration.ts b/app/api/routes/fasp_registration.ts new file mode 100644 index 000000000..51b4eedef --- /dev/null +++ b/app/api/routes/fasp_registration.ts @@ -0,0 +1,177 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { createDB } from "../DB/mod.ts"; +import { getEnv } from "../../shared/config.ts"; +import { + parseKeyId, + verifyContentDigest, + verifyRequest, +} from "../../shared/fasp.ts"; +import { b64ToBuf, bufToB64 } from "../../shared/buffer.ts"; + +/** + * FASP General の登録・provider_info・capability 管理エンドポイント。 + * docs/fasp/general/v0.1/registration.md と provider_info.md を参照。 + * docs/FASP.md 4.1-4.2 も参照。ベースパスは /fasp。 + */ +const app = new Hono(); + +const registrationSchema = z.object({ + name: z.string(), + baseUrl: z.string(), + serverId: z.string(), + publicKey: z.string(), +}); + +// FASP -> takos: 登録情報送信 +app.post("/registration", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const raw = new Uint8Array(await c.req.arrayBuffer()); + const digest = c.req.header("content-digest") ?? ""; + if (!await verifyContentDigest(raw, digest)) { + return c.json({ error: "invalid digest" }, 401); + } + const body = JSON.parse(new TextDecoder().decode(raw)); + const parsed = registrationSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: "invalid body" }, 400); + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const key = await crypto.subtle.importKey( + "raw", + b64ToBuf(parsed.data.publicKey), + { name: "Ed25519" }, + true, + ["verify"], + ); + const ok = await verifyRequest({ + method: c.req.method, + url: c.req.url, + body: raw, + key, + signatureInput: sigInput, + signature, + }); + if (!ok) return c.json({ error: "invalid signature" }, 401); + + const keyPair = await crypto.subtle.generateKey( + { name: "Ed25519", namedCurve: "Ed25519" }, + true, + ["sign", "verify"], + ); + const priv = bufToB64( + await crypto.subtle.exportKey("pkcs8", keyPair.privateKey), + ); + const pub = bufToB64(await crypto.subtle.exportKey("raw", keyPair.publicKey)); + const faspId = crypto.randomUUID().replace(/-/g, "").slice(0, 12); + await db.createFaspRegistration({ + fasp_id: faspId, + name: parsed.data.name, + base_url: parsed.data.baseUrl, + server_id: parsed.data.serverId, + public_key: parsed.data.publicKey, + private_key: priv, + our_public_key: pub, + capabilities: [], + }); + const completion = `https://${ + env["ROOT_DOMAIN"] ?? env["ACTIVITYPUB_DOMAIN"] + }/admin/fasps`; + return c.json({ + faspId, + publicKey: pub, + registrationCompletionUri: completion, + }, 201); +}); + +// takos -> FASP: provider info 取得 (DB から返す) +app.get("/provider_info", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const config = await db.findFaspConfig(); + if (!config) return c.json({ error: "not_configured" }, 404); + const caps = Object.entries(config.capabilities) + .filter(([_, v]) => v) + .map(([id, version]) => ({ id, version })); + return c.json({ name: env["FASP_NAME"] ?? "takos FASP", capabilities: caps }); +}); + +// takos <- server: capability 有効化 +app.post("/capabilities/:id/:ver/activation", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const raw = new Uint8Array(await c.req.arrayBuffer()); + const digest = c.req.header("content-digest") ?? ""; + if (!await verifyContentDigest(raw, digest)) { + return c.json({ error: "invalid digest" }, 401); + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const serverId = parseKeyId(sigInput); + if (!serverId) return c.json({ error: "missing key" }, 401); + const reg = await db.findFaspRegistrationByServerId(serverId); + if (!reg) return c.json({ error: "unknown server" }, 401); + const key = await crypto.subtle.importKey( + "raw", + b64ToBuf(reg.public_key), + { name: "Ed25519" }, + true, + ["verify"], + ); + const ok = await verifyRequest({ + method: c.req.method, + url: c.req.url, + body: raw, + key, + signatureInput: sigInput, + signature, + }); + if (!ok) return c.json({ error: "invalid signature" }, 401); + await db.updateFaspCapability(serverId, { + id: c.req.param("id"), + version: c.req.param("ver"), + }, true); + return c.body(null, 204); +}); + +// capability 無効化 +app.delete("/capabilities/:id/:ver/activation", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const raw = new Uint8Array(await c.req.arrayBuffer()); + const digest = c.req.header("content-digest") ?? ""; + if (!await verifyContentDigest(raw, digest)) { + return c.json({ error: "invalid digest" }, 401); + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const serverId = parseKeyId(sigInput); + if (!serverId) return c.json({ error: "missing key" }, 401); + const reg = await db.findFaspRegistrationByServerId(serverId); + if (!reg) return c.json({ error: "unknown server" }, 401); + const key = await crypto.subtle.importKey( + "raw", + b64ToBuf(reg.public_key), + { name: "Ed25519" }, + true, + ["verify"], + ); + const ok = await verifyRequest({ + method: c.req.method, + url: c.req.url, + body: raw, + key, + signatureInput: sigInput, + signature, + }); + if (!ok) return c.json({ error: "invalid signature" }, 401); + await db.updateFaspCapability(serverId, { + id: c.req.param("id"), + version: c.req.param("ver"), + }, false); + return c.body(null, 204); +}); + +export default app; diff --git a/app/api/routes/nodeinfo.ts b/app/api/routes/nodeinfo.ts index 42304d5a7..77c42022f 100644 --- a/app/api/routes/nodeinfo.ts +++ b/app/api/routes/nodeinfo.ts @@ -30,6 +30,13 @@ app.get("/.well-known/nodeinfo", (c) => { app.get("/nodeinfo/2.0", async (c) => { const env = getEnv(c); const { users, posts, version } = await getNodeStats(env); + const db = createDB(env); + const config = await db.findFaspConfig(); + const metadata: Record = {}; + if (config?.base_url) { + // FASP ベースURLは docs/FASP.md 2章の取り決めに従い公開する + metadata.faspBaseUrl = config.base_url; + } return c.json({ version: "2.0", software: { @@ -43,7 +50,7 @@ app.get("/nodeinfo/2.0", async (c) => { users: { total: users, activeMonth: users, activeHalfyear: users }, localPosts: posts, }, - metadata: {}, + metadata, }); }); diff --git a/app/api/routes/relays.ts b/app/api/routes/relays.ts deleted file mode 100644 index acfd96e0a..000000000 --- a/app/api/routes/relays.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Hono } from "hono"; -import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; -import { createDB } from "../DB/mod.ts"; -import authRequired from "../utils/auth.ts"; -import { getEnv } from "../../shared/config.ts"; -import { - createFollowActivity, - createUndoFollowActivity, - getDomain, - jsonResponse, - sendActivityPubObject, -} from "../utils/activitypub.ts"; -import { getSystemKey } from "../services/system_actor.ts"; -const app = new Hono(); -app.use("/relays/*", authRequired); - -app.get("/relays", async (c) => { - const env = getEnv(c); - const db = createDB(env); - const hosts = await db.listRelays(); - const dbService = createDB(env); - const relays = await dbService.findRelaysByHosts(hosts); - return jsonResponse(c, { relays }); -}); - -app.post( - "/relays", - zValidator("json", z.object({ inboxUrl: z.string() })), - async (c) => { - const { inboxUrl } = c.req.valid("json") as { inboxUrl: string }; - const host = new URL(inboxUrl).hostname; - const db = createDB(getEnv(c)); - const exists = await db.findRelayByHost(host); - if (exists) return jsonResponse(c, { error: "Already exists" }, 409); - const relay = await db.createRelay({ host, inboxUrl }); - const env = getEnv(c); - try { - await db.addRelay(host, inboxUrl); - } catch { - // URL パース失敗時は無視 - } - try { - const domain = getDomain(c); - const sysDb = createDB(env); - await getSystemKey(sysDb, domain); - const actorId = `https://${domain}/users/system`; - const target = "https://www.w3.org/ns/activitystreams#Public"; - const follow = createFollowActivity(domain, actorId, target); - await sendActivityPubObject(inboxUrl, follow, "system", domain, env); - } catch (err) { - console.error("Failed to follow relay:", err); - } - return jsonResponse(c, { id: relay._id, inboxUrl: relay.inboxUrl }); - }, -); - -app.delete("/relays/:id", async (c) => { - const id = c.req.param("id"); - const db = createDB(getEnv(c)); - const relay = await db.deleteRelayById(id); - if (!relay) return jsonResponse(c, { error: "Relay not found" }, 404); - const env = getEnv(c); - try { - const host = relay.host ?? new URL(relay.inboxUrl).hostname; - await db.removeRelay(host); - } catch { - // URL パース失敗時は無視 - } - try { - const domain = getDomain(c); - const sysDb = createDB(env); - await getSystemKey(sysDb, domain); - const actorId = `https://${domain}/users/system`; - const target = "https://www.w3.org/ns/activitystreams#Public"; - const undo = createUndoFollowActivity(domain, actorId, target); - await sendActivityPubObject(relay.inboxUrl, undo, "system", domain, env); - } catch (err) { - console.error("Failed to undo follow relay:", err); - } - return jsonResponse(c, { success: true }); -}); - -export default app; diff --git a/app/api/routes/service_actor.ts b/app/api/routes/service_actor.ts new file mode 100644 index 000000000..3043f74e3 --- /dev/null +++ b/app/api/routes/service_actor.ts @@ -0,0 +1,140 @@ +import { Hono } from "hono"; +import { createDB } from "../DB/mod.ts"; +import { getEnv } from "../../shared/config.ts"; +import { getServiceActorKey } from "../services/service_actor.ts"; +import { activityHandlers } from "../activity_handlers.ts"; +import { getDomain, jsonResponse } from "../utils/activitypub.ts"; +import { parseActivityRequest } from "../utils/inbox.ts"; +import { signedFetch } from "../../shared/fasp.ts"; +import { b64ToBuf } from "../../shared/buffer.ts"; + +/** + * 対象URIが FEP-5feb の discoverable 条件を満たすか判定。 + * docs/FASP.md 8章・docs/fasp/discovery/data_sharing/v0.1/data_sharing.md 参照。 + */ +async function isDiscoverable(uri: string): Promise { + try { + const headers = { + accept: + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }; + const res = await fetch(uri, { headers }); + if (!res.ok) return false; + const obj = await res.json(); + const publicUri = "https://www.w3.org/ns/activitystreams#Public"; + if (obj.type === "Actor") return obj.discoverable === true; + const to = Array.isArray(obj.to) ? obj.to : obj.to ? [obj.to] : []; + const cc = Array.isArray(obj.cc) ? obj.cc : obj.cc ? [obj.cc] : []; + if (![...to, ...cc].includes(publicUri)) return false; + const actorUri = typeof obj.attributedTo === "string" + ? obj.attributedTo + : obj.attributedTo?.id; + if (!actorUri) return false; + const aRes = await fetch(actorUri, { headers }); + if (!aRes.ok) return false; + const actor = await aRes.json(); + return actor.discoverable === true; + } catch { + return false; + } +} + +/** + * Service Actor 公開および inbox/outbox 実装。 + * docs/FASP.md 6章・2章に沿って実装。 + */ +const app = new Hono(); + +// Actor 表示 +app.get("/actor", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const domain = getDomain(c); + const { publicKey } = await getServiceActorKey(db, domain); + const actor = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: `https://${domain}/actor`, + type: "Service", + preferredUsername: "takos", + inbox: `https://${domain}/inbox`, + outbox: `https://${domain}/outbox`, + publicKey: { + id: `https://${domain}/actor#main-key`, + owner: `https://${domain}/actor`, + publicKeyPem: publicKey, + }, + }; + return jsonResponse(c, actor, 200, "application/activity+json"); +}); + +// outbox は空のコレクションを返す +app.get("/outbox", (c) => { + const domain = getDomain(c); + const outbox = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/outbox`, + type: "OrderedCollection", + totalItems: 0, + orderedItems: [], + }; + return jsonResponse(c, outbox, 200, "application/activity+json"); +}); + +// inbox 処理 +app.post("/inbox", async (c) => { + const result = await parseActivityRequest(c); + if (!result) return jsonResponse(c, { error: "Invalid signature" }, 401); + const { activity } = result; + const handler = activityHandlers[activity.type]; + if (handler) await handler(activity, "service", c); + return jsonResponse(c, { status: "ok" }, 200, "application/activity+json"); +}); + +// FASP へ announcement を配送 +app.post("/fasp/announcements", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const config = await db.findFaspConfig(); + if (!config) return c.json({ error: "not_configured" }, 400); + const reg = await db.findFaspRegistration(); + if (!reg) return c.json({ error: "not_registered" }, 400); + const url = `${config.base_url}/data_sharing/v0/announcements`; + const bodyObj = await c.req.json(); + const uris: string[] = Array.isArray(bodyObj.objectUris) + ? bodyObj.objectUris + : []; + const objectUris: string[] = []; + for (const uri of uris) { + if (await isDiscoverable(uri)) { + objectUris.push(uri); + } else { + console.warn(`discoverableでないため除外: ${uri}`); + } + } + if (objectUris.length === 0) { + return c.json({ error: "no_discoverable_object" }, 400); + } + bodyObj.objectUris = objectUris; + const raw = new TextEncoder().encode(JSON.stringify(bodyObj)); + const key = await crypto.subtle.importKey( + "pkcs8", + b64ToBuf(reg.private_key), + { name: "Ed25519" }, + false, + ["sign"], + ); + const res = await signedFetch({ + method: "POST", + url, + body: raw, + key, + keyId: reg.server_id, + headers: { "content-type": "application/json" }, + }); + return c.json({ status: res.status }); +}); + +export default app; diff --git a/app/api/server.ts b/app/api/server.ts index 60adc447e..8fe41a0be 100644 --- a/app/api/server.ts +++ b/app/api/server.ts @@ -15,8 +15,8 @@ import follow from "./routes/follow.ts"; import dms from "./routes/dms.ts"; import rootInbox from "./routes/root_inbox.ts"; import nodeinfo from "./routes/nodeinfo.ts"; +import serviceActor from "./routes/service_actor.ts"; import e2ee from "./routes/e2ee.ts"; -import relays from "./routes/relays.ts"; import videos, { initVideoModule, initVideoWebSocket, @@ -27,6 +27,8 @@ import config from "./routes/config.ts"; import fcm from "./routes/fcm.ts"; import placeholder from "./routes/placeholder.ts"; import trends from "./routes/trends.ts"; +import fasp from "./routes/fasp_registration.ts"; +import faspDiscovery from "./routes/fasp_discovery.ts"; import { fetchOgpData } from "./services/ogp.ts"; import { serveStatic } from "hono/deno"; import type { Context } from "hono"; @@ -69,7 +71,6 @@ export async function createTakosApp(env?: Record) { dms, files, search, - relays, users, e2ee, ]; @@ -79,11 +80,13 @@ export async function createTakosApp(env?: Record) { // ActivityPub ルートは / のみにマウントする - const rootRoutes = [nodeinfo, activitypub, rootInbox]; + const rootRoutes = [nodeinfo, activitypub, rootInbox, serviceActor]; // e2ee ルートは /api のみで提供し、ActivityPub ルートと競合しないようにする for (const r of rootRoutes) { app.route("/", r); } + app.route("/fasp", fasp); + app.route("/fasp", faspDiscovery); app.get("/api/ogp", async (c) => { const url = c.req.query("url"); diff --git a/app/api/services/service_actor.ts b/app/api/services/service_actor.ts new file mode 100644 index 000000000..dc16473ad --- /dev/null +++ b/app/api/services/service_actor.ts @@ -0,0 +1,16 @@ +import type { DB } from "../../shared/db.ts"; +import { generateKeyPair } from "../../shared/crypto.ts"; + +/** + * Service Actor の鍵を取得する。未生成の場合は新規作成。 + * docs/FASP.md 6章の Service Actor 仕様に基づく。 + */ +export async function getServiceActorKey(db: DB, domain: string) { + let doc = await db.findServiceActorKey(domain); + if (!doc) { + const keys = await generateKeyPair(); + await db.saveServiceActorKey(domain, keys.privateKey, keys.publicKey); + doc = { domain, ...keys }; + } + return doc; +} diff --git a/app/api/utils/activitypub.ts b/app/api/utils/activitypub.ts index c02a298ca..215a1a306 100644 --- a/app/api/utils/activitypub.ts +++ b/app/api/utils/activitypub.ts @@ -2,6 +2,7 @@ import { createDB } from "../DB/mod.ts"; import { getEnv } from "../../shared/config.ts"; import { pemToArrayBuffer } from "../../shared/crypto.ts"; import { getSystemKey } from "../services/system_actor.ts"; +import { getServiceActorKey } from "../services/service_actor.ts"; import type { Context } from "hono"; import { b64ToBuf, bufToB64 } from "../../shared/buffer.ts"; @@ -96,9 +97,12 @@ async function signAndSend( body: string, account: { userName: string; privateKey: string }, domain: string, + actorPath?: string, ): Promise { const res = await signAndPost(inboxUrl, body, { - id: `https://${domain}/users/${account.userName}`, + id: actorPath + ? `https://${domain}${actorPath}` + : `https://${domain}/users/${account.userName}`, privateKey: account.privateKey, }); const responseBody = await res.text(); @@ -119,10 +123,17 @@ export async function sendActivityPubObject( env: Record = {}, ): Promise { const body = JSON.stringify(object); - let key: { userName: string; privateKey: string }; + let key: { userName: string; privateKey: string; actorPath?: string }; if (actor === "system") { const sys = await getSystemKey(createDB(env), domain); key = { userName: "system", privateKey: sys.privateKey }; + } else if (actor === "service") { + const svc = await getServiceActorKey(createDB(env), domain); + key = { + userName: "service", + privateKey: svc.privateKey, + actorPath: "/actor", + }; } else { const db = createDB(env); const account = await db.findAccountByUserName(actor); @@ -133,7 +144,7 @@ export async function sendActivityPubObject( } try { - return await signAndSend(inboxUrl, body, key, domain); + return await signAndSend(inboxUrl, body, key, domain, key.actorPath); } catch (err) { console.error(`Failed to send ActivityPub object to ${inboxUrl}:`, err); throw err; @@ -147,12 +158,7 @@ export async function deliverActivityPubObject( domain: string, env: Record = {}, ): Promise { - const db = createDB(env); - const relayHosts = await db.listRelays(); - const relayTargets = relayHosts.map((h: string) => `https://${h}/inbox`); - const allTargets = [...targets, ...relayTargets]; - - const deliveryPromises = allTargets.map(async (addr) => { + const deliveryPromises = targets.map(async (addr) => { let iri = addr; // acct:username@domain または username@domain 形式を解決 if (!iri.startsWith("http")) { diff --git a/app/client/src/components/Setting/FaspSettings.tsx b/app/client/src/components/Setting/FaspSettings.tsx new file mode 100644 index 000000000..875d9a9df --- /dev/null +++ b/app/client/src/components/Setting/FaspSettings.tsx @@ -0,0 +1,106 @@ +import { Component, createResource, createSignal, For, Show } from "solid-js"; +import { + approveRegistration, + fetchProviderInfo, + fetchRegistrations, + toggleCapability, +} from "./faspApi.ts"; +import { b64ToBuf, bufToB64 } from "../../../../shared/buffer.ts"; +import type { FaspRegistrationDoc } from "../../../../shared/types.ts"; + +/** + * docs/FASP.md 4章の手順に基づく FASP 設定画面。 + * 登録済み FASP の承認と capability 切替を提供する。 + */ +const FaspSettings: Component = () => { + const [regs, { refetch }] = createResource(fetchRegistrations); + + const calcFingerprint = async (pubKey: string) => { + const hash = await crypto.subtle.digest("SHA-256", b64ToBuf(pubKey)); + return bufToB64(hash); + }; + + return ( +
+

FASP設定

+ + {(reg: FaspRegistrationDoc) => { + const [fp] = createResource(() => reg.public_key, calcFingerprint); + const [provider] = createResource( + () => reg.base_url, + fetchProviderInfo, + ); + const [active, setActive] = createSignal( + new Set(reg.capabilities.map((c) => `${c.id}@${c.version}`)), + ); + const onApprove = async () => { + const ok = await approveRegistration(reg.fasp_id); + if (ok) await refetch(); + }; + const onToggle = async ( + cap: { id: string; version: string }, + enable: boolean, + el: HTMLInputElement, + ) => { + const ok = await toggleCapability(reg, cap, enable); + if (ok) { + setActive((prev) => { + const s = new Set(prev); + const key = `${cap.id}@${cap.version}`; + if (enable) s.add(key); + else s.delete(key); + return s; + }); + } else { + el.checked = !enable; + } + }; + return ( +
+
名称: {reg.name}
+
Base URL: {reg.base_url}
+
+ 指紋: {fp()} +
+ + + + +
    + + {(cap) => { + const key = `${cap.id}@${cap.version}`; + return ( +
  • + + onToggle( + cap, + e.currentTarget.checked, + e.currentTarget, + )} + /> + {cap.id} v{cap.version} +
  • + ); + }} +
    +
+
+
+ ); + }} +
+
+ ); +}; + +export default FaspSettings; diff --git a/app/client/src/components/Setting/RelaySettings.tsx b/app/client/src/components/Setting/RelaySettings.tsx deleted file mode 100644 index 586a26dd0..000000000 --- a/app/client/src/components/Setting/RelaySettings.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Component, createResource, createSignal, For } from "solid-js"; -import { addRelay, deleteRelay, fetchRelays, Relay } from "./relaysApi.ts"; - -const RelaySettings: Component = () => { - const [relays, { mutate }] = createResource(fetchRelays); - const [url, setUrl] = createSignal(""); - - const onAdd = async () => { - const inboxUrl = url().trim(); - if (!inboxUrl) return; - const result = await addRelay(inboxUrl); - if (result) { - mutate((prev) => [...(prev ?? []), result]); - setUrl(""); - } - }; - - const onDelete = async (id: string) => { - const ok = await deleteRelay(id); - if (ok) { - mutate((prev) => prev?.filter((r) => r.id !== id) ?? []); - } - }; - - return ( -
-

リレーサーバー設定

-
- setUrl(e.currentTarget.value)} - /> - -
-
    - - {(relay: Relay) => ( -
  • - {relay.inboxUrl} - -
  • - )} -
    -
-
- ); -}; - -export default RelaySettings; diff --git a/app/client/src/components/Setting/faspApi.ts b/app/client/src/components/Setting/faspApi.ts new file mode 100644 index 000000000..b0e6b0653 --- /dev/null +++ b/app/client/src/components/Setting/faspApi.ts @@ -0,0 +1,82 @@ +import { apiFetch } from "../../utils/config.ts"; +import { signedFetch } from "../../../../shared/fasp.ts"; +import { b64ToBuf } from "../../../../shared/buffer.ts"; +import type { FaspRegistrationDoc } from "../../../../shared/types.ts"; + +/** + * FASP General/Discovery API クライアント。 + * docs/fasp/general/v0.1/ と docs/fasp/discovery/ を参照。 + */ +export interface ProviderInfo { + name: string; + capabilities: { id: string; version: string }[]; +} + +export async function fetchRegistrations(): Promise { + try { + const res = await apiFetch("/api/fasp/registrations"); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data.registrations) ? data.registrations : []; + } catch (err) { + console.error("FASP一覧取得失敗", err); + return []; + } +} + +export async function approveRegistration(id: string): Promise { + try { + const res = await apiFetch(`/api/fasp/registrations/${id}/approve`, { + method: "POST", + }); + return res.ok; + } catch (err) { + console.error("FASP承認失敗", err); + return false; + } +} + +export async function fetchProviderInfo( + baseUrl: string, +): Promise { + try { + const res = await fetch(new URL("/provider_info", baseUrl)); + if (!res.ok) return null; + return await res.json(); + } catch (err) { + console.error("provider_info取得失敗", err); + return null; + } +} + +export async function toggleCapability( + reg: FaspRegistrationDoc, + cap: { id: string; version: string }, + enable: boolean, +): Promise { + try { + const url = new URL( + `/capabilities/${cap.id}/${cap.version}/activation`, + reg.base_url, + ).toString(); + const key = await crypto.subtle.importKey( + "pkcs8", + b64ToBuf(reg.private_key), + { name: "Ed25519" }, + false, + ["sign"], + ); + const body = new Uint8Array(); + const res = await signedFetch({ + method: enable ? "POST" : "DELETE", + url, + body, + key, + keyId: reg.fasp_id, + }); + return res.ok; + } catch (err) { + console.error("capability切替失敗", err); + return false; + } +} diff --git a/app/client/src/components/Setting/index.tsx b/app/client/src/components/Setting/index.tsx index c98efd722..6b5cc333f 100644 --- a/app/client/src/components/Setting/index.tsx +++ b/app/client/src/components/Setting/index.tsx @@ -4,7 +4,7 @@ import { microblogPostLimitState, } from "../../states/settings.ts"; import { encryptionKeyState, loginState } from "../../states/session.ts"; -import RelaySettings from "./RelaySettings.tsx"; +import FaspSettings from "./FaspSettings.tsx"; import { apiFetch } from "../../utils/config.ts"; import { accounts as accountsAtom } from "../../states/account.ts"; import { deleteMLSDatabase } from "../e2ee/storage.ts"; @@ -62,7 +62,7 @@ export function Setting(props: SettingProps) { - +
+ + +
    + + {(cap) => { + const key = `${cap.id}@${cap.version}`; + return ( +
  • + + onToggle( + cap, + e.currentTarget.checked, + e.currentTarget, + )} + /> + {cap.id} v{cap.version} +
  • + ); + }} +
    +
+
- - - - -
    - - {(cap) => { - const key = `${cap.id}@${cap.version}`; - return ( -
  • - - onToggle( - cap, - e.currentTarget.checked, - e.currentTarget, - )} - /> - {cap.id} v{cap.version} -
  • - ); - }} -
    -
-
- - ); - }} - + ); + }} + + ); }; diff --git a/app/client/src/components/Setting/faspApi.ts b/app/client/src/components/Setting/faspApi.ts index b0e6b0653..58095f317 100644 --- a/app/client/src/components/Setting/faspApi.ts +++ b/app/client/src/components/Setting/faspApi.ts @@ -14,7 +14,7 @@ export interface ProviderInfo { export async function fetchRegistrations(): Promise { try { - const res = await apiFetch("/api/fasp/registrations"); + const res = await apiFetch("/fasp/registrations"); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data.registrations) ? data.registrations : []; @@ -26,7 +26,7 @@ export async function fetchRegistrations(): Promise { export async function approveRegistration(id: string): Promise { try { - const res = await apiFetch(`/api/fasp/registrations/${id}/approve`, { + const res = await apiFetch(`/fasp/registrations/${id}/approve`, { method: "POST", }); return res.ok; diff --git a/app/shared/db.ts b/app/shared/db.ts index a8373f5cb..4d48f6ddb 100644 --- a/app/shared/db.ts +++ b/app/shared/db.ts @@ -186,6 +186,10 @@ export interface DB { createFaspRegistration( reg: FaspRegistrationDoc, ): Promise; + /** 登録済みFASP一覧取得 */ + listFaspRegistrations(): Promise; + /** FASP 登録の承認 */ + approveFaspRegistration(id: string): Promise; /** server_id で登録情報検索 */ findFaspRegistrationByServerId( serverId: string, diff --git a/app/shared/types.ts b/app/shared/types.ts index 2213beb15..4d51ec07a 100644 --- a/app/shared/types.ts +++ b/app/shared/types.ts @@ -40,6 +40,7 @@ export interface FaspRegistrationDoc { public_key: string; private_key: string; our_public_key: string; + approved: boolean; capabilities: { id: string; version: string }[]; } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 253f6f6b8..f8d96d01a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -787,6 +787,24 @@ paths: description: 登録完了 "401": description: 署名または Content-Digest が不正 + /fasp/registrations: + get: + summary: 登録済みFASP一覧 + responses: + "200": + description: 一覧取得 + /fasp/registrations/{id}/approve: + parameters: + - name: id + in: path + required: true + schema: + type: string + post: + summary: FASP 登録承認 + responses: + "204": + description: 承認完了 /fasp/provider_info: get: summary: FASP 情報取得 From 8cf7f4f9185e90188344c36d07fc5d3991f5e140 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Thu, 7 Aug 2025 00:45:57 +0900 Subject: [PATCH 3/6] =?UTF-8?q?FASP=E8=A8=AD=E5=AE=9A=E3=82=92=E7=AE=A1?= =?UTF-8?q?=E7=90=86UI=E3=81=8B=E3=82=89=E6=9B=B4=E6=96=B0=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp_config.ts | 47 ++++++++ app/api/server.ts | 2 + .../src/components/Setting/FaspSettings.tsx | 101 +++++++++++++++++- app/client/src/components/Setting/faspApi.ts | 35 +++++- docs/FASP.md | 6 +- docs/openapi.yaml | 28 +++++ 6 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 app/api/routes/fasp_config.ts diff --git a/app/api/routes/fasp_config.ts b/app/api/routes/fasp_config.ts new file mode 100644 index 000000000..00e2a5498 --- /dev/null +++ b/app/api/routes/fasp_config.ts @@ -0,0 +1,47 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { createDB } from "../DB/mod.ts"; +import { getEnv } from "../../shared/config.ts"; +import type { FaspConfigDoc } from "../../shared/types.ts"; + +/** + * FASP 設定取得・更新エンドポイント。 + * docs/FASP.md 7.1 の設定例に基づき base_url や capability を管理する。 + */ +const app = new Hono(); + +app.get("/config", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const config = await db.findFaspConfig(); + return c.json({ config }); +}); + +const configSchema = z.object({ + enabled: z.boolean(), + baseUrl: z.string(), + capabilities: z.object({ + data_sharing: z.string().optional(), + trends: z.string().optional(), + account_search: z.string().optional(), + }), +}); + +app.put("/config", async (c) => { + const env = getEnv(c); + const db = createDB(env); + const body = await c.req.json(); + const parsed = configSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: "invalid body" }, 400); + } + const config: FaspConfigDoc = { + enabled: parsed.data.enabled, + base_url: parsed.data.baseUrl, + capabilities: parsed.data.capabilities, + }; + await db.saveFaspConfig(config); + return c.body(null, 204); +}); + +export default app; diff --git a/app/api/server.ts b/app/api/server.ts index 8fe41a0be..ea1f638e0 100644 --- a/app/api/server.ts +++ b/app/api/server.ts @@ -29,6 +29,7 @@ import placeholder from "./routes/placeholder.ts"; import trends from "./routes/trends.ts"; import fasp from "./routes/fasp_registration.ts"; import faspDiscovery from "./routes/fasp_discovery.ts"; +import faspConfig from "./routes/fasp_config.ts"; import { fetchOgpData } from "./services/ogp.ts"; import { serveStatic } from "hono/deno"; import type { Context } from "hono"; @@ -87,6 +88,7 @@ export async function createTakosApp(env?: Record) { } app.route("/fasp", fasp); app.route("/fasp", faspDiscovery); + app.route("/fasp", faspConfig); app.get("/api/ogp", async (c) => { const url = c.req.query("url"); diff --git a/app/client/src/components/Setting/FaspSettings.tsx b/app/client/src/components/Setting/FaspSettings.tsx index 79bc8b640..1b00d653f 100644 --- a/app/client/src/components/Setting/FaspSettings.tsx +++ b/app/client/src/components/Setting/FaspSettings.tsx @@ -1,20 +1,55 @@ -import { Component, createResource, createSignal, For, Show } from "solid-js"; +import { + Component, + createEffect, + createResource, + createSignal, + For, + Show, +} from "solid-js"; import { approveRegistration, + fetchConfig, fetchProviderInfo, fetchRegistrations, + saveConfig, toggleCapability, } from "./faspApi.ts"; import { b64ToBuf, bufToB64 } from "../../../../shared/buffer.ts"; -import type { FaspRegistrationDoc } from "../../../../shared/types.ts"; +import type { + FaspConfigDoc, + FaspRegistrationDoc, +} from "../../../../shared/types.ts"; /** * docs/FASP.md 4章の手順に基づく FASP 設定画面。 * 登録済み FASP の承認と capability 切替を提供する。 */ const FaspSettings: Component = () => { + const [cfg, { refetch: refetchConfig }] = createResource(fetchConfig); const [regs, { refetch }] = createResource(fetchRegistrations); + const [enabled, setEnabled] = createSignal(false); + const [baseUrl, setBaseUrl] = createSignal(""); + const [caps, setCaps] = createSignal>({}); + + createEffect(() => { + const c = cfg(); + if (c) { + setEnabled(c.enabled); + setBaseUrl(c.base_url); + setCaps({ ...c.capabilities }); + } + }); + + const save = async () => { + const ok = await saveConfig({ + enabled: enabled(), + base_url: baseUrl(), + capabilities: caps(), + } as FaspConfigDoc); + if (ok) await refetchConfig(); + }; + const calcFingerprint = async (pubKey: string) => { const hash = await crypto.subtle.digest("SHA-256", b64ToBuf(pubKey)); return bufToB64(hash); @@ -23,6 +58,68 @@ const FaspSettings: Component = () => { return (

FASP設定

+
+
+ setEnabled(e.currentTarget.checked)} + /> + 有効化 +
+
+ + setBaseUrl(e.currentTarget.value)} + /> +
+
+
提供Capabilities
+ + {(id) => ( +
+ + setCaps((prev) => { + const next = { ...prev }; + if (e.currentTarget.checked) { + next[id] = prev[id] ?? "0.1"; + } else { + delete next[id]; + } + return next; + })} + /> + {id} + + + setCaps((prev) => ({ + ...prev, + [id]: e.currentTarget.value, + }))} + /> + +
+ )} +
+
+ +
登録されたFASPはありません
} diff --git a/app/client/src/components/Setting/faspApi.ts b/app/client/src/components/Setting/faspApi.ts index 58095f317..f9e3773ac 100644 --- a/app/client/src/components/Setting/faspApi.ts +++ b/app/client/src/components/Setting/faspApi.ts @@ -1,7 +1,10 @@ import { apiFetch } from "../../utils/config.ts"; import { signedFetch } from "../../../../shared/fasp.ts"; import { b64ToBuf } from "../../../../shared/buffer.ts"; -import type { FaspRegistrationDoc } from "../../../../shared/types.ts"; +import type { + FaspConfigDoc, + FaspRegistrationDoc, +} from "../../../../shared/types.ts"; /** * FASP General/Discovery API クライアント。 @@ -12,6 +15,36 @@ export interface ProviderInfo { capabilities: { id: string; version: string }[]; } +export async function fetchConfig(): Promise { + try { + const res = await apiFetch("/fasp/config"); + if (!res.ok) return null; + const data = await res.json(); + return data.config ?? null; + } catch (err) { + console.error("FASP設定取得失敗", err); + return null; + } +} + +export async function saveConfig(config: FaspConfigDoc): Promise { + try { + const res = await apiFetch("/fasp/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: config.enabled, + baseUrl: config.base_url, + capabilities: config.capabilities, + }), + }); + return res.ok; + } catch (err) { + console.error("FASP設定保存失敗", err); + return false; + } +} + export async function fetchRegistrations(): Promise { try { const res = await apiFetch("/fasp/registrations"); diff --git a/docs/FASP.md b/docs/FASP.md index be4b50ab9..cd192bcc8 100644 --- a/docs/FASP.md +++ b/docs/FASP.md @@ -108,8 +108,10 @@ ### 4.3 管理UI -- `/admin/fasps` 画面で登録済みFASPの一覧と公開鍵指紋を表示。 -- 指紋を確認して「承認」を押すと capability 選択が有効になる。 +- `/admin/fasps` 画面で FASP 基本設定(有効化・Base URL・提供 capability + のバージョン)を編集できる。 +- 登録済みFASPの一覧と公開鍵指紋を表示し、指紋を確認して「承認」を押すと + capability 選択が有効になる。 - 各 capability には ON/OFF スイッチがあり、切り替え操作に応じて `POST` または `DELETE /capabilities///activation` を送信する。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f8d96d01a..a9f3bfbf4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -778,6 +778,34 @@ paths: responses: "200": description: Mastodon 互換のインスタンス情報 + /fasp/config: + get: + summary: FASP 設定取得 + responses: + "200": + description: 設定取得 + put: + summary: FASP 設定更新 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + baseUrl: + type: string + capabilities: + type: object + additionalProperties: + type: string + responses: + "204": + description: 更新完了 + "400": + description: リクエストエラー /fasp/registration: post: summary: FASP 登録情報受信 From cb8e06d32f84684510acd2a8e3efd0937fb3e64c Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Thu, 7 Aug 2025 01:31:07 +0900 Subject: [PATCH 4/6] =?UTF-8?q?NodeInfo=20=E3=81=AB=20node-info=20?= =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=83=AA=E3=82=A2=E3=82=B9=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/nodeinfo.ts | 6 ++++++ docs/openapi.yaml | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/app/api/routes/nodeinfo.ts b/app/api/routes/nodeinfo.ts index 77c42022f..545f96889 100644 --- a/app/api/routes/nodeinfo.ts +++ b/app/api/routes/nodeinfo.ts @@ -27,6 +27,9 @@ app.get("/.well-known/nodeinfo", (c) => { }); }); +// Mastodon 互換のエイリアス。誤って node-info を参照した場合でも NodeInfo を返す +app.get("/.well-known/node-info", (c) => c.redirect("/.well-known/nodeinfo")); + app.get("/nodeinfo/2.0", async (c) => { const env = getEnv(c); const { users, posts, version } = await getNodeStats(env); @@ -54,6 +57,9 @@ app.get("/nodeinfo/2.0", async (c) => { }); }); +// /node-info/2.0 へアクセスされた際は /nodeinfo/2.0 へリダイレクトする +app.get("/node-info/2.0", (c) => c.redirect("/nodeinfo/2.0")); + app.get("/api/v1/instance", async (c) => { const env = getEnv(c); const domain = getDomain(c); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a9f3bfbf4..52cf20509 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -740,6 +740,12 @@ paths: links: - rel: "http://nodeinfo.diaspora.software/ns/schema/2.0" href: "https://example.com/nodeinfo/2.0" + /.well-known/node-info: + get: + summary: NodeInfo リンク取得エイリアス + responses: + "302": + description: /.well-known/nodeinfo へリダイレクト /nodeinfo/2.0: get: summary: NodeInfo 2.0 @@ -766,6 +772,12 @@ paths: localPosts: 1 metadata: faspBaseUrl: "https://example.com/fasp" + /node-info/2.0: + get: + summary: NodeInfo 2.0 エイリアス + responses: + "302": + description: /nodeinfo/2.0 へリダイレクト /.well-known/x-nodeinfo2: get: summary: NodeInfo2 互換リダイレクト From c57378651876684ff23e3e21ea85225990db58af Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Thu, 7 Aug 2025 01:31:11 +0900 Subject: [PATCH 5/6] =?UTF-8?q?NodeInfo=E3=81=AE=E8=AA=A4=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E3=82=A8=E3=82=A4=E3=83=AA=E3=82=A2=E3=82=B9=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/nodeinfo.ts | 6 ------ docs/openapi.yaml | 12 ------------ 2 files changed, 18 deletions(-) diff --git a/app/api/routes/nodeinfo.ts b/app/api/routes/nodeinfo.ts index 545f96889..77c42022f 100644 --- a/app/api/routes/nodeinfo.ts +++ b/app/api/routes/nodeinfo.ts @@ -27,9 +27,6 @@ app.get("/.well-known/nodeinfo", (c) => { }); }); -// Mastodon 互換のエイリアス。誤って node-info を参照した場合でも NodeInfo を返す -app.get("/.well-known/node-info", (c) => c.redirect("/.well-known/nodeinfo")); - app.get("/nodeinfo/2.0", async (c) => { const env = getEnv(c); const { users, posts, version } = await getNodeStats(env); @@ -57,9 +54,6 @@ app.get("/nodeinfo/2.0", async (c) => { }); }); -// /node-info/2.0 へアクセスされた際は /nodeinfo/2.0 へリダイレクトする -app.get("/node-info/2.0", (c) => c.redirect("/nodeinfo/2.0")); - app.get("/api/v1/instance", async (c) => { const env = getEnv(c); const domain = getDomain(c); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 52cf20509..a9f3bfbf4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -740,12 +740,6 @@ paths: links: - rel: "http://nodeinfo.diaspora.software/ns/schema/2.0" href: "https://example.com/nodeinfo/2.0" - /.well-known/node-info: - get: - summary: NodeInfo リンク取得エイリアス - responses: - "302": - description: /.well-known/nodeinfo へリダイレクト /nodeinfo/2.0: get: summary: NodeInfo 2.0 @@ -772,12 +766,6 @@ paths: localPosts: 1 metadata: faspBaseUrl: "https://example.com/fasp" - /node-info/2.0: - get: - summary: NodeInfo 2.0 エイリアス - responses: - "302": - description: /nodeinfo/2.0 へリダイレクト /.well-known/x-nodeinfo2: get: summary: NodeInfo2 互換リダイレクト From 316cdc49c4a2d2fc100992217a70cc8fa49c7336 Mon Sep 17 00:00:00 2001 From: tako0614 Date: Thu, 7 Aug 2025 01:31:13 +0900 Subject: [PATCH 6/6] Remove legacy test object script and add new host-specific configurations for Deno projects in host1 and host2 directories. --- app/dev/deno.json | 8 -- app/dev/host1/deno.json | 8 ++ app/dev/{ => host1}/deno.lock | 0 app/dev/{ => host1}/test_object.ts | 8 +- app/dev/host2/deno.json | 8 ++ app/dev/host2/deno.lock | 168 +++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 12 deletions(-) delete mode 100644 app/dev/deno.json create mode 100644 app/dev/host1/deno.json rename app/dev/{ => host1}/deno.lock (100%) rename app/dev/{ => host1}/test_object.ts (84%) create mode 100644 app/dev/host2/deno.json create mode 100644 app/dev/host2/deno.lock diff --git a/app/dev/deno.json b/app/dev/deno.json deleted file mode 100644 index 8614cbc62..000000000 --- a/app/dev/deno.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "tasks": { - "host1": "deno task --cwd ../takos_host/ dev --env C:\\Users\\shout\\Desktop\\temp\\takos\\app\\dev\\.hostsEnv" - }, - "imports": { - "zod": "npm:zod@^4.0.14" - } -} diff --git a/app/dev/host1/deno.json b/app/dev/host1/deno.json new file mode 100644 index 000000000..6e1e27732 --- /dev/null +++ b/app/dev/host1/deno.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "dev": "deno task --cwd ../../takos_host/ dev --env C:\\Users\\shout\\Desktop\\temp\\takos\\app\\dev\\host1\\.hostsEnv" + }, + "imports": { + "zod": "npm:zod@^4.0.14" + } +} diff --git a/app/dev/deno.lock b/app/dev/host1/deno.lock similarity index 100% rename from app/dev/deno.lock rename to app/dev/host1/deno.lock diff --git a/app/dev/test_object.ts b/app/dev/host1/test_object.ts similarity index 84% rename from app/dev/test_object.ts rename to app/dev/host1/test_object.ts index f4c3598b6..5db7591cb 100644 --- a/app/dev/test_object.ts +++ b/app/dev/host1/test_object.ts @@ -1,7 +1,7 @@ -import { loadConfig } from "../shared/config.ts"; -import { connectDatabase } from "../shared/db.ts"; -import { createDB } from "../api/DB/mod.ts"; -import { createObjectId } from "../api/utils/activitypub.ts"; +import { loadConfig } from "../../shared/config.ts"; +import { connectDatabase } from "../../shared/db.ts"; +import { createDB } from "../../api/DB/mod.ts"; +import { createObjectId } from "../../api/utils/activitypub.ts"; // Takos Host 用のテストオブジェクトを複数追加するスクリプト // app/takos_host/.env を読み込み、ホスト用 MongoDB に接続します diff --git a/app/dev/host2/deno.json b/app/dev/host2/deno.json new file mode 100644 index 000000000..c1978d492 --- /dev/null +++ b/app/dev/host2/deno.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "dev": "deno task --cwd ../../takos_host/ dev --env C:\\Users\\shout\\Desktop\\temp\\takos\\app\\dev\\host2\\.hostsEnv" + }, + "imports": { + "zod": "npm:zod@^4.0.14" + } +} diff --git a/app/dev/host2/deno.lock b/app/dev/host2/deno.lock new file mode 100644 index 000000000..6fdbb0811 --- /dev/null +++ b/app/dev/host2/deno.lock @@ -0,0 +1,168 @@ +{ + "version": "5", + "specifiers": { + "npm:mongoose@7": "7.8.7", + "npm:zod@^4.0.14": "4.0.14" + }, + "npm": { + "@mongodb-js/saslprep@1.3.0": { + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", + "dependencies": [ + "sparse-bitfield" + ] + }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "@types/webidl-conversions@7.0.3": { + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "@types/whatwg-url@8.2.2": { + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "dependencies": [ + "@types/node", + "@types/webidl-conversions" + ] + }, + "bson@5.5.1": { + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==" + }, + "debug@4.4.1": { + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dependencies": [ + "ms" + ] + }, + "ip-address@9.0.5": { + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dependencies": [ + "jsbn", + "sprintf-js" + ] + }, + "jsbn@1.1.0": { + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, + "kareem@2.5.1": { + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==" + }, + "memory-pager@1.5.0": { + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, + "mongodb-connection-string-url@2.6.0": { + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "dependencies": [ + "@types/whatwg-url", + "whatwg-url" + ] + }, + "mongodb@5.9.2": { + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", + "dependencies": [ + "bson", + "mongodb-connection-string-url", + "socks" + ], + "optionalDependencies": [ + "@mongodb-js/saslprep" + ] + }, + "mongoose@7.8.7": { + "integrity": "sha512-5Bo4CrUxrPITrhMKsqUTOkXXo2CoRC5tXxVQhnddCzqDMwRXfyStrxj1oY865g8gaekSBhxAeNkYyUSJvGm9Hw==", + "dependencies": [ + "bson", + "kareem", + "mongodb", + "mpath", + "mquery", + "ms", + "sift" + ] + }, + "mpath@0.9.0": { + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==" + }, + "mquery@5.0.0": { + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": [ + "debug" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "sift@16.0.1": { + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" + }, + "smart-buffer@4.2.0": { + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" + }, + "socks@2.8.6": { + "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", + "dependencies": [ + "ip-address", + "smart-buffer" + ] + }, + "sparse-bitfield@3.0.3": { + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": [ + "memory-pager" + ] + }, + "sprintf-js@1.1.3": { + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + }, + "tr46@3.0.0": { + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dependencies": [ + "punycode" + ] + }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "whatwg-url@11.0.0": { + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "zod@4.0.14": { + "integrity": "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw==" + } + }, + "remote": { + "https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d", + "https://deno.land/std@0.224.0/dotenv/parse.ts": "09977ff88dfd1f24f9973a338f0f91bbdb9307eb5ff6085446e7c423e4c7ba0c", + "https://deno.land/std@0.224.0/dotenv/stringify.ts": "275da322c409170160440836342eaa7cf012a1d11a7e700d8ca4e7f2f8aa4615", + "https://deno.land/x/zod@v3.23.8/ZodError.ts": "528da200fbe995157b9ae91498b103c4ef482217a5c086249507ac850bd78f52", + "https://deno.land/x/zod@v3.23.8/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.23.8/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.23.8/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.23.8/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.23.8/helpers/parseUtil.ts": "c14814d167cc286972b6e094df88d7d982572a08424b7cd50f862036b6fcaa77", + "https://deno.land/x/zod@v3.23.8/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.23.8/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.23.8/helpers/util.ts": "30c273131661ca5dc973f2cfb196fa23caf3a43e224cdde7a683b72e101a31fc", + "https://deno.land/x/zod@v3.23.8/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.23.8/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.23.8/mod.ts": "ec6e2b1255c1a350b80188f97bd0a6bac45801bb46fc48f50b9763aa66046039", + "https://deno.land/x/zod@v3.23.8/types.ts": "1b172c90782b1eaa837100ebb6abd726d79d6c1ec336350c8e851e0fd706bf5c" + }, + "workspace": { + "dependencies": [ + "npm:zod@^4.0.14" + ] + } +}