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/fasp_server.ts b/app/api/models/takos_host/fasp_server.ts new file mode 100644 index 000000000..6fd7fc814 --- /dev/null +++ b/app/api/models/takos_host/fasp_server.ts @@ -0,0 +1,12 @@ +import mongoose from "mongoose"; + +const faspServerSchema = new mongoose.Schema({ + serverId: { type: String, required: true }, + publicKey: { type: String, required: true }, + privateKey: { type: String, required: true }, +}); + +const HostFaspServer = mongoose.models.HostFaspServer ?? + mongoose.model("HostFaspServer", faspServerSchema); + +export default HostFaspServer; 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/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..3686ee31a --- /dev/null +++ b/app/api/routes/fasp/capabilities.ts @@ -0,0 +1,105 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { + 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 signResponse from "./utils.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 signResponse(null, 204, fasp._id, fasp.privateKey); +} + +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..590364f4a --- /dev/null +++ b/app/api/routes/fasp/data_sharing.ts @@ -0,0 +1,271 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +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)); + const digestB64 = b64encode(digest); + const digestOk = digestHeader === `sha-256=:${digestB64}:`; + 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 now = Math.floor(Date.now() / 1000); + const timeOk = Math.abs(now - created) <= 300; + 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 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 }; +} + +// 保留中のバックフィル要求を処理してアナウンスを送信する +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); + if (error) return error; + const body = JSON.parse(new TextDecoder().decode(raw)); + if (!body.category || !body.subscriptionType) { + return signResponse( + { error: "Invalid body" }, + 422, + fasp._id, + fasp.privateKey, + ); + } + 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 signResponse({ subscription: { id } }, 201, fasp._id, fasp.privateKey); +}); + +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 signResponse( + { error: "Invalid body" }, + 422, + fasp._id, + fasp.privateKey, + ); + } + 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(); + await processBackfillRequests(getEnv(c), getDomain(c)); + return signResponse( + { backfillRequest: { id } }, + 201, + fasp._id, + fasp.privateKey, + ); +}); + +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 signResponse(null, 204, fasp._id, fasp.privateKey); +}); + +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 signResponse( + { error: "Unknown backfill request" }, + 404, + fasp._id, + fasp.privateKey, + ); + } + req.status = "pending"; + fasp.communications.push({ + direction: "in", + endpoint: `/data_sharing/v0/backfill_requests/${id}/continuation`, + payload: null, + }); + await fasp.save(); + await processBackfillRequests(getEnv(c), getDomain(c)); + return signResponse(null, 204, fasp._id, fasp.privateKey); + }, +); + +export default app; diff --git a/app/api/routes/fasp/provider_info.ts b/app/api/routes/fasp/provider_info.ts new file mode 100644 index 000000000..ba4472f61 --- /dev/null +++ b/app/api/routes/fasp/provider_info.ts @@ -0,0 +1,46 @@ +import { Hono } from "hono"; +import { encodeBase64 } from "https://deno.land/std@0.224.0/encoding/base64.ts"; +import HostFaspServer from "../../models/takos_host/fasp_server.ts"; + +const app = new Hono(); + +app.get("/fasp", (c) => c.json({ error: "not found" }, 404)); + +app.get("/fasp/provider_info", async (c) => { + let conf = await HostFaspServer.findOne().lean< + { + serverId: string; + publicKey: string; + privateKey: string; + } | null + >(); + if (!conf) { + const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ]) as CryptoKeyPair; + const pub = new Uint8Array( + await crypto.subtle.exportKey("raw", pair.publicKey), + ); + const priv = new Uint8Array( + await crypto.subtle.exportKey("pkcs8", pair.privateKey), + ); + conf = { + serverId: crypto.randomUUID(), + publicKey: encodeBase64(pub), + privateKey: encodeBase64(priv), + }; + await HostFaspServer.create(conf); + } + return c.json({ + serverId: conf.serverId, + publicKey: conf.publicKey, + capabilities: [ + { identifier: "data_sharing", versions: ["v0"] }, + { identifier: "account_search", versions: ["v0"] }, + { identifier: "trends", versions: ["v0"] }, + ], + }); +}); + +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..7abbede6c --- /dev/null +++ b/app/api/routes/fasp/registration.ts @@ -0,0 +1,134 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { + 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"; +import signResponse from "./utils.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); + } + + // 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), + ); + 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}/fasp/registrations` + : ""; + + const body = { + faspId, + publicKey: myPublic, + registrationCompletionUri, + }; + return signResponse(body, 201, faspId, myPrivate); +}); + +export default app; diff --git a/app/api/routes/fasp/settings.ts b/app/api/routes/fasp/settings.ts new file mode 100644 index 000000000..322feb1db --- /dev/null +++ b/app/api/routes/fasp/settings.ts @@ -0,0 +1,94 @@ +import { Hono } from "hono"; +import { + decodeBase64, + encodeBase64, +} from "https://deno.land/std@0.224.0/encoding/base64.ts"; +import Fasp from "../../models/takos/fasp.ts"; +import authRequired from "../../utils/auth.ts"; + +const app = new Hono(); +app.use("/fasp", authRequired); +app.use("/fasp/*", authRequired); + +app.get("/fasp", async (c) => { + const fasps = await Fasp.find().lean(); + return c.json({ fasps }); +}); + +app.post("/fasp", async (c) => { + const body = await c.req.json(); + let { baseUrl } = body as { baseUrl: string }; + try { + const u = new URL(baseUrl); + if (!u.pathname.endsWith("/fasp")) { + u.pathname = u.pathname.replace(/\/$/, "") + "/fasp"; + } + baseUrl = u.toString().replace(/\/$/, ""); + } catch { + return c.json({ error: "invalid baseUrl" }, 400); + } + const domain = new URL(baseUrl).hostname; + let serverId: string; + let faspPublicKey: string; + try { + const res = await fetch(new URL("/provider_info", baseUrl)); + const info = await res.json(); + serverId = info.serverId ?? info.server_id ?? ""; + faspPublicKey = info.publicKey ?? info.public_key ?? ""; + } catch { + return c.json({ error: "failed to fetch provider info" }, 400); + } + if (!serverId || !faspPublicKey) { + return c.json({ error: "provider info missing" }, 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 myPublic = encodeBase64(pub); + const myPrivate = encodeBase64(priv); + const id = crypto.randomUUID(); + const fasp = await Fasp.create({ + _id: id, + name: domain, + baseUrl, + serverId, + faspPublicKey, + publicKey: myPublic, + privateKey: myPrivate, + accepted: false, + }); + fasp.communications.push({ + direction: "in", + endpoint: c.req.path, + payload: { baseUrl }, + }); + await fasp.save(); + return c.json({ id, publicKey: myPublic }, 201); +}); + +app.post("/fasp/:id/accept", async (c) => { + const id = c.req.param("id"); + const fasp = await Fasp.findById(id); + if (!fasp) return c.json({ error: "not found" }, 404); + fasp.accepted = true; + const pub = decodeBase64(fasp.faspPublicKey); + const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", pub)); + const fingerprint = encodeBase64(hash); + await fasp.save(); + return c.json({ fingerprint }); +}); + +app.delete("/fasp/:id", async (c) => { + const id = c.req.param("id"); + await Fasp.findByIdAndDelete(id); + return c.json({ ok: true }); +}); + +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/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; diff --git a/app/api/routes/nodeinfo.ts b/app/api/routes/nodeinfo.ts index 42304d5a7..65d68ce81 100644 --- a/app/api/routes/nodeinfo.ts +++ b/app/api/routes/nodeinfo.ts @@ -30,6 +30,9 @@ 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 domain = getDomain(c); + metadata.faspBaseUrl = `https://${domain}/api/fasp`; return c.json({ version: "2.0", software: { @@ -43,7 +46,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..58d5c8109 100644 --- a/app/api/server.ts +++ b/app/api/server.ts @@ -28,6 +28,14 @@ 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 faspAnnouncements from "./routes/fasp/announcements.ts"; +import faspProviderInfo from "./routes/fasp/provider_info.ts"; +import faspSettings from "./routes/fasp/settings.ts"; import { fetchOgpData } from "./services/ogp.ts"; import { serveStatic } from "hono/deno"; import type { Context } from "hono"; @@ -74,15 +82,24 @@ export async function createTakosApp(env?: Record) { relays, users, e2ee, + faspSettings, + faspRegistration, + faspCapabilities, + faspDataSharing, + faspAccountSearch, + faspTrends, + faspAnnouncements, + faspProviderInfo, ]; for (const r of apiRoutes) { app.route("/api", r); } - // ActivityPub ルートは / のみにマウントする - - const rootRoutes = [nodeinfo, activitypub, rootInbox]; - // e2ee ルートは /api のみで提供し、ActivityPub ルートと競合しないようにする + const rootRoutes = [ + nodeinfo, + activitypub, + rootInbox, + ]; 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..679387dfb --- /dev/null +++ b/app/api/services/fasp.ts @@ -0,0 +1,226 @@ +import { + decodeBase64 as b64decode, + encodeBase64 as b64encode, +} from "https://deno.land/std@0.224.0/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 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; + const inputMatch = sigInputHeader.match( + /^sig1=\(([^)]+)\);created=(\d+);keyid="([^"]+)"/, + ); + const sigMatch = sigHeader.match(/^sig1=:([^:]+):/); + if (inputMatch && sigMatch) { + const [, paramStr2, created2, keyId] = inputMatch; + 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", + 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, + signatureVerified: verified, + digestVerified, + }, + }); + await fasp.save(); + if (!verified || !digestVerified) { + 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 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/client/public/env.mjs b/app/client/public/env.mjs new file mode 100644 index 000000000..cf4364917 --- /dev/null +++ b/app/client/public/env.mjs @@ -0,0 +1,5 @@ +// 自動生成されるenv.mjsが__DEFINES__を期待するため +// グローバルに空の定義を用意し、エラーを防ぐ +globalThis.__DEFINES__ ??= {}; +export const env = globalThis.__DEFINES__; +export default env; diff --git a/app/client/src/components/Setting/FaspSettings.tsx b/app/client/src/components/Setting/FaspSettings.tsx new file mode 100644 index 000000000..15a2ca65c --- /dev/null +++ b/app/client/src/components/Setting/FaspSettings.tsx @@ -0,0 +1,116 @@ +import { createSignal, For, onMount } from "solid-js"; +import { apiFetch } from "../../utils/config.ts"; + +interface Fasp { + _id: string; + name: string; + baseUrl: string; + accepted: boolean; +} + +const FaspSettings = () => { + const [fasps, setFasps] = createSignal([]); + const [baseUrl, setBaseUrl] = createSignal(""); + + const load = async () => { + try { + const res = await apiFetch("/api/fasp"); + const data = (await res.json()) as { fasps: Fasp[] }; + setFasps(data.fasps); + } catch (err) { + console.error("failed to fetch fasps", err); + } + }; + + const accept = async (id: string) => { + try { + await apiFetch(`/api/fasp/${id}/accept`, { method: "POST" }); + await load(); + } catch (err) { + console.error("accept failed", err); + } + }; + + const remove = async (id: string) => { + try { + await apiFetch(`/api/fasp/${id}`, { method: "DELETE" }); + await load(); + } catch (err) { + console.error("delete failed", err); + } + }; + + const add = async () => { + try { + await apiFetch("/api/fasp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ baseUrl: baseUrl() }), + }); + setBaseUrl(""); + await load(); + } catch (err) { + console.error("add failed", err); + } + }; + + onMount(load); + + return ( +
+

FASP 設定

+
+ setBaseUrl(e.currentTarget.value)} + /> + +
+
    + + {(f) => ( +
  • +
    {f.name}
    +
    {f.baseUrl}
    +
    + {!f.accepted && ( + + )} + +
    +
  • + )} +
    +
+ +
+ ); +}; + +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) { +