From 8df6093cffedb0c4a2cc8376300cd224e2609505 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:46:38 +0900 Subject: [PATCH 01/33] =?UTF-8?q?FASP=E7=99=BB=E9=8C=B2=E6=99=82=E3=81=AE?= =?UTF-8?q?=E9=80=9A=E4=BF=A1=E5=B1=A5=E6=AD=B4=E3=82=92=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- app/api/DB/host.ts | 98 +++--- app/api/models/takos/fasp.ts | 57 ++++ app/api/models/takos_host/relay.ts | 16 - app/api/models/takos_host/service_actor.ts | 18 ++ app/api/routes/accounts.ts | 55 +++- app/api/routes/fasp/account_search.ts | 19 ++ app/api/routes/fasp/admin.ts | 65 ++++ app/api/routes/fasp/announcements.ts | 34 +++ app/api/routes/fasp/capabilities.ts | 104 +++++++ app/api/routes/fasp/data_sharing.ts | 155 ++++++++++ app/api/routes/fasp/registration.ts | 131 ++++++++ app/api/routes/fasp/trends.ts | 33 ++ app/api/routes/nodeinfo.ts | 9 +- app/api/routes/posts.ts | 55 +++- app/api/server.ts | 23 +- app/api/services/fasp.ts | 256 ++++++++++++++++ app/takos_host/README.md | 26 +- app/takos_host/consumer.ts | 15 - app/takos_host/deno.json | 3 +- app/takos_host/main.ts | 6 + app/takos_host/service_actor.ts | 131 ++++++++ docs/FASP.md | 57 +++- docs/fasp/setup.md | 30 ++ scripts/host_cli.ts | 331 --------------------- 25 files changed, 1265 insertions(+), 465 deletions(-) create mode 100644 app/api/models/takos/fasp.ts delete mode 100644 app/api/models/takos_host/relay.ts create mode 100644 app/api/models/takos_host/service_actor.ts create mode 100644 app/api/routes/fasp/account_search.ts create mode 100644 app/api/routes/fasp/admin.ts create mode 100644 app/api/routes/fasp/announcements.ts create mode 100644 app/api/routes/fasp/capabilities.ts create mode 100644 app/api/routes/fasp/data_sharing.ts create mode 100644 app/api/routes/fasp/registration.ts create mode 100644 app/api/routes/fasp/trends.ts create mode 100644 app/api/services/fasp.ts create mode 100644 app/takos_host/service_actor.ts create mode 100644 docs/fasp/setup.md delete mode 100644 scripts/host_cli.ts diff --git a/README.md b/README.md index 547319f46..b59d7ae50 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,7 @@ WebSocket では Base64 文字列を、HTTP POST では multipart/form-data の を送信して追加 - `DELETE /api/relays/:id` – リレーを削除 -各インスタンスのリストは `relays` コレクションに基づきます。 takos host -のデフォルトリレーは自動登録されますが、一覧には表示されません。 +各インスタンスのリストは `relays` コレクションに基づきます。 ## アカウント管理 API diff --git a/app/api/DB/host.ts b/app/api/DB/host.ts index a60792308..f5ae0998d 100644 --- a/app/api/DB/host.ts +++ b/app/api/DB/host.ts @@ -11,8 +11,8 @@ 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 SystemKey from "../models/takos/system_key.ts"; -import HostRelay from "../models/takos_host/relay.ts"; import HostRemoteActor from "../models/takos_host/remote_actor.ts"; +import HostServiceActor from "../models/takos_host/service_actor.ts"; import HostSession from "../models/takos_host/session.ts"; import HostFcmToken from "../models/takos_host/fcm_token.ts"; import FcmToken from "../models/takos/fcm_token.ts"; @@ -22,7 +22,7 @@ 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, SessionDoc } from "../../shared/types.ts"; import type { SortOrder } from "mongoose"; import type { Db } from "mongodb"; import { connectDatabase } from "../../shared/db.ts"; @@ -39,15 +39,6 @@ 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 async searchObjects( filter: Record, sort?: Record, @@ -59,9 +50,6 @@ export class MongoDBHost implements DB { const conds: Record[] = [ { ...baseFilter, tenant_id: this.tenantId }, ]; - if (await this.useLocalObjects()) { - conds.push({ ...baseFilter, tenant_id: this.rootDomain }); - } const exec = async ( M: | typeof HostNote @@ -566,24 +554,16 @@ 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); + listRelays() { + return Promise.resolve([]); } - 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 }, - ); + addRelay(_relay: string, _inboxUrl?: string) { + return Promise.resolve(); } - async removeRelay(relay: string) { - await HostRelay.deleteOne({ tenant_id: this.tenantId, host: relay }); + removeRelay(_relay: string) { + return Promise.resolve(); } async addFollowerByName(username: string, follower: string) { @@ -811,41 +791,47 @@ 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, - })); + findRelaysByHosts(_hosts: string[]) { + return Promise.resolve([]); } - 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; + findRelayByHost(_host: string) { + return Promise.resolve(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 }; + createRelay(data: { host: string; inboxUrl: string }) { + return Promise.resolve({ + _id: "", + host: data.host, + inboxUrl: data.inboxUrl, + }); + } + + deleteRelayById(_id: string) { + return Promise.resolve(null); } - async deleteRelayById(id: string): Promise { - const doc = await HostRelay.findByIdAndDelete(id).lean< - { _id: mongoose.Types.ObjectId; host: string; inboxUrl: string } | null + async getServiceActorConfig() { + let doc = await HostServiceActor.findOne({}).lean< + { + enabled: boolean; + actorUrl: string; + type: string; + deliverBatchSize: number; + deliverMinIntervalMs: number; + allowInstances: string[]; + denyInstances: string[]; + followers: string[]; + } | null >(); - return doc - ? { _id: String(doc._id), host: doc.host, inboxUrl: doc.inboxUrl } - : null; + if (!doc) { + const actorUrl = this.rootDomain + ? `https://${this.rootDomain}/actor` + : ""; + const created = await HostServiceActor.create({ actorUrl }); + doc = created.toObject(); + } + return doc; } async findRemoteActorByUrl(url: string) { diff --git a/app/api/models/takos/fasp.ts b/app/api/models/takos/fasp.ts new file mode 100644 index 000000000..15a97fca4 --- /dev/null +++ b/app/api/models/takos/fasp.ts @@ -0,0 +1,57 @@ +import mongoose from "mongoose"; + +/** FASP登録情報と機能状態を保持するスキーマ */ +const capabilitySchema = new mongoose.Schema({ + identifier: { type: String, required: true }, + version: { type: String, required: true }, + enabled: { type: Boolean, default: false }, +}); + +const eventSubscriptionSchema = new mongoose.Schema({ + id: { type: String, required: true }, + category: { type: String, required: true }, + subscriptionType: { type: String, required: true }, + created_at: { type: Date, default: Date.now }, +}); + +const backfillRequestSchema = new mongoose.Schema({ + id: { type: String, required: true }, + category: { type: String, required: true }, + maxCount: { type: Number, required: true }, + status: { type: String, enum: ["pending", "completed"], default: "pending" }, + created_at: { type: Date, default: Date.now }, +}); + +const communicationLogSchema = new mongoose.Schema({ + direction: { type: String, enum: ["in", "out"], required: true }, + endpoint: { type: String, required: true }, + payload: { type: mongoose.Schema.Types.Mixed }, + created_at: { type: Date, default: Date.now }, +}); + +const faspSchema = new mongoose.Schema({ + _id: { type: String, required: true }, + name: { type: String, required: true }, + baseUrl: { type: String, required: true }, + serverId: { type: String, required: true }, + faspPublicKey: { type: String, required: true }, + publicKey: { type: String, required: true }, + privateKey: { type: String, required: true }, + accepted: { type: Boolean, default: false }, + capabilities: { type: [capabilitySchema], default: [] }, + eventSubscriptions: { type: [eventSubscriptionSchema], default: [] }, + backfillRequests: { type: [backfillRequestSchema], default: [] }, + communications: { type: [communicationLogSchema], default: [] }, + created_at: { type: Date, default: Date.now }, +}); + +const Fasp = mongoose.models.Fasp ?? mongoose.model("Fasp", faspSchema); + +export default Fasp; +export { + backfillRequestSchema, + capabilitySchema, + communicationLogSchema, + eventSubscriptionSchema, + faspSchema, +}; 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/models/takos_host/service_actor.ts b/app/api/models/takos_host/service_actor.ts new file mode 100644 index 000000000..6d922c09c --- /dev/null +++ b/app/api/models/takos_host/service_actor.ts @@ -0,0 +1,18 @@ +import mongoose from "mongoose"; + +const serviceActorSchema = new mongoose.Schema({ + enabled: { type: Boolean, default: true }, + actorUrl: { type: String, required: true }, + type: { type: String, default: "Service" }, + deliverBatchSize: { type: Number, default: 20 }, + deliverMinIntervalMs: { type: Number, default: 200 }, + allowInstances: { type: [String], default: ["*"] }, + denyInstances: { type: [String], default: [] }, + followers: { type: [String], default: [] }, +}); + +const HostServiceActor = mongoose.models.HostServiceActor ?? + mongoose.model("HostServiceActor", serviceActorSchema); + +export default HostServiceActor; +export { serviceActorSchema }; diff --git a/app/api/routes/accounts.ts b/app/api/routes/accounts.ts index 17b83b7b4..54845a9fa 100644 --- a/app/api/routes/accounts.ts +++ b/app/api/routes/accounts.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { jsonResponse } from "../utils/activitypub.ts"; +import { getDomain, jsonResponse } from "../utils/activitypub.ts"; import authRequired from "../utils/auth.ts"; import { createDB } from "../DB/mod.ts"; import { getEnv } from "../../shared/config.ts"; @@ -8,6 +8,8 @@ import type { AccountDoc } from "../../shared/types.ts"; import { b64ToBuf } from "../../shared/buffer.ts"; import { isUrl } from "../../shared/url.ts"; import { saveFile } from "../services/file.ts"; +import Fasp from "../models/takos/fasp.ts"; +import { sendAnnouncement } from "../services/fasp.ts"; function formatAccount(doc: AccountDoc) { return { @@ -51,6 +53,49 @@ async function resolveAvatar( return name.charAt(0).toUpperCase().substring(0, 2); } +async function notifyFaspAccount( + env: Record, + domain: string, + username: string, + eventType: "new" | "update" | "delete", +) { + const db = createDB(env); + const acc = await db.findAccountByUserName(username) as unknown as + | { extra?: { discoverable?: boolean; visibility?: string } } + | null; + if (!acc) return; + const isPublic = (acc.extra?.visibility ?? "public") === "public"; + const discoverable = Boolean(acc.extra?.discoverable); + if (!isPublic || !discoverable) return; + + const fasp = await Fasp.findOne({ accepted: true }) as unknown as + | { + eventSubscriptions: { + id: string; + category: string; + subscriptionType: string; + }[]; + } + | null; + if (!fasp) return; + const subs = (fasp.eventSubscriptions as { + id: string; + category: string; + subscriptionType: string; + }[]).filter((s) => + s.category === "account" && s.subscriptionType === "lifecycle" + ); + const uri = `https://${domain}/users/${username}`; + for (const sub of subs) { + await sendAnnouncement( + { subscription: { id: sub.id } }, + "account", + eventType, + [uri], + ); + } +} + const app = new Hono(); app.use("/accounts/*", authRequired); @@ -63,6 +108,7 @@ app.get("/accounts", async (c) => { }); app.post("/accounts", async (c) => { + const domain = getDomain(c); const env = getEnv(c); const { username, displayName, icon, privateKey, publicKey } = await c.req .json(); @@ -102,6 +148,7 @@ app.post("/accounts", async (c) => { following: [], dms: [], }); + await notifyFaspAccount(env, domain, username.trim(), "new"); return jsonResponse(c, formatAccount(account)); }); @@ -118,6 +165,7 @@ app.get("/accounts/:id", async (c) => { }); app.put("/accounts/:id", async (c) => { + const domain = getDomain(c); const env = getEnv(c); const db = createDB(env); const id = c.req.param("id"); @@ -149,15 +197,20 @@ app.put("/accounts/:id", async (c) => { const account = await db.updateAccountById(id, data); if (!account) return jsonResponse(c, { error: "Account not found" }, 404); + await notifyFaspAccount(env, domain, account.userName, "update"); return jsonResponse(c, formatAccount(account)); }); app.delete("/accounts/:id", async (c) => { + const domain = getDomain(c); const env = getEnv(c); const db = createDB(env); const id = c.req.param("id"); + const acc = await db.findAccountById(id); + if (!acc) return jsonResponse(c, { error: "Account not found" }, 404); const deleted = await db.deleteAccountById(id); if (!deleted) return jsonResponse(c, { error: "Account not found" }, 404); + await notifyFaspAccount(env, domain, acc.userName, "delete"); return jsonResponse(c, { success: true }); }); diff --git a/app/api/routes/fasp/account_search.ts b/app/api/routes/fasp/account_search.ts new file mode 100644 index 000000000..f3004ee8b --- /dev/null +++ b/app/api/routes/fasp/account_search.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono"; +import authRequired from "../../utils/auth.ts"; +import { accountSearch } from "../../services/fasp.ts"; + +const app = new Hono(); +app.use("/fasp/account_search/*", authRequired); + +app.get("/fasp/account_search", async (c) => { + const term = c.req.query("term"); + const next = c.req.query("next"); + if (!term && !next) { + return c.json({ error: "term or next is required" }, 400); + } + const limit = Number(c.req.query("limit") ?? "20"); + const result = await accountSearch(term, limit, next); + return c.json(result); +}); + +export default app; diff --git a/app/api/routes/fasp/admin.ts b/app/api/routes/fasp/admin.ts new file mode 100644 index 000000000..93fc85a72 --- /dev/null +++ b/app/api/routes/fasp/admin.ts @@ -0,0 +1,65 @@ +import { Hono } from "hono"; +import authRequired from "../../utils/auth.ts"; +import Fasp from "../../models/takos/fasp.ts"; +import { activateCapability, getProviderInfo } from "../../services/fasp.ts"; + +async function publicKeyFingerprint(pubKey: string): Promise { + const data = new TextEncoder().encode(pubKey); + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +const app = new Hono(); +app.use("/admin/*", authRequired); + +app.get("/admin/fasps", async (c) => { + const fasps = await Fasp.find().lean(); + return c.json({ fasps }); +}); + +app.get("/admin/fasps/provider_info", async (c) => { + const info = await getProviderInfo(); + if (!info) return c.json({ error: "not found" }, 404); + return c.json({ info }); +}); + +app.post("/admin/fasps/:id/accept", async (c) => { + const { id } = c.req.param(); + const fasp = await Fasp.findById(id); + if (!fasp) return c.json({ error: "not found" }, 404); + fasp.accepted = true; + await fasp.save(); + const fingerprint = await publicKeyFingerprint(fasp.faspPublicKey); + return c.json({ ok: true, fingerprint }); +}); + +app.delete("/admin/fasps/:id", async (c) => { + const { id } = c.req.param(); + const fasp = await Fasp.findById(id); + if (!fasp) return c.json({ error: "not found" }, 404); + fasp.accepted = false; + await fasp.save(); + return c.json({ ok: true }); +}); + +app.post( + "/admin/fasps/capabilities/:id/:version/activation", + async (c) => { + const { id, version } = c.req.param(); + const ok = await activateCapability(id, version, true); + return c.json({ ok }); + }, +); + +app.delete( + "/admin/fasps/capabilities/:id/:version/activation", + async (c) => { + const { id, version } = c.req.param(); + const ok = await activateCapability(id, version, false); + return c.json({ ok }); + }, +); + +export default app; diff --git a/app/api/routes/fasp/announcements.ts b/app/api/routes/fasp/announcements.ts new file mode 100644 index 000000000..42e8df9cb --- /dev/null +++ b/app/api/routes/fasp/announcements.ts @@ -0,0 +1,34 @@ +import { Hono } from "hono"; +import authRequired from "../../utils/auth.ts"; +import { sendAnnouncement } from "../../services/fasp.ts"; + +const app = new Hono(); +app.use("/fasp/data_sharing/v0/announcements", authRequired); + +app.post("/fasp/data_sharing/v0/announcements", async (c) => { + const body = await c.req.json().catch(() => null) as + | Record + | null; + if ( + !body || + typeof body.source !== "object" || + !Array.isArray(body.objectUris) || + body.objectUris.length === 0 || + typeof body.category !== "string" + ) { + return c.json({ error: "Invalid body" }, 422); + } + const ok = await sendAnnouncement( + body.source as Record, + body.category as "content" | "account", + body.eventType as "new" | "update" | "delete" | "trending" | undefined, + body.objectUris as string[], + body.moreObjectsAvailable as boolean | undefined, + ); + if (!ok) { + return c.json({ error: "Announcement failed" }, 500); + } + return c.body(null, 204); +}); + +export default app; diff --git a/app/api/routes/fasp/capabilities.ts b/app/api/routes/fasp/capabilities.ts new file mode 100644 index 000000000..38c3e8e46 --- /dev/null +++ b/app/api/routes/fasp/capabilities.ts @@ -0,0 +1,104 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { + decode as b64decode, + encode as b64encode, +} from "@std/encoding/base64.ts"; +import Fasp from "../../models/takos/fasp.ts"; + +const app = new Hono(); + +async function handleActivation(c: Context, enabled: boolean) { + const rawBody = new Uint8Array(await c.req.arrayBuffer()); + const digestHeader = c.req.header("content-digest") ?? ""; + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", rawBody)); + const digestB64 = b64encode(digest); + if (digestHeader !== `sha-256=:${digestB64}:`) { + return c.json({ error: "Invalid Content-Digest" }, 401); + } + + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const sigInputMatch = sigInput.match( + /^sig1=\(([^)]+)\);\s*created=(\d+);\s*keyid="([^"]+)"$/, + ); + const sigMatch = signature.match(/^sig1=:([A-Za-z0-9+/=]+):$/); + if (!sigInputMatch || !sigMatch) { + return c.json({ error: "Invalid Signature" }, 401); + } + const components = sigInputMatch[1].split(" ").map((s) => + s.replace(/"/g, "") + ); + const created = Number(sigInputMatch[2]); + const keyId = sigInputMatch[3]; + + const lines: string[] = []; + for (const comp of components) { + if (comp === "@method") { + lines.push('"@method": ' + c.req.method.toLowerCase()); + } else if (comp === "@target-uri") { + lines.push('"@target-uri": ' + c.req.url); + } else if (comp === "content-digest") { + lines.push('"content-digest": ' + digestHeader); + } + } + const paramStr = components.map((p) => `"${p}"`).join(" "); + lines.push( + `"@signature-params": (${paramStr});created=${created};keyid="${keyId}"`, + ); + const base = new TextEncoder().encode(lines.join("\n")); + + const signatureBytes = b64decode(sigMatch[1]); + const fasp = await Fasp.findOne({ serverId: keyId }); + if (!fasp) { + return c.json({ error: "Unknown FASP" }, 404); + } + const publicKeyBytes = b64decode(fasp.faspPublicKey); + const key = await crypto.subtle.importKey( + "raw", + publicKeyBytes, + { name: "Ed25519" }, + false, + ["verify"], + ); + const ok = await crypto.subtle.verify("Ed25519", key, signatureBytes, base); + if (!ok) { + return c.json({ error: "Invalid Signature" }, 401); + } + + const identifier = c.req.param("id"); + const version = c.req.param("version"); + const caps = fasp.capabilities as { + identifier: string; + version: string; + enabled: boolean; + }[]; + const idx = caps.findIndex((cap) => + cap.identifier === identifier && cap.version === version + ); + if (idx === -1) { + caps.push({ identifier, version, enabled }); + } else { + caps[idx].enabled = enabled; + } + fasp.capabilities = caps; + fasp.communications.push({ + direction: "in", + endpoint: c.req.path, + payload: { enabled }, + }); + await fasp.save(); + + return c.body(null, 204); +} + +app.post( + "/fasp/capabilities/:id/:version/activation", + (c) => handleActivation(c, true), +); +app.delete( + "/fasp/capabilities/:id/:version/activation", + (c) => handleActivation(c, false), +); + +export default app; diff --git a/app/api/routes/fasp/data_sharing.ts b/app/api/routes/fasp/data_sharing.ts new file mode 100644 index 000000000..42071b4bc --- /dev/null +++ b/app/api/routes/fasp/data_sharing.ts @@ -0,0 +1,155 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { + decode as b64decode, + encode as b64encode, +} from "@std/encoding/base64.ts"; +import Fasp from "../../models/takos/fasp.ts"; + +const app = new Hono(); + +async function verify(c: Context, rawBody: Uint8Array) { + const digestHeader = c.req.header("content-digest") ?? ""; + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", rawBody)); + const digestB64 = b64encode(digest); + if (digestHeader !== `sha-256=:${digestB64}:`) { + return { error: c.json({ error: "Invalid Content-Digest" }, 401) }; + } + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const sigInputMatch = sigInput.match( + /^sig1=\(([^)]+)\);\s*created=(\d+);\s*keyid="([^"]+)"$/, + ); + const sigMatch = signature.match(/^sig1=:([A-Za-z0-9+/=]+):$/); + if (!sigInputMatch || !sigMatch) { + return { error: c.json({ error: "Invalid Signature" }, 401) }; + } + const components = sigInputMatch[1].split(" ").map((s) => + s.replace(/"/g, "") + ); + const created = Number(sigInputMatch[2]); + const keyId = sigInputMatch[3]; + const lines: string[] = []; + for (const comp of components) { + if (comp === "@method") { + lines.push('"@method": ' + c.req.method.toLowerCase()); + } else if (comp === "@target-uri") { + lines.push('"@target-uri": ' + c.req.url); + } else if (comp === "content-digest") { + lines.push('"content-digest": ' + digestHeader); + } + } + const paramStr = components.map((p) => `"${p}"`).join(" "); + lines.push( + `"@signature-params": (${paramStr});created=${created};keyid="${keyId}"`, + ); + const base = new TextEncoder().encode(lines.join("\n")); + const signatureBytes = b64decode(sigMatch[1]); + const fasp = await Fasp.findOne({ serverId: keyId }); + if (!fasp) { + return { error: c.json({ error: "Unknown FASP" }, 404) }; + } + const publicKeyBytes = b64decode(fasp.faspPublicKey); + const key = await crypto.subtle.importKey( + "raw", + publicKeyBytes, + { name: "Ed25519" }, + false, + ["verify"], + ); + const ok = await crypto.subtle.verify("Ed25519", key, signatureBytes, base); + if (!ok) { + return { error: c.json({ error: "Invalid Signature" }, 401) }; + } + return { fasp }; +} + +app.post("/fasp/data_sharing/v0/event_subscriptions", async (c) => { + const raw = new Uint8Array(await c.req.arrayBuffer()); + const { fasp, error } = await verify(c, raw); + if (error) return error; + const body = JSON.parse(new TextDecoder().decode(raw)); + if (!body.category || !body.subscriptionType) { + return c.json({ error: "Invalid body" }, 422); + } + const id = crypto.randomUUID(); + fasp.eventSubscriptions.push({ + id, + category: body.category, + subscriptionType: body.subscriptionType, + }); + fasp.communications.push({ + direction: "in", + endpoint: "/data_sharing/v0/event_subscriptions", + payload: body, + }); + await fasp.save(); + return c.json({ subscription: { id } }, 201); +}); + +app.post("/fasp/data_sharing/v0/backfill_requests", async (c) => { + const raw = new Uint8Array(await c.req.arrayBuffer()); + const { fasp, error } = await verify(c, raw); + if (error) return error; + const body = JSON.parse(new TextDecoder().decode(raw)); + if (!body.category || typeof body.maxCount !== "number") { + return c.json({ error: "Invalid body" }, 422); + } + const id = crypto.randomUUID(); + fasp.backfillRequests.push({ + id, + category: body.category, + maxCount: body.maxCount, + status: "pending", + }); + fasp.communications.push({ + direction: "in", + endpoint: "/data_sharing/v0/backfill_requests", + payload: body, + }); + await fasp.save(); + return c.json({ backfillRequest: { id } }, 201); +}); + +app.delete("/fasp/data_sharing/v0/event_subscriptions/:id", async (c) => { + const raw = new Uint8Array(await c.req.arrayBuffer()); + const { fasp, error } = await verify(c, raw); + if (error) return error; + const id = c.req.param("id"); + fasp.eventSubscriptions = (fasp.eventSubscriptions as { id: string }[]) + .filter( + (s) => s.id !== id, + ); + fasp.communications.push({ + direction: "in", + endpoint: `/data_sharing/v0/event_subscriptions/${id}`, + payload: null, + }); + await fasp.save(); + return c.body(null, 204); +}); + +app.post( + "/fasp/data_sharing/v0/backfill_requests/:id/continuation", + async (c) => { + const raw = new Uint8Array(await c.req.arrayBuffer()); + const { fasp, error } = await verify(c, raw); + if (error) return error; + const id = c.req.param("id"); + const req = (fasp.backfillRequests as { id: string; status: string }[]) + .find((r) => r.id === id); + if (!req) { + return c.json({ error: "Unknown backfill request" }, 404); + } + req.status = "pending"; + fasp.communications.push({ + direction: "in", + endpoint: `/data_sharing/v0/backfill_requests/${id}/continuation`, + payload: null, + }); + await fasp.save(); + return c.body(null, 204); + }, +); + +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..21b30b0da --- /dev/null +++ b/app/api/routes/fasp/registration.ts @@ -0,0 +1,131 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { + decode as b64decode, + encode as b64encode, +} from "@std/encoding/base64.ts"; +import Fasp from "../../models/takos/fasp.ts"; +import { getEnv } from "../../shared/config.ts"; + +const schema = z.object({ + name: z.string(), + baseUrl: z.string().url(), + serverId: z.string(), + publicKey: z.string(), +}); + +const app = new Hono(); + +app.post("/fasp/registration", async (c) => { + const rawBody = new Uint8Array(await c.req.arrayBuffer()); + const digestHeader = c.req.header("content-digest") ?? ""; + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", rawBody)); + const digestB64 = b64encode(digest); + if (digestHeader !== `sha-256=:${digestB64}:`) { + return c.json({ error: "Invalid Content-Digest" }, 401); + } + + const sigInput = c.req.header("signature-input") ?? ""; + const signature = c.req.header("signature") ?? ""; + const sigInputMatch = sigInput.match( + /^sig1=\(([^)]+)\);\s*created=(\d+);\s*keyid="([^"]+)"$/, + ); + const sigMatch = signature.match(/^sig1=:([A-Za-z0-9+/=]+):$/); + if (!sigInputMatch || !sigMatch) { + return c.json({ error: "Invalid Signature" }, 401); + } + const components = sigInputMatch[1].split(" ").map((s) => + s.replace(/"/g, "") + ); + const created = Number(sigInputMatch[2]); + const keyId = sigInputMatch[3]; + + const bodyText = new TextDecoder().decode(rawBody); + let data; + try { + data = schema.parse(JSON.parse(bodyText)); + } catch { + return c.json({ error: "Invalid body" }, 400); + } + if (keyId !== data.serverId) { + return c.json({ error: "Invalid keyid" }, 401); + } + + const lines: string[] = []; + for (const comp of components) { + if (comp === "@method") { + lines.push('"@method": ' + c.req.method.toLowerCase()); + } else if (comp === "@target-uri") { + lines.push('"@target-uri": ' + c.req.url); + } else if (comp === "content-digest") { + lines.push('"content-digest": ' + digestHeader); + } + } + const paramStr = components.map((c) => `"${c}"`).join(" "); + lines.push( + `"@signature-params": (${paramStr});created=${created};keyid="${keyId}"`, + ); + const base = new TextEncoder().encode(lines.join("\n")); + + const signatureBytes = b64decode(sigMatch[1]); + const publicKeyBytes = b64decode(data.publicKey); + const key = await crypto.subtle.importKey( + "raw", + publicKeyBytes, + { + name: "Ed25519", + }, + false, + ["verify"], + ); + const ok = await crypto.subtle.verify("Ed25519", key, signatureBytes, base); + if (!ok) { + return c.json({ error: "Invalid Signature" }, 401); + } + + const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ]); + const publicExport = new Uint8Array( + await crypto.subtle.exportKey("raw", keyPair.publicKey), + ); + const privateExport = new Uint8Array( + await crypto.subtle.exportKey("pkcs8", keyPair.privateKey), + ); + const myPublic = b64encode(publicExport); + const myPrivate = b64encode(privateExport); + + const faspId = crypto.randomUUID(); + const fasp = await Fasp.create({ + _id: faspId, + name: data.name, + baseUrl: data.baseUrl, + serverId: data.serverId, + faspPublicKey: data.publicKey, + publicKey: myPublic, + privateKey: myPrivate, + accepted: false, + }); + + fasp.communications.push({ + direction: "in", + endpoint: c.req.path, + payload: data, + }); + await fasp.save(); + + const env = getEnv(c); + const domain = env["ROOT_DOMAIN"] ?? ""; + const registrationCompletionUri = domain + ? `https://${domain}/admin/fasps` + : ""; + + return c.json({ + faspId, + publicKey: myPublic, + registrationCompletionUri, + }, 201); +}); + +export default app; diff --git a/app/api/routes/fasp/trends.ts b/app/api/routes/fasp/trends.ts new file mode 100644 index 000000000..be9058c93 --- /dev/null +++ b/app/api/routes/fasp/trends.ts @@ -0,0 +1,33 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import authRequired from "../../utils/auth.ts"; +import { fetchTrends } from "../../services/fasp.ts"; + +const app = new Hono(); +app.use("/fasp/trends/*", authRequired); + +function collectParams(c: Context) { + const params: Record = {}; + for (const key of ["withinLastHours", "maxCount", "language"]) { + const v = c.req.query(key); + if (v) params[key] = v; + } + return params; +} + +app.get("/fasp/trends/content", async (c) => { + const data = await fetchTrends("content", collectParams(c)); + return c.json(data); +}); + +app.get("/fasp/trends/hashtags", async (c) => { + const data = await fetchTrends("hashtags", collectParams(c)); + return c.json(data); +}); + +app.get("/fasp/trends/links", async (c) => { + const data = await fetchTrends("links", collectParams(c)); + return c.json(data); +}); + +export default app; diff --git a/app/api/routes/nodeinfo.ts b/app/api/routes/nodeinfo.ts index 42304d5a7..8af180232 100644 --- a/app/api/routes/nodeinfo.ts +++ b/app/api/routes/nodeinfo.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { createDB } from "../DB/mod.ts"; import { getDomain } from "../utils/activitypub.ts"; import { getEnv } from "../../shared/config.ts"; +import Fasp from "../models/takos/fasp.ts"; // NodeInfo は外部から参照されるため認証は不要 const app = new Hono(); @@ -30,6 +31,12 @@ 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 metadata: Record = {}; + const fasp = await Fasp.findOne({ accepted: true }).lean(); + if (fasp) { + const domain = getDomain(c); + metadata.faspBaseUrl = `https://${domain}/fasp`; + } 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/posts.ts b/app/api/routes/posts.ts index 24e245520..14986df22 100644 --- a/app/api/routes/posts.ts +++ b/app/api/routes/posts.ts @@ -28,6 +28,8 @@ import { import { addNotification } from "../services/notification.ts"; import { rateLimit } from "../utils/rate_limit.ts"; import { broadcast, sendToUser } from "./ws.ts"; +import Fasp from "../models/takos/fasp.ts"; +import { sendAnnouncement } from "../services/fasp.ts"; interface PostDoc { _id?: string; @@ -36,6 +38,51 @@ interface PostDoc { content?: string; published?: string | Date; extra?: Record; + aud?: { to?: string[]; cc?: string[] }; +} + +async function notifyFaspContent( + env: Record, + objectUri: string, + eventType: "new" | "update" | "delete", +) { + const db = createDB(env); + const obj = await db.getObject(objectUri) as PostDoc | null; + if (!obj) return; + const isPublic = [ + ...(obj.aud?.to ?? []), + ...(obj.aud?.cc ?? []), + ].includes("https://www.w3.org/ns/activitystreams#Public"); + const discoverable = Boolean( + (obj.extra as { discoverable?: boolean } | undefined)?.discoverable, + ); + if (!isPublic || !discoverable) return; + + const fasp = await Fasp.findOne({ accepted: true }) as unknown as + | { + eventSubscriptions: { + id: string; + category: string; + subscriptionType: string; + }[]; + } + | null; + if (!fasp) return; + const subs = (fasp.eventSubscriptions as { + id: string; + category: string; + subscriptionType: string; + }[]).filter((s) => + s.category === "content" && s.subscriptionType === "lifecycle" + ); + for (const sub of subs) { + await sendAnnouncement( + { subscription: { id: sub.id } }, + "content", + eventType, + [objectUri], + ); + } } const app = new Hono(); @@ -242,7 +289,7 @@ app.post( payload: { timeline: "following", post: formatted }, }); } - + await notifyFaspContent(env, String(noteObject.id), "new"); return c.json(formatted, 201); }, ); @@ -301,7 +348,8 @@ app.put( domain, env, ); - + const objectUrl = `https://${domain}/objects/${id}`; + await notifyFaspContent(env, objectUrl, "update"); return c.json( formatUserInfoForPost( userInfo, @@ -469,11 +517,14 @@ app.post( ); app.delete("/posts/:id", async (c) => { + const domain = getDomain(c); const env = getEnv(c); const db = createDB(env); const id = c.req.param("id"); const deleted = await db.deleteNote(id); if (!deleted) return c.json({ error: "Not found" }, 404); + const objectUrl = `https://${domain}/objects/${id}`; + await notifyFaspContent(env, objectUrl, "delete"); return c.json({ success: true }); }); diff --git a/app/api/server.ts b/app/api/server.ts index 580d9c70f..e4d8a3700 100644 --- a/app/api/server.ts +++ b/app/api/server.ts @@ -28,6 +28,13 @@ 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 faspRegistration from "./routes/fasp/registration.ts"; +import faspCapabilities from "./routes/fasp/capabilities.ts"; +import faspDataSharing from "./routes/fasp/data_sharing.ts"; +import faspAccountSearch from "./routes/fasp/account_search.ts"; +import faspTrends from "./routes/fasp/trends.ts"; +import faspAdmin from "./routes/fasp/admin.ts"; +import faspAnnouncements from "./routes/fasp/announcements.ts"; import { fetchOgpData } from "./services/ogp.ts"; import { serveStatic } from "hono/deno"; import type { Context } from "hono"; @@ -79,10 +86,18 @@ export async function createTakosApp(env?: Record) { app.route("/api", r); } - // ActivityPub ルートは / のみにマウントする - - const rootRoutes = [nodeinfo, activitypub, rootInbox]; - // e2ee ルートは /api のみで提供し、ActivityPub ルートと競合しないようにする + const rootRoutes = [ + nodeinfo, + activitypub, + rootInbox, + faspRegistration, + faspCapabilities, + faspDataSharing, + faspAccountSearch, + faspTrends, + faspAdmin, + faspAnnouncements, + ]; for (const r of rootRoutes) { app.route("/", r); } diff --git a/app/api/services/fasp.ts b/app/api/services/fasp.ts new file mode 100644 index 000000000..7dcdda200 --- /dev/null +++ b/app/api/services/fasp.ts @@ -0,0 +1,256 @@ +import { + decode as b64decode, + encode as b64encode, +} from "@std/encoding/base64.ts"; +import Fasp from "../models/takos/fasp.ts"; + +interface FaspDoc { + baseUrl: string; + serverId: string; + faspPublicKey: string; + privateKey: string; + capabilities: Array< + { identifier: string; version: string; enabled: boolean } + >; + communications: Array; +} + +interface AnnouncementSource { + subscription?: { id: string }; + backfillRequest?: { id: string }; +} + +function hasCapability(fasp: FaspDoc, id: string) { + return fasp.capabilities.some((c) => c.identifier === id && c.enabled); +} + +async function signedFetch( + fasp: FaspDoc, + path: string, + method: string, + body?: unknown, +) { + const url = new URL(path, fasp.baseUrl); + const rawBody = body + ? new TextEncoder().encode(JSON.stringify(body)) + : new Uint8Array(); + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", rawBody), + ); + const digestB64 = b64encode(digest); + const digestHeader = `sha-256=:${digestB64}:`; + const created = Math.floor(Date.now() / 1000); + const lines = [ + `"@method": ${method.toLowerCase()}`, + `"@target-uri": ${url.toString()}`, + `"content-digest": ${digestHeader}`, + ]; + const paramStr = '"@method" "@target-uri" "content-digest"'; + lines.push( + `"@signature-params": (${paramStr});created=${created};keyid="${fasp.serverId}"`, + ); + const base = new TextEncoder().encode(lines.join("\n")); + const privateKeyBytes = b64decode(fasp.privateKey); + const key = await crypto.subtle.importKey( + "raw", + privateKeyBytes, + { name: "Ed25519" }, + false, + ["sign"], + ); + const sig = new Uint8Array( + await crypto.subtle.sign("Ed25519", key, base), + ); + const signature = `sig1=:${b64encode(sig)}:`; + const sigInput = + `sig1=(${paramStr});created=${created};keyid="${fasp.serverId}"`; + const headers: Record = { + "content-digest": digestHeader, + "signature-input": sigInput, + signature, + }; + if (body) headers["content-type"] = "application/json"; + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + const sigInputHeader = res.headers.get("signature-input") ?? ""; + const sigHeader = res.headers.get("signature") ?? ""; + let verified = false; + const inputMatch = sigInputHeader.match( + /^sig1=\(([^)]+)\);created=(\d+);keyid="([^"]+)"/, + ); + const sigMatch = sigHeader.match(/^sig1=:([^:]+):/); + if (inputMatch && sigMatch) { + const [, paramStr2, created2, keyId] = inputMatch; + const base = new TextEncoder().encode( + `"@status": ${res.status}\n"@signature-params": (${paramStr2});created=${created2};keyid="${keyId}"`, + ); + const publicKeyBytes = b64decode(fasp.faspPublicKey); + const verifyKey = await crypto.subtle.importKey( + "raw", + publicKeyBytes, + { name: "Ed25519" }, + false, + ["verify"], + ); + const sigBytes = b64decode(sigMatch[1]); + verified = await crypto.subtle.verify( + "Ed25519", + verifyKey, + sigBytes, + base, + ); + } + fasp.communications.push({ + direction: "out", + endpoint: url.pathname, + payload: { request: body ?? null, status: res.status, verified }, + }); + await fasp.save(); + if (!verified) { + throw new Error("応答署名の検証に失敗しました"); + } + return res; +} + +export async function accountSearch( + term: string | undefined, + limit = 20, + next?: string, +) { + const fasp = await Fasp.findOne({ accepted: true }) as unknown as + | FaspDoc + | null; + if (!fasp || !hasCapability(fasp, "account_search")) { + return { results: [], next: undefined }; + } + let path: string; + if (next) { + path = `/account_search/v0/search?next=${encodeURIComponent(next)}`; + } else if (term) { + path = `/account_search/v0/search?term=${ + encodeURIComponent(term) + }&limit=${limit}`; + } else { + return { results: [], next: undefined }; + } + const res = await signedFetch(fasp, path, "GET"); + if (!res.ok) return { results: [], next: undefined }; + const results = await res.json(); + let nextToken: string | undefined; + const link = res.headers.get("link"); + if (link) { + for (const part of link.split(",")) { + const section = part.trim(); + const urlMatch = section.match(/<([^>]+)>/); + if (!urlMatch) continue; + const paramsPart = section.slice(urlMatch[0].length); + for (const p of paramsPart.split(";")) { + const [k, v] = p.trim().split("="); + if (k === "rel" && v?.replace(/"/g, "") === "next") { + try { + const url = new URL(urlMatch[1]); + nextToken = url.searchParams.get("next") ?? undefined; + } catch { + // ignore invalid URL + } + } + } + } + } + return { results, next: nextToken }; +} + +export async function fetchTrends( + type: "content" | "hashtags" | "links", + params: Record, +) { + const fasp = await Fasp.findOne({ accepted: true }) as unknown as + | FaspDoc + | null; + if (!fasp || !hasCapability(fasp, "trends")) return []; + const query = new URLSearchParams(params).toString(); + const path = `/trends/v0/${type}${query ? `?${query}` : ""}`; + const res = await signedFetch(fasp, path, "GET"); + if (!res.ok) return []; + return await res.json(); +} + +export async function getProviderInfo() { + const fasp = await Fasp.findOne({ accepted: true }) as unknown as + | FaspDoc + | null; + if (!fasp) return null; + const res = await signedFetch(fasp, "/provider_info", "GET"); + if (!res.ok) return null; + const info = await res.json() as { + capabilities?: Array<{ id: string; version: string }>; + [key: string]: unknown; + }; + if (Array.isArray(info.capabilities)) { + fasp.capabilities = info.capabilities.map( + (c: { id: string; version: string }) => { + const current = fasp.capabilities.find((d) => d.identifier === c.id); + return { + identifier: c.id, + version: c.version, + enabled: current ? current.enabled : false, + }; + }, + ); + await fasp.save(); + } + return info; +} + +export async function activateCapability( + id: string, + version: string, + enabled: boolean, +) { + const fasp = await Fasp.findOne({ accepted: true }) as unknown as + | FaspDoc + | null; + if (!fasp) return false; + const path = `/capabilities/${id}/${version}/activation`; + const method = enabled ? "POST" : "DELETE"; + const res = await signedFetch(fasp, path, method); + if (!res.ok) return false; + const idx = fasp.capabilities.findIndex((c) => + c.identifier === id && c.version === version + ); + if (idx >= 0) { + fasp.capabilities[idx].enabled = enabled; + } else { + fasp.capabilities.push({ identifier: id, version, enabled }); + } + await fasp.save(); + return true; +} + +export async function sendAnnouncement( + source: AnnouncementSource, + category: "content" | "account", + eventType: "new" | "update" | "delete" | "trending" | undefined, + objectUris: string[], + moreObjectsAvailable?: boolean, +) { + const fasp = await Fasp.findOne({ accepted: true }) as unknown as + | FaspDoc + | null; + if (!fasp || !hasCapability(fasp, "data_sharing")) return false; + const body: Record = { source, category, objectUris }; + if (eventType) body.eventType = eventType; + if (typeof moreObjectsAvailable === "boolean") { + body.moreObjectsAvailable = moreObjectsAvailable; + } + const res = await signedFetch( + fasp, + "/data_sharing/v0/announcements", + "POST", + body, + ); + return res.ok; +} diff --git a/app/takos_host/README.md b/app/takos_host/README.md index 0ab9ed14f..918d06dc2 100644 --- a/app/takos_host/README.md +++ b/app/takos_host/README.md @@ -17,9 +17,8 @@ takos を運用できるようにすることが目的です。 個別のプロセスを起動・停止するわけではなく、takos と同等の API を共通 ロジックとして取り込むことで、多数のサーバーが動作しているかのように振る舞う だけの構成です。 -3. 各インスタンスはデフォルトで takos.jp - のリレーに参加しており、内部的にはリレーに近い仕組みを inbox - を介さずに実装します。リレー先は `ROOT_DOMAIN` で指定したドメインになります。 +3. 各インスタンスは takos host の Service Actor をフォローし、 + 公開投稿の通知を受け取ります。 4. ユーザーは発行されたドメインへアクセスし、OAuth 認証でログインします。パスワードを設定した場合は `/login` からログインできます。 @@ -114,24 +113,3 @@ OAuth ボタンを表示します。 で待ち受けるには `SERVER_CERT_FILE` と `SERVER_KEY_FILE` を指定します。開発モードでは `--unsafely-ignore-certificate-errors` を付けて起動することで自己署名証明書などの SSL エラーを無視します。 - -## CLI 管理ツール - -`scripts/host_cli.ts` を使用して takos host を CLI から操作できます。 MongoDB -へ直接接続してインスタンスを作成・削除するほか、リレーサーバー -の登録や削除も行えます。`--user` を省略すると管理ユーザー `system` -として実行されます。 - -### 使用例 - -```bash -deno task host list --user alice - -deno task host create --host myapp --password pw --user alice - -deno task host relay-list - -deno task host relay-add --inbox-url https://relay.example/inbox - -deno task host relay-delete --relay-id RELAY_ID -``` diff --git a/app/takos_host/consumer.ts b/app/takos_host/consumer.ts index a3697eb8d..4fed2264a 100644 --- a/app/takos_host/consumer.ts +++ b/app/takos_host/consumer.ts @@ -115,21 +115,6 @@ export function createConsumerApp( env, }); await ensureTenant(db, fullHost, fullHost); - if (rootDomain) { - const exists = await db.findRelayByHost(rootDomain); - if (!exists) { - await db.createRelay({ - host: rootDomain, - inboxUrl: `https://${rootDomain}/inbox`, - }); - } - const relayDb = createDB({ - ...env, - ACTIVITYPUB_DOMAIN: fullHost, - DB_MODE: "host", - }); - await relayDb.addRelay(rootDomain, `https://${rootDomain}/inbox`); - } invalidate?.(fullHost); return c.json({ success: true, host: fullHost }); }, diff --git a/app/takos_host/deno.json b/app/takos_host/deno.json index 90a24eccb..2cef004c7 100644 --- a/app/takos_host/deno.json +++ b/app/takos_host/deno.json @@ -2,8 +2,7 @@ "tasks": { "serve": "deno run -A --watch --unsafely-ignore-certificate-errors main.ts --", "dev": "deno run -A --unsafely-ignore-certificate-errors dev.ts --", - "build": "deno run -A --node-modules-dir --unstable-detect-cjs npm:vite build --config client/vite.config.mts", - "host": "deno run -A -c deno.json ../../scripts/host_cli.ts" + "build": "deno run -A --node-modules-dir --unstable-detect-cjs npm:vite build --config client/vite.config.mts" }, "imports": { "@motionone/solid": "npm:@motionone/solid@^10.16.4", diff --git a/app/takos_host/main.ts b/app/takos_host/main.ts index 78af3162c..3375f8e59 100644 --- a/app/takos_host/main.ts +++ b/app/takos_host/main.ts @@ -11,6 +11,7 @@ import oauthApp from "./oauth.ts"; import { serveStatic } from "hono/deno"; import type { Context } from "hono"; import { createRootActivityPubApp } from "./root_activitypub.ts"; +import { createServiceActorApp } from "./service_actor.ts"; import { logger } from "hono/logger"; import { takosEnv } from "./takos_env.ts"; import { dirname, fromFileUrl, join } from "@std/path"; @@ -73,6 +74,10 @@ const consumerApp = createConsumerApp( ); const authApp = createAuthApp({ rootDomain, termsRequired: !!termsText }); const isDev = Deno.env.get("DEV") === "1"; +const serviceActorApp = createServiceActorApp({ + ...takosEnv, + ACTIVITYPUB_DOMAIN: rootDomain, +}); /** * ホスト名部分のみを取り出すユーティリティ @@ -160,6 +165,7 @@ const root = new Hono(); root.route("/auth", authApp); root.route("/oauth", oauthApp); root.route("/user", consumerApp); +root.route("/", serviceActorApp); if (termsText) { root.get("/terms", () => new Response(termsText, { diff --git a/app/takos_host/service_actor.ts b/app/takos_host/service_actor.ts new file mode 100644 index 000000000..16a2d5055 --- /dev/null +++ b/app/takos_host/service_actor.ts @@ -0,0 +1,131 @@ +import { Hono } from "hono"; +import { createDB } from "../api/DB/mod.ts"; +import { getSystemKey } from "../api/services/system_actor.ts"; +import { + deliverActivityPubObject, + getDomain, + jsonResponse, +} from "../api/utils/activitypub.ts"; +import HostServiceActor from "../api/models/takos_host/service_actor.ts"; + +export function createServiceActorApp(env: Record) { + const app = new Hono(); + const rootDomain = env["ROOT_DOMAIN"] ?? ""; + + app.use("/*", async (c, next) => { + if (rootDomain && getDomain(c) !== rootDomain) { + return jsonResponse(c, { error: "not found" }, 404); + } + await next(); + }); + + app.get("/actor", async (c) => { + const domain = getDomain(c); + const db = createDB(env); + const config = await db.getServiceActorConfig(); + if (!config.enabled) { + return jsonResponse(c, { error: "disabled" }, 404); + } + const { publicKey } = await getSystemKey(db, domain); + const actorUrl = config.actorUrl || `https://${domain}/actor`; + const actor = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: actorUrl, + type: config.type ?? "Service", + preferredUsername: "takos", + inbox: `https://${domain}/inbox`, + outbox: `https://${domain}/outbox`, + publicKey: { + id: `${actorUrl}#main-key`, + owner: actorUrl, + publicKeyPem: publicKey, + }, + }; + return jsonResponse(c, actor, 200, "application/activity+json"); + }); + + app.post("/inbox", async (c) => { + const domain = getDomain(c); + const db = createDB(env); + const config = await db.getServiceActorConfig(); + const actorUrl = config.actorUrl || `https://${domain}/actor`; + const body = await c.req.json().catch(() => undefined); + + if (body?.type === "Follow" && body.object === actorUrl) { + await HostServiceActor.updateOne({}, { + $addToSet: { followers: body.actor }, + }); + const accept = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `${actorUrl}/accept/${crypto.randomUUID()}`, + type: "Accept", + actor: actorUrl, + object: body, + }; + await deliverActivityPubObject( + [body.actor], + accept, + "system", + domain, + env, + ).catch( + () => {}, + ); + } else if ( + body?.type === "Undo" && + body.object?.type === "Follow" && + body.object.object === actorUrl + ) { + await HostServiceActor.updateOne({}, { + $pull: { followers: body.object.actor }, + }); + } + return jsonResponse(c, { status: "ok" }, 202, "application/activity+json"); + }); + + app.post("/outbox", async (c) => { + const domain = getDomain(c); + const db = createDB(env); + const config = await db.getServiceActorConfig(); + const actorUrl = config.actorUrl || `https://${domain}/actor`; + const { uri } = await c.req.json().catch(() => ({ uri: undefined })); + if (!uri || typeof uri !== "string") { + return jsonResponse(c, { error: "bad request" }, 400); + } + const followers = config.followers ?? []; + if (followers.length > 0) { + const announce = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `${actorUrl}/activities/${crypto.randomUUID()}`, + type: "Announce", + actor: actorUrl, + object: uri, + }; + await deliverActivityPubObject( + followers, + announce, + "system", + domain, + env, + ).catch(() => {}); + } + return jsonResponse(c, { status: "ok" }, 202, "application/activity+json"); + }); + + 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"); + }); + + return app; +} diff --git a/docs/FASP.md b/docs/FASP.md index b4750a98b..0963c7df9 100644 --- a/docs/FASP.md +++ b/docs/FASP.md @@ -4,7 +4,8 @@ - **目的** - - takos に Fediscovery(FASP)クライアント機能を接続し、検索・発見(Discovery)機能を強化。 + - takos に + Fediscovery(FASP)クライアント機能を接続し、検索・発見(Discovery)機能を強化。 - takos host に **Service Actor** を実装し、従来のリレーサーバー代替として「フォロー可能な配信ハブ」を提供。 - takos host が従来提供していたリレーサーバー機能を廃止し、Service Actor @@ -12,8 +13,11 @@ - **スコープ** - - **takos**: FASP **クライアント機能**(プロバイダ情報取得・capability管理・Discovery API呼び出し)の実装。 - - **takos host**: **Service Actor**(ActivityStreamsの`Service`/`Application`)のみを公開し、**フォロー/Accept/配信**を行う。 + - **takos**: FASP + **クライアント機能**(プロバイダ情報取得・capability管理・Discovery + API呼び出し)の実装。 + - **takos host**: **Service + Actor**(ActivityStreamsの`Service`/`Application`)のみを公開し、**フォロー/Accept/配信**を行う。 - FASP **Discovery**のうち **data\_sharing** / **trends** / **account\_search** 対応。 - 詳細仕様は `docs/fasp/general/v0.1/` および `docs/fasp/discovery/` を参照。 @@ -37,7 +41,9 @@ - **構成要素** - **takos Core**:既存アプリケーション。 - - **takos FASP Client**:FASP クライアント機能(プロバイダ情報取得・capability管理・Discovery API呼び出し)。 + - **takos FASP Client**:FASP + クライアント機能(プロバイダ情報取得・capability管理・Discovery + API呼び出し)。 - **takos host Service Actor**:`https://{takos-host}/actor` に公開。inbox/outbox、公開鍵、フォロー受付、配信制御のみ。 - **FASP(Discovery Provider)**:外部サービス。 @@ -45,13 +51,15 @@ - **役割分担** - **takos**: FASP プロバイダとの通信、Discovery API の利用、検索結果の表示。 - - **takos host**: Service Actor としての配信ハブ機能のみ提供。FASP との直接通信は行わない。 + - **takos host**: Service Actor としての配信ハブ機能のみ提供。FASP + との直接通信は行わない。 - **ベースURLの取り決め** - takos は `.well-known/nodeinfo` の `metadata.faspBaseUrl` に takos 側FASPクライアントAPIのベースURLを掲載。例:`"faspBaseUrl": "https://{takos-instance}/fasp"`。 - - takos host は Service Actor のエンドポイント `https://{takos-host}/actor` のみ提供。 + - takos host は Service Actor のエンドポイント `https://{takos-host}/actor` + のみ提供。 --- @@ -106,6 +114,8 @@ - takos → FASP:`GET /provider_info` で capabilities を取得し、管理UIでON/OFF。 - ON時:takos → FASP:`POST /capabilities///activation`。OFF時:`DELETE`。 +- FASP → takos:capability 有効化通知 + `POST /fasp/capabilities///activation`、無効化は `DELETE`。 --- @@ -156,6 +166,14 @@ - 署名は RFC 9421(必要に応じ cavage-12)で、**FASPは「サーバ/インスタンスActor」として署名**(`/actor`に公開鍵)。 +takos 側では以下のエンドポイントを実装し、受信した情報を `Fasp` モデルの +`eventSubscriptions`・`backfillRequests` に保存する。すべての通信は +`communications` に履歴として記録される。 + +- `POST /fasp/data_sharing/v0/event_subscriptions` +- `POST /fasp/data_sharing/v0/backfill_requests` +- `DELETE /fasp/data_sharing/v0/event_subscriptions/{id}` + ### 5.2 `trends v0.1`(takos クライアント機能) - **takos → FASP API 呼び出し** @@ -165,7 +183,11 @@ - **応答処理** - `content`: `[{uri, rank}]`(`rank`は1..100、降順)の処理。 - - ハッシュタグ/リンクは正規化はFASP側裁量、takos側は自サーバ内の既存ロジックと同等に処理。 +- ハッシュタグ/リンクは正規化はFASP側裁量、takos側は自サーバ内の既存ロジックと同等に処理。 + +takos は `/fasp/trends/*` を通じて FASP の API +をプロキシし、結果をクライアントへ返す。 呼び出しはサービス層 +`app/api/services/fasp.ts` で署名され、通信履歴が保存される。 ### 5.3 `account_search v0.1`(takos クライアント機能) @@ -175,6 +197,9 @@ - 200で **Actor URI配列**を受信。`Link: rel="next"` によるページング対応。 - takosは返却URIをキャッシュし、必要に応じフォロー/プロフィール取得を行う。 +クライアントは `/fasp/account_search` から FASP への検索を行い、結果はそのまま +返却される。呼び出し履歴は `communications` に記録される。 + --- ## 6. takos host の **Service Actor** 仕様 @@ -251,7 +276,7 @@ fasp: base_url: "https://fasp.example.com" capabilities: data_sharing: "0.1" - trends: "0.1" + trends: "0.1" account_search: "0.1" ``` @@ -259,10 +284,13 @@ fasp: - `POST /fasp/registration`(FASP登録要求受理) - `GET /admin/fasps`(管理UI) +- `POST /admin/fasps/{id}/accept`(登録の受理) +- `DELETE /admin/fasps/{id}`(登録の削除) - `POST /fasp/data_sharing/v0/event_subscriptions` - `DELETE /fasp/data_sharing/v0/event_subscriptions/{id}` - `POST /fasp/data_sharing/v0/backfill_requests` - `POST /fasp/data_sharing/v0/backfill_requests/{id}/continuation` +- `POST /fasp/data_sharing/v0/announcements` (認証:RFC9421、`Content-Digest` 必須) @@ -331,13 +359,16 @@ service_actor: ## 10. 実装メモ(最小プロトタイプ) ### takos host (Service Actor のみ) + - Service Actor 公開(`/actor`)とinbox/outboxの実装。 - Follow受付・Accept配信の最小フロー。 ### takos (クライアント機能のみ) + - Nodeinfoに `faspBaseUrl` を追加。 - FASP Registration とCapability有効化の往復。 -- `data_sharing`の event\_subscriptions/backfill 受理・`announcements`送信の最小フロー。 +- `data_sharing`の event\_subscriptions/backfill + 受理・`announcements`送信の最小フロー。 - `trends`/`account_search` のクライアント呼び出しとUI表示。 --- @@ -349,8 +380,7 @@ FASP の Service Actor 配信へ移行するため、takos host ### API / CLI の更新 -- `scripts/host_cli.ts` から `relay-*` コマンドを削除し、takos host - でのリレーサーバー運用を停止する。 +- `scripts/host_cli.ts` を削除し、takos host でのリレーサーバー運用を停止する。 - takos host 専用のリレー関連 API やデータモデル(例:`app/api/models/takos_host/relay.ts`)を削除する。 @@ -365,6 +395,11 @@ FASP の Service Actor 配信へ移行するため、takos host - takos host がリレーサーバーとして振る舞う旨を記載しているドキュメントや README を更新し、誤解を避ける。 +## 12. FASP データ管理とマイグレーション + +FASP 登録情報と capability 状態は DB の `Fasp` モデルに保存する。 +初期データ作成と環境変数からの移行手順は `docs/fasp/setup.md` を参照。 + --- ## 参考 diff --git a/docs/fasp/setup.md b/docs/fasp/setup.md new file mode 100644 index 000000000..7d2167aac --- /dev/null +++ b/docs/fasp/setup.md @@ -0,0 +1,30 @@ +# FASP 初期設定とマイグレーション + +FASP 登録情報と capability 状態はデータベースの `Fasp` モデルで管理します。 +ここでは初期データの作成と既存環境からの移行手順をまとめます。 詳細な仕様は +`docs/FASP.md` および `docs/fasp/general/v0.1/` 内の文書を参照してください。 + +## 初期データ作成 + +1. FASP から `POST /registration` で送信された + `name`・`baseUrl`・`serverId`・`publicKey` を受け取り、 `Fasp` + モデルに保存します。 +2. サーバー側で `faspId` と Ed25519 キーペアを生成し、同モデルの + `publicKey`・`privateKey` に格納します。 +3. 管理者が登録を承認したら `accepted` を `true` に更新します。 + +## Capability マイグレーション + +1. `GET /provider_info` を呼び出して利用可能な capability + とバージョンを取得します。 +2. 管理 UI で有効化した capability を `capabilities` 配列に追加し、`enabled` + フラグを設定します。 +3. これまで `.env` に記載していた FASP 関連の設定値がある場合は削除し、すべて + `Fasp` モデルで管理します。 + +## データ共有イベントの管理 + +- FASP から受信した `event_subscriptions` と `backfill_requests` はそれぞれ + `eventSubscriptions`・`backfillRequests` に保存され、通信履歴は + `communications` に記録されます。必要に応じて管理者はこれらを確認し、問題が + あれば FASP 側と連絡を取ってください。 diff --git a/scripts/host_cli.ts b/scripts/host_cli.ts deleted file mode 100644 index 73fd35730..000000000 --- a/scripts/host_cli.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { parse } from "jsr:@std/flags"; -import { loadConfig } from "../app/shared/config.ts"; -import { connectDatabase } from "../app/shared/db.ts"; -import { createDB } from "../app/api/DB/mod.ts"; -import { ensureTenant } from "../app/api/services/tenant.ts"; -import { getSystemKey } from "../app/api/services/system_actor.ts"; -import type { DB } from "../app/shared/db.ts"; -import { - createFollowActivity, - createUndoFollowActivity, - sendActivityPubObject, -} from "../app/api/utils/activitypub.ts"; -import { hash } from "../app/takos_host/auth.ts"; -interface Args { - command: string; - host?: string; - user?: string; - password?: string; - inboxUrl?: string; - relayId?: string; -} - -let env: Record = {}; -let db: DB; - -function showHelp() { - console.log(`使用方法: deno task host [command] [options] - -Commands: - list --user インスタンス一覧を表示 - create --user --host [--password ] インスタンス作成 - delete --user --host インスタンス削除 - set-pass --user --host [--password ] パスワード設定/解除 - relay-list リレー一覧を表示 - relay-add --inbox-url リレーを追加 - relay-delete --relay-id リレーを削除 -`); -} - -function parseArgsFn(): Args | null { - const parsed = parse(Deno.args, { - string: [ - "host", - "user", - "password", - "inbox-url", - "relay-id", - ], - boolean: ["help"], - }); - if (parsed.help || parsed._.length === 0) { - showHelp(); - return null; - } - return { - command: String(parsed._[0]), - host: parsed.host ? String(parsed.host) : undefined, - user: parsed.user ? String(parsed.user) : undefined, - password: parsed.password ? String(parsed.password) : undefined, - inboxUrl: parsed["inbox-url"] ? String(parsed["inbox-url"]) : undefined, - relayId: parsed["relay-id"] ? String(parsed["relay-id"]) : undefined, - }; -} - -async function getUser(name: string) { - const col = (await db.getDatabase()).collection("hostusers"); - const user = await col.findOne<{ _id: unknown }>({ userName: name }); - if (!user) throw new Error(`ユーザー ${name} が見つかりません`); - return user; -} - -async function listInstances(userName: string) { - const user = await getUser(userName); - const col = (await db.getDatabase()).collection("instances"); - const list = await col.find({ owner: user._id }).toArray(); - for (const inst of list) { - console.log(inst.host); - } -} - -async function createInstance( - cfg: Record, - userName: string, - host: string, - pass?: string, -) { - const user = await getUser(userName); - const col = (await db.getDatabase()).collection("instances"); - const rootDomain = (cfg["ROOT_DOMAIN"] ?? "").toLowerCase(); - const reserved = (cfg["RESERVED_SUBDOMAINS"] ?? "") - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter((s) => s); - let fullHost = host.toLowerCase(); - if (rootDomain) { - if (host.includes(".")) { - if (!host.endsWith(`.${rootDomain}`) || host === rootDomain) { - throw new Error("ドメインが不正です"); - } - fullHost = host; - const sub = host.slice(0, -rootDomain.length - 1); - if (reserved.includes(sub)) { - throw new Error("利用できないサブドメインです"); - } - } else { - if (reserved.includes(host)) { - throw new Error("利用できないサブドメインです"); - } - fullHost = `${host}.${rootDomain}`; - } - } else if (reserved.includes(host)) { - throw new Error("利用できないサブドメインです"); - } - - const exists = await col.findOne({ host: fullHost }); - if (exists) throw new Error("既に存在します"); - - const instEnv: Record = {}; - if (rootDomain) { - instEnv.OAUTH_HOST = rootDomain; - const redirect = `https://${fullHost}`; - const clientId = redirect; - let clientSecret: string; - const cliCol = (await db.getDatabase()).collection("oauthclients"); - const existsCli = await cliCol.findOne<{ clientSecret: string }>({ - clientId, - }); - if (existsCli) { - clientSecret = existsCli.clientSecret; - } else { - clientSecret = crypto.randomUUID(); - await cliCol.insertOne({ - clientId, - clientSecret, - redirectUri: redirect, - createdAt: new Date(), - }); - } - instEnv.OAUTH_CLIENT_ID = clientId; - instEnv.OAUTH_CLIENT_SECRET = clientSecret; - } - if (pass) { - const salt = crypto.randomUUID(); - const hashed = await hash(pass); - instEnv.hashedPassword = hashed; - instEnv.salt = salt; - } - await col.insertOne({ - host: fullHost, - owner: user._id, - env: instEnv, - createdAt: new Date(), - }); - await ensureTenant(db, fullHost, fullHost); - if (rootDomain) { - const existsRelay = await db.findRelayByHost(rootDomain); - if (!existsRelay) { - await db.createRelay({ - host: rootDomain, - inboxUrl: `https://${rootDomain}/inbox`, - }); - } - const relayDb = createDB({ - ...cfg, - ACTIVITYPUB_DOMAIN: fullHost, - DB_MODE: "host", - }); - await relayDb.addRelay(rootDomain, `https://${rootDomain}/inbox`); - } - console.log(`作成しました: ${fullHost}`); -} - -async function deleteInstance(userName: string, host: string) { - const user = await getUser(userName); - const col = (await db.getDatabase()).collection("instances"); - await col.deleteOne({ host: host.toLowerCase(), owner: user._id }); - console.log("削除しました"); -} - -async function setPassword(userName: string, host: string, pass?: string) { - const user = await getUser(userName); - const col = (await db.getDatabase()).collection("instances"); - const inst = await col.findOne< - { _id: unknown; env?: Record } - >( - { host: host.toLowerCase(), owner: user._id }, - ); - if (!inst) throw new Error("インスタンスが見つかりません"); - if (pass) { - const salt = crypto.randomUUID(); - const hashed = await hash(pass); - const newEnv = { ...(inst.env ?? {}), hashedPassword: hashed, salt }; - await col.updateOne({ _id: inst._id }, { $set: { env: newEnv } }); - } else if (inst.env) { - const newEnv = { ...inst.env }; - delete newEnv.hashedPassword; - delete newEnv.salt; - await col.updateOne({ _id: inst._id }, { $set: { env: newEnv } }); - } - console.log("更新しました"); -} - -async function listRelays() { - const col = (await db.getDatabase()).collection("hostrelays"); - const list = await col.find().toArray() as Array<{ - _id: unknown; - host: string; - inboxUrl: string; - }>; - for (const r of list) console.log(`${r._id} ${r.host} ${r.inboxUrl}`); -} - -async function addRelay(env: Record, inboxUrl: string) { - const relayHost = new URL(inboxUrl).hostname; - const exists = await db.findRelayByHost(relayHost); - if (exists) throw new Error("既に存在します"); - const relay = await db.createRelay({ host: relayHost, inboxUrl }); - const rootDomain = env["ROOT_DOMAIN"]; - if (rootDomain) { - try { - const db = createDB({ - ...env, - ACTIVITYPUB_DOMAIN: rootDomain, - DB_MODE: "host", - }); - await db.addRelay(relayHost, inboxUrl); - } catch { - /* ignore */ - } - try { - await getSystemKey(db, rootDomain); - const actor = `https://${rootDomain}/users/system`; - const follow = createFollowActivity( - rootDomain, - actor, - "https://www.w3.org/ns/activitystreams#Public", - ); - await sendActivityPubObject(inboxUrl, follow, "system", rootDomain, env); - } catch (err) { - console.error("Failed to follow relay:", err); - } - } - console.log(`追加しました: ${relay._id}`); -} - -async function deleteRelay(env: Record, id: string) { - const relay = await db.deleteRelayById(id); - if (!relay) throw new Error("リレーが見つかりません"); - const rootDomain = env["ROOT_DOMAIN"]; - if (rootDomain) { - try { - const relayHost = relay.host ?? new URL(relay.inboxUrl).hostname; - const db = createDB({ - ...env, - ACTIVITYPUB_DOMAIN: rootDomain, - DB_MODE: "host", - }); - await db.removeRelay(relayHost); - } catch { - /* ignore */ - } - try { - await getSystemKey(db, rootDomain); - const actor = `https://${rootDomain}/users/system`; - const undo = createUndoFollowActivity( - rootDomain, - actor, - "https://www.w3.org/ns/activitystreams#Public", - ); - await sendActivityPubObject( - relay.inboxUrl, - undo, - "system", - rootDomain, - env, - ); - } catch (err) { - console.error("Failed to undo follow:", err); - } - } - console.log("削除しました"); -} - -async function main() { - const args = parseArgsFn(); - if (!args) return; - env = await loadConfig(); - env["DB_MODE"] = "host"; - await connectDatabase(env); - db = createDB(env); - const user = args.user ?? "system"; - try { - switch (args.command) { - case "list": - await listInstances(user); - break; - case "create": - if (!args.host) throw new Error("--host が必要です"); - await createInstance(env, user, args.host, args.password); - break; - case "delete": - if (!args.host) throw new Error("--host が必要です"); - await deleteInstance(user, args.host); - break; - case "set-pass": - if (!args.host) throw new Error("--host が必要です"); - await setPassword(user, args.host, args.password); - break; - case "relay-list": - await listRelays(); - break; - case "relay-add": - if (!args.inboxUrl) throw new Error("--inbox-url が必要です"); - await addRelay(env, args.inboxUrl); - break; - case "relay-delete": - if (!args.relayId) throw new Error("--relay-id が必要です"); - await deleteRelay(env, args.relayId); - break; - default: - console.error("不明なコマンドです"); - showHelp(); - } - } finally { - // nothing - } -} - -if (import.meta.main) { - main().catch((e) => console.error(e)); -} From 60242d7c358bc430747d9b05b69058e96bed01f4 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:59:47 +0900 Subject: [PATCH 02/33] =?UTF-8?q?std=20=E3=83=A2=E3=82=B8=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=81=AE=20import=20=E3=83=91=E3=82=B9?= =?UTF-8?q?=E3=82=92=E7=B5=B6=E5=AF=BE=20URL=20=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/capabilities.ts | 2 +- app/api/routes/fasp/data_sharing.ts | 2 +- app/api/routes/fasp/registration.ts | 2 +- app/api/services/fasp.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/routes/fasp/capabilities.ts b/app/api/routes/fasp/capabilities.ts index 38c3e8e46..744c3aab0 100644 --- a/app/api/routes/fasp/capabilities.ts +++ b/app/api/routes/fasp/capabilities.ts @@ -3,7 +3,7 @@ import type { Context } from "hono"; import { decode as b64decode, encode as b64encode, -} from "@std/encoding/base64.ts"; +} from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; const app = new Hono(); diff --git a/app/api/routes/fasp/data_sharing.ts b/app/api/routes/fasp/data_sharing.ts index 42071b4bc..0f872d3ed 100644 --- a/app/api/routes/fasp/data_sharing.ts +++ b/app/api/routes/fasp/data_sharing.ts @@ -3,7 +3,7 @@ import type { Context } from "hono"; import { decode as b64decode, encode as b64encode, -} from "@std/encoding/base64.ts"; +} from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; const app = new Hono(); diff --git a/app/api/routes/fasp/registration.ts b/app/api/routes/fasp/registration.ts index 21b30b0da..f11b65419 100644 --- a/app/api/routes/fasp/registration.ts +++ b/app/api/routes/fasp/registration.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { decode as b64decode, encode as b64encode, -} from "@std/encoding/base64.ts"; +} from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; import { getEnv } from "../../shared/config.ts"; diff --git a/app/api/services/fasp.ts b/app/api/services/fasp.ts index 7dcdda200..7852a0b0d 100644 --- a/app/api/services/fasp.ts +++ b/app/api/services/fasp.ts @@ -1,7 +1,7 @@ import { decode as b64decode, encode as b64encode, -} from "@std/encoding/base64.ts"; +} from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../models/takos/fasp.ts"; interface FaspDoc { From b0ac5ff6508aef7e2db3059a642d6a830d8da7d3 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 00:11:12 +0900 Subject: [PATCH 03/33] =?UTF-8?q?base64=E3=83=A2=E3=82=B8=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=81=AEAPI=E5=A4=89=E6=9B=B4=E3=81=AB?= =?UTF-8?q?=E8=BF=BD=E5=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/capabilities.ts | 4 ++-- app/api/routes/fasp/data_sharing.ts | 4 ++-- app/api/routes/fasp/registration.ts | 4 ++-- app/api/services/fasp.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/api/routes/fasp/capabilities.ts b/app/api/routes/fasp/capabilities.ts index 744c3aab0..f7f08a93c 100644 --- a/app/api/routes/fasp/capabilities.ts +++ b/app/api/routes/fasp/capabilities.ts @@ -1,8 +1,8 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { - decode as b64decode, - encode as b64encode, + decodeBase64 as b64decode, + encodeBase64 as b64encode, } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; diff --git a/app/api/routes/fasp/data_sharing.ts b/app/api/routes/fasp/data_sharing.ts index 0f872d3ed..cd89709c3 100644 --- a/app/api/routes/fasp/data_sharing.ts +++ b/app/api/routes/fasp/data_sharing.ts @@ -1,8 +1,8 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { - decode as b64decode, - encode as b64encode, + decodeBase64 as b64decode, + encodeBase64 as b64encode, } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; diff --git a/app/api/routes/fasp/registration.ts b/app/api/routes/fasp/registration.ts index f11b65419..1cb607494 100644 --- a/app/api/routes/fasp/registration.ts +++ b/app/api/routes/fasp/registration.ts @@ -1,8 +1,8 @@ import { Hono } from "hono"; import { z } from "zod"; import { - decode as b64decode, - encode as b64encode, + decodeBase64 as b64decode, + encodeBase64 as b64encode, } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; import { getEnv } from "../../shared/config.ts"; diff --git a/app/api/services/fasp.ts b/app/api/services/fasp.ts index 7852a0b0d..69304f1f9 100644 --- a/app/api/services/fasp.ts +++ b/app/api/services/fasp.ts @@ -1,6 +1,6 @@ import { - decode as b64decode, - encode as b64encode, + decodeBase64 as b64decode, + encodeBase64 as b64encode, } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../models/takos/fasp.ts"; From 54b1a6fdc60586c1ada30e63680379ac45aecea8 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 00:15:44 +0900 Subject: [PATCH 04/33] =?UTF-8?q?FASP=E7=99=BB=E9=8C=B2=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=81=AE=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0=E5=8F=96?= =?UTF-8?q?=E5=BE=97=E3=81=A8=E9=8D=B5=E7=94=9F=E6=88=90=E3=81=AE=E5=9E=8B?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/registration.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/routes/fasp/registration.ts b/app/api/routes/fasp/registration.ts index 1cb607494..01bdf158f 100644 --- a/app/api/routes/fasp/registration.ts +++ b/app/api/routes/fasp/registration.ts @@ -5,7 +5,7 @@ import { encodeBase64 as b64encode, } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; -import { getEnv } from "../../shared/config.ts"; +import { getEnv } from "../../../shared/config.ts"; const schema = z.object({ name: z.string(), @@ -83,10 +83,11 @@ app.post("/fasp/registration", async (c) => { return c.json({ error: "Invalid Signature" }, 401); } + // Ed25519 鍵ペアを生成 (CryptoKeyPair として扱う) const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ "sign", "verify", - ]); + ]) as CryptoKeyPair; const publicExport = new Uint8Array( await crypto.subtle.exportKey("raw", keyPair.publicKey), ); From fdfdae5c9f8bab47c9294f65238b30f1d924add8 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 00:49:29 +0900 Subject: [PATCH 05/33] =?UTF-8?q?Service=20Actor=20=E3=81=AE=E3=83=9E?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E3=83=88=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/takos_host/main.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/app/takos_host/main.ts b/app/takos_host/main.ts index 3375f8e59..587b1b07b 100644 --- a/app/takos_host/main.ts +++ b/app/takos_host/main.ts @@ -165,7 +165,6 @@ const root = new Hono(); root.route("/auth", authApp); root.route("/oauth", oauthApp); root.route("/user", consumerApp); -root.route("/", serviceActorApp); if (termsText) { root.get("/terms", () => new Response(termsText, { @@ -181,7 +180,11 @@ if (isDev) { root.use(async (c, next) => { const host = getRealHost(c); if (host === rootDomain) { - const res = await rootActivityPubApp.fetch(c.req.raw); + let res = await rootActivityPubApp.fetch(c.req.raw); + if (res.status !== 404) { + return res; + } + res = await serviceActorApp.fetch(c.req.raw); if (res.status !== 404) { return res; } @@ -220,8 +223,17 @@ if (!isDev && rootDomain) { root.all("/*", async (c) => { const host = getRealHost(c); - if (rootDomain && host === rootDomain && rootActivityPubApp) { - return rootActivityPubApp.fetch(c.req.raw); + if (rootDomain && host === rootDomain) { + if (rootActivityPubApp) { + const res = await rootActivityPubApp.fetch(c.req.raw); + if (res.status !== 404) { + return res; + } + } + const res = await serviceActorApp.fetch(c.req.raw); + if (res.status !== 404) { + return res; + } } console.log("rootDomain", rootDomain, "host", host); const app = await getAppForHost(host); From 0b5cd69d22facd3532bb70624a6491f10354b632 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 01:53:18 +0900 Subject: [PATCH 06/33] =?UTF-8?q?feat:=20FASP=20=E7=AE=A1=E7=90=86UI?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88?= =?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 --- .../src/components/Setting/FaspSettings.tsx | 135 ++++++++++++++++++ app/client/src/components/Setting/index.tsx | 2 + 2 files changed, 137 insertions(+) create mode 100644 app/client/src/components/Setting/FaspSettings.tsx diff --git a/app/client/src/components/Setting/FaspSettings.tsx b/app/client/src/components/Setting/FaspSettings.tsx new file mode 100644 index 000000000..c050ee408 --- /dev/null +++ b/app/client/src/components/Setting/FaspSettings.tsx @@ -0,0 +1,135 @@ +import { Component, createResource, createSignal, For, Show } from "solid-js"; +import { apiFetch } from "../../utils/config.ts"; + +interface Capability { + identifier: string; + version: string; + enabled: boolean; +} + +interface FaspItem { + _id: string; + name: string; + baseUrl: string; + accepted: boolean; + capabilities: Capability[]; +} + +async function fetchFasps(): Promise { + const res = await apiFetch("/admin/fasps"); + if (!res.ok) return []; + const data = await res.json() as { fasps: FaspItem[] }; + return data.fasps; +} + +const FaspSettings: Component = () => { + const [fasps, { refetch }] = createResource(fetchFasps); + const [loadingInfo, setLoadingInfo] = createSignal(false); + + const refreshInfo = async () => { + setLoadingInfo(true); + try { + await apiFetch("/admin/fasps/provider_info"); + } finally { + setLoadingInfo(false); + refetch(); + } + }; + + const accept = async (id: string) => { + await apiFetch(`/admin/fasps/${id}/accept`, { method: "POST" }); + refetch(); + }; + + const remove = async (id: string) => { + await apiFetch(`/admin/fasps/${id}`, { method: "DELETE" }); + refetch(); + }; + + const toggleCapability = async ( + id: string, + version: string, + enabled: boolean, + ) => { + const method = enabled ? "POST" : "DELETE"; + await apiFetch( + `/admin/fasps/capabilities/${id}/${version}/activation`, + { method }, + ); + refetch(); + }; + + return ( +
+
+

FASP 設定

+ +
+ + {(f) => ( +
+
+
+

{f.name}

+

{f.baseUrl}

+
+ accept(f._id)} + > + 承認 + + } + > + + +
+ +
+

利用可能なCapability

+
    + + {(c) => ( +
  • + + toggleCapability( + c.identifier, + c.version, + e.currentTarget.checked, + )} + /> + {c.identifier} v{c.version} +
  • + )} +
    +
+
+
+
+ )} +
+
+ ); +}; + +export default FaspSettings; diff --git a/app/client/src/components/Setting/index.tsx b/app/client/src/components/Setting/index.tsx index c98efd722..64dbf533d 100644 --- a/app/client/src/components/Setting/index.tsx +++ b/app/client/src/components/Setting/index.tsx @@ -5,6 +5,7 @@ import { } 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"; @@ -63,6 +64,7 @@ export function Setting(props: SettingProps) { +
+
+

FASP 接続

+
+ setName(e.currentTarget.value)} + /> + setBaseUrl(e.currentTarget.value)} + /> + setServerId(e.currentTarget.value)} + /> + setPublicKey(e.currentTarget.value)} + /> + +
+
{(f) => (
diff --git a/docs/FASP.md b/docs/FASP.md index 0963c7df9..b695dac65 100644 --- a/docs/FASP.md +++ b/docs/FASP.md @@ -284,6 +284,7 @@ fasp: - `POST /fasp/registration`(FASP登録要求受理) - `GET /admin/fasps`(管理UI) +- `POST /admin/fasps`(FASP登録情報の追加) - `POST /admin/fasps/{id}/accept`(登録の受理) - `DELETE /admin/fasps/{id}`(登録の削除) - `POST /fasp/data_sharing/v0/event_subscriptions` From b325fa2c841dc6c68208b91ec7461c83da2c1d9a Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 03:40:13 +0900 Subject: [PATCH 08/33] =?UTF-8?q?FASP=20=E7=AE=A1=E7=90=86UI=E3=82=92?= =?UTF-8?q?=E7=B0=A1=E7=B4=A0=E5=8C=96=E3=81=97=E5=85=AC=E9=96=8B=E9=8D=B5?= =?UTF-8?q?=E5=85=A5=E5=8A=9B=E3=82=92=E4=B8=8D=E8=A6=81=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/admin.ts | 37 ------------ .../src/components/Setting/FaspSettings.tsx | 58 ------------------- docs/FASP.md | 3 +- 3 files changed, 2 insertions(+), 96 deletions(-) diff --git a/app/api/routes/fasp/admin.ts b/app/api/routes/fasp/admin.ts index bb0297ddf..93fc85a72 100644 --- a/app/api/routes/fasp/admin.ts +++ b/app/api/routes/fasp/admin.ts @@ -1,5 +1,4 @@ import { Hono } from "hono"; -import { encodeBase64 as b64encode } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import authRequired from "../../utils/auth.ts"; import Fasp from "../../models/takos/fasp.ts"; import { activateCapability, getProviderInfo } from "../../services/fasp.ts"; @@ -20,42 +19,6 @@ app.get("/admin/fasps", async (c) => { return c.json({ fasps }); }); -app.post("/admin/fasps", async (c) => { - const body = await c.req.json(); - const { name, baseUrl, serverId, publicKey } = body; - if (!name || !baseUrl || !serverId || !publicKey) { - return c.json({ error: "invalid body" }, 400); - } - const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ - "sign", - "verify", - ]) as CryptoKeyPair; - const pub = new Uint8Array( - await crypto.subtle.exportKey("raw", keyPair.publicKey), - ); - const priv = new Uint8Array( - await crypto.subtle.exportKey("pkcs8", keyPair.privateKey), - ); - const faspId = crypto.randomUUID(); - const fasp = await Fasp.create({ - _id: faspId, - name, - baseUrl, - serverId, - faspPublicKey: publicKey, - publicKey: b64encode(pub), - privateKey: b64encode(priv), - accepted: false, - }); - fasp.communications.push({ - direction: "in", - endpoint: c.req.path, - payload: { name, baseUrl, serverId, publicKey }, - }); - await fasp.save(); - return c.json({ ok: true, id: faspId }, 201); -}); - app.get("/admin/fasps/provider_info", async (c) => { const info = await getProviderInfo(); if (!info) return c.json({ error: "not found" }, 404); diff --git a/app/client/src/components/Setting/FaspSettings.tsx b/app/client/src/components/Setting/FaspSettings.tsx index 7f6afd9a7..c050ee408 100644 --- a/app/client/src/components/Setting/FaspSettings.tsx +++ b/app/client/src/components/Setting/FaspSettings.tsx @@ -25,10 +25,6 @@ async function fetchFasps(): Promise { const FaspSettings: Component = () => { const [fasps, { refetch }] = createResource(fetchFasps); const [loadingInfo, setLoadingInfo] = createSignal(false); - const [name, setName] = createSignal(""); - const [baseUrl, setBaseUrl] = createSignal(""); - const [serverId, setServerId] = createSignal(""); - const [publicKey, setPublicKey] = createSignal(""); const refreshInfo = async () => { setLoadingInfo(true); @@ -63,24 +59,6 @@ const FaspSettings: Component = () => { refetch(); }; - const registerFasp = async () => { - await apiFetch("/admin/fasps", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: name(), - baseUrl: baseUrl(), - serverId: serverId(), - publicKey: publicKey(), - }), - }); - setName(""); - setBaseUrl(""); - setServerId(""); - setPublicKey(""); - refetch(); - }; - return (
@@ -94,42 +72,6 @@ const FaspSettings: Component = () => { {loadingInfo() ? "更新中..." : "プロバイダー情報取得"}
-
-

FASP 接続

-
- setName(e.currentTarget.value)} - /> - setBaseUrl(e.currentTarget.value)} - /> - setServerId(e.currentTarget.value)} - /> - setPublicKey(e.currentTarget.value)} - /> - -
-
{(f) => (
diff --git a/docs/FASP.md b/docs/FASP.md index b695dac65..0d9f3a03e 100644 --- a/docs/FASP.md +++ b/docs/FASP.md @@ -284,7 +284,6 @@ fasp: - `POST /fasp/registration`(FASP登録要求受理) - `GET /admin/fasps`(管理UI) -- `POST /admin/fasps`(FASP登録情報の追加) - `POST /admin/fasps/{id}/accept`(登録の受理) - `DELETE /admin/fasps/{id}`(登録の削除) - `POST /fasp/data_sharing/v0/event_subscriptions` @@ -293,6 +292,8 @@ fasp: - `POST /fasp/data_sharing/v0/backfill_requests/{id}/continuation` - `POST /fasp/data_sharing/v0/announcements` +FASPの公開鍵は登録要求に含まれる値をサーバー側で保持します。管理UIで公開鍵を手動入力する必要はありません。 + (認証:RFC9421、`Content-Digest` 必須) ### 7.3 takos → FASP(クライアント API) From 430ba4aa5b2cff3e2129d95159bca8bd3253fdb8 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 04:23:22 +0900 Subject: [PATCH 09/33] =?UTF-8?q?=E7=AE=A1=E7=90=86UI=E3=81=8B=E3=82=89FAS?= =?UTF-8?q?P=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/admin.ts | 16 ++++++- app/api/services/fasp.ts | 48 +++++++++++++++++++ .../src/components/Setting/FaspSettings.tsx | 35 ++++++++++++++ docs/FASP.md | 1 + 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/app/api/routes/fasp/admin.ts b/app/api/routes/fasp/admin.ts index 93fc85a72..91aa8d169 100644 --- a/app/api/routes/fasp/admin.ts +++ b/app/api/routes/fasp/admin.ts @@ -1,7 +1,12 @@ import { Hono } from "hono"; import authRequired from "../../utils/auth.ts"; import Fasp from "../../models/takos/fasp.ts"; -import { activateCapability, getProviderInfo } from "../../services/fasp.ts"; +import { + activateCapability, + getProviderInfo, + registerProvider, +} from "../../services/fasp.ts"; +import { getDomain } from "../../utils/activitypub.ts"; async function publicKeyFingerprint(pubKey: string): Promise { const data = new TextEncoder().encode(pubKey); @@ -14,6 +19,15 @@ async function publicKeyFingerprint(pubKey: string): Promise { const app = new Hono(); app.use("/admin/*", authRequired); +app.post("/admin/fasps", async (c) => { + const { baseUrl } = await c.req.json() as { baseUrl?: string }; + if (!baseUrl) return c.json({ error: "baseUrl required" }, 400); + const domain = getDomain(c); + const fasp = await registerProvider(baseUrl, domain); + if (!fasp) return c.json({ error: "registration failed" }, 400); + return c.json({ ok: true, id: fasp._id }); +}); + app.get("/admin/fasps", async (c) => { const fasps = await Fasp.find().lean(); return c.json({ fasps }); diff --git a/app/api/services/fasp.ts b/app/api/services/fasp.ts index 69304f1f9..aa94cbae2 100644 --- a/app/api/services/fasp.ts +++ b/app/api/services/fasp.ts @@ -254,3 +254,51 @@ export async function sendAnnouncement( ); return res.ok; } + +export async function registerProvider(baseUrl: string, domain: string) { + const infoRes = await fetch(new URL("/provider_info", baseUrl)); + if (!infoRes.ok) return null; + const info = await infoRes.json() as { name?: string }; + const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ]) as CryptoKeyPair; + const publicBytes = new Uint8Array( + await crypto.subtle.exportKey("raw", keyPair.publicKey), + ); + const privateBytes = new Uint8Array( + await crypto.subtle.exportKey("pkcs8", keyPair.privateKey), + ); + const publicKey = b64encode(publicBytes); + const privateKey = b64encode(privateBytes); + const serverId = crypto.randomUUID(); + const registrationBody = { + name: domain, + baseUrl: `https://${domain}/fasp`, + serverId, + publicKey, + }; + const regRes = await fetch(new URL("/registration", baseUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(registrationBody), + }); + if (!regRes.ok) return null; + const data = await regRes.json() as { faspId: string; publicKey: string }; + const fasp = await Fasp.create({ + _id: data.faspId, + name: info.name ?? baseUrl, + baseUrl, + serverId, + faspPublicKey: data.publicKey, + publicKey, + privateKey, + accepted: true, + }); + fasp.communications.push( + { direction: "out", endpoint: "/registration", payload: registrationBody }, + { direction: "in", endpoint: "/registration", payload: data }, + ); + await fasp.save(); + return fasp; +} diff --git a/app/client/src/components/Setting/FaspSettings.tsx b/app/client/src/components/Setting/FaspSettings.tsx index c050ee408..275c3ede8 100644 --- a/app/client/src/components/Setting/FaspSettings.tsx +++ b/app/client/src/components/Setting/FaspSettings.tsx @@ -25,6 +25,8 @@ async function fetchFasps(): Promise { const FaspSettings: Component = () => { const [fasps, { refetch }] = createResource(fetchFasps); const [loadingInfo, setLoadingInfo] = createSignal(false); + const [newUrl, setNewUrl] = createSignal(""); + const [adding, setAdding] = createSignal(false); const refreshInfo = async () => { setLoadingInfo(true); @@ -59,6 +61,22 @@ const FaspSettings: Component = () => { refetch(); }; + const addServer = async () => { + if (!newUrl()) return; + setAdding(true); + try { + await apiFetch("/admin/fasps", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: newUrl() }), + }); + setNewUrl(""); + refetch(); + } finally { + setAdding(false); + } + }; + return (
@@ -72,6 +90,23 @@ const FaspSettings: Component = () => { {loadingInfo() ? "更新中..." : "プロバイダー情報取得"}
+
+ setNewUrl(e.currentTarget.value)} + /> + +
{(f) => (
diff --git a/docs/FASP.md b/docs/FASP.md index 0d9f3a03e..d38e0d289 100644 --- a/docs/FASP.md +++ b/docs/FASP.md @@ -284,6 +284,7 @@ fasp: - `POST /fasp/registration`(FASP登録要求受理) - `GET /admin/fasps`(管理UI) +- `POST /admin/fasps`(FASP サーバー追加) - `POST /admin/fasps/{id}/accept`(登録の受理) - `DELETE /admin/fasps/{id}`(登録の削除) - `POST /fasp/data_sharing/v0/event_subscriptions` From 7b45c4aa377b75f5aa7ae601583d5543d6b01a11 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 06:06:32 +0900 Subject: [PATCH 10/33] =?UTF-8?q?Nodeinfo=20=E3=81=A7=20FASP=20=E3=83=99?= =?UTF-8?q?=E3=83=BC=E3=82=B9URL=E3=82=92=E5=B8=B8=E3=81=AB=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/nodeinfo.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/api/routes/nodeinfo.ts b/app/api/routes/nodeinfo.ts index 8af180232..abc9fd06f 100644 --- a/app/api/routes/nodeinfo.ts +++ b/app/api/routes/nodeinfo.ts @@ -2,7 +2,6 @@ import { Hono } from "hono"; import { createDB } from "../DB/mod.ts"; import { getDomain } from "../utils/activitypub.ts"; import { getEnv } from "../../shared/config.ts"; -import Fasp from "../models/takos/fasp.ts"; // NodeInfo は外部から参照されるため認証は不要 const app = new Hono(); @@ -32,11 +31,8 @@ app.get("/nodeinfo/2.0", async (c) => { const env = getEnv(c); const { users, posts, version } = await getNodeStats(env); const metadata: Record = {}; - const fasp = await Fasp.findOne({ accepted: true }).lean(); - if (fasp) { - const domain = getDomain(c); - metadata.faspBaseUrl = `https://${domain}/fasp`; - } + const domain = getDomain(c); + metadata.faspBaseUrl = `https://${domain}/fasp`; return c.json({ version: "2.0", software: { From 099eee50fb8a951eea32a622f348e6ba0a167369 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 06:06:39 +0900 Subject: [PATCH 11/33] =?UTF-8?q?FASP=E5=BF=9C=E7=AD=94=E7=BD=B2=E5=90=8D?= =?UTF-8?q?=E3=83=98=E3=83=AB=E3=83=91=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/capabilities.ts | 3 +- app/api/routes/fasp/data_sharing.ts | 35 +++++++++++++++---- app/api/routes/fasp/registration.ts | 6 ++-- app/api/routes/fasp/utils.ts | 52 +++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 app/api/routes/fasp/utils.ts diff --git a/app/api/routes/fasp/capabilities.ts b/app/api/routes/fasp/capabilities.ts index f7f08a93c..3686ee31a 100644 --- a/app/api/routes/fasp/capabilities.ts +++ b/app/api/routes/fasp/capabilities.ts @@ -5,6 +5,7 @@ import { encodeBase64 as b64encode, } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; +import signResponse from "./utils.ts"; const app = new Hono(); @@ -89,7 +90,7 @@ async function handleActivation(c: Context, enabled: boolean) { }); await fasp.save(); - return c.body(null, 204); + return signResponse(null, 204, fasp._id, fasp.privateKey); } app.post( diff --git a/app/api/routes/fasp/data_sharing.ts b/app/api/routes/fasp/data_sharing.ts index cd89709c3..e99e2cbc4 100644 --- a/app/api/routes/fasp/data_sharing.ts +++ b/app/api/routes/fasp/data_sharing.ts @@ -5,6 +5,7 @@ import { encodeBase64 as b64encode, } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; +import signResponse from "./utils.ts"; const app = new Hono(); @@ -70,7 +71,12 @@ app.post("/fasp/data_sharing/v0/event_subscriptions", async (c) => { if (error) return error; const body = JSON.parse(new TextDecoder().decode(raw)); if (!body.category || !body.subscriptionType) { - return c.json({ error: "Invalid body" }, 422); + return signResponse( + { error: "Invalid body" }, + 422, + fasp._id, + fasp.privateKey, + ); } const id = crypto.randomUUID(); fasp.eventSubscriptions.push({ @@ -84,7 +90,7 @@ app.post("/fasp/data_sharing/v0/event_subscriptions", async (c) => { payload: body, }); await fasp.save(); - return c.json({ subscription: { id } }, 201); + return signResponse({ subscription: { id } }, 201, fasp._id, fasp.privateKey); }); app.post("/fasp/data_sharing/v0/backfill_requests", async (c) => { @@ -93,7 +99,12 @@ app.post("/fasp/data_sharing/v0/backfill_requests", async (c) => { if (error) return error; const body = JSON.parse(new TextDecoder().decode(raw)); if (!body.category || typeof body.maxCount !== "number") { - return c.json({ error: "Invalid body" }, 422); + return signResponse( + { error: "Invalid body" }, + 422, + fasp._id, + fasp.privateKey, + ); } const id = crypto.randomUUID(); fasp.backfillRequests.push({ @@ -108,7 +119,12 @@ app.post("/fasp/data_sharing/v0/backfill_requests", async (c) => { payload: body, }); await fasp.save(); - return c.json({ backfillRequest: { id } }, 201); + return signResponse( + { backfillRequest: { id } }, + 201, + fasp._id, + fasp.privateKey, + ); }); app.delete("/fasp/data_sharing/v0/event_subscriptions/:id", async (c) => { @@ -126,7 +142,7 @@ app.delete("/fasp/data_sharing/v0/event_subscriptions/:id", async (c) => { payload: null, }); await fasp.save(); - return c.body(null, 204); + return signResponse(null, 204, fasp._id, fasp.privateKey); }); app.post( @@ -139,7 +155,12 @@ app.post( const req = (fasp.backfillRequests as { id: string; status: string }[]) .find((r) => r.id === id); if (!req) { - return c.json({ error: "Unknown backfill request" }, 404); + return signResponse( + { error: "Unknown backfill request" }, + 404, + fasp._id, + fasp.privateKey, + ); } req.status = "pending"; fasp.communications.push({ @@ -148,7 +169,7 @@ app.post( payload: null, }); await fasp.save(); - return c.body(null, 204); + return signResponse(null, 204, fasp._id, fasp.privateKey); }, ); diff --git a/app/api/routes/fasp/registration.ts b/app/api/routes/fasp/registration.ts index 01bdf158f..69f692451 100644 --- a/app/api/routes/fasp/registration.ts +++ b/app/api/routes/fasp/registration.ts @@ -6,6 +6,7 @@ import { } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import Fasp from "../../models/takos/fasp.ts"; import { getEnv } from "../../../shared/config.ts"; +import signResponse from "./utils.ts"; const schema = z.object({ name: z.string(), @@ -122,11 +123,12 @@ app.post("/fasp/registration", async (c) => { ? `https://${domain}/admin/fasps` : ""; - return c.json({ + const body = { faspId, publicKey: myPublic, registrationCompletionUri, - }, 201); + }; + return signResponse(body, 201, faspId, myPrivate); }); export default app; diff --git a/app/api/routes/fasp/utils.ts b/app/api/routes/fasp/utils.ts new file mode 100644 index 000000000..bed0ccb4d --- /dev/null +++ b/app/api/routes/fasp/utils.ts @@ -0,0 +1,52 @@ +import { + decodeBase64 as b64decode, + encodeBase64 as b64encode, +} from "https://deno.land/std@0.224.0/encoding/base64.ts"; + +/** + * レスポンスボディを署名し、必要なヘッダーを付与して返すヘルパー + */ +export async function signResponse( + body: unknown, + status: number, + keyId: string, + privateKeyB64: string, +): Promise { + const json = body ? JSON.stringify(body) : ""; + const raw = new TextEncoder().encode(json); + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", raw), + ); + const digestB64 = b64encode(digest); + const digestHeader = `sha-256=:${digestB64}:`; + const created = Math.floor(Date.now() / 1000); + const paramStr = '"@status" "content-digest"'; + const lines = [ + `"@status": ${status}`, + `"content-digest": ${digestHeader}`, + `"@signature-params": (${paramStr});created=${created};keyid="${keyId}"`, + ]; + const base = new TextEncoder().encode(lines.join("\n")); + const keyBytes = b64decode(privateKeyB64); + const key = await crypto.subtle.importKey( + "pkcs8", + keyBytes, + { name: "Ed25519" }, + false, + ["sign"], + ); + const sig = new Uint8Array( + await crypto.subtle.sign("Ed25519", key, base), + ); + const signature = `sig1=:${b64encode(sig)}:`; + const sigInput = `sig1=(${paramStr});created=${created};keyid="${keyId}"`; + const headers: Record = { + "content-digest": digestHeader, + "signature-input": sigInput, + signature, + }; + if (body) headers["content-type"] = "application/json"; + return new Response(json || null, { status, headers }); +} + +export default signResponse; From c154bc5a4ec9d0e1b45b5c8072e33028695d2112 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 06:06:51 +0900 Subject: [PATCH 12/33] =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E3=83=95?= =?UTF-8?q?=E3=82=A3=E3=83=AB=E8=A6=81=E6=B1=82=E3=81=AE=E5=87=A6=E7=90=86?= =?UTF-8?q?=E3=81=A8=E3=82=A2=E3=83=8A=E3=82=A6=E3=83=B3=E3=82=B9=E9=80=81?= =?UTF-8?q?=E4=BF=A1=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/data_sharing.ts | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/app/api/routes/fasp/data_sharing.ts b/app/api/routes/fasp/data_sharing.ts index e99e2cbc4..372b0f200 100644 --- a/app/api/routes/fasp/data_sharing.ts +++ b/app/api/routes/fasp/data_sharing.ts @@ -4,11 +4,16 @@ import { decodeBase64 as b64decode, encodeBase64 as b64encode, } from "https://deno.land/std@0.224.0/encoding/base64.ts"; +import { createDB } from "../../DB/mod.ts"; import Fasp from "../../models/takos/fasp.ts"; +import { getEnv } from "../../shared/config.ts"; +import { getDomain } from "../../utils/activitypub.ts"; +import { sendAnnouncement } from "../../services/fasp.ts"; import signResponse from "./utils.ts"; const app = new Hono(); +// 署名と Content-Digest を検証する async function verify(c: Context, rawBody: Uint8Array) { const digestHeader = c.req.header("content-digest") ?? ""; const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", rawBody)); @@ -65,6 +70,77 @@ async function verify(c: Context, rawBody: Uint8Array) { return { fasp }; } +// 保留中のバックフィル要求を処理してアナウンスを送信する +async function processBackfillRequests( + env: Record, + domain: string, +) { + const fasp = await Fasp.findOne({ accepted: true }) as unknown as + | { + _id: string; + backfillRequests: { + id: string; + category: string; + maxCount: number; + status: string; + }[]; + } + | null; + if (!fasp) return; + const db = createDB(env); + for (const req of fasp.backfillRequests) { + if (req.status !== "pending") continue; + let objectUris: string[] = []; + let moreObjectsAvailable = false; + if (req.category === "content") { + const notes = await db.getPublicNotes(req.maxCount + 1) as Array<{ + _id: string; + aud?: { to?: string[]; cc?: string[] }; + extra?: { discoverable?: boolean }; + }>; + const filtered = notes.filter((n) => { + const isPublic = [ + ...(n.aud?.to ?? []), + ...(n.aud?.cc ?? []), + ].includes("https://www.w3.org/ns/activitystreams#Public"); + const discoverable = Boolean(n.extra?.discoverable); + return isPublic && discoverable; + }); + moreObjectsAvailable = filtered.length > req.maxCount; + objectUris = filtered.slice(0, req.maxCount).map((n) => + `https://${domain}/objects/${n._id}` + ); + } else if (req.category === "account") { + const accounts = await db.listAccounts() as Array<{ + userName: string; + extra?: { discoverable?: boolean; visibility?: string }; + }>; + const filtered = accounts.filter((a) => { + const vis = a.extra?.visibility ?? "public"; + const discoverable = Boolean(a.extra?.discoverable); + return vis === "public" && discoverable; + }); + moreObjectsAvailable = filtered.length > req.maxCount; + objectUris = filtered.slice(0, req.maxCount).map((a) => + `https://${domain}/users/${a.userName}` + ); + } + if (objectUris.length > 0) { + await sendAnnouncement( + { backfillRequest: { id: req.id } }, + req.category as "content" | "account", + undefined, + objectUris, + moreObjectsAvailable, + ); + } + await Fasp.updateOne( + { _id: fasp._id, "backfillRequests.id": req.id }, + { $set: { "backfillRequests.$.status": "completed" } }, + ); + } +} + app.post("/fasp/data_sharing/v0/event_subscriptions", async (c) => { const raw = new Uint8Array(await c.req.arrayBuffer()); const { fasp, error } = await verify(c, raw); @@ -119,6 +195,7 @@ app.post("/fasp/data_sharing/v0/backfill_requests", async (c) => { payload: body, }); await fasp.save(); + await processBackfillRequests(getEnv(c), getDomain(c)); return signResponse( { backfillRequest: { id } }, 201, @@ -169,6 +246,7 @@ app.post( payload: null, }); await fasp.save(); + await processBackfillRequests(getEnv(c), getDomain(c)); return signResponse(null, 204, fasp._id, fasp.privateKey); }, ); From 11b3a7f3a818e4428066b0ebee8e5f8c8b658566 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 06:06:57 +0900 Subject: [PATCH 13/33] =?UTF-8?q?FASP=E5=BF=9C=E7=AD=94=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=81=AE=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/data_sharing.ts | 27 +++++++++++++++++++----- app/api/services/fasp.ts | 32 ++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/app/api/routes/fasp/data_sharing.ts b/app/api/routes/fasp/data_sharing.ts index 372b0f200..486d38d4f 100644 --- a/app/api/routes/fasp/data_sharing.ts +++ b/app/api/routes/fasp/data_sharing.ts @@ -18,9 +18,7 @@ async function verify(c: Context, rawBody: Uint8Array) { const digestHeader = c.req.header("content-digest") ?? ""; const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", rawBody)); const digestB64 = b64encode(digest); - if (digestHeader !== `sha-256=:${digestB64}:`) { - return { error: c.json({ error: "Invalid Content-Digest" }, 401) }; - } + const digestOk = digestHeader === `sha-256=:${digestB64}:`; const sigInput = c.req.header("signature-input") ?? ""; const signature = c.req.header("signature") ?? ""; const sigInputMatch = sigInput.match( @@ -35,6 +33,8 @@ async function verify(c: Context, rawBody: Uint8Array) { ); const created = Number(sigInputMatch[2]); const keyId = sigInputMatch[3]; + const now = Math.floor(Date.now() / 1000); + const timeOk = Math.abs(now - created) <= 300; const lines: string[] = []; for (const comp of components) { if (comp === "@method") { @@ -63,8 +63,25 @@ async function verify(c: Context, rawBody: Uint8Array) { false, ["verify"], ); - const ok = await crypto.subtle.verify("Ed25519", key, signatureBytes, base); - if (!ok) { + const sigOk = await crypto.subtle.verify( + "Ed25519", + key, + signatureBytes, + base, + ); + fasp.communications.push({ + direction: "in", + endpoint: c.req.path, + payload: { digestOk, timeOk, signatureOk: sigOk }, + }); + await fasp.save(); + if (!digestOk) { + return { error: c.json({ error: "Invalid Content-Digest" }, 401) }; + } + if (!timeOk) { + return { error: c.json({ error: "Invalid Signature" }, 401) }; + } + if (!sigOk) { return { error: c.json({ error: "Invalid Signature" }, 401) }; } return { fasp }; diff --git a/app/api/services/fasp.ts b/app/api/services/fasp.ts index aa94cbae2..229c19feb 100644 --- a/app/api/services/fasp.ts +++ b/app/api/services/fasp.ts @@ -75,6 +75,13 @@ async function signedFetch( headers, body: body ? JSON.stringify(body) : undefined, }); + const resBody = new Uint8Array(await res.clone().arrayBuffer()); + const resDigest = new Uint8Array( + await crypto.subtle.digest("SHA-256", resBody), + ); + const resDigestB64 = b64encode(resDigest); + const digestHeaderRes = res.headers.get("content-digest") ?? ""; + const digestVerified = digestHeaderRes === `sha-256=:${resDigestB64}:`; const sigInputHeader = res.headers.get("signature-input") ?? ""; const sigHeader = res.headers.get("signature") ?? ""; let verified = false; @@ -84,9 +91,19 @@ async function signedFetch( const sigMatch = sigHeader.match(/^sig1=:([^:]+):/); if (inputMatch && sigMatch) { const [, paramStr2, created2, keyId] = inputMatch; - const base = new TextEncoder().encode( - `"@status": ${res.status}\n"@signature-params": (${paramStr2});created=${created2};keyid="${keyId}"`, + const components = paramStr2.split(" "); + const lines2: string[] = []; + for (const comp of components) { + if (comp === '"@status"') { + lines2.push(`"@status": ${res.status}`); + } else if (comp === '"content-digest"') { + lines2.push(`"content-digest": ${digestHeaderRes}`); + } + } + lines2.push( + `"@signature-params": (${paramStr2});created=${created2};keyid="${keyId}"`, ); + const base = new TextEncoder().encode(lines2.join("\n")); const publicKeyBytes = b64decode(fasp.faspPublicKey); const verifyKey = await crypto.subtle.importKey( "raw", @@ -106,11 +123,16 @@ async function signedFetch( fasp.communications.push({ direction: "out", endpoint: url.pathname, - payload: { request: body ?? null, status: res.status, verified }, + payload: { + request: body ?? null, + status: res.status, + signatureVerified: verified, + digestVerified, + }, }); await fasp.save(); - if (!verified) { - throw new Error("応答署名の検証に失敗しました"); + if (!verified || !digestVerified) { + throw new Error("応答検証に失敗しました"); } return res; } From 106c4fa5fec90cb94be6aa08690509212fcbf30a Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 07:14:34 +0900 Subject: [PATCH 14/33] =?UTF-8?q?FASP=E7=99=BB=E9=8C=B2=E3=81=AE=E3=83=99?= =?UTF-8?q?=E3=83=BC=E3=82=B9URL=E6=A4=9C=E8=A8=BC=E3=82=92=E5=BC=B7?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/services/fasp.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/api/services/fasp.ts b/app/api/services/fasp.ts index 229c19feb..a39208982 100644 --- a/app/api/services/fasp.ts +++ b/app/api/services/fasp.ts @@ -278,8 +278,22 @@ export async function sendAnnouncement( } export async function registerProvider(baseUrl: string, domain: string) { - const infoRes = await fetch(new URL("/provider_info", baseUrl)); + let url: URL; + try { + url = new URL(baseUrl); + } catch { + return null; + } + if (!url.pathname || url.pathname === "/") { + url.pathname = "/fasp"; + } else if (!url.pathname.endsWith("/fasp")) { + url.pathname = url.pathname.replace(/\/?$/, "/fasp"); + } + const root = url.toString().replace(/\/$/, ""); + const infoRes = await fetch(`${root}/provider_info`); if (!infoRes.ok) return null; + const type = infoRes.headers.get("content-type") ?? ""; + if (!type.includes("application/json")) return null; const info = await infoRes.json() as { name?: string }; const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ "sign", @@ -300,7 +314,7 @@ export async function registerProvider(baseUrl: string, domain: string) { serverId, publicKey, }; - const regRes = await fetch(new URL("/registration", baseUrl), { + const regRes = await fetch(`${root}/registration`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(registrationBody), @@ -309,8 +323,8 @@ export async function registerProvider(baseUrl: string, domain: string) { const data = await regRes.json() as { faspId: string; publicKey: string }; const fasp = await Fasp.create({ _id: data.faspId, - name: info.name ?? baseUrl, - baseUrl, + name: info.name ?? root, + baseUrl: root, serverId, faspPublicKey: data.publicKey, publicKey, From b8e328dfe3978cb11d9b91f80a5bf3a1ca60789a Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 07:18:45 +0900 Subject: [PATCH 15/33] =?UTF-8?q?fix:=20FASP=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E5=85=B1=E6=9C=89=E3=83=AB=E3=83=BC=E3=83=88=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E8=AA=AD=E3=81=BF?= =?UTF-8?q?=E8=BE=BC=E3=81=BF=E3=83=91=E3=82=B9=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/fasp/data_sharing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/routes/fasp/data_sharing.ts b/app/api/routes/fasp/data_sharing.ts index 486d38d4f..590364f4a 100644 --- a/app/api/routes/fasp/data_sharing.ts +++ b/app/api/routes/fasp/data_sharing.ts @@ -6,7 +6,7 @@ import { } from "https://deno.land/std@0.224.0/encoding/base64.ts"; import { createDB } from "../../DB/mod.ts"; import Fasp from "../../models/takos/fasp.ts"; -import { getEnv } from "../../shared/config.ts"; +import { getEnv } from "../../../shared/config.ts"; import { getDomain } from "../../utils/activitypub.ts"; import { sendAnnouncement } from "../../services/fasp.ts"; import signResponse from "./utils.ts"; From 012a9181bbf510f9b34474caabb49b737dba5a6e Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 07:45:15 +0900 Subject: [PATCH 16/33] =?UTF-8?q?FASP=20=E7=AE=A1=E7=90=86=20API=20?= =?UTF-8?q?=E3=82=92=20/api=20=E9=85=8D=E4=B8=8B=E3=81=AB=E6=88=BB?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/server.ts | 2 +- docs/FASP.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/api/server.ts b/app/api/server.ts index e4d8a3700..3daee15ea 100644 --- a/app/api/server.ts +++ b/app/api/server.ts @@ -81,6 +81,7 @@ export async function createTakosApp(env?: Record) { relays, users, e2ee, + faspAdmin, ]; for (const r of apiRoutes) { app.route("/api", r); @@ -95,7 +96,6 @@ export async function createTakosApp(env?: Record) { faspDataSharing, faspAccountSearch, faspTrends, - faspAdmin, faspAnnouncements, ]; for (const r of rootRoutes) { diff --git a/docs/FASP.md b/docs/FASP.md index d38e0d289..6af131a46 100644 --- a/docs/FASP.md +++ b/docs/FASP.md @@ -283,10 +283,10 @@ fasp: ### 7.2 takos API(FASP登録・管理用) - `POST /fasp/registration`(FASP登録要求受理) -- `GET /admin/fasps`(管理UI) -- `POST /admin/fasps`(FASP サーバー追加) -- `POST /admin/fasps/{id}/accept`(登録の受理) -- `DELETE /admin/fasps/{id}`(登録の削除) +- `GET /api/admin/fasps`(管理UI) +- `POST /api/admin/fasps`(FASP サーバー追加) +- `POST /api/admin/fasps/{id}/accept`(登録の受理) +- `DELETE /api/admin/fasps/{id}`(登録の削除) - `POST /fasp/data_sharing/v0/event_subscriptions` - `DELETE /fasp/data_sharing/v0/event_subscriptions/{id}` - `POST /fasp/data_sharing/v0/backfill_requests` From 65177202cc7677827d38967c3067ce856b4a5761 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Fri, 8 Aug 2025 07:45:21 +0900 Subject: [PATCH 17/33] =?UTF-8?q?FASP=E3=81=AE=E7=AE=A1=E7=90=86=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=83=9D=E3=82=A4=E3=83=B3=E3=83=88=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/fasp/admin.ts | 79 -------- app/api/routes/fasp/registration.ts | 2 +- app/api/server.ts | 2 - app/api/services/fasp.ts | 114 ------------ .../src/components/Setting/FaspSettings.tsx | 170 ------------------ app/client/src/components/Setting/index.tsx | 2 - docs/FASP.md | 14 +- docs/fasp/general/v0.1/registration.md | 144 +++++++-------- docs/fasp/general/v0.1/server_openapi.yml | 10 +- 9 files changed, 80 insertions(+), 457 deletions(-) delete mode 100644 app/api/routes/fasp/admin.ts delete mode 100644 app/client/src/components/Setting/FaspSettings.tsx diff --git a/app/api/routes/fasp/admin.ts b/app/api/routes/fasp/admin.ts deleted file mode 100644 index 91aa8d169..000000000 --- a/app/api/routes/fasp/admin.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Hono } from "hono"; -import authRequired from "../../utils/auth.ts"; -import Fasp from "../../models/takos/fasp.ts"; -import { - activateCapability, - getProviderInfo, - registerProvider, -} from "../../services/fasp.ts"; -import { getDomain } from "../../utils/activitypub.ts"; - -async function publicKeyFingerprint(pubKey: string): Promise { - const data = new TextEncoder().encode(pubKey); - const digest = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(digest)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -const app = new Hono(); -app.use("/admin/*", authRequired); - -app.post("/admin/fasps", async (c) => { - const { baseUrl } = await c.req.json() as { baseUrl?: string }; - if (!baseUrl) return c.json({ error: "baseUrl required" }, 400); - const domain = getDomain(c); - const fasp = await registerProvider(baseUrl, domain); - if (!fasp) return c.json({ error: "registration failed" }, 400); - return c.json({ ok: true, id: fasp._id }); -}); - -app.get("/admin/fasps", async (c) => { - const fasps = await Fasp.find().lean(); - return c.json({ fasps }); -}); - -app.get("/admin/fasps/provider_info", async (c) => { - const info = await getProviderInfo(); - if (!info) return c.json({ error: "not found" }, 404); - return c.json({ info }); -}); - -app.post("/admin/fasps/:id/accept", async (c) => { - const { id } = c.req.param(); - const fasp = await Fasp.findById(id); - if (!fasp) return c.json({ error: "not found" }, 404); - fasp.accepted = true; - await fasp.save(); - const fingerprint = await publicKeyFingerprint(fasp.faspPublicKey); - return c.json({ ok: true, fingerprint }); -}); - -app.delete("/admin/fasps/:id", async (c) => { - const { id } = c.req.param(); - const fasp = await Fasp.findById(id); - if (!fasp) return c.json({ error: "not found" }, 404); - fasp.accepted = false; - await fasp.save(); - return c.json({ ok: true }); -}); - -app.post( - "/admin/fasps/capabilities/:id/:version/activation", - async (c) => { - const { id, version } = c.req.param(); - const ok = await activateCapability(id, version, true); - return c.json({ ok }); - }, -); - -app.delete( - "/admin/fasps/capabilities/:id/:version/activation", - async (c) => { - const { id, version } = c.req.param(); - const ok = await activateCapability(id, version, false); - return c.json({ ok }); - }, -); - -export default app; diff --git a/app/api/routes/fasp/registration.ts b/app/api/routes/fasp/registration.ts index 69f692451..7abbede6c 100644 --- a/app/api/routes/fasp/registration.ts +++ b/app/api/routes/fasp/registration.ts @@ -120,7 +120,7 @@ app.post("/fasp/registration", async (c) => { const env = getEnv(c); const domain = env["ROOT_DOMAIN"] ?? ""; const registrationCompletionUri = domain - ? `https://${domain}/admin/fasps` + ? `https://${domain}/fasp/registrations` : ""; const body = { diff --git a/app/api/server.ts b/app/api/server.ts index 3daee15ea..cc90e4aa6 100644 --- a/app/api/server.ts +++ b/app/api/server.ts @@ -33,7 +33,6 @@ import faspCapabilities from "./routes/fasp/capabilities.ts"; import faspDataSharing from "./routes/fasp/data_sharing.ts"; import faspAccountSearch from "./routes/fasp/account_search.ts"; import faspTrends from "./routes/fasp/trends.ts"; -import faspAdmin from "./routes/fasp/admin.ts"; import faspAnnouncements from "./routes/fasp/announcements.ts"; import { fetchOgpData } from "./services/ogp.ts"; import { serveStatic } from "hono/deno"; @@ -81,7 +80,6 @@ export async function createTakosApp(env?: Record) { relays, users, e2ee, - faspAdmin, ]; for (const r of apiRoutes) { app.route("/api", r); diff --git a/app/api/services/fasp.ts b/app/api/services/fasp.ts index a39208982..679387dfb 100644 --- a/app/api/services/fasp.ts +++ b/app/api/services/fasp.ts @@ -200,58 +200,6 @@ export async function fetchTrends( return await res.json(); } -export async function getProviderInfo() { - const fasp = await Fasp.findOne({ accepted: true }) as unknown as - | FaspDoc - | null; - if (!fasp) return null; - const res = await signedFetch(fasp, "/provider_info", "GET"); - if (!res.ok) return null; - const info = await res.json() as { - capabilities?: Array<{ id: string; version: string }>; - [key: string]: unknown; - }; - if (Array.isArray(info.capabilities)) { - fasp.capabilities = info.capabilities.map( - (c: { id: string; version: string }) => { - const current = fasp.capabilities.find((d) => d.identifier === c.id); - return { - identifier: c.id, - version: c.version, - enabled: current ? current.enabled : false, - }; - }, - ); - await fasp.save(); - } - return info; -} - -export async function activateCapability( - id: string, - version: string, - enabled: boolean, -) { - const fasp = await Fasp.findOne({ accepted: true }) as unknown as - | FaspDoc - | null; - if (!fasp) return false; - const path = `/capabilities/${id}/${version}/activation`; - const method = enabled ? "POST" : "DELETE"; - const res = await signedFetch(fasp, path, method); - if (!res.ok) return false; - const idx = fasp.capabilities.findIndex((c) => - c.identifier === id && c.version === version - ); - if (idx >= 0) { - fasp.capabilities[idx].enabled = enabled; - } else { - fasp.capabilities.push({ identifier: id, version, enabled }); - } - await fasp.save(); - return true; -} - export async function sendAnnouncement( source: AnnouncementSource, category: "content" | "account", @@ -276,65 +224,3 @@ export async function sendAnnouncement( ); return res.ok; } - -export async function registerProvider(baseUrl: string, domain: string) { - let url: URL; - try { - url = new URL(baseUrl); - } catch { - return null; - } - if (!url.pathname || url.pathname === "/") { - url.pathname = "/fasp"; - } else if (!url.pathname.endsWith("/fasp")) { - url.pathname = url.pathname.replace(/\/?$/, "/fasp"); - } - const root = url.toString().replace(/\/$/, ""); - const infoRes = await fetch(`${root}/provider_info`); - if (!infoRes.ok) return null; - const type = infoRes.headers.get("content-type") ?? ""; - if (!type.includes("application/json")) return null; - const info = await infoRes.json() as { name?: string }; - const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ - "sign", - "verify", - ]) as CryptoKeyPair; - const publicBytes = new Uint8Array( - await crypto.subtle.exportKey("raw", keyPair.publicKey), - ); - const privateBytes = new Uint8Array( - await crypto.subtle.exportKey("pkcs8", keyPair.privateKey), - ); - const publicKey = b64encode(publicBytes); - const privateKey = b64encode(privateBytes); - const serverId = crypto.randomUUID(); - const registrationBody = { - name: domain, - baseUrl: `https://${domain}/fasp`, - serverId, - publicKey, - }; - const regRes = await fetch(`${root}/registration`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(registrationBody), - }); - if (!regRes.ok) return null; - const data = await regRes.json() as { faspId: string; publicKey: string }; - const fasp = await Fasp.create({ - _id: data.faspId, - name: info.name ?? root, - baseUrl: root, - serverId, - faspPublicKey: data.publicKey, - publicKey, - privateKey, - accepted: true, - }); - fasp.communications.push( - { direction: "out", endpoint: "/registration", payload: registrationBody }, - { direction: "in", endpoint: "/registration", payload: data }, - ); - await fasp.save(); - return fasp; -} diff --git a/app/client/src/components/Setting/FaspSettings.tsx b/app/client/src/components/Setting/FaspSettings.tsx deleted file mode 100644 index 275c3ede8..000000000 --- a/app/client/src/components/Setting/FaspSettings.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { Component, createResource, createSignal, For, Show } from "solid-js"; -import { apiFetch } from "../../utils/config.ts"; - -interface Capability { - identifier: string; - version: string; - enabled: boolean; -} - -interface FaspItem { - _id: string; - name: string; - baseUrl: string; - accepted: boolean; - capabilities: Capability[]; -} - -async function fetchFasps(): Promise { - const res = await apiFetch("/admin/fasps"); - if (!res.ok) return []; - const data = await res.json() as { fasps: FaspItem[] }; - return data.fasps; -} - -const FaspSettings: Component = () => { - const [fasps, { refetch }] = createResource(fetchFasps); - const [loadingInfo, setLoadingInfo] = createSignal(false); - const [newUrl, setNewUrl] = createSignal(""); - const [adding, setAdding] = createSignal(false); - - const refreshInfo = async () => { - setLoadingInfo(true); - try { - await apiFetch("/admin/fasps/provider_info"); - } finally { - setLoadingInfo(false); - refetch(); - } - }; - - const accept = async (id: string) => { - await apiFetch(`/admin/fasps/${id}/accept`, { method: "POST" }); - refetch(); - }; - - const remove = async (id: string) => { - await apiFetch(`/admin/fasps/${id}`, { method: "DELETE" }); - refetch(); - }; - - const toggleCapability = async ( - id: string, - version: string, - enabled: boolean, - ) => { - const method = enabled ? "POST" : "DELETE"; - await apiFetch( - `/admin/fasps/capabilities/${id}/${version}/activation`, - { method }, - ); - refetch(); - }; - - const addServer = async () => { - if (!newUrl()) return; - setAdding(true); - try { - await apiFetch("/admin/fasps", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ baseUrl: newUrl() }), - }); - setNewUrl(""); - refetch(); - } finally { - setAdding(false); - } - }; - - return ( -
-
-

FASP 設定

- -
-
- setNewUrl(e.currentTarget.value)} - /> - -
- - {(f) => ( -
-
-
-

{f.name}

-

{f.baseUrl}

-
- accept(f._id)} - > - 承認 - - } - > - - -
- -
-

利用可能なCapability

-
    - - {(c) => ( -
  • - - toggleCapability( - c.identifier, - c.version, - e.currentTarget.checked, - )} - /> - {c.identifier} v{c.version} -
  • - )} -
    -
-
-
-
- )} -
-
- ); -}; - -export default FaspSettings; diff --git a/app/client/src/components/Setting/index.tsx b/app/client/src/components/Setting/index.tsx index 64dbf533d..c98efd722 100644 --- a/app/client/src/components/Setting/index.tsx +++ b/app/client/src/components/Setting/index.tsx @@ -5,7 +5,6 @@ import { } 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"; @@ -64,7 +63,6 @@ export function Setting(props: SettingProps) {
-
+ )} + +
+ + )} +
+ + +
+ ); +}; + +export default FaspSettings; diff --git a/app/client/src/components/Setting/index.tsx b/app/client/src/components/Setting/index.tsx index c98efd722..64dbf533d 100644 --- a/app/client/src/components/Setting/index.tsx +++ b/app/client/src/components/Setting/index.tsx @@ -5,6 +5,7 @@ import { } 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"; @@ -63,6 +64,7 @@ export function Setting(props: SettingProps) {
+