diff --git a/app/api/DB/mod.ts b/app/api/DB/mod.ts index 9446a576c..5df774064 100644 --- a/app/api/DB/mod.ts +++ b/app/api/DB/mod.ts @@ -2,16 +2,10 @@ import type { DB } from "../../shared/db.ts"; import { MongoDB, startInactiveSessionJob, - startKeyPackageCleanupJob, startPendingInviteJob, } from "./mongo.ts"; -export { - MongoDB, - startInactiveSessionJob, - startKeyPackageCleanupJob, - startPendingInviteJob, -}; +export { MongoDB, startInactiveSessionJob, startPendingInviteJob }; /** MongoDB 実装を生成する */ export function createDB(env: Record): DB { diff --git a/app/api/DB/mongo.ts b/app/api/DB/mongo.ts index 62433813e..a9925ac6e 100644 --- a/app/api/DB/mongo.ts +++ b/app/api/DB/mongo.ts @@ -4,26 +4,21 @@ import Attachment from "../models/takos/attachment.ts"; import FollowEdge from "../models/takos/follow_edge.ts"; import { createObjectId } from "../utils/activitypub.ts"; import Account from "../models/takos/account.ts"; -import Chatroom from "../models/takos/chatroom.ts"; import EncryptedKeyPair from "../models/takos/encrypted_keypair.ts"; -import EncryptedMessage from "../models/takos/encrypted_message.ts"; -import KeyPackage from "../models/takos/key_package.ts"; import Notification from "../models/takos/notification.ts"; import SystemKey from "../models/takos/system_key.ts"; import RemoteActor from "../models/takos/remote_actor.ts"; import Session from "../models/takos/session.ts"; import FcmToken from "../models/takos/fcm_token.ts"; import HostFcmToken from "../models/takos_host/fcm_token.ts"; -import HandshakeMessage from "../models/takos/handshake_message.ts"; -import HostHandshakeMessage from "../models/takos_host/handshake_message.ts"; import PendingInvite from "../models/takos/pending_invite.ts"; +import DMMessage from "../models/takos/dm_message.ts"; import Instance from "../../takos_host/models/instance.ts"; import OAuthClient from "../../takos_host/models/oauth_client.ts"; import HostDomain from "../../takos_host/models/domain.ts"; import Tenant from "../models/takos/tenant.ts"; import mongoose from "mongoose"; -// chatroom メンバー管理ロジック削除により activity 配信は未使用 -import type { ChatroomInfo, DB, ListOpts } from "../../shared/db.ts"; +import type { DB, ListOpts } from "../../shared/db.ts"; import type { AccountDoc, SessionDoc } from "../../shared/types.ts"; import type { SortOrder } from "mongoose"; import type { Db } from "mongodb"; @@ -249,66 +244,6 @@ export class MongoDB implements DB { return acc?.following ?? []; } - async listChatrooms(id: string) { - const query = this.withTenant(Chatroom.find({ owner: id })); - const rooms = await query.lean< - (ChatroomInfo & { owner: string })[] - >(); - return rooms.map(({ owner: _o, ...room }) => room); - } - - async listChatroomsByMember(_member: string) { - // 現状未実装: 空配列。lint 対策のため await を挿入。 - await Promise.resolve(); - return [] as ChatroomInfo[]; - } - - async addChatroom( - id: string, - room: ChatroomInfo, - ) { - const doc = new Chatroom({ - owner: id, - ...room, - }); - if (this.env["DB_MODE"] === "host") { - (doc as unknown as { $locals?: { env?: Record } }) - .$locals = { env: this.env }; - } - await doc.save(); - return await this.listChatrooms(id); - } - - async removeChatroom(id: string, roomId: string) { - const query = Chatroom.deleteOne({ owner: id, id: roomId }); - this.withTenant(query); - await query; - return await this.listChatrooms(id); - } - - async findChatroom(roomId: string) { - const query = this.withTenant(Chatroom.findOne({ id: roomId })); - const doc = await query.lean< - (ChatroomInfo & { owner: string }) | null - >(); - if (doc) { - const { owner, ...room } = doc; - return { owner, room }; - } - // 履歴からの補完は行わない - return null; - } - - async updateChatroom( - owner: string, - room: ChatroomInfo, - ) { - // もはや更新対象フィールドが無いので no-op - const query = Chatroom.updateOne({ owner, id: room.id }, { $set: {} }); - this.withTenant(query); - await query; - } - async saveNote( domain: string, author: string, @@ -422,6 +357,28 @@ export class MongoDB implements DB { return await query.sort(sort ?? {}).lean(); } + async saveDMMessage(from: string, to: string, content: string) { + const doc = new DMMessage({ from, to, content }); + if (this.env["DB_MODE"] === "host") { + (doc as unknown as { $locals?: { env?: Record } }) + .$locals = { env: this.env }; + } + await doc.save(); + return doc.toObject(); + } + + async listDMsBetween(user1: string, user2: string) { + const query = this.withTenant( + DMMessage.find({ + $or: [ + { from: user1, to: user2 }, + { from: user2, to: user1 }, + ], + }), + ); + return await query.sort({ createdAt: 1 }).lean(); + } + async findObjects( filter: Record, sort?: Record, @@ -531,48 +488,6 @@ export class MongoDB implements DB { return await query; } - async createEncryptedMessage(data: { - roomId?: string; - from: string; - to: string[]; - content: string; - mediaType?: string; - encoding?: string; - }) { - const doc = new EncryptedMessage({ - roomId: data.roomId, - from: data.from, - to: data.to, - content: data.content, - mediaType: data.mediaType ?? "message/mls", - encoding: data.encoding ?? "base64", - }); - if (this.env["DB_MODE"] === "host") { - (doc as unknown as { $locals?: { env?: Record } }) - .$locals = { env: this.env }; - } - await doc.save(); - return doc.toObject(); - } - - async findEncryptedMessages( - condition: Record, - opts: { before?: string; after?: string; limit?: number } = {}, - ) { - const query = this.withTenant(EncryptedMessage.find(condition)); - if (opts.before) { - query.where("createdAt").lt(new Date(opts.before) as unknown as number); - } - if (opts.after) { - query.where("createdAt").gt(new Date(opts.after) as unknown as number); - } - const list = await query - .sort({ createdAt: -1 }) - .limit(opts.limit ?? 50) - .lean(); - return list; - } - async findEncryptedKeyPair(userName: string, deviceId: string) { const query = this.withTenant( EncryptedKeyPair.findOne({ userName, deviceId }), @@ -606,161 +521,6 @@ export class MongoDB implements DB { await query; } - async listKeyPackages(userName: string) { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const query = this.withTenant(KeyPackage.find({ - userName, - tenant_id: tenantId, - used: false, - })); - return await query.lean(); - } - - // KeyPackage の残数と lastResort の有無を取得 - async summaryKeyPackages(userName: string) { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const base = { userName, tenant_id: tenantId, used: false }; - const countQuery = this.withTenant( - KeyPackage.countDocuments({ ...base, lastResort: { $ne: true } }), - ); - const count = await countQuery; - const lastResortQuery = this.withTenant( - KeyPackage.exists({ ...base, lastResort: true }), - ); - const hasLastResort = await lastResortQuery; - return { count, hasLastResort: !!hasLastResort }; - } - - async findKeyPackage(userName: string, id: string) { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const query = this.withTenant( - KeyPackage.findOne({ _id: id, userName, tenant_id: tenantId }), - ); - return await query.lean(); - } - - async createKeyPackage( - userName: string, - content: string, - mediaType = "message/mls", - encoding = "base64", - groupInfo?: string, - expiresAt?: Date, - deviceId?: string, - version?: string, - cipherSuite?: number, - generator?: string | { id: string; type: string; name: string }, - id?: string, - lastResort?: boolean, - ) { - // keyPackageRef: sha256 of decoded content (raw KeyPackage bytes) - let keyPackageRef: string | undefined; - try { - const bin = atob(content); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - const hashBuf = await crypto.subtle.digest("SHA-256", bytes); - keyPackageRef = Array.from(new Uint8Array(hashBuf)).map((b) => - b.toString(16).padStart(2, "0") - ).join(""); - } catch (_e) { - // ignore hash errors (content may be invalid base64) – validation happens elsewhere - } - const doc = new KeyPackage({ - _id: id, - userName, - deviceId, - content, - mediaType, - encoding, - groupInfo, - expiresAt, - version, - cipherSuite, - generator, - keyPackageRef, - lastResort: lastResort ?? false, - tenant_id: this.env["ACTIVITYPUB_DOMAIN"] ?? "", - }); - if (this.env["DB_MODE"] === "host") { - (doc as unknown as { $locals?: { env?: Record } }) - .$locals = { - env: this.env, - }; - } - await doc.save(); - return doc.toObject(); - } - - async markKeyPackageUsed(userName: string, id: string) { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const query = KeyPackage.updateOne({ - _id: id, - userName, - tenant_id: tenantId, - }, { - used: true, - }); - this.withTenant(query); - await query; - } - - async markKeyPackageUsedByRef(userName: string, keyPackageRef: string) { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const query = KeyPackage.updateOne({ - userName, - keyPackageRef, - tenant_id: tenantId, - used: false, - }, { used: true }); - this.withTenant(query as unknown as mongoose.Query); - await query; - } - - async cleanupKeyPackages(userName: string) { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const deviceQuery = EncryptedKeyPair.find({ - userName, - tenant_id: tenantId, - }).distinct("deviceId"); - this.withTenant(deviceQuery as unknown as mongoose.Query); - const devices = await deviceQuery as unknown as string[]; - const query = KeyPackage.deleteMany({ - userName, - tenant_id: tenantId, - $or: [ - { used: true }, - { expiresAt: { $lt: new Date() } }, - // lastResort かつ未使用は保持 - { - $and: [{ deviceId: { $nin: devices } }, { - $or: [{ lastResort: { $ne: true } }, { used: true }], - }], - }, - ], - }); - this.withTenant(query); - await query; - } - - async deleteKeyPackage(userName: string, id: string) { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const query = KeyPackage.deleteOne({ - _id: id, - userName, - tenant_id: tenantId, - }); - this.withTenant(query); - await query; - } - - async deleteKeyPackagesByUser(userName: string) { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const query = KeyPackage.deleteMany({ userName, tenant_id: tenantId }); - this.withTenant(query); - await query; - } - async savePendingInvite( roomId: string, userName: string, @@ -805,56 +565,6 @@ export class MongoDB implements DB { await query; } - async createHandshakeMessage(data: { - roomId?: string; - sender: string; - recipients: string[]; - message: string; - }): Promise { - const Model = this.env["DB_MODE"] === "host" - ? HostHandshakeMessage - : HandshakeMessage; - const doc = new Model({ - roomId: data.roomId, - sender: data.sender, - recipients: data.recipients, - message: data.message, - tenant_id: this.env["ACTIVITYPUB_DOMAIN"] ?? "", - }); - if (this.env["DB_MODE"] === "host") { - (doc as unknown as { $locals?: { env?: Record } }) - .$locals = { - env: this.env, - }; - } - await doc.save(); - return doc.toObject() as unknown; - } - - async findHandshakeMessages( - condition: Record, - opts: { before?: string; after?: string; limit?: number } = {}, - ): Promise { - const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; - const Model = this.env["DB_MODE"] === "host" - ? HostHandshakeMessage - : HandshakeMessage; - const query = this.withTenant( - Model.find({ ...condition, tenant_id: tenantId }), - ); - if (opts.before) { - query.where("createdAt").lt(new Date(opts.before) as unknown as number); - } - if (opts.after) { - query.where("createdAt").gt(new Date(opts.after) as unknown as number); - } - const list = await query - .sort({ createdAt: -1 }) - .limit(opts.limit ?? 50) - .lean(); - return list as unknown[]; - } - async listNotifications(owner: string) { const tenantId = this.env["ACTIVITYPUB_DOMAIN"] ?? ""; const query = this.withTenant( @@ -1254,25 +964,6 @@ export function startPendingInviteJob(env: Record) { setInterval(job, 60 * 60 * 1000); } -/** - * KeyPackage コレクションの expiresAt を監視し、 - * 有効期限切れのエントリを定期的に削除します。 - */ -export function startKeyPackageCleanupJob(env: Record) { - async function job() { - const tenantId = env["ACTIVITYPUB_DOMAIN"] ?? ""; - const query = KeyPackage.deleteMany({ - tenant_id: tenantId, - expiresAt: { $lt: new Date() }, - }); - if (env["DB_MODE"] === "host") { - query.setOptions({ $locals: { env } }); - } - await query.catch((err) => console.error("KeyPackage cleanup failed", err)); - } - setInterval(job, 60 * 60 * 1000); -} - /** * セッションの最終利用日時を監視し、長期間活動のない端末を * チャットルームから除籍して再招待します。 diff --git a/app/api/activity_handlers.ts b/app/api/activity_handlers.ts index 6029e48c7..72b696f75 100644 --- a/app/api/activity_handlers.ts +++ b/app/api/activity_handlers.ts @@ -8,8 +8,6 @@ import { } from "./utils/activitypub.ts"; import { broadcast, sendToUser } from "./routes/ws.ts"; import { formatUserInfoForPost, getUserInfo } from "./services/user-info.ts"; -import { decodeMlsMessage } from "https://esm.sh/ts-mls@1.1.0" -// MLS関連データは検証せずそのまま保持する function iriToHandle(iri: string): string { try { @@ -22,13 +20,6 @@ function iriToHandle(iri: string): string { } } -function b64ToBytes(b64: string): Uint8Array { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - export type ActivityHandler = ( activity: Record, username: string, @@ -87,173 +78,35 @@ export const activityHandlers: Record = { username: string, c: unknown, ) { - if (typeof activity.object === "object" && activity.object !== null) { - const obj = activity.object as Record; - const objTypes = Array.isArray(obj.type) ? obj.type : [obj.type]; - const isMLS = objTypes.includes("PublicMessage") || - objTypes.includes("PrivateMessage") || - objTypes.includes("Welcome"); - if (isMLS && typeof obj.content === "string") { - const mediaType = typeof obj.mediaType === "string" - ? obj.mediaType - : "message/mls"; - const encoding = typeof obj.encoding === "string" - ? obj.encoding - : "base64"; - // mediaType / encoding の仕様チェック: 期待値以外は保存せずエラー扱い - if (mediaType !== "message/mls" || encoding !== "base64") { - console.error( - "Unsupported MLS message format", - { mediaType, encoding, types: objTypes }, - ); - return; // 仕様外メッセージは保存しない - } - const toRaw = Array.isArray(activity.to) - ? activity.to - : activity.to - ? [activity.to] - : []; - const objToRaw = Array.isArray(obj.to) - ? obj.to - : obj.to - ? [obj.to] - : []; - // 宛先リストにコレクション URI (as:Public や /followers) が含まれている場合は拒否 - const allRecipientRaw = [...toRaw, ...objToRaw].filter(( - v, - ): v is string => typeof v === "string"); - const hasCollectionRecipient = allRecipientRaw.some((iri) => { - if (iri === "as:Public") return true; - try { - const u = new URL(iri); - const parts = u.pathname.split("/").filter(Boolean); - if (parts.includes("followers")) return true; - } catch { - // 非URLの文字列もチェック(as:Public 以外の拡張が来る可能性) - if (typeof iri === "string" && iri.includes("/followers")) { - return true; - } - } - return false; - }); - if (hasCollectionRecipient) { - // c が Hono の Context ならエラーレスポンスを返す - if (c && typeof (c as Context).json === "function") { - return (c as Context).json({ error: "invalid recipients" }, 400); - } - console.error("Rejected MLS message due to collection recipient", { - recipients: allRecipientRaw, - }); - return; - } - const recipients = [...toRaw, ...objToRaw] - .filter((v): v is string => typeof v === "string") - .map(iriToHandle); - const from = typeof activity.actor === "string" - ? iriToHandle(activity.actor) - : username; - const env = (c as { get: (k: string) => unknown }).get( - "env", - ) as Record; - const db = createDB(env); - const domain = getDomain(c as Context); - const selfHandle = `${username}@${domain}`; - const roomId = typeof obj.roomId === "string" ? obj.roomId : undefined; + if (typeof activity.object !== "object" || activity.object === null) { + return; + } + const obj = activity.object as Record; + const objTypes = Array.isArray(obj.type) ? obj.type : [obj.type]; + const toList = Array.isArray(obj.to) + ? obj.to + : typeof obj.to === "string" + ? [obj.to] + : []; - if (objTypes.includes("PrivateMessage")) { - const msg = await db.createEncryptedMessage({ - roomId, - from, - to: recipients, - content: obj.content, - mediaType, - encoding, - }) as { - _id: unknown; - roomId?: string; - from: string; - to: string[]; - content: string; - mediaType: string; - encoding: string; - createdAt: unknown; - }; - // REST-first: WS は本文を送らず「更新あり」の最小通知のみ送る - const newMsgMeta = { - id: String(msg._id), - roomId: msg.roomId, - from, - to: recipients, - createdAt: msg.createdAt, - }; - // 通知は軽量化: 種別と参照IDのみ送る - sendToUser(selfHandle, { - type: "hasUpdate", - payload: { kind: "encryptedMessage", id: newMsgMeta.id }, - }); - } else { - const msg = await db.createHandshakeMessage({ - roomId, - sender: from, - recipients, - message: obj.content, - }) as { - _id: unknown; - roomId?: string; - sender: string; - recipients: string[]; - message: string; - createdAt: unknown; - }; - const newMsgMeta = { - id: String(msg._id), - roomId: msg.roomId, - sender: from, - recipients, - createdAt: msg.createdAt, - }; - // Handshake系は minimal notification - sendToUser(selfHandle, { - type: "hasUpdate", - payload: { kind: "handshake", id: newMsgMeta.id }, - }); - // Welcome オブジェクトを受信した場合はサーバー側で通知を作成(ローカル受信者のみ) - if (objTypes.includes("Welcome")) { - if (roomId) { - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - await db.savePendingInvite(roomId, username, "", expiresAt); - // pending invite の存在を最小通知で送る - sendToUser(selfHandle, { - type: "hasUpdate", - payload: { kind: "pendingInvite", roomId }, - }); - } - try { - const acc = await db.findAccountByUserName(username); - if (acc && acc._id) { - await db.createNotification( - String(acc._id), - "会話招待", - JSON.stringify({ - kind: "chat-invite", - roomId: msg.roomId, - sender: from, - }), - "chat-invite", - ); - // 通知作成の存在を最小通知で送る(詳細は /api/notifications で取得する想定) - sendToUser(selfHandle, { - type: "hasUpdate", - payload: { kind: "notification", type: "chat-invite" }, - }); - } - } catch (e) { - console.error("failed to create welcome notification", e); - } - } + // to が1件の Note は DM とみなす + if (objTypes.includes("Note") && toList.length === 1) { + const target = toList[0]; + const isCollection = (url: string): boolean => { + if (url === "https://www.w3.org/ns/activitystreams#Public") return true; + try { + const path = new URL(url).pathname; + return path.endsWith("/followers") || + path.endsWith("/following") || + path.endsWith("/outbox") || + path.endsWith("/collections") || + path.endsWith("/liked") || + path.endsWith("/likes"); + } catch { + return false; } - return; - } + }; + if (isCollection(target)) return; const actor = typeof activity.actor === "string" ? activity.actor @@ -262,30 +115,76 @@ export const activityHandlers: Record = { string, string >; - const saved = await saveObject( - env, - obj, - actor, - ); const domain = getDomain(c as Context); - const userInfo = await getUserInfo( - (saved.actor_id as string) ?? actor, + const db = createDB(env); + const msg = await db.saveMessage( domain, - env, - ); - const formatted = formatUserInfoForPost( - userInfo, - saved, - ); - broadcast({ - type: "newPost", - payload: { timeline: "latest", post: formatted }, - }); - sendToUser(`${username}@${domain}`, { - type: "newPost", - payload: { timeline: "following", post: formatted }, - }); + actor, + typeof obj.content === "string" ? obj.content : "", + typeof obj.extra === "object" && obj.extra !== null + ? obj.extra as Record + : {}, + { to: toList, cc: Array.isArray(obj.cc) ? obj.cc : [] }, + ) as { _id: unknown }; + + const fromHandle = iriToHandle(actor); + const toHandle = iriToHandle(target); + const payload = { + id: String(msg._id), + from: fromHandle, + to: toHandle, + content: typeof obj.content === "string" ? obj.content : "", + }; + sendToUser(toHandle, { type: "dm", payload }); + sendToUser(fromHandle, { type: "dm", payload }); + + const targetHost = (() => { + try { + return new URL(target).hostname; + } catch { + return ""; + } + })(); + if (targetHost && targetHost !== domain) { + deliverActivityPubObject([target], activity, actor, domain, env).catch( + (err) => { + console.error("Delivery failed:", err); + }, + ); + } + return; } + + const actor = typeof activity.actor === "string" + ? activity.actor + : username; + const env = (c as { get: (k: string) => unknown }).get("env") as Record< + string, + string + >; + const saved = await saveObject( + env, + obj, + actor, + ); + const domain = getDomain(c as Context); + const userInfo = await getUserInfo( + (saved.actor_id as string) ?? actor, + domain, + env, + ); + const formatted = formatUserInfoForPost( + userInfo, + saved, + ); + broadcast({ + type: "newPost", + payload: { timeline: "latest", post: formatted }, + }); + sendToUser(`${username}@${domain}`, { + type: "newPost", + payload: { timeline: "following", post: formatted }, + }); }, async Follow( @@ -319,152 +218,4 @@ export const activityHandlers: Record = { }, ); }, - - async Add( - activity: Record, - username: string, - c: unknown, - ) { - if (typeof activity.object !== "object" || activity.object === null) { - return; - } - const obj = activity.object as Record; - const objTypes = Array.isArray(obj.type) ? obj.type : [obj.type]; - if (!objTypes.includes("KeyPackage") || typeof obj.content !== "string") { - return; - } - const env = (c as { get: (k: string) => unknown }).get("env") as Record< - string, - string - >; - const db = createDB(env); - const actorUrl = typeof activity.actor === "string" - ? activity.actor - : undefined; - const actor = actorUrl ? iriToHandle(actorUrl) : username; - const mediaType = typeof obj.mediaType === "string" - ? obj.mediaType - : "message/mls"; - const encoding = typeof obj.encoding === "string" ? obj.encoding : "base64"; - const groupInfo = typeof obj.groupInfo === "string" - ? obj.groupInfo - : undefined; - const expiresAt = typeof obj.expiresAt === "string" - ? new Date(obj.expiresAt) - : obj.expiresAt instanceof Date - ? obj.expiresAt - : undefined; - const deviceId = typeof obj.deviceId === "string" - ? obj.deviceId - : undefined; - const version = typeof obj.version === "string" ? obj.version : undefined; - const cipherSuite = typeof obj.cipherSuite === "number" - ? obj.cipherSuite - : typeof (obj as { cipher_suite?: number }).cipher_suite === "number" - ? (obj as { cipher_suite: number }).cipher_suite - : undefined; - const generator = typeof obj.generator === "object" && obj.generator && - typeof (obj.generator as { id?: unknown }).id === "string" && - typeof (obj.generator as { type?: unknown }).type === "string" && - typeof (obj.generator as { name?: unknown }).name === "string" - ? { - id: (obj.generator as { id: string }).id, - type: (obj.generator as { type: string }).type, - name: (obj.generator as { name: string }).name, - } - : typeof obj.generator === "string" - ? { id: obj.generator, type: "Application", name: obj.generator } - : undefined; - const keyId = typeof obj.id === "string" - ? obj.id.split("/").pop() - : undefined; - // KeyPackage の BasicCredential.identity を検証 - let identity: string | null = null; - try { - const decoded = decodeMlsMessage(b64ToBytes(obj.content), 0)?.[0] as - | { - wireformat?: string; - keyPackage?: { - leafNode?: { - credential?: { - credentialType?: string; - identity?: Uint8Array; - }; - }; - }; - } - | undefined; - if ( - decoded && - decoded.wireformat === "mls_key_package" && - decoded.keyPackage?.leafNode?.credential?.credentialType === "basic" && - decoded.keyPackage?.leafNode?.credential?.identity - ) { - identity = new TextDecoder().decode( - decoded.keyPackage.leafNode.credential.identity, - ); - } - } catch (err) { - console.error("KeyPackage verification failed", err); - return; - } - if (!identity || identity !== actorUrl) { - console.error("KeyPackage identity mismatch", identity, actorUrl); - return; - } - await db.createKeyPackage( - actor, - obj.content, - mediaType, - encoding, - groupInfo, - expiresAt, - deviceId, - version, - cipherSuite, - generator, - keyId, - ); - await db.cleanupKeyPackages(actor); - }, - - async Remove( - activity: Record, - username: string, - c: unknown, - ) { - if (typeof activity.object !== "string") return; - const keyId = activity.object.split("/").pop(); - if (!keyId) return; - const env = (c as { get: (k: string) => unknown }).get("env") as Record< - string, - string - >; - const db = createDB(env); - const actor = typeof activity.actor === "string" - ? iriToHandle(activity.actor) - : username; - await db.deleteKeyPackage(actor, keyId); - await db.cleanupKeyPackages(actor); - }, - - async Delete( - activity: Record, - username: string, - c: unknown, - ) { - if (typeof activity.object !== "string") return; - const keyId = activity.object.split("/").pop(); - if (!keyId) return; - const env = (c as { get: (k: string) => unknown }).get("env") as Record< - string, - string - >; - const db = createDB(env); - const actor = typeof activity.actor === "string" - ? iriToHandle(activity.actor) - : username; - await db.deleteKeyPackage(actor, keyId); - await db.cleanupKeyPackages(actor); - }, }; diff --git a/app/api/models/takos/dm_message.ts b/app/api/models/takos/dm_message.ts new file mode 100644 index 000000000..ee63d5fcb --- /dev/null +++ b/app/api/models/takos/dm_message.ts @@ -0,0 +1,17 @@ +import mongoose from "mongoose"; +import tenantScope from "../plugins/tenant_scope.ts"; + +const dmMessageSchema = new mongoose.Schema({ + from: { type: String, required: true, index: true }, + to: { type: String, required: true, index: true }, + content: { type: String, required: true }, + createdAt: { type: Date, default: Date.now }, +}); + +dmMessageSchema.plugin(tenantScope, { envKey: "ACTIVITYPUB_DOMAIN" }); + +const DMMessage = mongoose.models.DMMessage ?? + mongoose.model("DMMessage", dmMessageSchema, "dm_messages"); + +export default DMMessage; +export { dmMessageSchema }; diff --git a/app/api/models/takos/encrypted_message.ts b/app/api/models/takos/encrypted_message.ts deleted file mode 100644 index 45f104b15..000000000 --- a/app/api/models/takos/encrypted_message.ts +++ /dev/null @@ -1,20 +0,0 @@ -import mongoose from "mongoose"; -import tenantScope from "../plugins/tenant_scope.ts"; - -const encryptedMessageSchema = new mongoose.Schema({ - roomId: { type: String, index: true }, - from: { type: String, required: true }, - to: { type: [String], required: true }, - content: { type: String, required: true }, - mediaType: { type: String, default: "message/mls" }, - encoding: { type: String, default: "base64" }, - createdAt: { type: Date, default: Date.now }, -}); - -encryptedMessageSchema.plugin(tenantScope, { envKey: "ACTIVITYPUB_DOMAIN" }); - -const EncryptedMessage = mongoose.models.EncryptedMessage ?? - mongoose.model("EncryptedMessage", encryptedMessageSchema); - -export default EncryptedMessage; -export { encryptedMessageSchema }; diff --git a/app/api/models/takos/handshake_message.ts b/app/api/models/takos/handshake_message.ts deleted file mode 100644 index 4bc80c001..000000000 --- a/app/api/models/takos/handshake_message.ts +++ /dev/null @@ -1,24 +0,0 @@ -import mongoose from "mongoose"; -import tenantScope from "../plugins/tenant_scope.ts"; - -const handshakeMessageSchema = new mongoose.Schema({ - roomId: { type: String, index: true }, - sender: { type: String, required: true }, - recipients: { type: [String], required: true }, - message: { type: String, required: true }, - tenant_id: { type: String, index: true }, - createdAt: { type: Date, default: Date.now }, -}); - -handshakeMessageSchema.plugin(tenantScope, { envKey: "ACTIVITYPUB_DOMAIN" }); -handshakeMessageSchema.index({ roomId: 1, tenant_id: 1, createdAt: -1 }); - -const HandshakeMessage = mongoose.models.HandshakeMessage ?? - mongoose.model( - "HandshakeMessage", - handshakeMessageSchema, - "handshakemessages", - ); - -export default HandshakeMessage; -export { handshakeMessageSchema }; diff --git a/app/api/models/takos/key_package.ts b/app/api/models/takos/key_package.ts deleted file mode 100644 index 3dde24218..000000000 --- a/app/api/models/takos/key_package.ts +++ /dev/null @@ -1,34 +0,0 @@ -import mongoose from "mongoose"; -import tenantScope from "../plugins/tenant_scope.ts"; - -const keyPackageSchema = new mongoose.Schema({ - _id: { - type: String, - default: () => new mongoose.Types.ObjectId().toString(), - }, - userName: { type: String, required: true, index: true }, - deviceId: { type: String }, - content: { type: String, required: true }, - mediaType: { type: String, default: "message/mls" }, - encoding: { type: String, default: "base64" }, - // MLS KeyPackageRef (SHA-256 hash hex of the raw KeyPackage bytes) - keyPackageRef: { type: String, index: true }, - lastResort: { type: Boolean, default: false }, - groupInfo: { type: String }, - version: { type: String }, - cipherSuite: { type: Number }, - generator: { type: mongoose.Schema.Types.Mixed }, - used: { type: Boolean, default: false }, - expiresAt: { type: Date }, - tenant_id: { type: String, index: true }, - createdAt: { type: Date, default: Date.now }, -}); - -keyPackageSchema.plugin(tenantScope, { envKey: "ACTIVITYPUB_DOMAIN" }); -keyPackageSchema.index({ userName: 1, deviceId: 1, tenant_id: 1 }); - -const KeyPackage = mongoose.models.KeyPackage ?? - mongoose.model("KeyPackage", keyPackageSchema); - -export default KeyPackage; -export { keyPackageSchema }; diff --git a/app/api/models/takos_host/encrypted_message.ts b/app/api/models/takos_host/encrypted_message.ts deleted file mode 100644 index 9e390dffa..000000000 --- a/app/api/models/takos_host/encrypted_message.ts +++ /dev/null @@ -1,12 +0,0 @@ -import mongoose from "mongoose"; -import { encryptedMessageSchema } from "../takos/encrypted_message.ts"; - -const HostEncryptedMessage = mongoose.models.HostEncryptedMessage ?? - mongoose.model( - "HostEncryptedMessage", - encryptedMessageSchema, - "encryptedmessages", - ); - -export default HostEncryptedMessage; -export { encryptedMessageSchema }; diff --git a/app/api/models/takos_host/handshake_message.ts b/app/api/models/takos_host/handshake_message.ts deleted file mode 100644 index af573621a..000000000 --- a/app/api/models/takos_host/handshake_message.ts +++ /dev/null @@ -1,12 +0,0 @@ -import mongoose from "mongoose"; -import { handshakeMessageSchema } from "../takos/handshake_message.ts"; - -const HostHandshakeMessage = mongoose.models.HostHandshakeMessage ?? - mongoose.model( - "HostHandshakeMessage", - handshakeMessageSchema, - "handshakemessages", - ); - -export default HostHandshakeMessage; -export { handshakeMessageSchema }; diff --git a/app/api/models/takos_host/key_package.ts b/app/api/models/takos_host/key_package.ts deleted file mode 100644 index 0fab22c37..000000000 --- a/app/api/models/takos_host/key_package.ts +++ /dev/null @@ -1,8 +0,0 @@ -import mongoose from "mongoose"; -import { keyPackageSchema } from "../takos/key_package.ts"; - -const HostKeyPackage = mongoose.models.HostKeyPackage ?? - mongoose.model("HostKeyPackage", keyPackageSchema, "keypackages"); - -export default HostKeyPackage; -export { keyPackageSchema }; diff --git a/app/api/routes/activitypub.ts b/app/api/routes/activitypub.ts index b693ea713..3cc7a6b04 100644 --- a/app/api/routes/activitypub.ts +++ b/app/api/routes/activitypub.ts @@ -43,10 +43,6 @@ export async function getActivityPubFollowCollection( ); } -interface KeyPackageDoc { - _id: unknown; -} - app.get("/.well-known/webfinger", async (c) => { const resource = c.req.query("resource"); if (!resource?.startsWith("acct:")) { @@ -124,41 +120,6 @@ app.get("/users/:username", async (c) => { displayName: account.displayName, publicKey: account.publicKey, }); - const packages = await db.listKeyPackages(username) as KeyPackageDoc[]; - // Reduce packages to at most one per deviceId (choose the newest per device). - // If deviceId is missing, treat each package as coming from a unique source. - const packagesByDevice = new Map(); - for (const p of packages) { - const pp = p as unknown as Record; - const key = typeof pp.deviceId === "string" ? (pp.deviceId as string) : `__nodevice__:${String(pp._id)}`; - const prev = packagesByDevice.get(key) as Record | undefined; - if (!prev) { - packagesByDevice.set(key, p); - continue; - } - const prevCreated = prev.createdAt as string | number | Date | undefined; - const curCreated = pp.createdAt as string | number | Date | undefined; - const prevTime = prevCreated ? new Date(prevCreated).getTime() : 0; - const curTime = curCreated ? new Date(curCreated).getTime() : 0; - if (curTime > prevTime) packagesByDevice.set(key, p); - } - const publishedPackages = Array.from(packagesByDevice.values()); - - actor.keyPackages = { - type: "Collection", - id: `https://${domain}/users/${username}/keyPackages`, - totalItems: publishedPackages.length, - items: publishedPackages.map((p) => - `https://${domain}/users/${username}/keyPackages/${p._id}` - ), - }; - // keyPackages を含める場合は MLS コンテキストを追加 - const mls = "https://purl.archive.org/socialweb/mls"; - if (Array.isArray(actor["@context"])) { - if (!actor["@context"].includes(mls)) actor["@context"].push(mls); - } else if (actor["@context"] !== mls) { - actor["@context"] = [actor["@context"], mls]; - } return jsonResponse(c, actor, 200, "application/activity+json"); }); diff --git a/app/api/routes/dm.ts b/app/api/routes/dm.ts new file mode 100644 index 000000000..b6b38c8c9 --- /dev/null +++ b/app/api/routes/dm.ts @@ -0,0 +1,56 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { zValidator } from "@hono/zod-validator"; +import authRequired from "../utils/auth.ts"; +import { getEnv } from "../../shared/config.ts"; +import { createDB } from "../DB/mod.ts"; +import { sendToUser } from "./ws.ts"; + +// DM 用のシンプルな REST エンドポイント + +const app = new Hono(); +app.use("/dm/*", authRequired); + +app.post( + "/dm", + zValidator( + "json", + z.object({ + from: z.string(), + to: z.string(), + content: z.string(), + }), + ), + async (c) => { + const { from, to, content } = c.req.valid("json") as { + from: string; + to: string; + content: string; + }; + const db = createDB(getEnv(c)); + const doc = await db.saveDMMessage(from, to, content) as { _id: string }; + const payload = { id: doc._id, from, to, content }; + sendToUser(to, { type: "dm", payload }); + sendToUser(from, { type: "dm", payload }); + return c.json(payload); + }, +); + +app.get( + "/dm", + zValidator( + "query", + z.object({ user1: z.string(), user2: z.string() }), + ), + async (c) => { + const { user1, user2 } = c.req.valid("query") as { + user1: string; + user2: string; + }; + const db = createDB(getEnv(c)); + const messages = await db.listDMsBetween(user1, user2); + return c.json(messages); + }, +); + +export default app; diff --git a/app/api/routes/e2ee.ts b/app/api/routes/e2ee.ts deleted file mode 100644 index 2a27b3ac2..000000000 --- a/app/api/routes/e2ee.ts +++ /dev/null @@ -1,1397 +0,0 @@ -import { Hono } from "hono"; -import { getCookie } from "hono/cookie"; -import { createDB } from "../DB/mod.ts"; -import authRequired from "../utils/auth.ts"; -import { getEnv } from "../../shared/config.ts"; -import { rateLimit } from "../utils/rate_limit.ts"; -import { - type ActivityPubActor, - buildActivityFromStored, - createAddActivity, - createCreateActivity, - createDeleteActivity, - createObjectId, - createRemoveActivity, - deliverActivityPubObject, - fetchJson, - getDomain, - jsonResponse, - resolveActor, -} from "../utils/activitypub.ts"; -import { deliverToFollowers } from "../utils/deliver.ts"; -import { sendToUser } from "./ws.ts"; -import { decodeMlsMessage } from "https://esm.sh/ts-mls@1.1.0" -// MLS関連処理はクライアント側で完結するが、最低限の検証は行う - -interface ActivityPubActivity { - [key: string]: unknown; - "@context"?: unknown; - to?: unknown; - cc?: unknown; -} - -interface RemoteActorCache { - actorUrl: string; -} - -// KeyPackage 情報の簡易的な型定義 -interface GeneratorInfo { - id: string; - type: string; - name: string; -} - -export interface KeyPackageDoc { - _id?: unknown; - content: string; - mediaType: string; - encoding: string; - groupInfo?: string; - expiresAt?: unknown; - used?: boolean; - version?: string; - cipherSuite?: number; - generator?: string | GeneratorInfo; - deviceId?: string; - createdAt: string | number | Date; -} - -interface EncryptedKeyPairDoc { - content: string; -} - -interface EncryptedMessageDoc { - _id?: unknown; - roomId?: string; - from: string; - to: string[]; - content: string; - mediaType: string; - encoding: string; - createdAt: unknown; - extra?: { attachments?: unknown[] }; -} - -interface HandshakeMessageDoc { - _id?: unknown; - roomId?: string; - sender: string; - recipients: string[]; - message: string; - createdAt: unknown; -} - -function b64ToBytes(b64: string): Uint8Array { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -export async function registerKeyPackage( - env: Record, - domain: string, - db: ReturnType, - user: string, - data: Record, - sessionId?: string, - deliver: typeof deliverToFollowers = deliverToFollowers, -): Promise< - | { keyId: string; groupInfo?: string; keyPackageRef?: string } - | { error: string } -> { - const { - content, - mediaType, - encoding, - groupInfo, - expiresAt, - deviceId, - version, - cipherSuite, - generator, - lastResort, - } = data; - - if (typeof content !== "string") { - return { error: "content is required" }; - } - const mt = typeof mediaType === "string" && mediaType === "message/mls" - ? mediaType - : null; - if (!mt) { - return { error: 'mediaType must be "message/mls"' }; - } - const enc = typeof encoding === "string" && encoding === "base64" - ? encoding - : null; - if (!enc) { - return { error: 'encoding must be "base64"' }; - } - const actorId = `https://${domain}/users/${user}`; - try { - const decoded = decodeMlsMessage(b64ToBytes(content), 0)?.[0] as - | { - wireformat?: string; - keyPackage?: { - leafNode?: { - credential?: { - credentialType?: string; - identity?: Uint8Array; - }; - }; - }; - } - | undefined; - if ( - !decoded || - decoded.wireformat !== "mls_key_package" || - decoded.keyPackage?.leafNode?.credential?.credentialType !== "basic" || - !decoded.keyPackage?.leafNode?.credential?.identity - ) { - return { error: "ap_mls.binding.policy_violation" }; - } - const id = new TextDecoder().decode( - decoded.keyPackage.leafNode.credential.identity, - ); - if (id !== actorId) { - return { error: "ap_mls.binding.identity_mismatch" }; - } - } catch (_err) { - return { error: "ap_mls.binding.policy_violation" }; - } - const gi = typeof groupInfo === "string" ? groupInfo : undefined; - let genObj: GeneratorInfo | undefined; - if ( - typeof generator === "object" && generator && - typeof (generator as { id?: unknown }).id === "string" && - typeof (generator as { type?: unknown }).type === "string" && - typeof (generator as { name?: unknown }).name === "string" - ) { - genObj = { - id: (generator as { id: string }).id, - type: (generator as { type: string }).type, - name: (generator as { name: string }).name, - }; - } else if (typeof generator === "string") { - genObj = { id: generator, type: "Application", name: generator }; - } - // Resolve deviceId: prefer server-stored session.deviceId when sessionId is present. - let deviceIdToUse: string | undefined = typeof deviceId === "string" - ? deviceId - : undefined; - if (typeof sessionId === "string") { - try { - const sess = await db.findSessionById(sessionId); - if (sess && typeof sess.deviceId === "string") { - deviceIdToUse = sess.deviceId; - } - } catch (e) { - console.error("failed to resolve session for deviceId:", e); - } - } - const pkg = await db.createKeyPackage( - user, - content, - mt, - enc, - gi, - typeof expiresAt === "string" ? new Date(expiresAt) : undefined, - deviceIdToUse, - typeof version === "string" ? version : undefined, - typeof cipherSuite === "number" ? cipherSuite : undefined, - genObj, - undefined, - typeof lastResort === "boolean" ? lastResort : undefined, - ) as KeyPackageDoc; - const keyObj = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://purl.archive.org/socialweb/mls", - ], - id: `https://${domain}/users/${user}/keyPackages/${pkg._id}`, - type: ["Object", "KeyPackage"], - attributedTo: actorId, - to: ["https://www.w3.org/ns/activitystreams#Public"], - mediaType: pkg.mediaType, - encoding: pkg.encoding, - summary: - "This is binary-encoded cryptographic key package. See https://swicg.github.io/activitypub-e2ee/ …", - content: pkg.content, - groupInfo: pkg.groupInfo, - expiresAt: pkg.expiresAt, - version: pkg.version, - cipherSuite: pkg.cipherSuite, - generator: normalizeGenerator(pkg.generator), - keyPackageRef: (pkg as { keyPackageRef?: string }).keyPackageRef, - lastResort: (pkg as { lastResort?: boolean }).lastResort, - }; - try { - const bin = atob(pkg.content); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - const buf = await crypto.subtle.digest("SHA-256", bytes); - const toHex = (arr: Uint8Array) => - Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join(""); - const hash = toHex(new Uint8Array(buf)); - await fetch(`https://${domain}/.well-known/key-transparency/append`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - actor: actorId, - keyPackageUrl: keyObj.id, - keyPackageHash: hash, - }), - }); - } catch (err) { - console.error("KT append failed", err); - } - const createActivity = createCreateActivity(domain, actorId, keyObj); - (createActivity as ActivityPubActivity).cc = []; - await deliver(env, user, createActivity, domain); - const addActivity = createAddActivity( - domain, - actorId, - createActivity.id, - `https://${domain}/users/${user}/keyPackages`, - ); - await deliver(env, user, addActivity, domain); - return { - keyId: String(pkg._id), - groupInfo: pkg.groupInfo, - keyPackageRef: (pkg as { keyPackageRef?: string }).keyPackageRef, - }; -} - -async function resolveActorCached( - acct: string, - env: Record, -) { - const [name, host] = acct.split("@"); - if (!name || !host) return null; - - const db = createDB(env); - const cached = await db.findRemoteActorByUrl( - acct.startsWith("http") ? acct : "", - ) as RemoteActorCache | null; - - let actor: - | (ActivityPubActor & { keyPackages?: string | { id?: string } }) - | null = null; - if (cached) { - try { - actor = await fetchJson< - ActivityPubActor & { keyPackages?: string | { id?: string } } - >( - cached.actorUrl, - {}, - undefined, - env, - ); - } catch (err) { - console.error(`Failed to fetch cached actor ${cached.actorUrl}:`, err); - } - } - - if (!actor) { - actor = await resolveActor(name, host) as - | (ActivityPubActor & { keyPackages?: string | { id?: string } }) - | null; - if (actor) { - await db.upsertRemoteActor({ - actorUrl: actor.id, - name: actor.name || "", - preferredUsername: actor.preferredUsername || "", - icon: actor.icon || null, - summary: actor.summary || "", - }); - } - } - - return actor; -} - -// Resolve a list of recipient identifiers (http URLs, acct:..., or user@host) -// to ActivityPub actor IRIs. Unresolvable entries are returned in `unresolved`. -async function resolveRecipientsToActorIris( - recipients: string[], - env: Record, -): Promise<{ resolved: string[]; unresolved: string[] }> { - const resolved: string[] = []; - const unresolved: string[] = []; - - for (const r of recipients) { - if (!r || typeof r !== "string") continue; - // If it's already an absolute IRI, keep as-is - if (r.startsWith("http")) { - resolved.push(r); - continue; - } - - // Strip acct: prefix if present - let acct = r; - if (acct.startsWith("acct:")) acct = acct.slice(5); - - // Expect username@host - if (acct.includes("@")) { - try { - const actor = await resolveActorCached(acct, env); - if (actor && typeof actor.id === "string") { - resolved.push(actor.id); - continue; - } - } catch (err) { - console.error("resolveActorCached failed for", acct, err); - } - // Could not resolve -> exclude and mark unresolved - unresolved.push(r); - continue; - } - - // Unknown format -> mark unresolved - unresolved.push(r); - } - - return { resolved: Array.from(new Set(resolved)), unresolved }; -} - -// KeyPackage の選択ロジックをテストから利用できるよう公開 -export function selectKeyPackages( - list: KeyPackageDoc[], - suite: number, - M = 3, -): KeyPackageDoc[] { - return list - .filter((kp) => - kp.version === "1.0" && kp.cipherSuite === suite && kp.used !== true - ) - .sort((a, b) => { - const da = new Date(a.createdAt).getTime(); - const db = new Date(b.createdAt).getTime(); - return db - da; - }) - .reduce((acc: KeyPackageDoc[], kp) => { - const g = typeof kp.generator === "string" - ? kp.generator - : kp.generator?.id; - if ( - g && - acc.some((v) => { - const vg = typeof v.generator === "string" - ? v.generator - : v.generator?.id; - return vg === g; - }) - ) { - return acc; - } - acc.push(kp); - return acc; - }, []) - .slice(0, M); -} - -function normalizeGenerator( - gen?: string | GeneratorInfo, -): GeneratorInfo | undefined { - if (!gen) return undefined; - if (typeof gen === "string") { - return { id: gen, type: "Application", name: gen }; - } - return gen; -} - -const app = new Hono(); - -async function handleHandshake( - env: Record, - domain: string, - roomId: string, - body: Record, -): Promise< - | { ok: true; id: string } - | { ok: false; status: number; error: string } -> { - const { from, to, content, mediaType, encoding, attachments } = body as { - from?: unknown; - to?: unknown; - content?: unknown; - mediaType?: unknown; - encoding?: unknown; - attachments?: unknown; - }; - if (typeof from !== "string" || typeof content !== "string") { - return { ok: false, status: 400, error: "invalid body" }; - } - if (!Array.isArray(to) || to.some((v) => typeof v !== "string")) { - return { ok: false, status: 400, error: "invalid recipients" }; - } - if (mediaType !== undefined && mediaType !== "message/mls") { - return { ok: false, status: 400, error: "invalid mediaType" }; - } - if (encoding !== undefined && encoding !== "base64") { - return { ok: false, status: 400, error: "invalid encoding" }; - } - // Public や followers などのコレクション URI を拒否 - const hasCollection = (to as string[]).some((v) => { - if (v === "https://www.w3.org/ns/activitystreams#Public") return true; - if (v.includes("/followers") || v.includes("/following")) { - try { - const path = v.startsWith("http") ? new URL(v).pathname : v; - return path.endsWith("/followers") || path.endsWith("/following"); - } catch { - return true; - } - } - return false; - }); - if (hasCollection) { - return { ok: false, status: 400, error: "invalid recipients" }; - } - const [sender] = from.split("@"); - if (!sender) { - return { ok: false, status: 400, error: "invalid user format" }; - } - const context = Array.isArray(body["@context"]) - ? body["@context"] as string[] - : [ - "https://www.w3.org/ns/activitystreams", - "https://purl.archive.org/socialweb/mls", - ]; - const db = createDB(env); - const found = await db.findChatroom(roomId); - if (!found) return { ok: false, status: 404, error: "room not found" }; - // 宛先はクライアント(MLS ロスター)から供給されたものを使用 - const recipients = Array.from( - new Set((to as string[]).filter((m) => m && m !== from)), - ); - if (recipients.length === 0) { - return { ok: false, status: 400, error: "no recipients" }; - } - - const mType = typeof mediaType === "string" ? mediaType : "message/mls"; - const encType = typeof encoding === "string" ? encoding : "base64"; - - // --- MLS TLV デコードで種別判定 (client の mls_message.ts と整合) --- - function decodeMlsEnvelope( - b64: string, - ): { type: string; originalType: string; body: Uint8Array } | null { - try { - const raw = atob(b64); - const bin = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) bin[i] = raw.charCodeAt(i); - if (bin.length < 3) return null; - const typeByte = bin[0]; - const len = (bin[1] << 8) | bin[2]; - if (bin.length < 3 + len) return null; - const typeMap: Record = { - 1: "PublicMessage", - 2: "PrivateMessage", - 3: "Welcome", - 4: "KeyPackage", - 5: "Commit", - 6: "Proposal", - 7: "GroupInfo", - }; - const originalType = typeMap[typeByte] ?? ""; - // Commit / Proposal は ActivityPub Object type として公開せず、PrivateMessage として扱う - // (仕様で定義された表示用タイプのみ利用: PublicMessage / PrivateMessage / Welcome / KeyPackage / GroupInfo) - const allowedExposure = new Set([ - "PublicMessage", - "PrivateMessage", - "Welcome", - "KeyPackage", - "GroupInfo", - ]); - let normalized = originalType; - if (originalType === "Commit" || originalType === "Proposal") { - normalized = "PrivateMessage"; // Handshake系を内部的には識別するが外部公開は PrivateMessage として統一 - } - if (!allowedExposure.has(normalized)) normalized = "PublicMessage"; - return { - type: normalized, - originalType, - body: bin.subarray(3, 3 + len), - }; - } catch { - return null; - } - } - const envelope = decodeMlsEnvelope(content); - const localTargets = envelope && - (envelope.originalType === "Welcome" || - envelope.originalType === "Commit") - ? recipients.filter((m) => m.endsWith(`@${domain}`) && m !== from) - : []; - - const msg = await db.createHandshakeMessage({ - roomId, - sender: from, - recipients, - message: content, - }) as HandshakeMessageDoc; - - const extra: Record = { - mediaType: mType, - encoding: encType, - }; - if (Array.isArray(attachments)) { - extra.attachments = attachments; - } - - // Save message; if it's a remote welcome, we'll mark object type as Welcome when building activity - const object = await db.saveMessage( - domain, - sender, - content, - extra, - { to: recipients, cc: [] }, - ); - const saved = object as Record; - const activityObj = buildActivityFromStored( - { - ...saved, - // 公開用タイプのみ利用(Commit / Proposal は decode 時点で PrivateMessage に正規化済み) - type: envelope?.type ?? "PublicMessage", - } as { - _id: unknown; - type: string; - content: string; - published: unknown; - extra: Record; - }, - domain, - sender, - false, - ); - (activityObj as ActivityPubActivity)["@context"] = context; - - const actorId = `https://${domain}/users/${sender}`; - const activity = createCreateActivity(domain, actorId, activityObj); - (activity as ActivityPubActivity)["@context"] = context; - // Resolve recipients to actor IRIs (acct: or user@host) where possible. - try { - const { resolved, unresolved } = await resolveRecipientsToActorIris( - recipients, - env, - ); - if (resolved.length === 0) { - return { ok: false, status: 400, error: "no valid recipients" }; - } - (activity as ActivityPubActivity).to = resolved; - if (unresolved.length > 0) { - console.warn("some recipients could not be resolved:", unresolved); - } - } catch (err) { - console.error("failed to resolve recipients", err); - return { ok: false, status: 500, error: "failed to resolve recipients" }; - } - (activity as ActivityPubActivity).cc = []; - - const newMsg = { - id: String(msg._id), - roomId, - sender: from, - recipients: recipients, - createdAt: msg.createdAt, - }; - // REST-first: WebSocket は本文を渡さず軽量通知のみ送る - sendToUser(from, { - type: "hasUpdate", - payload: { kind: "handshake", id: newMsg.id }, - }); - for (const t of recipients) { - sendToUser(t, { - type: "hasUpdate", - payload: { kind: "handshake", id: newMsg.id }, - }); - } - - if (localTargets.length > 0) { - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - for (const lm of localTargets) { - const uname = lm.split("@")[0]; - await db.savePendingInvite(roomId, uname, "", expiresAt); - // pending invite の存在を軽量通知で送る - sendToUser(lm, { - type: "hasUpdate", - payload: { kind: "pendingInvite", roomId }, - }); - try { - // ローカルユーザー向けにサーバー側で通知を作成 - const acc = await db.findAccountByUserName(uname); - if (acc && acc._id) { - await db.createNotification( - String(acc._id), - "会話招待", - JSON.stringify({ kind: "chat-invite", roomId, sender: from }), - "chat-invite", - ); - // 通知作成の存在を軽量通知で送る(詳細は /api/notifications で取得) - sendToUser(lm, { - type: "hasUpdate", - payload: { kind: "notification", subtype: "chat-invite" }, - }); - } - } catch (e) { - console.error("failed to create invite notification", e); - } - } - } - - // Welcome/Commit/Proposal などのハンドシェイクはリモートメンバーへ個別配送 - if ( - envelope && - ["Welcome", "Commit", "Proposal"].includes(envelope.originalType) - ) { - const remoteMembers = recipients.filter((m) => !m.endsWith(`@${domain}`)); - if (remoteMembers.length > 0) { - for (const mem of remoteMembers) { - let actorIri = ""; - try { - const actor = await resolveActorCached(mem, env); - if (actor?.id) actorIri = actor.id; - } catch { - // ignore - } - if (!actorIri) { - if (mem.startsWith("http")) { - actorIri = mem; - } else { - const [n, h] = mem.split("@"); - if (n && h) actorIri = `https://${h}/users/${n}`; - } - } - - const hsObj = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://purl.archive.org/socialweb/mls", - ], - id: createObjectId(domain, "objects"), - // envelope.type は正規化済み(Commit/Proposal -> PrivateMessage) - type: ["Object", envelope.type], - attributedTo: `https://${domain}/users/${sender}`, - content, - mediaType: "message/mls", - encoding: "base64", - summary: - "This is an encrypted private message. See https://swicg.github.io/activitypub-e2ee/ …", - }; - - if (hsObj.mediaType !== "message/mls" || hsObj.encoding !== "base64") { - return { - ok: false, - status: 500, - error: - "ハンドシェイクオブジェクトに必要なフィールドが不足しています", - }; - } - - const hsActivity = createCreateActivity( - domain, - `https://${domain}/users/${sender}`, - hsObj, - ); - (hsActivity as ActivityPubActivity)["@context"] = context; - (hsActivity as ActivityPubActivity).to = [actorIri]; - (hsActivity as ActivityPubActivity).cc = []; - - try { - await deliverActivityPubObject( - [mem], - hsActivity, - sender, - domain, - env, - ); - } catch (err) { - console.error( - `deliver remote ${envelope.type.toLowerCase()} failed for ${mem}`, - err, - ); - } - } - } - return { ok: true, id: String(msg._id) }; - } - - // default: deliver as before - deliverActivityPubObject(recipients, activity, sender, domain, env).catch( - (err) => { - console.error("deliver failed", err); - }, - ); - - return { ok: true, id: String(msg._id) }; -} - -// ルーム管理 API (ActivityPub 対応) - -// ActivityPub ルーム一覧取得 -// --- ルームメタ一覧 API(明示作成されたもののみ) --- -// GET /api/rooms?owner=:id -// - サーバが保持するメタデータ(id, name, icon)のみ返却 -// - 検索・フィルタは行わない(クライアント側実装) -app.get("/rooms", authRequired, async (c) => { - const owner = c.req.query("owner"); - if (!owner) return jsonResponse(c, { error: "missing owner" }, 400); - const db = createDB(getEnv(c)); - const account = await db.findAccountById(owner); - if (!account) return jsonResponse(c, { error: "Account not found" }, 404); - const list = await db.listChatrooms(owner); - return jsonResponse(c, { rooms: list.map((r) => ({ id: r.id })) }); -}); - -// Get pending invites for a local user (non-acked) -app.get("/users/:user/pendingInvites", authRequired, async (c) => { - const user = c.req.param("user"); - const env = getEnv(c); - const db = createDB(env); - try { - const tenantId = env["ACTIVITYPUB_DOMAIN"] ?? ""; - const list = await db.findPendingInvites({ - userName: user, - acked: false, - tenant_id: tenantId, - }); - return c.json(list); - } catch (err) { - console.error("failed to fetch pending invites", err); - return jsonResponse(c, { error: "failed" }, 500); - } -}); - -// Ack a pending invite: { roomId, deviceId } -app.post("/users/:user/pendingInvites/ack", authRequired, async (c) => { - const user = c.req.param("user"); - const body = await c.req.json(); - if (!body || typeof body.roomId !== "string") { - return jsonResponse(c, { error: "invalid" }, 400); - } - const deviceId = typeof body.deviceId === "string" ? body.deviceId : ""; - const env = getEnv(c); - const db = createDB(env); - try { - await db.markInviteAcked(body.roomId, user, deviceId); - return c.json({ ok: true }); - } catch (err) { - console.error("failed to mark invite acked", err); - return jsonResponse(c, { error: "failed" }, 500); - } -}); - -// ルーム作成とハンドシェイク -app.post("/ap/rooms", authRequired, async (c) => { - const body = await c.req.json(); - if ( - typeof body !== "object" || - typeof body.owner !== "string" - ) { - return jsonResponse(c, { error: "invalid room" }, 400); - } - const env = getEnv(c); - const db = createDB(env); - const account = await db.findAccountById(body.owner); - if (!account) return jsonResponse(c, { error: "Account not found" }, 404); - const id = typeof body.id === "string" ? body.id : crypto.randomUUID(); - await db.addChatroom(body.owner, { id }); - const domain = getDomain(c); - if (body.handshake && typeof body.handshake === "object") { - const hs = await handleHandshake(env, domain, id, body.handshake); - if (!hs.ok) { - return jsonResponse(c, { error: hs.error }, hs.status); - } - } - return jsonResponse(c, { id }, 201, "application/json"); -}); - -app.get( - "/users/:user/keyPackages", - rateLimit({ windowMs: 60_000, limit: 20 }), - async (c) => { - const identifier = c.req.param("user"); - const domain = getDomain(c); - const summary = c.req.query("summary"); - - const [user, host] = identifier.split("@"); - if (!host || host === domain) { - const username = user ?? identifier; - const db = createDB(getEnv(c)); - if (summary === "1" || summary === "true") { - const info = await db.summaryKeyPackages(username); - return c.json(info); - } - const list = await db.listKeyPackages(username) as KeyPackageDoc[]; - // Reduce to at most one KeyPackage per deviceId (choose newest per device). - const packagesByDevice = new Map(); - for (const doc of list) { - const key = typeof doc.deviceId === "string" ? doc.deviceId : `__nodevice__:${String(doc._id)}`; - const prev = packagesByDevice.get(key); - if (!prev) { - packagesByDevice.set(key, doc); - continue; - } - const prevTime = new Date(prev.createdAt).getTime(); - const curTime = new Date(doc.createdAt).getTime(); - if (curTime > prevTime) packagesByDevice.set(key, doc); - } - const published = Array.from(packagesByDevice.values()); - const items = published.map((doc) => ({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://purl.archive.org/socialweb/mls", - ], - id: `https://${domain}/users/${username}/keyPackages/${doc._id}`, - type: ["Object", "KeyPackage"], - content: doc.content, - mediaType: doc.mediaType, - encoding: doc.encoding, - summary: - "This is binary-encoded cryptographic key package. See https://swicg.github.io/activitypub-e2ee/ …", - groupInfo: doc.groupInfo, - expiresAt: doc.expiresAt, - version: doc.version, - cipherSuite: doc.cipherSuite, - generator: normalizeGenerator(doc.generator), - createdAt: doc.createdAt, - keyPackageRef: (doc as { keyPackageRef?: string }).keyPackageRef, - })); - return c.json({ type: "Collection", items }); - } - - const acct = identifier; - - const actor = await resolveActorCached( - acct, - getEnv(c), - ); - if (!actor) return c.json({ type: "Collection", items: [] }); - const kpUrl = typeof actor.keyPackages === "string" - ? actor.keyPackages - : actor.keyPackages?.id; - if (!kpUrl) return c.json({ type: "Collection", items: [] }); - - try { - const col = await fetchJson<{ items?: unknown[] }>( - kpUrl, - {}, - undefined, - getEnv(c), - ); - const items = Array.isArray(col.items) ? col.items : []; - // Ensure each remote item has @context and type array format - const norm = items.map((it) => { - const obj = it as Record; - const out: Record = { ...obj }; - if (!Array.isArray(out["@context"])) { - out["@context"] = [ - "https://www.w3.org/ns/activitystreams", - "https://purl.archive.org/socialweb/mls", - ]; - } - const t = out.type; - if (Array.isArray(t)) { - out.type = t; - } else if (typeof t === "string") { - out.type = ["Object", t]; - } else { - out.type = ["Object", "KeyPackage"]; - } - if (typeof out.summary !== "string") { - out.summary = - "This is binary-encoded cryptographic key package. See https://swicg.github.io/activitypub-e2ee/ …"; - } - return out; - }); - return c.json({ type: "Collection", items: norm }); - } catch (_err) { - console.error("remote keyPackages fetch failed", _err); - return c.json({ type: "Collection", items: [] }); - } - }, -); - -app.get("/users/:user/keyPackages/:keyId", async (c) => { - const user = c.req.param("user"); - const keyId = c.req.param("keyId"); - const domain = getDomain(c); - const db = createDB(getEnv(c)); - const doc = await db.findKeyPackage(user, keyId) as KeyPackageDoc | null; - if (!doc) return c.body("Not Found", 404); - await db.markKeyPackageUsed(user, keyId); - await db.cleanupKeyPackages(user); - const object = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://purl.archive.org/socialweb/mls", - ], - id: `https://${domain}/users/${user}/keyPackages/${keyId}`, - type: ["Object", "KeyPackage"], - attributedTo: `https://${domain}/users/${user}`, - to: ["https://www.w3.org/ns/activitystreams#Public"], - mediaType: doc.mediaType, - encoding: doc.encoding, - summary: - "This is binary-encoded cryptographic key package. See https://swicg.github.io/activitypub-e2ee/ …", - content: doc.content, - groupInfo: doc.groupInfo, - expiresAt: doc.expiresAt, - version: doc.version, - cipherSuite: doc.cipherSuite, - generator: normalizeGenerator(doc.generator), - keyPackageRef: (doc as { keyPackageRef?: string }).keyPackageRef, - }; - return c.json(object); -}); - -app.post("/users/:user/keyPackages", authRequired, async (c) => { - const user = c.req.param("user"); - const body = await c.req.json(); - const env = getEnv(c); - const domain = getDomain(c); - const db = createDB(env); - const sid = getCookie(c, "sessionId"); - const result = await registerKeyPackage(env, domain, db, user, body, sid); - if ("error" in result) { - return c.json({ error: result.error }, 400); - } - return c.json({ - result: "ok", - keyId: result.keyId, - groupInfo: result.groupInfo, - keyPackageRef: result.keyPackageRef, - }); -}); - -app.post("/keyPackages/bulk", authRequired, async (c) => { - const body = await c.req.json(); - if (!Array.isArray(body)) { - return c.json({ error: "array required" }, 400); - } - const env = getEnv(c); - const domain = getDomain(c); - const db = createDB(env); - const sid = getCookie(c, "sessionId"); - const results: unknown[] = []; - for (const item of body) { - if ( - !item || typeof item.user !== "string" || - !Array.isArray(item.keyPackages) - ) { - continue; - } - const user = item.user as string; - const resList: unknown[] = []; - for (const kp of item.keyPackages) { - const r = await registerKeyPackage(env, domain, db, user, kp, sid); - resList.push(r); - } - results.push({ user, results: resList }); - } - return c.json({ results }); -}); - -// 一括で複数ユーザーの KeyPackage summary を取得する -app.post("/keyPackages/summary", authRequired, async (c) => { - const body = await c.req.json().catch(() => ({})); - const users = Array.isArray(body.users) ? body.users : []; - if (users.length === 0) return c.json({ results: [] }); - const env = getEnv(c); - const db = createDB(env); - const results: { user: string; count: number; hasLastResort: boolean }[] = []; - for (const u of users) { - if (typeof u !== "string") continue; - try { - const info = await db.summaryKeyPackages(u); - results.push({ user: u, count: info.count, hasLastResort: info.hasLastResort }); - } catch (e) { - console.error("failed to fetch summary for", u, e); - results.push({ user: u, count: 0, hasLastResort: false }); - } - } - return c.json({ results }); -}); - -// KeyPackageRef で使用済みにマーキング: Welcome 消費後にクライアントがまとめて通知 -app.post("/users/:user/keyPackages/markUsed", authRequired, async (c) => { - const user = c.req.param("user"); - const body = await c.req.json().catch(() => ({})); - const refs = Array.isArray(body.keyPackageRefs) ? body.keyPackageRefs : []; - if (refs.length === 0) { - return c.json({ ok: false, error: "keyPackageRefs required" }, 400); - } - const db = createDB(getEnv(c)); - for (const ref of refs) { - if (typeof ref === "string" && /^[0-9a-fA-F]{64}$/.test(ref)) { - await db.markKeyPackageUsedByRef(user, ref.toLowerCase()); - } - } - await db.cleanupKeyPackages(user); - return c.json({ ok: true }); -}); - -app.delete("/users/:user/keyPackages/:keyId", authRequired, async (c) => { - const user = c.req.param("user"); - const keyId = c.req.param("keyId"); - const domain = getDomain(c); - const db = createDB(getEnv(c)); - await db.deleteKeyPackage(user, keyId); - await db.cleanupKeyPackages(user); - const actorId = `https://${domain}/users/${user}`; - const removeActivity = createRemoveActivity( - domain, - actorId, - `https://${domain}/users/${user}/keyPackages/${keyId}`, - ); - const deleteActivity = createDeleteActivity( - domain, - actorId, - `https://${domain}/users/${user}/keyPackages/${keyId}`, - ); - await deliverToFollowers(getEnv(c), user, removeActivity, domain); - await deliverToFollowers(getEnv(c), user, deleteActivity, domain); - return c.json({ result: "removed" }); -}); - -app.get( - "/users/:user/devices/:device/encryptedKeyPair", - authRequired, - async (c) => { - const user = c.req.param("user"); - const device = c.req.param("device"); - const db = createDB(getEnv(c)); - const doc = await db.findEncryptedKeyPair( - user, - device, - ) as EncryptedKeyPairDoc | null; - if (!doc) return c.json({ content: null }); - return c.json({ content: doc.content }); - }, -); - -app.post( - "/users/:user/devices/:device/encryptedKeyPair", - authRequired, - async (c) => { - const user = c.req.param("user"); - const device = c.req.param("device"); - const { content } = await c.req.json(); - if (typeof content !== "string") { - return c.json({ error: "invalid body" }, 400); - } - const db = createDB(getEnv(c)); - await db.upsertEncryptedKeyPair(user, device, content); - return c.json({ result: "ok" }); - }, -); - -app.delete( - "/users/:user/devices/:device/encryptedKeyPair", - authRequired, - async (c) => { - const user = c.req.param("user"); - const device = c.req.param("device"); - const db = createDB(getEnv(c)); - await db.deleteEncryptedKeyPair(user, device); - return c.json({ result: "removed" }); - }, -); - -app.post("/users/:user/resetKeys", authRequired, async (c) => { - const user = c.req.param("user"); - const domain = getDomain(c); - const actorId = `https://${domain}/users/${user}`; - const db = createDB(getEnv(c)); - const keyPkgs = await db.listKeyPackages(user) as KeyPackageDoc[]; - for (const pkg of keyPkgs) { - const removeActivity = createRemoveActivity( - domain, - actorId, - `https://${domain}/users/${user}/keyPackages/${pkg._id}`, - ); - const deleteActivity = createDeleteActivity( - domain, - actorId, - `https://${domain}/users/${user}/keyPackages/${pkg._id}`, - ); - await deliverToFollowers(getEnv(c), user, removeActivity, domain); - await deliverToFollowers(getEnv(c), user, deleteActivity, domain); - } - await db.deleteKeyPackagesByUser(user); - await db.deleteEncryptedKeyPairsByUser(user); - return c.json({ result: "reset" }); -}); - -app.post( - "/rooms/:room/messages", - authRequired, - rateLimit({ windowMs: 60_000, limit: 20 }), - async (c) => { - const roomId = c.req.param("room"); - const body = await c.req.json(); - const { from, to, content, mediaType, encoding, attachments } = body as { - from?: unknown; - to?: unknown; - content?: unknown; - mediaType?: unknown; - encoding?: unknown; - attachments?: unknown; - }; - if ( - typeof from !== "string" || - typeof content !== "string" - ) { - return c.json({ error: "invalid body" }, 400); - } - if (!Array.isArray(to) || to.some((v) => typeof v !== "string")) { - return c.json({ error: "invalid recipients" }, 400); - } - const [sender, senderDomain] = from.split("@"); - if (!sender || !senderDomain) { - return c.json({ error: "invalid user format" }, 400); - } - const context = Array.isArray(body["@context"]) - ? body["@context"] as string[] - : [ - "https://www.w3.org/ns/activitystreams", - "https://purl.archive.org/socialweb/mls", - ]; - const domain = getDomain(c); - const env = getEnv(c); - const db = createDB(env); - const found = await db.findChatroom(roomId); - if (!found) return c.json({ error: "room not found" }, 404); - // 宛先はクライアント提供(MLS ロスター由来)を使用 - const recipients = Array.from( - new Set((to as string[]).filter((m) => m && m !== from)), - ); - if (recipients.length === 0) { - return c.json({ error: "no recipients" }, 400); - } - - // パブリックやフォロワー宛ての配送は拒否する - if ( - recipients.some((r) => - r === "https://www.w3.org/ns/activitystreams#Public" || - r.endsWith("/followers") || - r.endsWith("/following") - ) - ) { - return c.json({ error: "invalid recipients" }, 400); - } - - const mType = typeof mediaType === "string" ? mediaType : "message/mls"; - const encType = typeof encoding === "string" ? encoding : "base64"; - // MLS 以外の形式や Base64 以外のエンコードは受け付けない - if (mType !== "message/mls" || encType !== "base64") { - return c.json({ error: "unsupported format" }, 400); - } - const storedContent = typeof content === "string" ? content : ""; - const msg = await db.createEncryptedMessage({ - roomId, - from, - to: recipients, - content: storedContent, - mediaType: mType, - encoding: encType, - }) as EncryptedMessageDoc; - - const extra: Record = { - mediaType: mType, - encoding: encType, - }; - if (Array.isArray(attachments)) { - extra.attachments = attachments; - } - - const object = await db.saveMessage( - domain, - sender, - storedContent, - extra, - { to: recipients, cc: [] }, - ); - const saved = object as Record; - - const activityObj = buildActivityFromStored( - { - ...saved, - type: "PrivateMessage", - } as { - _id: unknown; - type: string; - content: string; - published: unknown; - extra: Record; - }, - domain, - sender, - false, - ); - (activityObj as ActivityPubActivity)["@context"] = context; - - const actorId = `https://${domain}/users/${sender}`; - const activity = createCreateActivity(domain, actorId, activityObj); - (activity as ActivityPubActivity)["@context"] = context; - // Resolve recipients to actor IRIs and exclude unresolved entries. - try { - const { resolved, unresolved } = await resolveRecipientsToActorIris( - recipients, - env, - ); - if (resolved.length === 0) { - return c.json({ error: "no valid recipients" }, 400); - } - (activity as ActivityPubActivity).to = resolved; - (activity as ActivityPubActivity).cc = []; - if (unresolved.length > 0) { - console.warn("some recipients could not be resolved:", unresolved); - } - deliverActivityPubObject(resolved, activity, sender, domain, env).catch( - (err) => { - console.error("deliver failed", err); - }, - ); - } catch (err) { - console.error("failed to resolve recipients", err); - return c.json({ error: "failed to resolve recipients" }, 500); - } - - // WebSocket はリアルタイム通知のみ(本文等は送らない) - const newMsg = { - id: String(msg._id), - roomId, - from, - to: recipients, - createdAt: msg.createdAt, - }; - // 本文は REST (/rooms/:room/messages) で取得させる - sendToUser(from, { - type: "hasUpdate", - payload: { kind: "encryptedMessage", id: newMsg.id }, - }); - for (const t of recipients) { - sendToUser(t, { - type: "hasUpdate", - payload: { kind: "encryptedMessage", id: newMsg.id }, - }); - } - - return c.json({ result: "sent", id: String(msg._id) }); - }, -); - -app.post( - "/rooms/:room/handshakes", - authRequired, - rateLimit({ windowMs: 60_000, limit: 20 }), - async (c) => { - const roomId = c.req.param("room"); - const body = await c.req.json(); - const domain = getDomain(c); - const env = getEnv(c); - const result = await handleHandshake(env, domain, roomId, body); - if (!result.ok) { - return jsonResponse(c, { error: result.error }, result.status); - } - return c.json({ result: "sent", id: result.id }); - }, -); - -app.get("/rooms/:room/messages", authRequired, async (c) => { - const roomId = c.req.param("room"); - const limit = Math.min( - parseInt(c.req.query("limit") ?? "50", 10) || 50, - 100, - ); - const before = c.req.query("before"); - const after = c.req.query("after"); - const db = createDB(getEnv(c)); - const sid = getCookie(c, "sessionId"); - if (sid) { - await db.updateSessionActivity(sid); - } - const list = await db.findEncryptedMessages({ roomId }, { - before: before ?? undefined, - after: after ?? undefined, - limit, - }) as EncryptedMessageDoc[]; - list.sort((a, b) => { - const dateA = - typeof a.createdAt === "string" || typeof a.createdAt === "number" || - a.createdAt instanceof Date - ? new Date(a.createdAt) - : new Date(0); - const dateB = - typeof b.createdAt === "string" || typeof b.createdAt === "number" || - b.createdAt instanceof Date - ? new Date(b.createdAt) - : new Date(0); - return dateA.getTime() - dateB.getTime(); - }); - list.reverse(); - const domain = getDomain(c); - const messages = list.slice(0, limit).map((doc) => ({ - id: String(doc._id), - roomId: doc.roomId, - from: doc.from, - to: doc.to, - content: doc.content, - mediaType: doc.mediaType, - encoding: doc.encoding, - createdAt: doc.createdAt, - attachments: - Array.isArray((doc.extra as Record)?.attachments) - ? (doc.extra as { attachments: unknown[] }).attachments.map( - (att: unknown, idx: number) => { - const a = att as Record; - if (typeof a.url === "string") { - return { - url: a.url, - mediaType: typeof a.mediaType === "string" - ? a.mediaType - : "application/octet-stream", - key: a.key, - iv: a.iv, - }; - } - return { - url: `https://${domain}/api/files/messages/${doc._id}/${idx}`, - mediaType: (a.mediaType as string) || "application/octet-stream", - key: a.key as string | undefined, - iv: a.iv as string | undefined, - }; - }, - ) - : undefined, - })); - return c.json(messages); -}); - -app.get("/rooms/:room/handshakes", authRequired, async (c) => { - const roomId = c.req.param("room"); - const limit = Math.min( - parseInt(c.req.query("limit") ?? "50", 10) || 50, - 100, - ); - const before = c.req.query("before"); - const after = c.req.query("after"); - const db = createDB(getEnv(c)); - const list = await db.findHandshakeMessages({ roomId }, { - before: before ?? undefined, - after: after ?? undefined, - limit, - }) as HandshakeMessageDoc[]; - const messages = list.map((doc) => ({ - id: String(doc._id), - roomId: doc.roomId, - sender: doc.sender, - recipients: doc.recipients, - message: doc.message, - createdAt: doc.createdAt, - })); - return c.json(messages); -}); - -export default app; diff --git a/app/api/routes/e2ee_test.ts b/app/api/routes/e2ee_test.ts deleted file mode 100644 index 4a6a3acc0..000000000 --- a/app/api/routes/e2ee_test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - assert, - assertEquals, -} from "https://deno.land/std@0.208.0/assert/mod.ts"; -import { stub } from "https://deno.land/std@0.208.0/testing/mock.ts"; -import { registerKeyPackage } from "./e2ee.ts"; -import { createDB } from "../DB/mod.ts"; - -Deno.test("/keyPackages/bulk で KeyPackage の identity を検証できる", async () => { - const domain = "example.com"; - const env: Record = {}; - let counter = 0; - const db = { - createKeyPackage( - _user: string, - content: string, - mediaType: string, - encoding: string, - _groupInfo?: string, - _expiresAt?: Date, - _deviceId?: string, - _version?: string, - _cipherSuite?: number, - _generator?: unknown, - ) { - counter++; - return Promise.resolve({ - _id: counter, - content, - mediaType, - encoding, - createdAt: new Date().toISOString(), - version: "1.0", - cipherSuite: 1, - generator: "g", - }); - }, - cleanupKeyPackages(_user: string) { - return Promise.resolve(); - }, - }; - const fetchStub = stub( - globalThis, - "fetch", - () => Promise.resolve(new Response("{}", { status: 200 })), - ); - const deliver = () => Promise.resolve(); - try { - const good = btoa("good"); - const bad = btoa("bad"); - const body = [{ - user: "alice", - keyPackages: [ - { content: good, mediaType: "message/mls", encoding: "base64" }, - { content: bad, mediaType: "message/mls", encoding: "base64" }, - ], - }]; - const results: unknown[] = []; - for (const item of body) { - const resList: unknown[] = []; - for (const kp of item.keyPackages) { - const r = await registerKeyPackage( - env, - domain, - db as unknown as ReturnType, - item.user, - kp, - undefined, - deliver, - ); - resList.push(r); - } - results.push({ user: item.user, results: resList }); - } - const first = (results[0] as { results: unknown[] }).results; - assert("keyId" in (first[0] as Record)); - assertEquals(first[1], { error: "ap_mls.binding.identity_mismatch" }); - } finally { - fetchStub.restore(); - } -}); diff --git a/app/api/routes/ws.ts b/app/api/routes/ws.ts index ced2a6ee2..63b9d6ffb 100644 --- a/app/api/routes/ws.ts +++ b/app/api/routes/ws.ts @@ -14,7 +14,6 @@ export type LifecycleHandler = ( ) => void | Promise; const messageHandlers = new Map(); -let binaryHandler: MessageHandler | null = null; const openHandlers: LifecycleHandler[] = []; const closeHandlers: LifecycleHandler[] = []; const errorHandlers: LifecycleHandler[] = []; @@ -46,10 +45,6 @@ export function registerMessageHandler( messageHandlers.set(type, handler); } -export function registerBinaryHandler(handler: MessageHandler) { - binaryHandler = handler; -} - export function registerOpenHandler(handler: LifecycleHandler) { openHandlers.push(handler); } @@ -94,49 +89,18 @@ app.get( for (const h of openHandlers) h(ws, state); }, onMessage(evt, ws) { - // WebSocket は通知目的に限定する: - // - 文字列メッセージは JSON を期待。type フィールドによりハンドラを呼ぶ。 - // - ただしタイプ "handshake" は受け付けない(クライアントは REST を使うこと)。 - // - バイナリペイロード(MLS ワイヤフォーマット想定)は一切受け付けずエラー応答する。 + // WebSocket は DM 通知用に使用する。 + // 文字列メッセージは JSON を期待し、type フィールドでハンドラを呼ぶ。 if (typeof evt.data === "string") { try { const msg = JSON.parse(evt.data); - if (msg && msg.type === "handshake") { - // ハンドシェイク本体は REST (/rooms/:room/handshakes) へ移行済み - // ログを追加して、どの接続・ユーザーから来たかを追跡可能にする(リプレイや誤送信の確認用) - try { - const user = state.user as string | undefined; - console.warn("websocket: rejected handshake message", { - user: user ?? "unknown", - origin: (ws as any)?.context?.req?.headers?.get?.("origin") ?? - null, - }); - } catch (e) { - console.warn( - "websocket: rejected handshake (failed to read state)", - e, - ); - } - ws.send(JSON.stringify({ - error: "handshake_not_allowed_on_websocket", - message: "Use REST /rooms/:room/handshakes for MLS handshakes", - })); - return; - } const handler = messageHandlers.get(msg.type); handler?.(msg.payload, ws, state); } catch { ws.send(JSON.stringify({ error: "invalid message" })); } } else { - // バイナリデータは MLS ハンドシェイク等を含む可能性があるため拒否する - ws.send( - JSON.stringify({ - error: "binary_payload_not_allowed", - message: - "Binary payloads (MLS) are not allowed over websocket; use REST APIs", - }), - ); + ws.send(JSON.stringify({ error: "binary_payload_not_allowed" })); } }, async onClose(_evt, ws) { diff --git a/app/api/server.ts b/app/api/server.ts index 0e70356fc..3a8efb8a6 100644 --- a/app/api/server.ts +++ b/app/api/server.ts @@ -15,7 +15,6 @@ import follow from "./routes/follow.ts"; import keep from "./routes/keep.ts"; import rootInbox from "./routes/root_inbox.ts"; import nodeinfo from "./routes/nodeinfo.ts"; -import e2ee from "./routes/e2ee.ts"; import fasp from "./routes/fasp.ts"; import files, { initFileModule } from "./routes/files.ts"; import wsRouter from "./routes/ws.ts"; @@ -25,6 +24,7 @@ import placeholder from "./routes/placeholder.ts"; import image from "./routes/image.ts"; import trends from "./routes/trends.ts"; import systemSetup from "./routes/system_setup.ts"; +import dm from "./routes/dm.ts"; import { fetchOgpData } from "./services/ogp.ts"; import { serveStatic } from "hono/deno"; import type { Context } from "hono"; @@ -33,7 +33,7 @@ import { deleteCookie, getCookie } from "hono/cookie"; import { issueSession } from "./utils/session.ts"; import { bootstrapDefaultFasp } from "./services/fasp_bootstrap.ts"; import { migrateFaspCollections } from "./services/fasp_migration.ts"; -import { startKeyPackageCleanupJob, startPendingInviteJob } from "./DB/mod.ts"; +import { startPendingInviteJob } from "./DB/mod.ts"; const isDev = Deno.env.get("DEV") === "1"; @@ -72,7 +72,7 @@ export async function createTakosApp(env?: Record) { files, search, users, - e2ee, + dm, ]; for (const r of apiRoutes) { app.route("/api", r); @@ -80,7 +80,7 @@ export async function createTakosApp(env?: Record) { // ActivityPub や公開エンドポイントは / にマウントする - const rootRoutes = [nodeinfo, activitypub, rootInbox, fasp, e2ee]; + const rootRoutes = [nodeinfo, activitypub, rootInbox, fasp]; for (const r of rootRoutes) { app.route("/", r); } @@ -206,7 +206,6 @@ if (import.meta.main) { // 起動を妨げない } startPendingInviteJob(env); - startKeyPackageCleanupJob(env); const app = await createTakosApp(env); const hostname = env["SERVER_HOST"]; const port = Number(env["SERVER_PORT"] ?? "80"); diff --git a/app/api/utils/activitypub.ts b/app/api/utils/activitypub.ts index d598dfe06..7014bff0a 100644 --- a/app/api/utils/activitypub.ts +++ b/app/api/utils/activitypub.ts @@ -731,7 +731,6 @@ export function createActor( "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", - "https://purl.archive.org/socialweb/mls", ], id: `https://${domain}/users/${account.userName}`, type: "Person", @@ -753,8 +752,6 @@ export function createActor( owner: `https://${domain}/users/${account.userName}`, publicKeyPem: ensurePem(account.publicKey, "PUBLIC KEY"), }, - // 任意の拡張プロパティ - keyPackages: undefined as unknown, }; if (!includeIcon) delete actor.icon; return actor; diff --git a/app/client/src/components/Application.tsx b/app/client/src/components/Application.tsx index 12bbe0eb7..947007f63 100644 --- a/app/client/src/components/Application.tsx +++ b/app/client/src/components/Application.tsx @@ -1,8 +1,8 @@ -import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"; +import { createEffect, createSignal, onMount } from "solid-js"; import { useAtom } from "solid-jotai"; import { selectedAppState } from "../states/app.ts"; import { selectedRoomState } from "../states/chat.ts"; -import { activeAccount, accounts as accountsAtom } from "../states/account.ts"; +import { activeAccount } from "../states/account.ts"; import { Home } from "./Home.tsx"; import Profile from "./Profile.tsx"; import { Microblog } from "./Microblog.tsx"; @@ -12,15 +12,12 @@ import UnifiedToolsContent from "./home/UnifiedToolsContent.tsx"; import Header from "./header/header.tsx"; import { connectWebSocket, registerUser } from "../utils/ws.ts"; import { getDomain } from "../utils/config.ts"; -import { topUpKeyPackagesBulk } from "./e2ee/api.ts"; export function Application() { const [selectedApp] = useAtom(selectedAppState); const [selectedRoom] = useAtom(selectedRoomState); const [account] = useAtom(activeAccount); - const [allAccounts] = useAtom(accountsAtom); const [isMobile, setIsMobile] = createSignal(false); - let topUpTimer: number | undefined; // モバイルかどうかを判定 onMount(() => { @@ -37,39 +34,9 @@ export function Application() { createEffect(() => { const user = account(); - // Ensure active user's presence is registered for websocket etc. if (user) { registerUser(`${user.userName}@${getDomain()}`); } - - // Top up KeyPackages for all configured accounts (bulk) instead of only the active one. - const accs = allAccounts(); - if (accs && accs.length > 0) { - const payload = accs.map((a) => ({ userName: a.userName, accountId: a.id })); - // Run immediately and stop periodic top-up if uploads were performed. - void (async () => { - try { - const uploaded = await topUpKeyPackagesBulk(payload); - if (uploaded && topUpTimer) { - clearInterval(topUpTimer); - topUpTimer = undefined; - } - } catch (e) { - console.warn("topUpKeyPackagesBulk failed:", e); - } - })(); - if (topUpTimer) clearInterval(topUpTimer); - topUpTimer = setInterval(() => { - void topUpKeyPackagesBulk(payload); - }, 300_000); - } else if (topUpTimer) { - clearInterval(topUpTimer); - topUpTimer = undefined; - } - }); - - onCleanup(() => { - if (topUpTimer) clearInterval(topUpTimer); }); // チャットページかつスマホ版かつチャンネルが選択されている場合にヘッダーが非表示の場合のクラス名を生成 diff --git a/app/client/src/components/Chat.tsx b/app/client/src/components/Chat.tsx index eafd0c568..e524c019b 100644 --- a/app/client/src/components/Chat.tsx +++ b/app/client/src/components/Chat.tsx @@ -1,3212 +1,231 @@ -import { - createEffect, - createMemo, - createSignal, - on, - onCleanup, - onMount, - Show, -} from "solid-js"; +import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; import { useAtom } from "solid-jotai"; import { selectedRoomState } from "../states/chat.ts"; -import { type Account, activeAccount } from "../states/account.ts"; -import { - fetchFollowing, - fetchUserInfo, - fetchUserInfoBatch, -} from "./microblog/api.ts"; -import { - addKeyPackage, - addRoom, - fetchEncryptedMessages, - fetchHandshakes, - fetchKeepMessages, - fetchKeyPackages, - fetchPendingInvites, - importRosterEvidence, - searchRooms, - sendEncryptedMessage, - sendGroupMetadata, - sendHandshake, - sendKeepMessage, - uploadFile, -} from "./e2ee/api.ts"; -import { apiFetch, getDomain } from "../utils/config.ts"; -import { addMessageHandler, removeMessageHandler } from "../utils/ws.ts"; -import { - createCommitAndWelcomes, - createMLSGroup, - decryptMessage, - encryptMessage, - generateKeyPair, - joinWithWelcome, - processCommit, - processProposal, - removeMembers, - type RosterEvidence, - type StoredGroupState, - verifyWelcome, -} from "./e2ee/mls_wrapper.ts"; -import { - decodePublicMessage, - encodePublicMessage, -} from "./e2ee/mls_message.ts"; -import { decodeMlsMessage } from "ts-mls"; -import { - appendRosterEvidence, - getCacheItem, - loadAllMLSKeyPairs, - loadDecryptedMessages, - loadKeyPackageRecords, - loadMLSGroupStates, - loadMLSKeyPair, - saveDecryptedMessages, - saveMLSGroupStates, - saveMLSKeyPair, - setCacheItem, -} from "./e2ee/storage.ts"; -import { isAdsenseEnabled, loadAdsenseConfig } from "../utils/adsense.ts"; +import { activeAccount } from "../states/account.ts"; import { ChatRoomList } from "./chat/ChatRoomList.tsx"; import { ChatTitleBar } from "./chat/ChatTitleBar.tsx"; import { ChatSettingsOverlay } from "./chat/ChatSettingsOverlay.tsx"; import { ChatMessageList } from "./chat/ChatMessageList.tsx"; import { ChatSendForm } from "./chat/ChatSendForm.tsx"; -import { GroupCreateDialog } from "./chat/GroupCreateDialog.tsx"; -import type { ActorID, ChatMessage, Room } from "./chat/types.ts"; -import { b64ToBuf, bufToB64 } from "../../../shared/buffer.ts"; -import type { GeneratedKeyPair } from "./e2ee/mls_wrapper.ts"; -import { useMLS } from "./e2ee/useMLS.ts"; - -function adjustHeight(el?: HTMLTextAreaElement) { - if (el) { - el.style.height = "auto"; - el.style.height = `${el.scrollHeight}px`; - } -} - -function bufToUrl(buf: ArrayBuffer, type: string): string { - const blob = new Blob([buf], { type }); - return URL.createObjectURL(blob); -} - -// ActivityPub の Note 形式のテキストから content を取り出す -function _parseActivityPubContent(text: string): string { - try { - const obj = JSON.parse(text); - if (obj && typeof obj === "object" && typeof obj.content === "string") { - return obj.content; - } - } catch { - /* JSON ではない場合はそのまま返す */ - } - return text; -} - -interface ActivityPubPreview { - url: string; - mediaType: string; - width?: number; - height?: number; - key?: string; - iv?: string; -} - -interface ActivityPubAttachment { - url: string; - mediaType: string; - key?: string; - iv?: string; - preview?: ActivityPubPreview; -} - -interface ParsedActivityPubNote { - id?: string; - content: string; - attachments?: ActivityPubAttachment[]; -} - -function parseActivityPubNote(text: string): ParsedActivityPubNote { - try { - const obj = JSON.parse(text); - if (obj && typeof obj === "object" && typeof obj.content === "string") { - const rawAtt = (obj as { attachment?: unknown }).attachment; - const attachments = Array.isArray(rawAtt) - ? rawAtt - .map((a: unknown) => { - if ( - a && typeof a === "object" && - typeof (a as { url?: unknown }).url === "string" - ) { - const mediaType = - typeof (a as { mediaType?: unknown }).mediaType === "string" - ? (a as { mediaType: string }).mediaType - : "application/octet-stream"; - const key = typeof (a as { key?: unknown }).key === "string" - ? (a as { key: string }).key - : undefined; - const iv = typeof (a as { iv?: unknown }).iv === "string" - ? (a as { iv: string }).iv - : undefined; - const rawPrev = (a as { preview?: unknown }).preview; - let preview: ActivityPubPreview | undefined; - if ( - rawPrev && typeof rawPrev === "object" && - typeof (rawPrev as { url?: unknown }).url === "string" - ) { - preview = { - url: (rawPrev as { url: string }).url, - mediaType: - typeof (rawPrev as { mediaType?: unknown }).mediaType === - "string" - ? (rawPrev as { mediaType: string }).mediaType - : "image/jpeg", - width: typeof (rawPrev as { width?: unknown }).width === - "number" - ? (rawPrev as { width: number }).width - : undefined, - height: typeof (rawPrev as { height?: unknown }).height === - "number" - ? (rawPrev as { height: number }).height - : undefined, - key: typeof (rawPrev as { key?: unknown }).key === "string" - ? (rawPrev as { key: string }).key - : undefined, - iv: typeof (rawPrev as { iv?: unknown }).iv === "string" - ? (rawPrev as { iv: string }).iv - : undefined, - }; - } - return { - url: (a as { url: string }).url, - mediaType, - key, - iv, - preview, - } as ActivityPubAttachment; - } - return null; - }) - .filter(( - a: ActivityPubAttachment | null, - ): a is ActivityPubAttachment => !!a) - : undefined; - const id = typeof (obj as { id?: unknown }).id === "string" - ? (obj as { id: string }).id - : undefined; - return { id, content: obj.content, attachments }; - } - } catch { - /* ignore */ - } - return { content: text }; -} - -// joinAck シグナル (初回参加確認) を表示用メッセージから除外するための判定 -function isJoinAckText(text: string): boolean { - try { - const obj = JSON.parse(text); - return !!obj && typeof obj === "object" && - (obj as { type?: unknown }).type === "joinAck"; - } catch { - return false; - } -} - -async function encryptFile(file: File) { - const buf = await file.arrayBuffer(); - const key = await crypto.subtle.generateKey( - { name: "AES-GCM", length: 256 }, - true, - ["encrypt", "decrypt"], - ); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const enc = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, buf); - const rawKey = await crypto.subtle.exportKey("raw", key); - return { - data: enc, - key: bufToB64(rawKey), - iv: bufToB64(iv.buffer), - mediaType: file.type, - name: file.name, - }; -} - -async function decryptFile( - data: ArrayBuffer, - keyB64: string, - ivB64: string, -): Promise { - const key = await crypto.subtle.importKey( - "raw", - b64ToBuf(keyB64), - { name: "AES-GCM" }, - false, - ["decrypt"], - ); - const iv = b64ToBuf(ivB64); - const dec = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - key, - data, - ); - return dec; -} -// 画像からプレビュー用の縮小画像を生成 -async function generateImagePreview( - file: File, -): Promise<{ file: File; width: number; height: number } | null> { - return await new Promise((resolve) => { - const img = new Image(); - const url = URL.createObjectURL(file); - img.onload = () => { - const max = 320; - const scale = Math.min(1, max / img.width); - const w = Math.round(img.width * scale); - const h = Math.round(img.height * scale); - const canvas = document.createElement("canvas"); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext("2d"); - if (!ctx) { - URL.revokeObjectURL(url); - resolve(null); - return; - } - ctx.drawImage(img, 0, 0, w, h); - canvas.toBlob( - (blob) => { - URL.revokeObjectURL(url); - if (blob) { - resolve({ - file: new File([blob], `preview-${file.name}.jpg`, { - type: "image/jpeg", - }), - width: w, - height: h, - }); - } else { - resolve(null); - } - }, - "image/jpeg", - 0.8, - ); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - resolve(null); - }; - img.src = url; - }); -} -// 動画からプレビュー用の静止画を生成 -async function generateVideoPreview( - file: File, -): Promise<{ file: File; width: number; height: number } | null> { - return await new Promise((resolve) => { - const video = document.createElement("video"); - const url = URL.createObjectURL(file); - video.preload = "metadata"; - video.muted = true; - video.src = url; - video.onloadeddata = () => { - const max = 320; - const scale = Math.min(1, max / video.videoWidth); - const w = Math.round(video.videoWidth * scale); - const h = Math.round(video.videoHeight * scale); - const canvas = document.createElement("canvas"); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext("2d"); - if (!ctx) { - URL.revokeObjectURL(url); - resolve(null); - return; - } - ctx.drawImage(video, 0, 0, w, h); - canvas.toBlob( - (blob) => { - URL.revokeObjectURL(url); - if (blob) { - resolve({ - file: new File([blob], `preview-${file.name}.jpg`, { - type: "image/jpeg", - }), - width: w, - height: h, - }); - } else { - resolve(null); - } - }, - "image/jpeg", - 0.8, - ); - }; - video.onerror = () => { - URL.revokeObjectURL(url); - resolve(null); - }; - }); -} -// 添付ファイルをアップロードし、必要ならプレビューも付与 -async function buildAttachment(file: File) { - const enc = await encryptFile(file); - const url = await uploadFile({ - content: enc.data, - mediaType: enc.mediaType, - key: enc.key, - iv: enc.iv, - name: file.name, - }); - if (!url) return undefined; - let preview: ActivityPubPreview | undefined; - if (file.type.startsWith("image/")) { - const p = await generateImagePreview(file); - if (p) { - const pEnc = await encryptFile(p.file); - const pUrl = await uploadFile({ - content: pEnc.data, - mediaType: pEnc.mediaType, - key: pEnc.key, - iv: pEnc.iv, - name: p.file.name, - }); - if (pUrl) { - preview = { - url: pUrl, - mediaType: pEnc.mediaType, - key: pEnc.key, - iv: pEnc.iv, - width: p.width, - height: p.height, - }; - } - } - } else if (file.type.startsWith("video/")) { - const p = await generateVideoPreview(file); - if (p) { - const pEnc = await encryptFile(p.file); - const pUrl = await uploadFile({ - content: pEnc.data, - mediaType: pEnc.mediaType, - key: pEnc.key, - iv: pEnc.iv, - name: p.file.name, - }); - if (pUrl) { - preview = { - url: pUrl, - mediaType: pEnc.mediaType, - key: pEnc.key, - iv: pEnc.iv, - width: p.width, - height: p.height, - }; - } - } - } - const attType = file.type.startsWith("image/") - ? "Image" - : file.type.startsWith("video/") - ? "Video" - : file.type.startsWith("audio/") - ? "Audio" - : "Document"; - const att: Record = { - type: attType, - url, - mediaType: enc.mediaType, - key: enc.key, - iv: enc.iv, - }; - if (preview) { - att.preview = { type: "Image", ...preview }; - } - return att; -} - -function getSelfRoomId(_user: Account | null): string | null { - // セルフルーム(TAKO Keep)のIDは固定で "memo" - return _user ? "memo" : null; -} +import type { ChatMessage, Room } from "./chat/types.ts"; +import { addMessageHandler, removeMessageHandler } from "../utils/ws.ts"; +import { apiFetch, getDomain } from "../utils/config.ts"; export function Chat() { - const [selectedRoom, setSelectedRoom] = useAtom(selectedRoomState); // グローバル状態を使用 + const [selectedRoom, setSelectedRoom] = useAtom(selectedRoomState); const [account] = useAtom(activeAccount); - const { bindingStatus, bindingInfo, assessBinding, ktInfo } = useMLS( - account()?.userName ?? "", - ); - const [newMessage, setNewMessage] = createSignal(""); - const [mediaFile, setMediaFile] = createSignal(null); - const [mediaPreview, setMediaPreview] = createSignal(null); - const [showRoomList, setShowRoomList] = createSignal(true); // モバイル用: 部屋リスト表示制御 - const [isMobile, setIsMobile] = createSignal(false); // モバイル判定 - const [chatRooms, setChatRooms] = createSignal([]); - - const [messages, setMessages] = createSignal([]); - // ルームごとの復号済みメッセージキャッシュ(再選択時の再復号を回避) - const [messagesByRoom, setMessagesByRoom] = createSignal< - Record - >({}); - const roomCacheKey = (roomId: string): string => { - const user = account(); - return user ? `${user.id}:${roomId}` : roomId; - }; - const [groups, setGroups] = createSignal>( + const [rooms, setRooms] = createSignal([]); + const [messages, setMessages] = createSignal>( {}, ); - const [keyPair, setKeyPair] = createSignal(null); - const [partnerHasKey, setPartnerHasKey] = createSignal(true); - const messageLimit = 30; - const [showAds, setShowAds] = createSignal(false); - onMount(async () => { - await loadAdsenseConfig(); - setShowAds(isAdsenseEnabled()); - }); - const [cursor, setCursor] = createSignal(null); - const [hasMore, setHasMore] = createSignal(true); - const [loadingOlder, setLoadingOlder] = createSignal(false); - const selectedRoomInfo = createMemo(() => - chatRooms().find((r) => r.id === selectedRoom()) ?? null - ); - const [showGroupDialog, setShowGroupDialog] = createSignal(false); - const [groupDialogMode, setGroupDialogMode] = createSignal< - "create" | "invite" - >("create"); - const [initialMembers, setInitialMembers] = createSignal([]); + const [newMessage, setNewMessage] = createSignal(""); + const [showSettings, setShowSettings] = createSignal(false); const [segment, setSegment] = createSignal<"all" | "people" | "groups">( "all", ); - // 設定オーバーレイ表示状態 - const [showSettings, setShowSettings] = createSignal(false); - // 受信した Welcome を保留し、ユーザーに参加可否を尋ねる - const [pendingWelcomes, setPendingWelcomes] = createSignal< - Record - >({}); - const actorUrl = createMemo(() => { + const me = () => { const user = account(); - return user - ? new URL(`/users/${user.userName}`, globalThis.location.origin).href - : null; - }); - - createEffect(() => { - const user = account(); - const roomId = selectedRoom(); - const actor = actorUrl(); - if (!user || !roomId || !actor) return; - void (async () => { - const records = await loadKeyPackageRecords(user.id, roomId); - const last = records[records.length - 1]; - if (last) { - await assessBinding( - user.id, - roomId, - actor, - last.credentialFingerprint, - last.ktIncluded, - ); - } - })(); - }); - - // ルーム重複防止ユーティリティ - function upsertRooms(next: Room[]) { - setChatRooms((prev) => { - const map = new Map(); - // 既存を入れてから next で上書き(最新情報を反映) - for (const r of prev) map.set(r.id, r); - for (const r of next) map.set(r.id, r); - return Array.from(map.values()); - }); - } - function upsertRoom(room: Room) { - upsertRooms([room]); - } - - // MLSの状態から参加者(自分以外)を抽出(actor URL / handle を正規化しつつ重複除去) - const participantsFromState = (roomId: string): string[] => { - const user = account(); - if (!user) return []; - const state = groups()[roomId]; - if (!state) return []; - const selfHandle = `${user.userName}@${getDomain()}` as ActorID; - try { - const raws = extractMembers(state); - const normed = raws - .map((m) => normalizeHandle(m as ActorID) ?? m) - .filter((m): m is string => !!m); - const withoutSelf = normed.filter((m) => { - const h = normalizeHandle(m as ActorID) ?? m; - return h !== selfHandle; - }); - return Array.from(new Set(withoutSelf)); - } catch { - return []; - } + return user ? `${user.userName}@${getDomain()}` : ""; }; - // 受信メッセージの送信者ハンドルから、メンバーIDをフルハンドル形式に補正 - const updatePeerHandle = (roomId: string, fromHandle: string) => { - const user = account(); - if (!user) return; - const selfHandle = `${user.userName}@${getDomain()}`; - const fullFrom = normalizeHandle(fromHandle as ActorID) ?? fromHandle; - if (fromHandle === selfHandle) return; - const [fromUser] = splitActor(fromHandle as ActorID); - setChatRooms((prev) => - prev.map((r) => { - if (r.id !== roomId) return r; - const members = (r.members ?? []).map((m) => { - if (typeof m === "string" && !m.includes("@")) { - // ユーザー名だけ一致している場合はフルハンドルに置き換え - const [mu] = splitActor(m as ActorID); - if (mu === fromUser) return fullFrom as ActorID; - } - return m; - }); - // 1対1・未命名のとき、タイトルがローカル名等に上書きされていたらハンドルに補正 - const isDm = r.type !== "memo" && (r.members?.length ?? 0) === 1 && - !(r.hasName || r.hasIcon); - let displayName = r.displayName; - if ( - isDm && - (!displayName || displayName === user.displayName || - displayName === user.userName || displayName === selfHandle) - ) { - displayName = fullFrom; - } - return { ...r, displayName, members }; - }) - ); - }; - const updateRoomLast = (roomId: string, msg?: ChatMessage) => { - setChatRooms((rooms) => { - let updated = false; - const newRooms = rooms.map((r) => { - if (r.id !== roomId) return r; - const lastMessage = msg?.attachments && msg.attachments.length > 0 - ? "[添付]" + (msg.content ? " " + msg.content : "") - : msg?.content ?? ""; - const lastMessageTime = msg?.timestamp; - if ( - r.lastMessage !== lastMessage || - r.lastMessageTime?.getTime() !== lastMessageTime?.getTime() - ) { - updated = true; - return { ...r, lastMessage, lastMessageTime }; - } - return r; - }); - return updated ? newRooms : rooms; - }); + const ensureRoom = (id: string) => { + if (!rooms().some((r) => r.id === id)) { + const [userName, domain] = id.split("@"); + setRooms((prev) => [ + ...prev, + { + id, + name: id, + userName, + domain, + unreadCount: 0, + members: [me(), id], + type: "group", + }, + ]); + } }; - // 1対1ルームで、選択時に相手の情報と members を補正する - const ensureDmPartnerInfo = async (room: Room) => { - const user = account(); - if (!user || room.type === "memo") return; - const selfHandle = `${user.userName}@${getDomain()}`; - // UUID のルームはグループとみなし、DM用の名称/アイコン補完は行わない - const uuidRe = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - const isUuidRoom = uuidRe.test(room.id); - // MLSの状態から相手を特定(自分以外) - const partner = participantsFromState(room.id)[0]; - if (!partner) return; - - // 画面表示用に client 側で members を補完(サーバーから返らない想定) - setChatRooms((prev) => - prev.map((r) => { - if (r.id !== room.id) return r; - const cur = r.members ?? []; - const norm = normalizeHandle(partner as string) as string | undefined; - if (!norm) return r; - if (cur.length === 1 && cur[0] === norm) return r; - return { ...r, members: [norm] }; - }) + const loadMessages = async (id: string) => { + const from = me(); + if (!from) return; + const res = await apiFetch( + `/api/dm?user1=${encodeURIComponent(from)}&user2=${ + encodeURIComponent(id) + }`, ); - - // 名前が未設定/自分名に見える場合は相手の displayName を取得して補完 + if (!res.ok) return; + const list = await res.json() as { + _id: string; + from: string; + to: string; + content: string; + createdAt: string; + }[]; + const msgs: ChatMessage[] = list.map((m) => ({ + id: m._id, + author: m.from, + displayName: m.from, + address: m.from, + content: m.content, + timestamp: new Date(m.createdAt), + type: "text", + isMe: m.from === from, + })); + setMessages((prev) => ({ ...prev, [id]: msgs })); + }; + + const handleWs = (data: unknown) => { + if (typeof data !== "object" || data === null) return; + const msg = data as { type?: string; payload?: unknown }; if ( - !isUuidRoom && - !(room.hasName || room.hasIcon) && - ((room.displayName ?? room.name) === "" || - (room.displayName ?? room.name) === user.displayName || - (room.displayName ?? room.name) === user.userName || - (room.displayName ?? room.name) === selfHandle) + msg.type !== "dm" || typeof msg.payload !== "object" || + msg.payload === null ) { - try { - const info = await fetchUserInfo(partner as ActorID); - if (info) { - setChatRooms((prev) => - prev.map((r) => - r.id === room.id - ? { - ...r, - displayName: info.displayName || info.userName, - avatar: info.authorAvatar || r.avatar, - } - : r - ) - ); - } - } catch (err) { - // ネットワークエラーや404は致命的ではないので無視 - console.warn("相手情報の取得に失敗しました", err); - } - } - }; - let textareaRef: HTMLTextAreaElement | undefined; - let wsCleanup: (() => void) | undefined; - let acceptCleanup: (() => void) | undefined; - - const loadGroupStates = async () => { - const user = account(); - if (!user) return; - try { - const stored = await loadMLSGroupStates(user.id); - setGroups(stored); - } catch (err) { - console.error("Failed to load group states", err); - } - }; - - const saveGroupStates = async () => { - const user = account(); - if (!user) return; - try { - await saveMLSGroupStates(user.id, groups()); - } catch (e) { - console.error("グループ状態の保存に失敗しました", e); - } - }; - - // グループ状態が存在しなければ初期化して保存 - const initGroupState = async (roomId: string) => { - try { - if (groups()[roomId]) return; - const user = account(); - if (!user) return; - // 保存済みの状態があればそれを復元 - try { - const stored = await loadMLSGroupStates(user.id); - if (stored[roomId]) { - setGroups((prev) => ({ ...prev, [roomId]: stored[roomId] })); - return; - } - } catch (err) { - console.error("グループ状態の読み込みに失敗しました", err); - } - const pair = await ensureKeyPair(); - if (!pair) return; - let initState: StoredGroupState | undefined; - try { - // アクターURLを identity に用いた正しい Credential で生成 - const actor = - new URL(`/users/${user.userName}`, globalThis.location.origin).href; - const created = await createMLSGroup(actor); - initState = created.state; - } catch (e) { - console.error( - "グループ初期化時にキーからの初期化に失敗しました", - e, - ); - } - if (initState) { - setGroups((prev) => ({ - ...prev, - [roomId]: initState, - })); - await saveGroupStates(); - } - } catch (e) { - console.error("ローカルグループ初期化に失敗しました", e); - } - }; - - const [isGeneratingKeyPair, setIsGeneratingKeyPair] = createSignal(false); - - const ensureKeyPair = async (): Promise => { - if (isGeneratingKeyPair()) return null; - - let pair: GeneratedKeyPair | null = keyPair(); - const user = account(); - if (!user) return null; - if (!pair) { - setIsGeneratingKeyPair(true); - try { - pair = await loadMLSKeyPair(user.id); - } catch (err) { - console.error("鍵ペアの読み込みに失敗しました", err); - pair = null; - } - if (!pair) { - // MLS の identity はアクターURLを用いる(外部連合との整合性維持) - const actor = - new URL(`/users/${user.userName}`, globalThis.location.origin).href; - const kp = await generateKeyPair(actor); - pair = { public: kp.public, private: kp.private, encoded: kp.encoded }; - try { - await saveMLSKeyPair(user.id, pair); - await addKeyPackage(user.userName, { content: kp.encoded }); - } catch (err) { - console.error("鍵ペアの保存に失敗しました", err); - setIsGeneratingKeyPair(false); - return null; - } - } - setKeyPair(pair); - setIsGeneratingKeyPair(false); - } - return pair; - }; - - // Handshake の再取得カーソルは ID ではなく時刻ベースで管理(APIが createdAt を after に使うため) - const lastHandshakeId = new Map(); - - async function syncHandshakes(room: Room) { - const user = account(); - if (!user) return; - let group = groups()[room.id]; - if (!group) { - await initGroupState(room.id); - group = groups()[room.id]; - if (!group) return; - } - const after = lastHandshakeId.get(room.id); - const hs = await fetchHandshakes( - room.id, - after ? { limit: 100, after } : { limit: 100 }, - ); - if (hs.length === 0) return; - const ordered = [...hs].sort((a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - let updated = false; - for (const h of ordered) { - const body = decodePublicMessage(h.message); - if (!body) continue; - try { - try { - const dec = decodeMlsMessage(body, 0)?.[0]; - if (dec && dec.wireformat === "mls_public_message") { - group = await processCommit( - group, - dec.publicMessage as unknown as never, - ); - updated = true; - lastHandshakeId.set(room.id, String(h.createdAt)); - continue; - } - } catch { - /* not a commit */ - } - try { - const dec = decodeMlsMessage(body, 0)?.[0]; - if (dec && dec.wireformat === "mls_public_message") { - group = await processProposal( - group, - dec.publicMessage as unknown as never, - ); - updated = true; - lastHandshakeId.set(room.id, String(h.createdAt)); - continue; - } - } catch { - /* not a proposal */ - } - try { - const obj = JSON.parse(new TextDecoder().decode(body)); - if (obj?.type === "welcome" && Array.isArray(obj.data)) { - const wBytes = new Uint8Array(obj.data as number[]); - const ok = await verifyWelcome(wBytes); - if (!ok) { - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "warning", - title: "無視しました", - description: - "不正なWelcomeメッセージを受信したため無視しました", - }, - }), - ); - } else { - // 参加はユーザーの同意後に行うため保留に入れる - setPendingWelcomes((prev) => ({ ...prev, [room.id]: wBytes })); - } - lastHandshakeId.set(room.id, String(h.createdAt)); - continue; - } - if (obj?.type === "RosterEvidence") { - const ev = obj as RosterEvidence; - const okEv = await importRosterEvidence( - user.id, - room.id, - ev, - ); - if (okEv) { - await appendRosterEvidence(user.id, room.id, [ev]); - const actor = actorUrl(); - if (actor && ev.actor === actor) { - await assessBinding( - user.id, - room.id, - actor, - ev.leafSignatureKeyFpr, - ); - } - } - lastHandshakeId.set(room.id, String(h.createdAt)); - continue; - } - } catch { - /* not a JSON handshake */ - } - } catch (e) { - console.warn("handshake apply failed", e); - } - } - if (updated) { - setGroups({ ...groups(), [room.id]: group }); - await saveGroupStates(); - } - } - - const fetchMessagesForRoom = async ( - room: Room, - params?: { - limit?: number; - before?: string; - after?: string; - dryRun?: boolean; - }, - ): Promise => { - const user = account(); - if (!user) return []; - if (room.type === "memo") { - const list = await fetchKeepMessages( - `${user.userName}@${getDomain()}`, - params, - ); - const msgs = list.map((m) => ({ - id: m.id, - author: `${user.userName}@${getDomain()}`, - displayName: user.displayName || user.userName, - address: `${user.userName}@${getDomain()}`, - content: m.content, - timestamp: new Date(m.createdAt), - type: "text" as const, - isMe: true, - avatar: room.avatar, - })); - return msgs.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - } - const encryptedMsgs: ChatMessage[] = []; - const isDryRun = Boolean(params?.dryRun); - let group = groups()[room.id]; - if (!group) { - await initGroupState(room.id); - group = groups()[room.id]; - if (!group) return []; - } - await syncHandshakes(room); - group = groups()[room.id]; - const selfHandle = `${user.userName}@${getDomain()}`; - const participantsNow = extractMembers(group) - .map((x) => normalizeHandle(x) ?? x) - .filter((v): v is string => !!v); - const isJoined = participantsNow.includes(selfHandle); - if (!isJoined && room.type !== "memo") { - // 未参加(招待のみ)の場合は復号を試みず空で返す(UI側で招待状態を表示) - return []; - } - const list = await fetchEncryptedMessages( - room.id, - `${user.userName}@${getDomain()}`, - params, - ); - // 復号は古い順に処理しないとラチェットが進まず失敗するため昇順で処理 - const ordered = [...list].sort((a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - for (const m of ordered) { - const data = b64ToBuf(m.content); - let res: { plaintext: Uint8Array; state: StoredGroupState } | null = null; - try { - console.debug("[decrypt] attempt", { - id: m.id, - room: room.id, - from: m.from, - mediaType: m.mediaType, - encoding: m.encoding, - contentLen: m.content ? m.content.length : 0, - }); - try { - const peek = decodeMlsMessage(data, 0)?.[0]; - console.debug("[decrypt] peekWireformat", { - id: m.id, - wireformat: peek?.wireformat, - }); - } catch (e) { - console.debug("[decrypt] peek failed", { id: m.id, err: e }); - } - res = await decryptMessage(group, data); - console.debug("[decrypt] result", { - id: m.id, - ok: !!res, - updatedState: !!res?.state, - }); - } catch (err) { - if (err instanceof DOMException && err.name === "OperationError") { - // DOMException(OperationError) は対象メッセージをスキップ - console.error("decryptMessage OperationError", err, { - id: m.id, - room: room.id, - message: err.message, - }); - continue; - } - console.error("decryptMessage failed", err, { - id: m.id, - room: room.id, - }); - } - if (!res) { - const isMe = m.from === `${user.userName}@${getDomain()}`; - // 自分発の暗号文で復号に失敗した場合はプレースホルダを表示せずスキップ - // (送信直後の世代ズレなど一時的要因で発生し得るが、後続の差分取得で解消される) - if (isMe) { - continue; - } - try { - const peek2 = decodeMlsMessage(b64ToBuf(m.content), 0)?.[0]; - console.warn("[decrypt] failed -> placeholder", { - id: m.id, - room: room.id, - from: m.from, - mediaType: m.mediaType, - encoding: m.encoding, - contentLen: m.content ? m.content.length : 0, - peekWireformat: peek2?.wireformat, - }); - } catch (e) { - console.warn("[decrypt] failed -> placeholder (peek failed)", { - id: m.id, - room: room.id, - err: e, - }); - } - if (!isMe) updatePeerHandle(room.id, m.from); - const selfH = `${user.userName}@${getDomain()}`; - const baseName = room.displayName ?? room.name; - const otherName = (!baseName || baseName === user.displayName || - baseName === user.userName || baseName === selfH) - ? m.from - : baseName; - const displayName = isMe - ? (user.displayName || user.userName) - : otherName; - // 復号できない暗号文はプレースホルダ表示 (後で再同期時に再取得対象) - encryptedMsgs.push({ - id: m.id, - author: m.from, - displayName, - address: m.from, - content: "[未復号]", // m.content そのまま出さない - timestamp: new Date(m.createdAt), - type: "text", - isMe, - avatar: room.avatar, - }); - continue; - } - group = res.state; - const plaintextStr = new TextDecoder().decode(res.plaintext); - // joinAck は UI に表示しない - if (isJoinAckText(plaintextStr)) { - continue; - } - const note = parseActivityPubNote(plaintextStr); - const text = note.content; - const listAtt = Array.isArray(m.attachments) - ? m.attachments - : note.attachments; - let attachments: - | { - data?: string; - url?: string; - mediaType: string; - preview?: { - url?: string; - data?: string; - mediaType?: string; - key?: string; - iv?: string; - }; - }[] - | undefined; - if (Array.isArray(listAtt)) { - attachments = []; - for (const at of listAtt) { - if (typeof at.url === "string") { - const attachmentItem = at as typeof at & { - preview?: ActivityPubPreview; - }; - const mt = typeof attachmentItem.mediaType === "string" - ? attachmentItem.mediaType - : "application/octet-stream"; - let preview; - if ( - attachmentItem.preview && - typeof attachmentItem.preview.url === "string" - ) { - const previewItem = attachmentItem.preview; - const pmt = typeof previewItem.mediaType === "string" - ? previewItem.mediaType - : "image/jpeg"; - try { - const pres = await fetch(previewItem.url); - let pbuf = await pres.arrayBuffer(); - if ( - typeof previewItem.key === "string" && - typeof previewItem.iv === "string" - ) { - pbuf = await decryptFile( - pbuf, - previewItem.key, - previewItem.iv, - ); - } - preview = { url: bufToUrl(pbuf, pmt), mediaType: pmt }; - } catch { - preview = { url: previewItem.url, mediaType: pmt }; - } - } - try { - const res = await fetch(attachmentItem.url); - let buf = await res.arrayBuffer(); - if ( - typeof attachmentItem.key === "string" && - typeof attachmentItem.iv === "string" - ) { - buf = await decryptFile( - buf, - attachmentItem.key, - attachmentItem.iv, - ); - } - if ( - mt.startsWith("video/") || - mt.startsWith("audio/") || - buf.byteLength > 1024 * 1024 - ) { - attachments.push({ - url: bufToUrl(buf, mt), - mediaType: mt, - preview, - }); - } else { - attachments.push({ - data: bufToB64(buf), - mediaType: mt, - preview, - }); - } - } catch { - attachments.push({ - url: attachmentItem.url, - mediaType: mt, - preview, - }); - } - } - } - } - const fullId = `${user.userName}@${getDomain()}`; - const isMe = m.from === fullId; - if (!isMe) updatePeerHandle(room.id, m.from); - const selfH2 = `${user.userName}@${getDomain()}`; - const baseName2 = room.displayName ?? room.name; - const otherName = (!baseName2 || baseName2 === user.displayName || - baseName2 === user.userName || baseName2 === selfH2) - ? m.from - : baseName2; - const displayName = isMe - ? (user.displayName || user.userName) - : otherName; - encryptedMsgs.push({ - id: m.id, - author: m.from, - displayName, - address: m.from, - content: text, - attachments, - timestamp: new Date(m.createdAt), - type: attachments && attachments.length > 0 - ? attachments[0].mediaType.startsWith("image/") ? "image" : "file" - : "text", - isMe, - avatar: room.avatar, - }); - } - const msgs = encryptedMsgs.sort((a, b) => - a.timestamp.getTime() - b.timestamp.getTime() - ); - if (!isDryRun) { - setGroups({ ...groups(), [room.id]: group }); - saveGroupStates(); - // 参加メンバーに合わせて招待中を整流化 - try { - const acc = account(); - if (acc) { - const participants = extractMembers(group).map((x) => - normalizeHandle(x) ?? x - ).filter((v): v is string => !!v); - await syncPendingWithParticipants(acc.id, room.id, participants); - } - } catch { - console.error("参加メンバーの同期に失敗しました"); - } - } - return msgs; - }; - - const loadMessages = async (room: Room, _isSelectedRoom: boolean) => { - const user = account(); - const cached = messagesByRoom()[roomCacheKey(room.id)] ?? ( - user - ? (await loadDecryptedMessages(user.id, room.id)) ?? undefined - : undefined - ); - if (cached && selectedRoom() === room.id) { - setMessages(cached); - if (cached.length > 0) { - setCursor(cached[0].timestamp.toISOString()); - updateRoomLast(room.id, cached[cached.length - 1]); - } else { - setCursor(null); - } - // 差分のみ取得(最新のタイムスタンプ以降) - const lastTs = cached.length > 0 - ? cached[cached.length - 1].timestamp.toISOString() - : undefined; - const fetched = await fetchMessagesForRoom( - room, - lastTs ? { after: lastTs } : { limit: messageLimit }, - ); - if (fetched.length > 0) { - const ids = new Set(cached.map((m) => m.id)); - const add = fetched.filter((m) => !ids.has(m.id)); - if (add.length > 0) { - const next = [...cached, ...add]; - setMessages(next); - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(room.id)]: next, - }); - if (user) await saveDecryptedMessages(user.id, room.id, next); - updateRoomLast(room.id, next[next.length - 1]); - } - } - setHasMore(cached.length >= messageLimit); return; } - const msgs = await fetchMessagesForRoom(room, { limit: messageLimit }); - setMessagesByRoom({ ...messagesByRoom(), [roomCacheKey(room.id)]: msgs }); - if (user) await saveDecryptedMessages(user.id, room.id, msgs); - if (msgs.length > 0) { - setCursor(msgs[0].timestamp.toISOString()); - } else { - setCursor(null); - } - setHasMore(msgs.length === messageLimit); - if (selectedRoom() === room.id) { - setMessages(msgs); - } - const lastMessage = msgs.length > 0 ? msgs[msgs.length - 1] : undefined; - updateRoomLast(room.id, lastMessage); - // 招待のみで未参加なら送信を抑止(参加後に自動解除) - try { - const g = groups()[room.id]; - if (g && user) { - const selfHandle = `${user.userName}@${getDomain()}`; - const members = extractMembers(g).map((x) => normalizeHandle(x) ?? x) - .filter((v): v is string => !!v); - setPartnerHasKey(members.includes(selfHandle)); - } - } catch { /* ignore */ } - }; - - const loadOlderMessages = async (room: Room) => { - if (!hasMore() || loadingOlder()) return; - setLoadingOlder(true); - const msgs = await fetchMessagesForRoom(room, { - limit: messageLimit, - before: cursor() ?? undefined, - }); - if (msgs.length > 0 && selectedRoom() === room.id) { - setCursor(msgs[0].timestamp.toISOString()); - setMessages((prev) => { - const next = [...msgs, ...prev]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(room.id)]: next, - }); - const user = account(); - if (user) void saveDecryptedMessages(user.id, room.id, next); - return next; - }); - } - setHasMore(msgs.length === messageLimit); - setLoadingOlder(false); - }; - - const extractMembers = (state: StoredGroupState): string[] => { - const list: string[] = []; - const tree = state.ratchetTree as unknown as { - nodeType: string; - leaf?: { credential?: { identity?: Uint8Array } }; - }[]; - for (const node of tree) { - if (node?.nodeType === "leaf") { - const id = node.leaf?.credential?.identity; - if (id) list.push(new TextDecoder().decode(id)); - } - } - return list; - }; - - const loadRooms = async () => { - const user = account(); - if (!user) return; - const rooms: Room[] = [ - { - id: "memo", - name: "TAKO Keep", - userName: user.userName, - domain: getDomain(), - avatar: "📝", - unreadCount: 0, - type: "memo", - members: [`${user.userName}@${getDomain()}`], - lastMessage: "...", - lastMessageTime: undefined, - }, - ]; - const handle = `${user.userName}@${getDomain()}` as ActorID; - // 暗黙のルーム(メッセージ由来)は除外して、明示的に作成されたもののみ取得 - const serverRooms = await searchRooms(user.id, { implicit: "include" }); - for (const item of serverRooms) { - const state = groups()[item.id]; - const name = ""; - const icon = ""; - // 参加者は MLS の leaf から導出。MLS が未同期の場合は pending 招待から暫定的に補完(UI表示用) - let members = state - ? extractMembers(state) - .map((m) => normalizeHandle(m as ActorID) ?? m) - .filter((m) => (normalizeHandle(m as ActorID) ?? m) !== handle) - : [] as string[]; - if (members.length === 0) { - try { - const pend = await readPending(user.id, item.id); - const others = (pend || []).filter((m) => m && m !== handle); - if (others.length > 0) members = others; - } catch { - /* ignore */ - } - } - rooms.push({ - id: item.id, - name, - userName: user.userName, - domain: getDomain(), - avatar: icon || (name ? name.charAt(0).toUpperCase() : "👥"), - unreadCount: 0, - type: "group", - members, - hasName: false, - hasIcon: false, - lastMessage: "...", - lastMessageTime: undefined, - }); - } - - await applyDisplayFallback(rooms); - - const unique = rooms.filter( - (room, idx, arr) => arr.findIndex((r) => r.id === room.id) === idx, - ); - setChatRooms(unique); - // 初期表示のため、各ルームの最新メッセージをバックグラウンドで取得し一覧のプレビューを更新 - // (選択中ルーム以外は本文状態には反映せず、lastMessage/lastMessageTime のみ更新) - void (async () => { - for (const r of unique) { - try { - const msgs = await fetchMessagesForRoom(r, { - limit: 1, - dryRun: true, - }); - if (msgs.length > 0) { - updateRoomLast(r.id, msgs[msgs.length - 1]); - } - } catch (e) { - // ネットワーク不通や復号不可などは致命的ではないため一覧更新のみ諦める - console.warn("最新メッセージの事前取得に失敗しました", r.id, e); - } - } - })(); - }; - - const applyDisplayFallback = async (rooms: Room[]) => { - const user = account(); - if (!user) return; - const selfHandle = `${user.userName}@${getDomain()}` as ActorID; - // 参加者は MLS の leaf から導出済みの room.members のみを信頼(APIやpendingは使わない) - const uniqueOthers = (r: Room): string[] => - (r.members ?? []).filter((m) => m && m !== selfHandle); - - // MLS 同期前の暫定表示: members が空のルームは pending 招待から1名だけでも補完 - for (const r of rooms) { - try { - if ((r.members?.length ?? 0) === 0 && r.type !== "memo") { - const pend = await readPending(user.id, r.id); - const cand = (pend || []).filter((m) => m && m !== selfHandle); - if (cand.length > 0) { - r.members = [cand[0]]; - } - } - } catch { - // ignore - } - } - const totalMembers = (r: Room) => 1 + uniqueOthers(r).length; // 自分+その他 - // 事前補正: 2人想定で名前が自分の表示名/ユーザー名のときは未命名として扱う - for (const r of rooms) { - if (r.type === "memo") continue; - const others = uniqueOthers(r); - // 自分の名前がタイトルに入ってしまう誤表示を防止(相手1人または未確定0人のとき) - if ( - others.length <= 1 && - (r.name === user.displayName || r.name === user.userName) - ) { - r.displayName = ""; - r.hasName = false; - // アバターが自分の頭文字(1文字)なら一旦消して再計算に委ねる - const selfInitial = (user.displayName || user.userName || "").charAt(0) - .toUpperCase(); - if ( - typeof r.avatar === "string" && r.avatar.length === 1 && - r.avatar.toUpperCase() === selfInitial - ) { - r.avatar = ""; - } - } - } - - const twoNoName = rooms.filter((r) => - r.type !== "memo" && totalMembers(r) === 2 && !(r.hasName || r.hasIcon) - ); - const ids = twoNoName - .map((r) => uniqueOthers(r)[0]) - .filter((v): v is string => !!v); - if (ids.length > 0) { - const infos = await fetchUserInfoBatch(ids, user.id); - for (let i = 0; i < twoNoName.length; i++) { - const info = infos[i]; - const r = twoNoName[i]; - if (info) { - r.displayName = info.displayName || info.userName; - r.avatar = info.authorAvatar || r.avatar; - // 参加者リストは MLS 由来を保持する(表示名のみ補完) - } - } - } - // 3人以上の自動生成(簡易) - const multi = rooms.filter((r) => - r.type !== "memo" && totalMembers(r) >= 3 && !(r.hasName) + const payload = msg.payload as { + id: string; + from: string; + to: string; + content: string; + }; + const self = me(); + const other = payload.from === self ? payload.to : payload.from; + ensureRoom(other); + const chatMsg: ChatMessage = { + id: payload.id, + author: payload.from, + displayName: payload.from, + address: payload.from, + content: payload.content, + timestamp: new Date(), + type: "text", + isMe: payload.from === self, + }; + setMessages((prev) => ({ + ...prev, + [other]: [...(prev[other] ?? []), chatMsg], + })); + setRooms((prev) => + prev.map((r) => + r.id === other + ? { + ...r, + lastMessage: payload.content, + lastMessageTime: new Date(), + unreadCount: r.id === selectedRoom() || payload.from === self + ? 0 + : r.unreadCount + 1, + } + : r + ) ); - const needIds = Array.from(new Set(multi.flatMap((r) => uniqueOthers(r)))); - if (needIds.length > 0) { - const infos = await fetchUserInfoBatch(needIds, user.id); - const map = new Map(); - for (let i = 0; i < needIds.length; i++) map.set(needIds[i], infos[i]); - for (const r of multi) { - const names = uniqueOthers(r).map((m) => - map.get(m)?.displayName || map.get(m)?.userName - ).filter(Boolean) as string[]; - const top = names.slice(0, 2); - const rest = Math.max(0, names.length + 1 - top.length - 1); // +1 = 自分 - r.displayName = top.length > 0 - ? `${top.join("、")}${rest > 0 ? ` ほか${rest}名` : ""}` - : r.displayName ?? r.name; - r.avatar = r.avatar || "👥"; - } - } }; - const openRoomDialog = (friendId?: string) => { - setGroupDialogMode("create"); - setInitialMembers(friendId ? [friendId] : []); - setShowGroupDialog(true); - }; - - const createRoom = async ( - name: string, - membersInput: string, - autoOpen = true, - ) => { - const user = account(); - if (!user) return; - const members = membersInput - .split(",") - .map((m) => normalizeActor(m.trim() as ActorID)) - .filter((m): m is string => !!m); - if (members.length === 0) return; - const me = `${user.userName}@${getDomain()}`; - if (!members.includes(me)) members.push(me); - const others = members.filter((m) => m !== me); - // すべてのトークは同等。毎回新規作成してサーバ保存する - const finalName = (name ?? "").trim(); - - const newId = crypto.randomUUID(); - const room: Room = { - id: newId, - name: finalName || "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - // UI表示用に招待先を入れておく(MLS同期後は state 由来に上書きされる) - members: others, - hasName: Boolean(finalName), - hasIcon: false, - lastMessage: "...", - lastMessageTime: undefined, - }; - try { - await applyDisplayFallback([room]); - } catch (e) { - console.error("相手の表示情報取得に失敗しました", e); - } - upsertRoom(room); - await initGroupState(room.id); - try { - await addRoom( - user.id, - { id: room.id, name: room.name, members }, - { from: me, content: "hi", to: members }, - ); - } catch (e) { - console.error("ルーム作成に失敗しました", e); - } - // MLS 即時開始: 可能なら相手の KeyPackage を取得して Add→Commit→Welcome を送信 - try { - const group = groups()[room.id]; - if (group) { - const kpInputs: { - content: string; - actor?: string; - deviceId?: string; - }[] = []; - for (const h of others) { - const [uname, dom] = splitActor(h as ActorID); - const kps = await fetchKeyPackages(uname, dom); - if (kps && kps.length > 0) { - const kp = pickUsableKeyPackage( - kps as unknown as { - content: string; - expiresAt?: string; - used?: boolean; - deviceId?: string; - }[], - ); - if (!kp) continue; - const actor = dom ? `https://${dom}/users/${uname}` : undefined; - kpInputs.push({ - content: kp.content, - actor, - deviceId: kp.deviceId, - }); - } - } - if (kpInputs.length > 0) { - const resAdd = await createCommitAndWelcomes(group, kpInputs); - const commitContent = encodePublicMessage(resAdd.commit); - const ok = await sendHandshake( - room.id, - `${user.userName}@${getDomain()}`, - commitContent, - // ルーム作成時は members が最新のロスター - members, - ); - if (ok) { - for (const w of resAdd.welcomes) { - const wContent = encodePublicMessage(w.data); - const wk = await sendHandshake( - room.id, - `${user.userName}@${getDomain()}`, - wContent, - members, - ); - if (!wk) break; - } - let gstate: StoredGroupState = resAdd.state; - const meta = await sendGroupMetadata( - room.id, - `${user.userName}@${getDomain()}`, - gstate, - members, - { name: room.name, icon: room.avatar }, - ); - if (meta) gstate = meta; - setGroups({ ...groups(), [room.id]: gstate }); - saveGroupStates(); - // 招待中として登録(Join後に設定画面で自動的にメンバー側へ移動) - await addPendingInvites(user.id, room.id, others); - } - } - // UI上は常に招待中として表示(Joinしたら自動的にメンバーへ移動) - await addPendingInvites(user.id, room.id, others); - } - } catch (e) { - console.warn("作成時のAdd/Welcome送信に失敗しました", e); - } - if (autoOpen) setSelectedRoom(room.id); - setShowGroupDialog(false); - }; + onMount(() => addMessageHandler(handleWs)); + onCleanup(() => removeMessageHandler(handleWs)); - const removeActorLeaves = async (actorId: string): Promise => { - const roomId = selectedRoom(); - const user = account(); - if (!roomId || !user) return false; - const group = groups()[roomId]; - if (!group) return false; - try { - const records = await loadKeyPackageRecords(user.id, roomId); - const indices = Array.from( - new Set( - records.filter((r) => r.actorId === actorId).map((r) => r.leafIndex), - ), - ); - if (indices.length === 0) return false; - const res = await removeMembers(group, indices); - const content = encodePublicMessage(res.commit); - const room = chatRooms().find((r) => r.id === roomId); - const toList = participantsFromState(roomId).length > 0 - ? participantsFromState(roomId) - : (room?.members ?? []).filter((m) => !!m); - const ok = await sendHandshake( - roomId, - `${user.userName}@${getDomain()}`, - content, - toList, + createEffect(() => { + const id = selectedRoom(); + if (id) { + ensureRoom(id); + void loadMessages(id); + setRooms((prev) => + prev.map((r) => r.id === id ? { ...r, unreadCount: 0 } : r) ); - if (!ok) return false; - setGroups({ ...groups(), [roomId]: res.state }); - await saveGroupStates(); - await apiFetch(`/ap/rooms/${encodeURIComponent(roomId)}/members`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "Remove", object: actorId }), - }); - return true; - } catch (e) { - console.error("メンバー削除に失敗しました", e); - return false; } - }; + }); const sendMessage = async () => { const text = newMessage().trim(); - const roomId = selectedRoom(); - const user = account(); - if (!text && !mediaFile() || !roomId || !user) return; - const room = chatRooms().find((r) => r.id === roomId); - if (!room) return; - if (room.type === "memo") { - const res = await sendKeepMessage( - `${user.userName}@${getDomain()}`, - text, - ); - if (!res) { - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "error", - title: "保存エラー", - description: "メモの保存に失敗しました", - }, - }), - ); - return; - } + const id = selectedRoom(); + const from = me(); + if (!text || !id || !from) return; + const res = await apiFetch("/api/dm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from, to: id, content: text }), + }); + if (res.ok) { + const doc = await res.json() as { id: string }; const msg: ChatMessage = { - id: res.id, - author: `${user.userName}@${getDomain()}`, - displayName: user.displayName || user.userName, - address: `${user.userName}@${getDomain()}`, - content: res.content, - timestamp: new Date(res.createdAt), - type: "text", - isMe: true, - avatar: room.avatar, - }; - // まだメモが選択中かを確認してからUIに反映 - if (selectedRoom() === room.id) { - setMessages((prev) => [...prev, msg]); - } - // 部屋ごとのキャッシュと永続化を更新 - setMessagesByRoom((prev) => { - const key = roomCacheKey(room.id); - const list = (prev[key] ?? []).concat(msg); - const next = { ...prev, [key]: list }; - const user2 = account(); - if (user2) void saveDecryptedMessages(user2.id, room.id, list); - return next; - }); - setNewMessage(""); - setMediaFile(null); - setMediaPreview(null); - return; - } - if (!partnerHasKey()) { - alert("このユーザーは暗号化された会話に対応していません。"); - return; - } - // クライアント側で仮のメッセージIDを生成しておく - const localId = crypto.randomUUID(); - const note: Record = { - "@context": "https://www.w3.org/ns/activitystreams", - type: "Note", - id: `urn:uuid:${localId}`, - content: text, - }; - if (mediaFile()) { - const file = mediaFile()!; - const att = await buildAttachment(file); - if (att) note.attachment = [att]; - } - let group = groups()[roomId]; - if (!group) { - await initGroupState(roomId); - group = groups()[roomId]; - if (!group) { - alert("グループ初期化に失敗したため送信できません"); - return; - } - } - // 必要であれば、相手の KeyPackage を使って Add→Commit→Welcome を先行送信 - try { - const self = `${user.userName}@${getDomain()}`; - const current = participantsFromState(roomId); - const targets = (room.members ?? []).filter((m) => m && m !== self); - const need = targets.filter((t) => !current.includes(t)); - if (need.length > 0) { - const kpInputs: { - content: string; - actor?: string; - deviceId?: string; - }[] = []; - for (const h of need) { - const [uname, dom] = splitActor(h as ActorID); - const kps = await fetchKeyPackages(uname, dom); - if (kps && kps.length > 0) { - const kp = pickUsableKeyPackage( - kps as unknown as { - content: string; - expiresAt?: string; - used?: boolean; - deviceId?: string; - }[], - ); - if (!kp) continue; - const actor = dom ? `https://${dom}/users/${uname}` : undefined; - kpInputs.push({ - content: kp.content, - actor, - deviceId: kp.deviceId, - }); - } - } - if (kpInputs.length > 0) { - const resAdd = await createCommitAndWelcomes(group, kpInputs); - const commitContent = encodePublicMessage(resAdd.commit); - const toList = Array.from( - new Set([ - ...current, - ...need, - self, - ]), - ); - const ok = await sendHandshake( - roomId, - `${user.userName}@${getDomain()}`, - commitContent, - toList, - ); - if (!ok) throw new Error("Commit送信に失敗しました"); - for (const w of resAdd.welcomes) { - const wContent = encodePublicMessage(w.data); - const wk = await sendHandshake( - roomId, - `${user.userName}@${getDomain()}`, - wContent, - toList, - ); - if (!wk) throw new Error("Welcome送信に失敗しました"); - } - let gstate: StoredGroupState = resAdd.state; - const meta = await sendGroupMetadata( - roomId, - `${user.userName}@${getDomain()}`, - gstate, - toList, - { name: room.name, icon: room.avatar }, - ); - if (meta) gstate = meta; - group = gstate; - setGroups({ ...groups(), [roomId]: group }); - saveGroupStates(); - try { - const acc = account(); - if (acc) { - const participants = extractMembers(group).map((x) => - normalizeHandle(x) ?? x - ).filter((v): v is string => !!v); - await syncPendingWithParticipants(acc.id, roomId, participants); - } - } catch { - console.error("参加メンバーの同期に失敗しました"); - } - // 招待中に登録 - await addPendingInvites(user.id, roomId, need); - } - // UI上は常に招待中として表示 - await addPendingInvites(user.id, roomId, need); - } - } catch (e) { - console.warn("初回Add/Welcome処理に失敗しました", e); - } - // joinAck をルーム/端末ごとに一度だけ送る(永続化して再送を防止) - const ackCacheKey = `ackSent:${roomId}`; - try { - const sent = await getCacheItem(user.id, ackCacheKey); - if (!sent) { - const ackBody = JSON.stringify({ - type: "joinAck", - roomId, - deviceId: user.id, - }); - const ack = await encryptMessage(group, ackBody); - const ok = await sendEncryptedMessage( - roomId, - `${user.userName}@${getDomain()}`, - participantsFromState(roomId).length > 0 - ? participantsFromState(roomId) - : (room.members ?? []).map((m) => m || "").filter((v) => !!v), - { - content: bufToB64(ack.message), - mediaType: "message/mls", - encoding: "base64", - }, - ); - if (ok) { - group = ack.state; - setGroups({ ...groups(), [roomId]: group }); - saveGroupStates(); - await setCacheItem(user.id, ackCacheKey, true); - } - } - } catch (e) { - console.warn("joinAck の送信または永続化に失敗しました", e); - } - const msgEnc = await encryptMessage(group, JSON.stringify(note)); - let success = true; - { - const ok = await sendEncryptedMessage( - roomId, - `${user.userName}@${getDomain()}`, - participantsFromState(roomId).length > 0 - ? participantsFromState(roomId) - : (room.members ?? []).map((m) => m || "").filter((v) => !!v), - { - content: bufToB64(msgEnc.message), - mediaType: "message/mls", - encoding: "base64", - }, - ); - if (!ok) success = false; - } - if (!success) { - alert("メッセージの送信に失敗しました"); - return; - } - setGroups({ ...groups(), [roomId]: msgEnc.state }); - saveGroupStates(); - - // 楽観的に自分の送信メッセージをUIへ即時反映(再取得は行わない) - try { - const meHandle = `${user.userName}@${getDomain()}`; - const dispName = user.displayName || user.userName; - let attachmentsUi: { - data?: string; - url?: string; - mediaType: string; - preview?: { url?: string; data?: string; mediaType?: string }; - }[] | undefined; - if (mediaFile()) { - const file = mediaFile()!; - const purl = mediaPreview(); - attachmentsUi = [{ - mediaType: file.type || "application/octet-stream", - ...(purl ? { url: purl } : {}), - }]; - } - const optimistic: ChatMessage = { - id: localId, - author: meHandle, - displayName: dispName, - address: meHandle, + id: doc.id, + author: from, + displayName: from, + address: from, content: text, - attachments: attachmentsUi, timestamp: new Date(), - type: attachmentsUi && attachmentsUi.length > 0 - ? attachmentsUi[0].mediaType.startsWith("image/") ? "image" : "file" - : "text", + type: "text", isMe: true, - avatar: room.avatar, }; - setMessages((old) => { - const next = [...old, optimistic]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(roomId)]: next, - }); - const user2 = account(); - if (user2) void saveDecryptedMessages(user2.id, roomId, next); - return next; - }); - updateRoomLast(roomId, optimistic); - } catch (e) { - console.warn("楽観表示の反映に失敗しました", e); - } - - // 入力欄と選択中のメディアをクリア - setNewMessage(""); - setMediaFile(null); - setMediaPreview(null); - }; - - // 画面サイズ検出 - const checkMobile = () => { - setIsMobile(globalThis.innerWidth < 768); - }; - - // モバイルでの部屋選択時の動作 - const selectRoom = (roomId: string) => { - console.log("selected room:", roomId); // for debug - setSelectedRoom(roomId); - if (isMobile()) { - setShowRoomList(false); // モバイルではチャット画面に切り替え + setMessages((prev) => ({ + ...prev, + [id]: [...(prev[id] ?? []), msg], + })); + setRooms((prev) => + prev.map((r) => + r.id === id + ? { ...r, lastMessage: text, lastMessageTime: new Date() } + : r + ) + ); + setNewMessage(""); } - // メッセージの取得は selectedRoom 監視の createEffect に任せる }; - // チャット一覧に戻る(モバイル用) - const backToRoomList = () => { - setShowRoomList(true); - setSelectedRoom(null); // チャンネル選択状態をリセット + const currentRoom = () => + rooms().find((r) => r.id === selectedRoom()) ?? null; + const currentMessages = () => { + const id = selectedRoom(); + return id ? messages()[id] ?? [] : []; }; - // イベントリスナーの設定 - onMount(() => { - checkMobile(); - globalThis.addEventListener("resize", checkMobile); - // ルーム情報はアカウント取得後の createEffect で読み込む - loadGroupStates(); - ensureKeyPair(); - - // WebSocket からのメッセージを安全に型ガードして処理する - interface IncomingAttachment { - url: string; - mediaType: string; - key?: string; - iv?: string; - preview?: { url?: string; data?: string; mediaType?: string }; - } - // WS はトリガー用: 本文は含まれない想定 - interface IncomingPayload { - id: string; - roomId?: string; - from: string; - to: string[]; - createdAt: string; - // 旧仕様互換のため任意に残す(存在しても使わない) - content?: string; - mediaType?: string; - encoding?: string; - attachments?: IncomingAttachment[]; - } - interface HandshakePayload { - id: string; - roomId: string; - sender: string; - recipients: string[]; - createdAt: string; - } - type IncomingMessage = - | { type: "handshake"; payload: HandshakePayload } - | { - type: "encryptedMessage" | "publicMessage"; - payload: IncomingPayload; - }; - const isStringArray = (v: unknown): v is string[] => - Array.isArray(v) && v.every((x) => typeof x === "string"); - - const isAttachment = (v: unknown): v is IncomingAttachment => - typeof v === "object" && - v !== null && - typeof (v as { url?: unknown }).url === "string" && - typeof (v as { mediaType?: unknown }).mediaType === "string" && - (typeof (v as { key?: unknown }).key === "string" || - typeof (v as { key?: unknown }).key === "undefined") && - (typeof (v as { iv?: unknown }).iv === "string" || - typeof (v as { iv?: unknown }).iv === "undefined"); - - const isPayload = (v: unknown): v is IncomingPayload => { - if (typeof v !== "object" || v === null) return false; - const o = v as Record; - const base = typeof o.id === "string" && - typeof o.from === "string" && - isStringArray(o.to) && - typeof o.createdAt === "string"; - if (!base) return false; - if (typeof o.attachments === "undefined") return true; - return Array.isArray(o.attachments) && o.attachments.every(isAttachment); - }; - - const isHandshakePayload = (v: unknown): v is HandshakePayload => { - if (typeof v !== "object" || v === null) return false; - const o = v as Record; - return typeof o.id === "string" && - typeof o.roomId === "string" && - typeof o.sender === "string" && - isStringArray(o.recipients) && - typeof o.createdAt === "string"; - }; - - const isIncomingMessage = (v: unknown): v is IncomingMessage => { - if (typeof v !== "object" || v === null) return false; - const o = v as Record; - const t = o.type; - if (t === "handshake") return isHandshakePayload(o.payload); - if (t !== "encryptedMessage" && t !== "publicMessage") return false; - return isPayload(o.payload); - }; - - const handler = async (msg: unknown) => { - // WS 経由で送られる pendingInvite は isIncomingMessage に含まれないため - // 先に専用に処理する(チャット一覧へプレースホルダを作成して同期する) - try { - if (typeof msg === "object" && msg !== null) { - const m = msg as Record; - if (typeof m.type === "string" && m.type === "pendingInvite") { - const payload = m.payload as Record | undefined; - if (payload && typeof payload.roomId === "string") { - const user = account(); - if (!user) return; - const self = `${user.userName}@${getDomain()}`; - // 既に一覧にあれば同期処理だけ行う - let room = chatRooms().find((r) => r.id === payload.roomId); - if (!room) { - const maybeFrom = typeof payload.from === "string" - ? payload.from - : undefined; - const others = Array.from( - new Set( - [maybeFrom].filter((m): m is string => - typeof m === "string" && m !== self - ), - ), - ); - const newRoom = { - id: payload.roomId, - name: "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: others, - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(newRoom); - try { - await applyDisplayFallback([newRoom]); - } catch { /* ignore */ } - await initGroupState(newRoom.id); - room = newRoom; - } - if (room) await syncHandshakes(room); - } - return; - } - } - } catch (e) { - console.warn("failed to handle pendingInvite message", e); - } - - if (!isIncomingMessage(msg)) { - // 想定外のメッセージは無視 - return; - } - const user = account(); - if (!user) return; - const self = `${user.userName}@${getDomain()}`; - - if (msg.type === "handshake") { - const data = msg.payload; - if (!(data.recipients.includes(self) || data.sender === self)) { - return; - } - - // 招待元がフォロー中かどうかを先に判定 - let isFollowing = false; - try { - const me = account(); - if (me) { - const following = await fetchFollowing(me.userName); - isFollowing = Array.isArray(following) - ? following.some((u: string) => - u === data.sender || u === normalizeActor(data.sender) - ) - : false; - } - } catch { - // 判定失敗時はフォロー外として扱う - isFollowing = false; - } - // 自分が送信者(招待した側)の場合は通知しない - if (data.sender === self) { - isFollowing = true; - } - - if (!isFollowing) { - // フォロー外の招待はサーバー側で通知化(ここでは案内のみ) - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "info", - title: "会話招待", - description: - `${data.sender} から会話招待が届きました(フォロー外)。通知に表示します。`, - duration: 5000, - }, - }), - ); - // フォロー外の場合は自動参加・同期しない - return; - } - - // フォロー中ならチャット一覧にプレースホルダを作成して同期 - let room = chatRooms().find((r) => r.id === data.roomId); - if (!room) { - const others = Array.from( - new Set([ - ...data.recipients, - data.sender, - ].filter((m) => m && m !== self)), - ); - room = { - id: data.roomId, - name: "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: others, - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - try { - await applyDisplayFallback([room]); - } catch { /* ignore */ } - await initGroupState(room.id); - } - if (room) await syncHandshakes(room); - return; - } - - const data = msg.payload; - // フィルタ: 自分宛て/自分発でないメッセージは無視 - if (!(data.to.includes(self) || data.from === self)) { - return; - } - - // まず roomId が来ていればそれで特定する(UUIDグループ等に強い) - let room = data.roomId - ? chatRooms().find((r) => r.id === data.roomId) - : undefined; - - const partnerId = data.from === self - ? (data.to.find((v) => v !== self) ?? data.to[0]) - : data.from; - - const normalizedPartner = normalizeActor(partnerId); - const [partnerName] = splitActor(normalizedPartner); - const uuidRe = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - if (!room) room = chatRooms().find((r) => r.id === partnerName); - if (!room) { - for (const t of data.to) { - const normalized = normalizeActor(t); - const [toName] = splitActor(normalized); - const g = chatRooms().find((r) => r.id === toName); - if (g) { - room = g; - break; - } - } - } - // 名前付き1:1ルームなど、IDがパートナーと一致しない場合のフォールバック - if (!room) { - room = chatRooms().find((r) => - (r.members?.length ?? 0) === 1 && - r.members.includes(normalizedPartner) - ); - } - if (!room && uuidRe.test(partnerName)) { - // グループIDと推測されるがまだ一覧に存在しない場合はルームを作成しない - return; - } - if (!room) { - room = chatRooms().find((r) => r.id === normalizedPartner); - if (!room) { - if ( - confirm( - `${normalizedPartner} からメッセージが届きました。許可しますか?`, - ) - ) { - const info = await fetchUserInfo(normalizeActor(normalizedPartner)); - if (info) { - room = { - id: normalizedPartner, - name: "", - displayName: info.displayName || info.userName, - userName: info.userName, - domain: info.domain, - avatar: info.authorAvatar || - info.userName.charAt(0).toUpperCase(), - unreadCount: 0, - type: "group" as const, - members: [normalizedPartner], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room!); - } else { - return; - } - } else { - return; - } - } - } - - const isMe = data.from === self; - if (!isMe) updatePeerHandle(room.id, data.from); - const selfH3 = `${user.userName}@${getDomain()}`; - const baseName3 = room.displayName ?? room.name; - const otherName = (!baseName3 || baseName3 === user.displayName || - baseName3 === user.userName || baseName3 === selfH3) - ? data.from - : baseName3; - const _displayName = isMe - ? (user.displayName || user.userName) - : otherName; - const _text: string = ""; - const _attachments: - | { - data?: string; - url?: string; - mediaType: string; - preview?: { url?: string; data?: string; mediaType?: string }; - }[] - | undefined = undefined; - const _localId: string | undefined = undefined; - - // WSは通知のみ: RESTから取得して反映 - if (msg.type === "encryptedMessage") { - // 自分が送信した直後の通知は再取得せず無視(ラチェット巻き戻り防止) - if (msg.payload.from === self) { - return; - } - const _isSelected = selectedRoom() === room.id; - if (room.type === "memo") return; // メモはWS対象外 - if (selectedRoom() === room.id) { - const prev = messages(); - const lastTs = prev.length > 0 - ? prev[prev.length - 1].timestamp.toISOString() - : undefined; - const fetched = await fetchMessagesForRoom( - room, - lastTs ? { after: lastTs } : { limit: 1 }, - ); - if (fetched.length > 0 && selectedRoom() === room.id) { - setMessages((old) => { - const ids = new Set(old.map((m) => m.id)); - const add = fetched.filter((m) => !ids.has(m.id)); - const next = [...old, ...add]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(room.id)]: next, - }); - const user = account(); - if (user) void saveDecryptedMessages(user.id, room.id, next); - return next; - }); - const last = fetched[fetched.length - 1]; - updateRoomLast(room.id, last); - } - } else { - // 一覧のみ更新(最新1件を取得してプレビュー) - const fetched = await fetchMessagesForRoom(room, { - limit: 1, - dryRun: true, - }); - if (fetched.length > 0) { - updateRoomLast(room.id, fetched[fetched.length - 1]); - } - } - return; - } - - // publicMessage 等の将来拡張が来た場合はRESTで取得する - if (room.type === "memo") return; // メモはWS対象外 - const fetched = await fetchMessagesForRoom(room, { - limit: 1, - dryRun: true, - }); - if (fetched.length > 0) { - const last = fetched[fetched.length - 1]; - if (selectedRoom() === room.id) { - setMessages((prev) => { - if (prev.some((x) => x.id === last.id)) return prev; - const next = [...prev, last]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(room.id)]: next, - }); - const user = account(); - if (user) void saveDecryptedMessages(user.id, room.id, next); - return next; - }); - } - updateRoomLast(room.id, last); - } - }; - // 通知画面からの「参加する」操作を受信して処理 - const onAcceptInvite = async (ev: Event) => { - const e = ev as CustomEvent<{ roomId: string; sender?: string }>; - const targetRoomId = e.detail?.roomId; - if (!targetRoomId) return; - const user = account(); - if (!user) return; - // 一覧になければプレースホルダを作成 - let room = chatRooms().find((r) => r.id === targetRoomId); - if (!room) { - room = { - id: targetRoomId, - name: "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: [], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - await initGroupState(room.id); - } - try { - await syncHandshakes(room); - const w = pendingWelcomes()[room.id]; - if (w) { - const pairs = await loadAllMLSKeyPairs(user.id); - let joined: StoredGroupState | null = null; - const list = pairs.length > 0 - ? pairs - : (await ensureKeyPair() ? [await ensureKeyPair()!] : []); - for (const p of list) { - try { - if (!p) throw new Error("key pair not prepared"); - const st = await joinWithWelcome(w, p); - joined = st; - break; - } catch { /* try next */ } - } - if (joined) { - // 参加成功: 自分の chatrooms に登録 - try { - await addRoom(user.id, { id: room.id }); - } catch { /* ignore */ } - setGroups({ ...groups(), [room.id]: joined }); - await saveGroupStates(); - setPendingWelcomes((prev) => { - const n = { ...prev }; - delete n[room!.id]; - return n; - }); - await loadMessages(room, true); - setSelectedRoom(room.id); - // 招待のACK(任意) - try { - await apiFetch( - `/api/users/${ - encodeURIComponent(user.userName) - }/pendingInvites/ack`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ roomId: room.id, deviceId: "" }), - }, - ); - } catch { /* ignore */ } - // サーバー側の chatrooms 登録反映を一覧に再取得 - try { - await loadRooms(); - } catch { /* ignore */ } - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "success", - title: "参加しました", - description: "会話に参加しました", - }, - }), - ); - } else { - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "error", - title: "参加に失敗", - description: "Welcomeの適用に失敗しました", - }, - }), - ); - } - } else { - // Welcome がまだ無い場合はルームを開いて手動参加に委ねる - setSelectedRoom(room.id); - } - } catch (err) { - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "error", - title: "参加に失敗", - description: String(err), - }, - }), - ); - } - }; - - globalThis.addEventListener( - "app:accept-invite", - onAcceptInvite as EventListener, - ); - acceptCleanup = () => - globalThis.removeEventListener( - "app:accept-invite", - onAcceptInvite as EventListener, - ); - - addMessageHandler(handler); - wsCleanup = () => removeMessageHandler(handler); - // 初期表示時のメッセージ読み込みも - // selectedRoom 監視の createEffect に任せる - adjustHeight(textareaRef); - }); - - // 保留中招待の同期: 初期ロード時に取得し、その後は WS 通知に任せる - createEffect(() => { - const user = account(); - if (!user) return; - void (async () => { - try { - const list = await fetchPendingInvites(user.userName); - for (const it of list) { - const rid = it.roomId; - if (!rid) continue; - let room = chatRooms().find((r) => r.id === rid); - if (!room) { - room = { - id: rid, - name: "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: [], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - await initGroupState(room.id); - } - await syncHandshakes(room); - } - } catch { /* ignore */ } - })(); - }); - - // 一覧のプレビュー更新を緩やかにポーリング(最大10件) - let previewPoller: number | undefined; - createEffect(() => { - const user = account(); - if (!user) return; - if (previewPoller) clearInterval(previewPoller); - previewPoller = setInterval(async () => { - try { - const rooms = chatRooms(); - const targets = rooms - .filter((r) => r.type !== "memo") - .slice(0, 10); - for (const r of targets) { - try { - const msgs = await fetchMessagesForRoom(r, { - limit: 1, - dryRun: true, - }); - if (msgs.length > 0) updateRoomLast(r.id, msgs[msgs.length - 1]); - } catch { /* ignore one */ } - } - } catch { /* ignore all */ } - }, 60_000) as unknown as number; - }); - - // ルーム一覧の読み込みはアカウント変更時と初期表示時のみ実行 - onMount(() => { - void loadRooms(); - }); - createEffect( - on( - () => account(), - () => { - void loadRooms(); - }, - ), - ); - - // MLS グループ状態の更新に合わせてメンバー/表示名を補正 - createEffect( - on( - () => groups(), - async () => { - const user = account(); - if (!user) return; - const list = chatRooms(); - if (list.length === 0) return; - - // members を MLS 由来に同期(変更がある場合のみ更新) - let changed = false; - const nextA = list.map((r) => { - if (r.type === "memo") return r; - const parts = participantsFromState(r.id); - if (parts.length === 0) return r; - const cur = r.members ?? []; - const equals = cur.length === parts.length && - cur.every((v, i) => v === parts[i]); - if (!equals) { - changed = true; - return { ...r, members: parts }; - } - return r; - }); - if (changed) setChatRooms(nextA); - - // 1対1・未命名の表示名補完(変更がある場合のみ更新) - // ただし UUID などグループIDのルームは対象外(誤ってDM扱いしない) - const base = changed ? nextA : list; - const uuidRe = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - const candidates = base.filter((r) => - r.type !== "memo" && (r.members?.length ?? 0) === 1 && - !(r.hasName || r.hasIcon) && !uuidRe.test(r.id) - ); - const ids = candidates.map((r) => r.members[0]).filter(( - v, - ): v is string => !!v); - if (ids.length === 0) return; - try { - const infos = await fetchUserInfoBatch(ids, user.id); - const map = new Map(); - for (let i = 0; i < ids.length; i++) map.set(ids[i], infos[i]); - let nameChanged = false; - const nextB = base.map((r) => { - if ( - r.type === "memo" || !(r.members?.length === 1) || - (r.hasName || r.hasIcon) - ) return r; - const info = map.get(r.members[0]); - if (!info) return r; - const newName = info.displayName || info.userName; - const newAvatar = info.authorAvatar || r.avatar; - if ( - (r.displayName ?? r.name) !== newName || r.avatar !== newAvatar - ) { - nameChanged = true; - return { ...r, displayName: newName, avatar: newAvatar }; - } - return r; - }); - if (nameChanged) setChatRooms(nextB); - } catch { - // ignore - } - }, - ), - ); - - createEffect( - on( - () => selectedRoom(), - async (roomId) => { - const selfRoomId = getSelfRoomId(account()); - if (!roomId) { - setMessages([]); - return; - } - - const normalizedRoomId = normalizeActor(roomId); - let room = chatRooms().find((r) => r.id === normalizedRoomId); - - // ルームが存在しない場合は作成を試行 - if (!room && normalizedRoomId !== selfRoomId) { - const info = await fetchUserInfo(normalizeActor(normalizedRoomId)); - const user = account(); - if (info && user) { - room = { - id: normalizedRoomId, - name: "", - displayName: info.displayName || info.userName, - userName: info.userName, - domain: info.domain, - avatar: info.authorAvatar || - info.userName.charAt(0).toUpperCase(), - unreadCount: 0, - type: "group", - members: [normalizedRoomId], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - } - } - - // ルームが見つかった場合は相手情報を補正した上でメッセージを読み込み - if (room) { - await ensureDmPartnerInfo(room); - await loadMessages(room, true); - } else if (roomId === selfRoomId) { - // セルフルーム(TAKO Keep)の場合は空のメッセージリストを設定 - setMessages([]); - } else { - setMessages([]); - } - }, - ), - ); - - // WS通知に反応して差分取得する方式へ移行(定期ポーリングは廃止) - - // 非選択ルームのプレビュー更新もWS通知時のみ(定期ポーリングは廃止) - - // 新規ルーム検出はWS handshake通知時と手動同期に限定(定期サーチは廃止) - - // URLから直接チャットを開いた場合、モバイルでは自動的にルーム表示を切り替える - createEffect(() => { - if (!isMobile()) return; - const roomId = selectedRoom(); - if (roomId && showRoomList()) { - setShowRoomList(false); - } else if (!roomId && !showRoomList()) { - setShowRoomList(true); - } - }); - - createEffect(() => { - account(); - loadGroupStates(); - ensureKeyPair(); - }); - - createEffect(() => { - groups(); - saveGroupStates(); - }); - - createEffect(() => { - newMessage(); - adjustHeight(textareaRef); - }); - - createEffect(() => { - if (!partnerHasKey()) { - alert("このユーザーは暗号化された会話に対応していません。"); - } - }); - - onCleanup(() => { - globalThis.removeEventListener("resize", checkMobile); - wsCleanup?.(); - acceptCleanup?.(); - if (previewPoller) clearInterval(previewPoller); - }); - - // APIベースのイベントで更新(WS不要運用向け) - onMount(async () => { - try { - const user = account(); - if (user) { - const cur = await getCacheItem(user.id, "eventsCursor"); - if (typeof cur === "string") setEventsCursor(cur); - } - } catch { /* ignore */ } - - const processEvents = async ( - evs: { - id: string; - type: string; - roomId?: string; - from?: string; - to?: string[]; - createdAt?: string; - }[], - ) => { - const user = account(); - if (!user) return; - let maxTs = eventsCursor(); - const byRoom = new Map< - string, - { handshake: boolean; message: boolean } - >(); - for (const ev of evs) { - const rid = ev.roomId; - if (!rid) continue; - const cur = byRoom.get(rid) || { handshake: false, message: false }; - if (ev.type === "handshake") cur.handshake = true; - if (ev.type === "encryptedMessage" || ev.type === "publicMessage") { - cur.message = true; - } - byRoom.set(rid, cur); - if (ev.createdAt && (!maxTs || ev.createdAt > maxTs)) { - maxTs = ev.createdAt; - } - } - for (const [rid, flg] of byRoom) { - let room = chatRooms().find((r) => r.id === rid); - if (!room) { - room = { - id: rid, - name: "", - userName: account()?.userName || "", - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: [], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - try { - await applyDisplayFallback([room]); - } catch { /* ignore */ } - await initGroupState(rid); - } - if (room && flg.handshake) await syncHandshakes(room); - if (room && flg.message) { - const isSel = selectedRoom() === rid; - if (isSel) { - const prev = messages(); - const lastTs = prev.length > 0 - ? prev[prev.length - 1].timestamp.toISOString() - : undefined; - const fetched = await fetchMessagesForRoom( - room, - lastTs ? { after: lastTs } : { limit: 1 }, - ); - if (fetched.length > 0) { - setMessages((old) => { - const ids = new Set(old.map((m) => m.id)); - const add = fetched.filter((m) => !ids.has(m.id)); - const next = [...old, ...add]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(rid)]: next, - }); - const user2 = account(); - if (user2) void saveDecryptedMessages(user2.id, rid, next); - return next; - }); - updateRoomLast(rid, fetched[fetched.length - 1]); - } - } else { - const fetched = await fetchMessagesForRoom(room, { - limit: 1, - dryRun: true, - }); - if (fetched.length > 0) { - updateRoomLast(rid, fetched[fetched.length - 1]); - } - } - } - } - if (maxTs) { - setEventsCursor(maxTs); - try { - const user2 = account(); - if (user2) await setCacheItem(user2.id, "eventsCursor", maxTs); - } catch { /* ignore */ } - } - }; - - const syncOnce = async () => { - try { - const evs = await fetchEvents({ - since: eventsCursor() ?? undefined, - limit: 100, - }); - if (evs.length > 0) await processEvents(evs); - } catch { /* ignore */ } - }; - - await syncOnce(); - const onFocus = () => void syncOnce(); - globalThis.addEventListener("focus", onFocus); - globalThis.addEventListener("online", onFocus); - globalThis.addEventListener("visibilitychange", () => { - if (!document.hidden) void syncOnce(); - }); - onCleanup(() => { - globalThis.removeEventListener("focus", onFocus); - globalThis.removeEventListener("online", onFocus); - }); - }); - return ( <> -
-
- {/* ルームリスト */} -
- openRoomDialog()} - segment={segment()} - onSegmentChange={setSegment} - onCreateFriendRoom={(friendId: string) => { - openRoomDialog(friendId); - }} +
+
+ setSelectedRoom(id)} + showAds={false} + onCreateRoom={() => {}} + segment={segment()} + onSegmentChange={setSegment} + /> +
+
+ setSelectedRoom(null)} + onOpenSettings={() => setShowSettings(true)} + /> +
+ {}} />
-
- -
-
- - - -
-

- {isMobile() ? "チャンネルを選択" : "チャンネルを選択"} -

-

- {isMobile() - ? "チャンネルを選択してください" - : "左のサイドバーからチャンネルを選択して会話を開始しましょう"} -

-
-
- } - > -
- m !== selfHandle) ?? - r.members[0]; - const isDm = r.type !== "memo" && - (r.members?.length ?? 0) === 1 && - !(r.hasName || r.hasIcon); - const looksLikeSelf = me && - (r.name === me.displayName || r.name === me.userName); - if (isDm || looksLikeSelf) { - const other = rawOther && rawOther !== selfHandle - ? (normalizeHandle(rawOther) ?? null) - : null; - return { ...r, name: other ?? (r.name || "不明") }; - } - return r; - })()} - onBack={backToRoomList} - onOpenSettings={() => setShowSettings(true)} - showSettings={(function () { - const r = selectedRoomInfo(); - return r ? r.type !== "memo" : true; - })()} - bindingStatus={(function () { - const r = selectedRoomInfo(); - return r && r.type !== "memo" ? bindingStatus() : null; - })()} - bindingInfo={(function () { - const r = selectedRoomInfo(); - return r && r.type !== "memo" ? bindingInfo() : null; - })()} - ktInfo={(function () { - const r = selectedRoomInfo(); - return r && r.type !== "memo" ? ktInfo() : null; - })()} - /> - {/* 旧 group 操作UIは削除(イベントソース派生に移行) */} - { - const roomId = selectedRoom(); - if (roomId) { - const room = chatRooms().find((r) => r.id === roomId); - if (room) loadOlderMessages(room); - } - }} - /> - {/* Welcome 受信時の参加確認バナー */} - -
-
- この会話に招待されています。参加しますか? -
-
- - -
-
-
- -
- -
+ {}} + mediaPreview={null} + setMediaPreview={() => {}} + sendMessage={sendMessage} + allowMedia={false} + />
- { - setShowGroupDialog(false); - }} - onCreate={createRoom} - initialMembers={initialMembers()} - /> setShowSettings(false)} - onRoomUpdated={(partial) => { - const id = selectedRoom(); - if (!id) return; - setChatRooms((prev) => - prev.map((r) => r.id === id ? { ...r, ...partial } : r) - ); - }} - bindingStatus={bindingStatus()} - bindingInfo={bindingInfo()} - ktInfo={ktInfo()} - onRemoveMember={removeActorLeaves} /> ); } - -function splitActor(actor: ActorID): [string, string | undefined] { - if (actor.startsWith("http")) { - const url = new URL(actor); - return [url.pathname.split("/").pop()!, url.hostname]; - } - if (actor.includes("@")) { - const [user, domain] = actor.split("@"); - return [user, domain]; - } - return [actor, undefined]; -} - -function normalizeActor(actor: ActorID): string { - if (actor.startsWith("http")) { - try { - const url = new URL(actor); - const name = url.pathname.split("/").pop()!; - return `${name}@${url.hostname}`; - } catch { - return actor; - } - } - return actor; -} - -// 招待中のローカル管理(設定オーバーレイが参照) -const cacheKeyPending = (roomId: string) => `pendingInvites:${roomId}`; -async function readPending( - accountId: string, - roomId: string, -): Promise { - const raw = await getCacheItem(accountId, cacheKeyPending(roomId)); - return Array.isArray(raw) - ? (raw as unknown[]).filter((v) => typeof v === "string") as string[] - : []; -} -async function writePending(accountId: string, roomId: string, ids: string[]) { - const uniq = Array.from(new Set(ids)); - await setCacheItem(accountId, cacheKeyPending(roomId), uniq); -} -async function addPendingInvites( - accountId: string, - roomId: string, - ids: string[], -) { - const cur = await readPending(accountId, roomId); - await writePending(accountId, roomId, [...cur, ...ids]); -} -async function _removePendingInvite( - accountId: string, - roomId: string, - id: string, -) { - const cur = (await readPending(accountId, roomId)).filter((v) => v !== id); - await writePending(accountId, roomId, cur); -} -async function syncPendingWithParticipants( - accountId: string, - roomId: string, - participants: string[], -) { - const present = new Set(participants); - const cur = await readPending(accountId, roomId); - const next = cur.filter((v) => !present.has(v)); - await writePending(accountId, roomId, next); -} - -function pickUsableKeyPackage( - list: { - content: string; - expiresAt?: string; - used?: boolean; - deviceId?: string; - lastResort?: boolean; - }[], -): - | { - content: string; - expiresAt?: string; - used?: boolean; - deviceId?: string; - lastResort?: boolean; - } - | null { - const now = Date.now(); - const normal = list.filter((k) => !k.lastResort); - const lastResort = list.filter((k) => k.lastResort); - const usableNormal = normal.filter((k) => - !k.used && (!k.expiresAt || Date.parse(k.expiresAt) > now) - ); - if (usableNormal.length > 0) return usableNormal[0]; - // 通常キーが無い場合のみ lastResort を候補にする(unused/未期限切れ優先) - const usableLR = lastResort.filter((k) => - !k.used && (!k.expiresAt || Date.parse(k.expiresAt) > now) - ); - if (usableLR.length > 0) return usableLR[0]; - // それでも無ければ全体から最初 - return list[0] ?? null; -} - -function normalizeHandle(actor: ActorID): string | null { - if (actor.startsWith("http")) { - try { - const url = new URL(actor); - const name = url.pathname.split("/").pop()!; - return `${name}@${url.hostname}`; - } catch { - return null; - } - } - if (actor.includes("@")) return actor; - // 裸の文字列(displayName/uuid等)はハンドルとみなさない - return null; -} diff --git a/app/client/src/components/Profile.tsx b/app/client/src/components/Profile.tsx index 820a44b65..9d4ff83d2 100644 --- a/app/client/src/components/Profile.tsx +++ b/app/client/src/components/Profile.tsx @@ -8,7 +8,6 @@ import { } from "./microblog/api.ts"; import { PostList } from "./microblog/Post.tsx"; import { UserAvatar } from "./microblog/UserAvatar.tsx"; -import { addRoom } from "./e2ee/api.ts"; import { accounts as accountsAtom, activeAccount, @@ -240,17 +239,11 @@ export default function Profile() { return actor; }; - const openChat = async () => { + const openChat = () => { const name = username(); const user = account(); if (!name || !user) return; const handle = normalizeActor(name); - const me = `${user.userName}@${getDomain()}`; - await addRoom( - user.id, - { id: handle, name: handle, members: [handle, me] }, - { from: me, content: "hi", to: [handle, me] }, - ); setRoom(handle); setApp("chat"); }; diff --git a/app/client/src/components/Setting/index.tsx b/app/client/src/components/Setting/index.tsx index 66bab38a9..8c09851af 100644 --- a/app/client/src/components/Setting/index.tsx +++ b/app/client/src/components/Setting/index.tsx @@ -5,24 +5,13 @@ import { } from "../../states/settings.ts"; import { loginState } from "../../states/session.ts"; import { apiFetch } from "../../utils/config.ts"; -import { - accounts as accountsAtom, - activeAccount, -} from "../../states/account.ts"; -import { deleteMLSDatabase } from "../e2ee/storage.ts"; import { FaspProviders } from "./FaspProviders.tsx"; -import { useMLS } from "../e2ee/useMLS.ts"; -import { Show } from "solid-js"; export function Setting() { const [language, setLanguage] = useAtom(languageState); const [postLimit, setPostLimit] = useAtom(microblogPostLimitState); const [, setIsLoggedIn] = useAtom(loginState); - const [accs] = useAtom(accountsAtom); - const [account] = useAtom(activeAccount); - const { generateKeys, status, error } = useMLS( - account()?.userName ?? "", - ); + // ログアウト処理のみで使用する状態 const handleLogout = async () => { try { @@ -30,11 +19,7 @@ export function Setting() { } catch (err) { console.error("logout failed", err); } finally { - for (const acc of accs()) { - await deleteMLSDatabase(acc.id); - } setIsLoggedIn(false); - localStorage.removeItem("encryptionKey"); } }; @@ -69,22 +54,6 @@ export function Setting() {

FASP 設定

-
-

MLS 鍵管理

- - -

{status()}

-
- -

{error()}

-
-
- - - -
- -
- -
- {error()} -
-
-
- -
-
- - setRoomName(e.currentTarget.value)} - class="w-full bg-[#2b2b2b] border border-[#3a3a3a] rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500" - placeholder="ルーム名" - /> -
-
- -
-
- {roomIcon() - ? ( - room icon - ) - : なし} -
- -
-
-
- -
-
-
- -
- -
- 状態: {props.bindingInfo!.label} - -

- {props.bindingInfo!.caution} -

-
- -

監査未検証

-
- - - -
-
-
-
- - setNewMember(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleAddMember(); - } - }} - placeholder="@alice@example.com" - class="w-full bg-[#2b2b2b] border border-[#3a3a3a] rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500 text-sm" - /> -
- -
-
-

- メンバー一覧 -

-
- 0}> -
-

メンバー

-
- - {(m) => ( -
-
- {m.avatar - ? ( - {m.display} - ) - : m.display[0]} -
-
-

- {m.display} -

-

- {m.actor ?? m.id} -

-

- {m.bindingInfo.label} - - - {m.bindingInfo.caution} - - - - - 監査未検証 - - -

-
-
- - - - - - -
-
- )} -
-
-
-
- 0}> -
-
- - {(m) => ( -
-
- {m.avatar - ? ( - {m.display} - ) - : m.display[0]} -
-
-

- {m.display} -

-

- {m.actor ?? m.id} -

-
- {/* pending badge intentionally hidden */} -
- )} -
-
-
-
- -
メンバー無し
-
-
-
-
-
- -
-

- 外観設定(テーマ / 色 / 通知音 など)は今後拡張予定です。 -

-
-
-
- +
+
+

チャット設定

+

現在、設定項目はありません。

+
diff --git a/app/client/src/components/chat/ChatTitleBar.tsx b/app/client/src/components/chat/ChatTitleBar.tsx index a889cf595..881dbfc1f 100644 --- a/app/client/src/components/chat/ChatTitleBar.tsx +++ b/app/client/src/components/chat/ChatTitleBar.tsx @@ -4,7 +4,6 @@ import { isFriendRoom } from "./types.ts"; import { useAtom } from "solid-jotai"; import { activeAccount } from "../../states/account.ts"; import { getDomain } from "../../utils/config.ts"; -import type { BindingStatus } from "../e2ee/binding.ts"; interface ChatTitleBarProps { isMobile: boolean; @@ -12,9 +11,6 @@ interface ChatTitleBarProps { onBack: () => void; onOpenSettings: () => void; // 右上設定メニュー表示 showSettings?: boolean; - bindingStatus?: BindingStatus | null; - bindingInfo?: { label: string; caution?: string } | null; - ktInfo?: { included: boolean } | null; } export function ChatTitleBar(props: ChatTitleBarProps) { @@ -102,22 +98,6 @@ export function ChatTitleBar(props: ChatTitleBarProps) {

{titleFor(props.selectedRoom)}

- - - {props.bindingInfo!.label} - - - - - {props.bindingInfo!.caution} - - - - 監査未検証 -
diff --git a/app/client/src/components/e2ee/api.ts b/app/client/src/components/e2ee/api.ts deleted file mode 100644 index 40cac026d..000000000 --- a/app/client/src/components/e2ee/api.ts +++ /dev/null @@ -1,1224 +0,0 @@ -import { apiFetch, getKpPoolSize, getDomain } from "../../utils/config.ts"; -import { decodeGroupInfo, encodePublicMessage } from "./mls_message.ts"; -import { - encryptMessage, - type GeneratedKeyPair, - generateKeyPair, - joinWithGroupInfo, - type RawKeyPackageInput, - type RosterEvidence, - type StoredGroupState, - updateKey, - verifyGroupInfo, - verifyKeyPackage, -} from "./mls_wrapper.ts"; -import { bufToB64 } from "../../../../shared/buffer.ts"; -import { - appendKeyPackageRecords, - appendRosterEvidence, - loadKeyPackageRecords, - saveMLSKeyPair, -} from "./storage.ts"; -import { decodeMlsMessage } from "ts-mls"; - -const bindingErrorMessages: Record = { - "ap_mls.binding.identity_mismatch": - "Credentialのidentityがアクターと一致しません", - "ap_mls.binding.policy_violation": "KeyPackageの形式が不正です", -}; - -export interface KeyPackage { - id: string; - type: "KeyPackage"; - content: string; - mediaType: string; - encoding: string; - groupInfo?: string; - expiresAt?: string; - used?: boolean; - createdAt: string; - attributedTo?: string; - deviceId?: string; - keyPackageRef?: string; - lastResort?: boolean; -} - -export interface EncryptedMessage { - id: string; - roomId: string; - from: string; - to: string[]; - content: string; - mediaType: string; - encoding: string; - createdAt: string; - attachments?: { - url: string; - mediaType: string; - key?: string; - iv?: string; - }[]; -} - -export const fetchKeyPackages = async ( - user: string, - domain?: string, -): Promise => { - try { - const identifier = domain ? `${user}@${domain}` : user; - const res = await apiFetch( - `/api/users/${encodeURIComponent(identifier)}/keyPackages`, - ); - if (!res.ok) { - throw new Error("Failed to fetch key packages"); - } - const data = await res.json(); - const items = Array.isArray(data.items) ? data.items : []; - const result: KeyPackage[] = []; - for (const item of items) { - if (typeof item.groupInfo === "string") { - const bytes = decodeGroupInfo(item.groupInfo); - if (!bytes || !(await verifyGroupInfo(bytes))) { - delete item.groupInfo; - } - } - const expected = typeof item.attributedTo === "string" - ? item.attributedTo - : domain - ? `https://${domain}/users/${user}` - : new URL(`/users/${user}`, globalThis.location.origin).href; - if (!await verifyKeyPackage(item.content, expected)) { - continue; - } - result.push(item as KeyPackage); - } - return result; - } catch (err) { - console.error("Error fetching key packages:", err); - return []; - } -}; - -export const addKeyPackage = async ( - user: string, - pkg: { - content: string; - mediaType?: string; - encoding?: string; - groupInfo?: string; - expiresAt?: string; - lastResort?: boolean; - }, -): Promise< - { keyId: string | null; groupInfo?: string; keyPackageRef?: string } -> => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(pkg), - }, - ); - if (!res.ok) { - let msg = "KeyPackageの登録に失敗しました"; - try { - const err = await res.json(); - if (typeof err.error === "string") { - msg = bindingErrorMessages[err.error] ?? err.error; - } - } catch (_) { - /* noop */ - } - throw new Error(msg); - } - const data = await res.json(); - let gi = typeof data.groupInfo === "string" ? data.groupInfo : undefined; - if (gi) { - const bytes = decodeGroupInfo(gi); - if (!bytes || !(await verifyGroupInfo(bytes))) { - gi = undefined; - } - } - return { - keyId: typeof data.keyId === "string" ? data.keyId : null, - groupInfo: gi, - keyPackageRef: typeof data.keyPackageRef === "string" - ? data.keyPackageRef - : undefined, - }; - } catch (err) { - console.error("Error adding key package:", err); - if (err instanceof Error) throw err; - throw new Error("KeyPackageの登録に失敗しました"); - } -}; - -export const addKeyPackagesBulk = async ( - items: { - user: string; - keyPackages: { - content: string; - mediaType?: string; - encoding?: string; - groupInfo?: string; - expiresAt?: string; - lastResort?: boolean; - deviceId?: string; - }[]; - }[], -): Promise => { - try { - // Normalize each keyPackage to include required fields expected by the server. - // Server requires mediaType === "message/mls" and encoding === "base64". - const normalized = items.map((it) => ({ - user: it.user, - keyPackages: it.keyPackages.map((kp) => ({ - content: kp.content, - mediaType: kp.mediaType ?? "message/mls", - encoding: kp.encoding ?? "base64", - groupInfo: kp.groupInfo, - expiresAt: kp.expiresAt, - lastResort: kp.lastResort, - deviceId: kp.deviceId, - })), - })); - const res = await apiFetch("/api/keyPackages/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(normalized), - }); - if (!res.ok) { - throw new Error("KeyPackageの登録に失敗しました"); - } - const data = await res.json(); - return Array.isArray(data.results) ? data.results : []; - } catch (err) { - console.error("Error adding key packages in bulk:", err); - if (err instanceof Error) throw err; - throw new Error("KeyPackageの登録に失敗しました"); - } -}; - -// KeyPackage 残数と lastResort 有無を取得 -export const fetchKeyPackageSummary = async ( - user: string, -): Promise<{ count: number; hasLastResort: boolean }> => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages?summary=1`, - ); - if (!res.ok) throw new Error("failed"); - const data = await res.json(); - return { - count: typeof data.count === "number" ? data.count : 0, - hasLastResort: Boolean(data.hasLastResort), - }; - } catch (e) { - console.error("Error fetching key package summary:", e); - return { count: 0, hasLastResort: false }; - } -}; - -// 一括で複数ユーザーの KeyPackage summary を取得する -export const fetchKeyPackageSummaries = async ( - users: string[], -): Promise<{ user: string; count: number; hasLastResort: boolean }[]> => { - try { - const res = await apiFetch(`/api/keyPackages/summary`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ users }), - }); - if (!res.ok) throw new Error("failed"); - const data = await res.json(); - return Array.isArray(data.results) ? data.results : []; - } catch (e) { - console.error("Error fetching key package summaries:", e); - return users.map((u) => ({ user: u, count: 0, hasLastResort: false })); - } -}; - -export const topUpKeyPackages = async ( - userName: string, - accountId: string, -): Promise => { - return await topUpKeyPackagesBulk([{ userName, accountId }]); -}; - -// 複数アカウントの KeyPackage をまとめて補充 -export const topUpKeyPackagesBulk = async ( - accounts: { userName: string; accountId: string }[], -): Promise => { - try { - const target = getKpPoolSize(); - if (!target || target <= 0) return false; - // Fetch summaries in bulk to avoid per-account network calls - const users = accounts.map((a) => a.userName); - const summaries = await fetchKeyPackageSummaries(users); - const summaryMap = new Map(); - for (const s of summaries) summaryMap.set(s.user, s); - - const uploads: { - user: string; - keyPackages: { content: string; lastResort?: boolean }[]; - }[] = []; - - for (const acc of accounts) { - const sum = summaryMap.get(acc.userName) ?? { user: acc.userName, count: 0, hasLastResort: false }; - const actor = `https://${getDomain()}/users/${acc.userName}`; - const kpList: { content: string; lastResort?: boolean }[] = []; - if (!sum.hasLastResort) { - try { - const kp = await generateKeyPair(actor); - await saveMLSKeyPair(acc.accountId, { - public: kp.public, - private: kp.private, - encoded: kp.encoded, - }); - kpList.push({ content: kp.encoded, lastResort: true }); - } catch (e) { - console.warn("lastResort KeyPackage 生成に失敗", e); - } - } - const need = target - sum.count; - for (let i = 0; i < need; i++) { - try { - const kp = await generateKeyPair(actor); - await saveMLSKeyPair(acc.accountId, { - public: kp.public, - private: kp.private, - encoded: kp.encoded, - }); - kpList.push({ content: kp.encoded }); - } catch (e) { - console.warn("KeyPackage 補充に失敗しました", e); - break; - } - } - if (kpList.length > 0) { - uploads.push({ user: acc.userName, keyPackages: kpList }); - } - } - if (uploads.length > 0) { - await addKeyPackagesBulk(uploads); - return true; - } - return false; - } catch (e) { - console.warn("KeyPackage プール確認に失敗しました", e); - return false; - } -}; - -export const fetchKeyPackage = async ( - user: string, - keyId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages/${ - encodeURIComponent(keyId) - }`, - ); - if (!res.ok) return null; - const data = await res.json(); - if (typeof data.groupInfo === "string") { - const bytes = decodeGroupInfo(data.groupInfo); - if (!bytes || !(await verifyGroupInfo(bytes))) { - delete data.groupInfo; - } - } - const expected = typeof data.attributedTo === "string" - ? data.attributedTo - : new URL(`/users/${user}`, globalThis.location.origin).href; - if (!await verifyKeyPackage(data.content, expected)) { - return null; - } - return data as KeyPackage; - } catch (err) { - console.error("Error fetching key package:", err); - return null; - } -}; - -export const markKeyPackagesUsedByRef = async ( - user: string, - refs: string[], -): Promise => { - if (!refs.length) return; - try { - await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages/markUsed`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ keyPackageRefs: refs }), - }, - ); - } catch (e) { - console.warn("markKeyPackagesUsedByRef failed", e); - } -}; - -// KeyPackage の URL を指定して Actor とのバインディングを検証しつつ取得する -export const fetchVerifiedKeyPackage = async ( - kpUrl: string, - candidateActor?: string, - record?: { accountId: string; roomId: string; leafIndex: number }, -): Promise => { - try { - const res = await fetch(kpUrl, { - headers: { Accept: "application/activity+json" }, - }); - if (!res.ok) return null; - const kp = await res.json(); - if ( - typeof kp.attributedTo !== "string" || - typeof kp.content !== "string" - ) { - return null; - } - const actorId = kp.attributedTo; - if (candidateActor && candidateActor !== actorId) return null; - const actorRes = await fetch(actorId, { - headers: { Accept: "application/activity+json" }, - }); - if (!actorRes.ok) return null; - const actor = await actorRes.json(); - const kpId = typeof kp.id === "string" ? kp.id : kpUrl; - let listed = false; - const col = actor.keyPackages; - if (Array.isArray(col)) { - listed = col.includes(kpId); - } else if (col && Array.isArray(col.items)) { - listed = col.items.includes(kpId); - } - if (!listed) return null; - if (!await verifyKeyPackage(kp.content, actorId)) return null; - const b64ToBytes = (b64: string) => { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; - }; - const bytes = b64ToBytes(kp.content); - const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); - const toHex = (arr: Uint8Array) => - Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join(""); - const hashHex = toHex(new Uint8Array(hashBuffer)); - let ktIncluded = false; - try { - const origin = new URL(kpId).origin; - const proofRes = await fetch( - `${origin}/.well-known/key-transparency?hash=${hashHex}`, - ); - if (proofRes.ok) { - const proof = await proofRes.json(); - ktIncluded = Boolean(proof?.included); - } - } catch { - // KT 検証に失敗しても致命的ではない - } - let fpr: string | undefined; - const decoded = decodeMlsMessage(bytes, 0)?.[0] as unknown as { - keyPackage?: unknown; - }; - const key = (decoded?.keyPackage as { - leafNode?: { signaturePublicKey?: Uint8Array }; - })?.leafNode?.signaturePublicKey; - if (key) fpr = `p256:${toHex(key)}`; - const result: RawKeyPackageInput = { - content: kp.content, - actor: actorId, - deviceId: typeof kp.deviceId === "string" ? kp.deviceId : undefined, - url: kpId, - hash: hashHex, - leafSignatureKeyFpr: fpr, - fetchedAt: new Date().toISOString(), - etag: res.headers.get("ETag") ?? undefined, - kt: { included: ktIncluded }, - }; - if (record && fpr) { - await appendKeyPackageRecords(record.accountId, record.roomId, [{ - kpUrl: kpId, - actorId, - leafIndex: record.leafIndex, - credentialFingerprint: fpr, - time: result.fetchedAt!, - ktIncluded, - }]); - } - return result; - } catch (err) { - console.error("KeyPackage の検証に失敗しました:", err); - return null; - } -}; - -export const fetchGroupInfo = async ( - user: string, - keyId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages/${ - encodeURIComponent(keyId) - }`, - ); - if (!res.ok) return null; - const data = await res.json(); - if (typeof data.groupInfo === "string") { - const bytes = decodeGroupInfo(data.groupInfo); - if (bytes && (await verifyGroupInfo(bytes))) { - return data.groupInfo; - } - } - return null; - } catch (err) { - console.error("Error fetching group info:", err); - return null; - } -}; - -// RosterEvidence を検証する -export const importRosterEvidence = async ( - accountId: string, - roomId: string, - evidence: RosterEvidence, - leafIndex = -1, -): Promise => { - try { - const res = await fetch(evidence.keyPackageUrl, { - headers: { Accept: "application/activity+json" }, - }); - if (!res.ok) return false; - const kp = await res.json(); - if (typeof kp.content !== "string" || kp.attributedTo !== evidence.actor) { - return false; - } - const b64ToBytes = (b64: string) => { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; - }; - const bytes = b64ToBytes(kp.content); - const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); - const toHex = (arr: Uint8Array) => - Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join(""); - const hashHex = toHex(new Uint8Array(hashBuffer)); - if (`sha256:${hashHex}` !== evidence.keyPackageHash) return false; - const decoded = decodeMlsMessage(bytes, 0)?.[0] as unknown as { - keyPackage?: unknown; - }; - const key = (decoded?.keyPackage as { - leafNode?: { signaturePublicKey?: Uint8Array }; - })?.leafNode?.signaturePublicKey; - if (!key || `p256:${toHex(key)}` !== evidence.leafSignatureKeyFpr) { - return false; - } - if (!await verifyKeyPackage(kp.content, evidence.actor)) return false; - await appendKeyPackageRecords(accountId, roomId, [{ - kpUrl: evidence.keyPackageUrl, - actorId: evidence.actor, - leafIndex, - credentialFingerprint: evidence.leafSignatureKeyFpr, - time: evidence.fetchedAt, - ktIncluded: false, - }]); - return true; - } catch (err) { - console.error("RosterEvidence の検証に失敗しました:", err); - return false; - } -}; - -export const deleteKeyPackage = async ( - user: string, - keyId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages/${ - encodeURIComponent(keyId) - }`, - { method: "DELETE" }, - ); - return res.ok; - } catch (err) { - console.error("Error deleting key package:", err); - return false; - } -}; - -export const sendEncryptedMessage = async ( - roomId: string, - from: string, - to: string[], - data: { - content: string; - mediaType?: string; - encoding?: string; - attachments?: unknown[]; - }, -): Promise => { - try { - const payload: Record = { - from, - to, - content: data.content, - mediaType: data.mediaType ?? "message/mls", - encoding: data.encoding ?? "base64", - }; - if (data.attachments) payload.attachments = data.attachments; - const res = await apiFetch( - `/api/rooms/${encodeURIComponent(roomId)}/messages`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - return res.ok; - } catch (err) { - console.error("Error sending message:", err); - return false; - } -}; - -// グループ設定(名前・アイコン)を application_data で送信 -export const sendGroupMetadata = async ( - roomId: string, - from: string, - state: StoredGroupState, - to: string[], - info: { name?: string; icon?: string }, -): Promise => { - try { - const obj: Record = { - "@context": "https://www.w3.org/ns/activitystreams", - type: "Object", - }; - if (info.name) obj.name = info.name; - if (info.icon?.startsWith("data:")) { - const m = info.icon.match(/^data:(.+);base64,(.+)$/); - if (m) { - obj.icon = { - type: "Image", - mediaType: m[1], - encoding: "base64", - content: m[2], - }; - } - } - const enc = await encryptMessage(state, JSON.stringify(obj)); - const ok = await sendEncryptedMessage(roomId, from, to, { - content: bufToB64(enc.message), - mediaType: "message/mls", - encoding: "base64", - }); - return ok ? enc.state : null; - } catch (err) { - console.error("Error sending group metadata:", err); - return null; - } -}; - -export const fetchEncryptedMessages = async ( - roomId: string, - member: string, - params?: { limit?: number; before?: string; after?: string }, -): Promise => { - try { - const search = new URLSearchParams(); - if (params?.limit) search.set("limit", String(params.limit)); - if (params?.before) search.set("before", params.before); - if (params?.after) search.set("after", params.after); - search.set("member", member); - const query = search.toString(); - const url = `/api/rooms/${encodeURIComponent(roomId)}/messages${ - query ? `?${query}` : "" - }`; - const res = await apiFetch(url); - if (!res.ok) { - throw new Error("Failed to fetch messages"); - } - const data = await res.json(); - console.debug("[fetchEncryptedMessages]", { - roomId, - member, - params, - count: Array.isArray(data) ? data.length : undefined, - raw: data, - }); - if (!Array.isArray(data)) return []; - const result: EncryptedMessage[] = []; - for (const msg of data) { - if ( - msg && - typeof msg === "object" && - (msg as { mediaType?: unknown }).mediaType === "message/mls" && - (msg as { encoding?: unknown }).encoding === "base64" - ) { - result.push(msg as EncryptedMessage); - } else { - console.warn( - "[fetchEncryptedMessages] 不正なメッセージを破棄しました", - msg, - ); - } - } - return result; - } catch (err) { - console.error("Error fetching messages:", err); - return []; - } -}; - -export interface HandshakeMessage { - id: string; - roomId: string; - sender: string; - recipients: string[]; - message: string; - createdAt: string; -} - -export const sendHandshake = async ( - roomId: string, - from: string, - content: string, - to?: string[], -): Promise => { - try { - const payload: Record = { - from, - content, - mediaType: "message/mls", - encoding: "base64", - }; - if (Array.isArray(to)) { - // 送信先(MLS ロスター由来)を明示し、サーバー側の検証/配送に使用する - payload.to = to; - } - const res = await apiFetch( - `/api/rooms/${encodeURIComponent(roomId)}/handshakes`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - return res.ok; - } catch (err) { - console.error("Error sending handshake:", err); - return false; - } -}; - -export const removeRoomMembers = async ( - roomId: string, - from: string, - targets: string[], -): Promise => { - try { - const res = await apiFetch( - `/api/rooms/${encodeURIComponent(roomId)}/remove`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from, targets }), - }, - ); - if (!res.ok) return null; - const data = await res.json(); - return typeof data.commit === "string" ? data.commit : null; - } catch (err) { - console.error("Error removing members:", err); - return null; - } -}; - -type UpdateResult = Awaited>; - -export const updateRoomKey = async ( - roomId: string, - from: string, - identity: string, - state: StoredGroupState, -): Promise => { - try { - const records = await loadKeyPackageRecords(from, roomId); - const rec = records.find((r) => r.actorId === identity); - if (!rec) { - throw new Error("保存済みの actorId と一致しません"); - } - const res = await updateKey(state, identity); - const content = encodePublicMessage(res.commit); - const ok = await sendHandshake(roomId, from, content); - if (!ok) return null; - return res; - } catch (err) { - console.error("Error updating room key:", err); - return null; - } -}; - -export const joinGroupWithInfo = async ( - roomId: string, - from: string, - groupInfo: string, - keyPair: GeneratedKeyPair, -): Promise<{ state: StoredGroupState } | null> => { - try { - const infoBytes = decodeGroupInfo(groupInfo); - if (!infoBytes) return null; - const res = await joinWithGroupInfo(infoBytes, keyPair); - const ok = await sendHandshake(roomId, from, res.commit); - if (!ok) return null; - return { state: res.state }; - } catch (err) { - console.error("Error joining with group info:", err); - return null; - } -}; - -export const fetchHandshakes = async ( - roomId: string, - params?: { limit?: number; before?: string; after?: string }, -): Promise => { - try { - const search = new URLSearchParams(); - if (params?.limit) search.set("limit", String(params.limit)); - if (params?.before) search.set("before", params.before); - if (params?.after) search.set("after", params.after); - const query = search.toString(); - const url = `/api/rooms/${encodeURIComponent(roomId)}/handshakes${ - query ? `?${query}` : "" - }`; - const res = await apiFetch(url); - if (!res.ok) { - throw new Error("Failed to fetch handshakes"); - } - const data = await res.json(); - return Array.isArray(data) ? data : []; - } catch (err) { - console.error("Error fetching handshakes:", err); - return []; - } -}; - -export const fetchEncryptedKeyPair = async ( - user: string, - deviceId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/devices/${ - encodeURIComponent(deviceId) - }/encryptedKeyPair`, - ); - if (!res.ok) return null; - const data = await res.json(); - return typeof data.content === "string" ? data.content : null; - } catch (err) { - console.error("Error fetching encrypted key pair:", err); - return null; - } -}; - -// ローカルユーザー向けの保留中招待一覧を取得 -export const fetchPendingInvites = async ( - user: string, -): Promise< - { roomId: string; deviceId?: string; expiresAt?: string; acked?: boolean }[] -> => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/pendingInvites`, - ); - if (!res.ok) return []; - const data = await res.json(); - return Array.isArray(data) - ? data - .map((d: unknown) => { - const o = d as Record; - return { - roomId: typeof o.roomId === "string" ? o.roomId : "", - deviceId: typeof o.deviceId === "string" ? o.deviceId : undefined, - expiresAt: typeof o.expiresAt === "string" - ? o.expiresAt - : undefined, - acked: typeof o.acked === "boolean" ? o.acked : undefined, - }; - }) - .filter((x) => x.roomId !== "") - : []; - } catch (err) { - console.error("Error fetching pending invites:", err); - return []; - } -}; - -// 統合イベントAPI(ActivityPub前提のサーバ側集約を想定) -export interface UnifiedEvent { - id: string; - type: "handshake" | "encryptedMessage" | "publicMessage"; - roomId: string; - from: string; - to: string[]; - createdAt: string; -} - -export const fetchEvents = async ( - params?: { since?: string; limit?: number; types?: string[] }, -): Promise => { - try { - const search = new URLSearchParams(); - if (params?.since) search.set("since", params.since); - if (params?.limit) search.set("limit", String(params.limit)); - if (params?.types?.length) search.set("types", params.types.join(",")); - const url = `/api/events${ - search.toString() ? `?${search.toString()}` : "" - }`; - const res = await apiFetch(url); - if (!res.ok) return []; - const data = await res.json(); - return Array.isArray(data) ? data as UnifiedEvent[] : []; - } catch (err) { - console.error("Error fetching events:", err); - return []; - } -}; - -export const saveEncryptedKeyPair = async ( - user: string, - deviceId: string, - content: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/devices/${ - encodeURIComponent(deviceId) - }/encryptedKeyPair`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }, - ); - return res.ok; - } catch (err) { - console.error("Error saving encrypted key pair:", err); - return false; - } -}; - -export const deleteEncryptedKeyPair = async ( - user: string, - deviceId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/devices/${ - encodeURIComponent(deviceId) - }/encryptedKeyPair`, - { method: "DELETE" }, - ); - return res.ok; - } catch (err) { - console.error("Error deleting encrypted key pair:", err); - return false; - } -}; - -export const uploadFile = async ( - data: { - content: ArrayBuffer; - mediaType?: string; - key?: string; - iv?: string; - name?: string; - }, -): Promise => { - try { - const form = new FormData(); - form.append( - "file", - new Blob([data.content], { type: data.mediaType }), - data.name ?? "file", - ); - if (data.key) form.append("key", data.key); - if (data.iv) form.append("iv", data.iv); - const res = await apiFetch("/api/files", { - method: "POST", - body: form, - }); - if (!res.ok) return null; - const d = await res.json(); - return typeof d.url === "string" ? d.url : null; - } catch (err) { - console.error("Error uploading attachment:", err); - return null; - } -}; - -export const resetKeyData = async (user: string): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/resetKeys`, - { method: "POST" }, - ); - return res.ok; - } catch (err) { - console.error("Error resetting key data:", err); - return false; - } -}; - -export interface Room { - id: string; - // name や members はサーバ保存対象外(必要ならクライアント内のみで使用) - name?: string; - members?: string[]; -} - -export interface RoomsSearchItem { - id: string; - // 他フィールドはサーバーから提供されない -} - -export const searchRooms = async ( - owner: string, - params?: { - participants?: string[]; - match?: "all" | "any" | "none"; - hasName?: boolean; - hasIcon?: boolean; - members?: string; // e.g., "eq:2", "ge:3" - // 暗黙(メッセージ履歴から推定)のルームの扱い - implicit?: "include" | "exclude" | "only"; - }, -): Promise => { - try { - const search = new URLSearchParams(); - search.set("owner", owner); - if (params?.participants?.length) { - search.set("participants", params.participants.join(",")); - } - if (params?.match) search.set("match", params.match); - if (typeof params?.hasName === "boolean") { - search.set("hasName", String(params.hasName)); - } - if (typeof params?.hasIcon === "boolean") { - search.set("hasIcon", String(params.hasIcon)); - } - if (params?.members) search.set("members", params.members); - if (params?.implicit) search.set("implicit", params.implicit); - const res = await apiFetch(`/api/rooms?${search.toString()}`); - if (!res.ok) throw new Error("failed to search rooms"); - const data = await res.json(); - return Array.isArray(data.rooms) - ? data.rooms.map((r: unknown) => { - if (r && typeof r === "object" && "id" in r) { - // deno-lint-ignore no-explicit-any - return { id: String((r as any).id) }; - } - return { id: "" }; - }).filter((r: RoomsSearchItem) => r.id !== "") - : []; - } catch (err) { - console.error("Error searching rooms:", err); - return []; - } -}; - -export const addRoom = async ( - id: string, - room: Room, - handshake?: { - from: string; - content: string; - mediaType?: string; - encoding?: string; - to?: string[]; // recipients (ハンドシェイク時必須) - }, -): Promise => { - try { - const body: Record = { owner: id, id: room.id }; - if (handshake) { - body.handshake = { - from: handshake.from, - content: handshake.content, - mediaType: handshake.mediaType ?? "message/mls", - encoding: handshake.encoding ?? "base64", - to: Array.isArray(handshake.to) - ? handshake.to - : (room.members ?? []).filter((m): m is string => - typeof m === "string" - ), - }; - } - const res = await apiFetch(`/api/ap/rooms`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return res.ok; - } catch (err) { - console.error("Error adding room:", err); - return false; - } -}; - -export const updateRoomMember = async ( - roomId: string, - action: "Add" | "Remove", - member: string, -): Promise => { - try { - const res = await apiFetch(`/api/ap/rooms/${roomId}/members`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: action, object: member }), - }); - return res.ok; - } catch (err) { - console.error("Error updating member:", err); - return false; - } -}; - -export const addRoomMember = async ( - roomId: string, - member: string, -): Promise => { - return await updateRoomMember(roomId, "Add", member); -}; - -export const removeRoomMember = async ( - roomId: string, - member: string, -): Promise => { - return await updateRoomMember(roomId, "Remove", member); -}; - -export interface KeepMessage { - id: string; - content: string; - createdAt: string; -} - -export const fetchKeepMessages = async ( - user: string, - params?: { limit?: number; before?: string; after?: string }, -): Promise => { - try { - const search = new URLSearchParams(); - if (params?.limit) search.set("limit", String(params.limit)); - if (params?.before) search.set("before", params.before); - if (params?.after) search.set("after", params.after); - const query = search.toString(); - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keep${query ? `?${query}` : ""}`, - ); - if (!res.ok) throw new Error("failed to fetch keep messages"); - const data = await res.json(); - return Array.isArray(data) ? data : []; - } catch (err) { - console.error("Error fetching keep messages:", err); - return []; - } -}; - -export const sendKeepMessage = async ( - user: string, - content: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keep`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }, - ); - if (!res.ok) return null; - return await res.json(); - } catch (err) { - console.error("Error sending keep message:", err); - return null; - } -}; - -// MLS関連のサーバ通信 -export interface MLSProposalPayload { - type: "add" | "remove"; - member: string; - keyPackage?: string; -} - -export interface MLSWelcomePayload { - type: "welcome"; - member: string; - epoch: number; - tree: Record; - secret: string; -} - -export interface MLSCommitPayload { - type: "commit"; - epoch: number; - proposals: MLSProposalPayload[]; - welcomes?: MLSWelcomePayload[]; - evidences?: RosterEvidence[]; -} - -export const sendProposal = async ( - roomId: string, - from: string, - proposal: MLSProposalPayload, -): Promise => { - const content = encodePublicMessage( - new TextEncoder().encode(JSON.stringify(proposal)), - ); - return await sendHandshake(roomId, from, content); -}; - -export const sendCommit = async ( - roomId: string, - from: string, - commit: MLSCommitPayload, -): Promise => { - const content = encodePublicMessage( - new TextEncoder().encode(JSON.stringify(commit)), - ); - const ok = await sendHandshake(roomId, from, content); - if (!ok) return false; - if (commit.welcomes) { - for (const w of commit.welcomes) { - const wContent = encodePublicMessage( - new TextEncoder().encode(JSON.stringify(w)), - ); - const success = await sendHandshake(roomId, from, wContent); - if (!success) return false; - } - } - if (commit.evidences) { - for (const ev of commit.evidences) { - const evContent = encodePublicMessage( - new TextEncoder().encode(JSON.stringify(ev)), - ); - const okEv = await sendHandshake(roomId, from, evContent); - if (!okEv) return false; - const verified = await importRosterEvidence(from, roomId, ev); - if (verified) { - await appendRosterEvidence(from, roomId, [ev]); - } - } - } - return true; -}; diff --git a/app/client/src/components/e2ee/binding.ts b/app/client/src/components/e2ee/binding.ts deleted file mode 100644 index eff10688c..000000000 --- a/app/client/src/components/e2ee/binding.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { RosterEvidence } from "./mls_wrapper.ts"; - -export type BindingStatus = "Verified" | "BoundOnly" | "Unbound"; - -/** - * Evidence と Credential の一致状況からバインディング状態を判定する - */ -export function evaluateBinding( - credentialActor: string | undefined, - leafSignatureKeyFpr: string, - evidences: RosterEvidence[], -): BindingStatus { - const ev = evidences.find((e) => - e.leafSignatureKeyFpr === leafSignatureKeyFpr - ); - if (!ev) return "Unbound"; - return credentialActor === ev.actor ? "Verified" : "BoundOnly"; -} - -/** - * 判定結果に応じて表示用ラベルと注意文言を返す - */ -export function bindingMessage( - status: BindingStatus, -): { label: string; caution?: string } { - switch (status) { - case "Verified": - return { label: "検証済み" }; - case "BoundOnly": - return { - label: "バインドのみ", - caution: "鍵の出所はサーバ経由。指紋確認で検証可", - }; - default: - return { - label: "未リンクの端末", - caution: "指紋の確認を推奨します", - }; - } -} diff --git a/app/client/src/components/e2ee/mls_message.ts b/app/client/src/components/e2ee/mls_message.ts deleted file mode 100644 index 75389be57..000000000 --- a/app/client/src/components/e2ee/mls_message.ts +++ /dev/null @@ -1,135 +0,0 @@ -// RFC 9420 に基づく MLS メッセージの TLV 形式シリアライズ - -import { b64ToBuf, bufToB64 } from "../../../../shared/buffer.ts"; - -export type MLSMessageType = - | "PublicMessage" - | "PrivateMessage" - | "Welcome" - | "KeyPackage" - | "Commit" - | "Proposal" - | "GroupInfo"; - -const typeToByte: Record = { - PublicMessage: 1, - PrivateMessage: 2, - Welcome: 3, - KeyPackage: 4, - Commit: 5, - Proposal: 6, - GroupInfo: 7, -}; - -const byteToType: Record = { - 1: "PublicMessage", - 2: "PrivateMessage", - 3: "Welcome", - 4: "KeyPackage", - 5: "Commit", - 6: "Proposal", - 7: "GroupInfo", -}; - -function toBytes(body: Uint8Array | string): Uint8Array { - return typeof body === "string" ? new TextEncoder().encode(body) : body; -} - -function serialize( - type: MLSMessageType, - body: Uint8Array | string, -): Uint8Array { - const bodyBuf = toBytes(body); - const len = bodyBuf.length; - const out = new Uint8Array(3 + len); - out[0] = typeToByte[type]; - out[1] = (len >>> 8) & 0xff; - out[2] = len & 0xff; - out.set(bodyBuf, 3); - return out; -} - -function deserialize( - data: Uint8Array, -): { type: MLSMessageType; body: Uint8Array } | null { - if (data.length < 3) return null; - const type = byteToType[data[0]]; - if (!type) return null; - const len = (data[1] << 8) | data[2]; - if (data.length < 3 + len) return null; - return { type, body: data.slice(3, 3 + len) }; -} - -export function encodePublicMessage(body: Uint8Array | string): string { - return bufToB64(serialize("PublicMessage", body)); -} - -export function encodePrivateMessage(body: Uint8Array | string): string { - return bufToB64(serialize("PrivateMessage", body)); -} - -export function encodeWelcome(body: Uint8Array | string): string { - return bufToB64(serialize("Welcome", body)); -} - -export function encodeKeyPackage(body: Uint8Array | string): string { - return bufToB64(serialize("KeyPackage", body)); -} - -export function encodeCommit(body: Uint8Array | string): string { - return bufToB64(serialize("Commit", body)); -} - -export function encodeProposal(body: Uint8Array | string): string { - return bufToB64(serialize("Proposal", body)); -} - -export function encodeGroupInfo(body: Uint8Array | string): string { - return bufToB64(serialize("GroupInfo", body)); -} - -export function decodePublicMessage(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "PublicMessage" ? decoded.body : null; -} - -export function decodePrivateMessage(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "PrivateMessage" ? decoded.body : null; -} - -export function decodeWelcome(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "Welcome" ? decoded.body : null; -} - -export function decodeKeyPackage(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "KeyPackage" ? decoded.body : null; -} - -export function decodeCommit(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "Commit" ? decoded.body : null; -} - -export function decodeProposal(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "Proposal" ? decoded.body : null; -} - -export function decodeGroupInfo(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "GroupInfo" ? decoded.body : null; -} - -export function parseMLSMessage( - data: string, -): { type: MLSMessageType; body: Uint8Array } | null { - try { - const u8 = b64ToBuf(data); - return deserialize(u8); - } catch { - return null; - } -} diff --git a/app/client/src/components/e2ee/mls_test.ts b/app/client/src/components/e2ee/mls_test.ts deleted file mode 100644 index 9f21e3c6f..000000000 --- a/app/client/src/components/e2ee/mls_test.ts +++ /dev/null @@ -1,167 +0,0 @@ -function assert(condition: unknown, message?: string): asserts condition { - if (!condition) throw new Error(message ?? "assertion failed"); -} -function assertEquals(actual: T, expected: T, message?: string): void { - if (actual !== expected) { - throw new Error(message ?? `assertion failed: ${actual} !== ${expected}`); - } -} -import { - createCommitAndWelcomes, - decryptMessage, - encryptMessageWithAck, - generateKeyPair, - joinWithWelcome, - verifyKeyPackage, - verifyWelcome, -} from "./mls_wrapper.ts"; -import { fetchKeyPackages } from "./api.ts"; - -// サーバー側の KeyPackage 選択ロジックをテスト用に簡易実装 -interface KeyPackageDoc { - _id: unknown; - content: string; - mediaType: string; - encoding: string; - createdAt: string; - version: string; - cipherSuite: number; - generator: string; - used?: boolean; -} - -function selectKeyPackages( - list: KeyPackageDoc[], - suite: number, - M = 3, -): KeyPackageDoc[] { - return list - .filter((kp) => - kp.version === "1.0" && kp.cipherSuite === suite && kp.used !== true - ) - .sort((a, b) => { - const da = new Date(a.createdAt).getTime(); - const db = new Date(b.createdAt).getTime(); - return db - da; - }) - .reduce((acc: KeyPackageDoc[], kp) => { - if (kp.generator && acc.some((v) => v.generator === kp.generator)) { - return acc; - } - acc.push(kp); - return acc; - }, []) - .slice(0, M); -} - -Deno.test("ts-mlsでCommitとWelcomeを生成できる", async () => { - const bob = await generateKeyPair("bob"); - assert(await verifyKeyPackage(bob.encoded, "bob")); - const { commit, welcomes } = await createCommitAndWelcomes(1, ["alice"], [ - { content: bob.encoded, actor: "bob" }, - ]); - assert(commit instanceof Uint8Array); - assertEquals(welcomes.length, 1); -}); - -Deno.test("KeyPackage取得とCommit/Welcome交換ができる", async () => { - const kp1 = await generateKeyPair("alice1"); - const kp2 = await generateKeyPair("alice2"); - const list: KeyPackageDoc[] = [ - { - _id: 1, - content: kp1.encoded, - mediaType: "application/mls+json", - encoding: "base64", - createdAt: "2023-01-01T00:00:00.000Z", - version: "1.0", - cipherSuite: 1, - generator: "g1", - }, - { - _id: 2, - content: kp2.encoded, - mediaType: "application/mls+json", - encoding: "base64", - createdAt: "2023-01-02T00:00:00.000Z", - version: "1.0", - cipherSuite: 1, - generator: "g2", - }, - ]; - const selected = selectKeyPackages(list, 1, 1); - const originalFetch = globalThis.fetch; - globalThis.fetch = (_input: RequestInfo | URL, _init?: RequestInit) => - Promise.resolve( - new Response( - JSON.stringify({ - items: selected.map((kp) => ({ - id: String(kp._id), - type: "KeyPackage", - content: kp.content, - mediaType: kp.mediaType, - encoding: kp.encoding, - createdAt: kp.createdAt, - })), - }), - { status: 200 }, - ), - ); - const fetched = await fetchKeyPackages("alice"); - assertEquals(fetched.length, 1); - globalThis.fetch = originalFetch; - - const { welcomes, state: serverState } = await createCommitAndWelcomes( - 1, - ["alice"], - [{ content: fetched[0].content, actor: "alice" }], - ); - assertEquals(welcomes.length, 1); - const welcome = welcomes[0].data; - assert(await verifyWelcome(welcome)); - const aliceState = await joinWithWelcome(welcome, kp2); - assert(aliceState); - assert(serverState); -}); - -Deno.test("Ackが一度だけ送信される", async () => { - const bob = await generateKeyPair("bob"); - const { welcomes, state: serverState0 } = await createCommitAndWelcomes( - 1, - ["bob"], - [{ content: bob.encoded, actor: "bob" }], - ); - const welcome = welcomes[0].data; - const clientState0 = await joinWithWelcome(welcome, bob); - let serverState = serverState0; - let clientState = clientState0; - - const { messages, state: clientState1 } = await encryptMessageWithAck( - clientState, - "こんにちは", - "room1", - "device1", - ); - clientState = clientState1; - assertEquals(messages.length, 2); - const resAck = await decryptMessage(serverState, messages[0]); - assert(resAck); - serverState = resAck.state; - const ackJson = JSON.parse(new TextDecoder().decode(resAck.plaintext)); - assertEquals(ackJson.type, "joinAck"); - const resMsg = await decryptMessage(serverState, messages[1]); - assert(resMsg); - serverState = resMsg.state; - assertEquals(new TextDecoder().decode(resMsg.plaintext), "こんにちは"); - - const { messages: msgs2 } = await encryptMessageWithAck( - clientState, - "2回目", - "room1", - "device1", - ); - assertEquals(msgs2.length, 1); - const res2 = await decryptMessage(serverState, msgs2[0]); - assert(res2); - assertEquals(new TextDecoder().decode(res2.plaintext), "2回目"); -}); diff --git a/app/client/src/components/e2ee/mls_wrapper.ts b/app/client/src/components/e2ee/mls_wrapper.ts deleted file mode 100644 index dea6b28d3..000000000 --- a/app/client/src/components/e2ee/mls_wrapper.ts +++ /dev/null @@ -1,619 +0,0 @@ -// ts-mls のラッパーモジュール - -import { - acceptAll, - bytesToBase64, - type CiphersuiteName, - type ClientState, - createApplicationMessage, - createCommit, - createGroup, - createGroupInfoWithExternalPubAndRatchetTree, - type Credential, - decodeMlsMessage, - defaultCapabilities, - defaultLifetime, - emptyPskIndex, - encodeMlsMessage, - generateKeyPackage as tsGenerateKeyPackage, - getCiphersuiteFromName, - getCiphersuiteImpl, - joinGroup, - joinGroupExternal, - type KeyPackage, - makePskIndex, - type PrivateKeyPackage, - processPrivateMessage, - processPublicMessage, - type Proposal, -} from "ts-mls"; -import "@noble/curves/p256"; -import { encodePublicMessage } from "./mls_message.ts"; - - -// ts-mls does not publish some internal helpers via package exports; use local fallbacks/types -type PublicMessage = { - content: { commit?: unknown; proposal?: unknown } & Record; - auth?: unknown; -} & Record; - -export type StoredGroupState = ClientState; - -export interface GeneratedKeyPair { - public: KeyPackage; - private: PrivateKeyPackage; - encoded: string; -} - -export interface RawKeyPackageInput { - content: string; - actor?: string; - deviceId?: string; - url?: string; - hash?: string; - leafSignatureKeyFpr?: string; - fetchedAt?: string; - etag?: string; - kt?: { included: boolean }; -} - -export interface RosterEvidence { - type: "RosterEvidence"; - actor: string; - keyPackageUrl: string; - keyPackageHash: string; - leafSignatureKeyFpr: string; - fetchedAt: string; - etag?: string; -} - -export interface WelcomeEntry { - actor?: string; - deviceId?: string; - data: Uint8Array; -} - -const DEFAULT_SUITE: CiphersuiteName = - "MLS_128_DHKEMP256_AES128GCM_SHA256_P256"; - -async function getSuite(name: CiphersuiteName) { - return await getCiphersuiteImpl(getCiphersuiteFromName(name)); -} - -function b64ToBytes(b64: string): Uint8Array { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function buildPskIndex( - state: StoredGroupState | undefined, - psks?: Record, -) { - if (!psks) return emptyPskIndex; - const map: Record = {}; - for (const [id, secret] of Object.entries(psks)) { - map[id] = b64ToBytes(secret); - } - return makePskIndex(state, map); -} - -export async function generateKeyPair( - identity: string, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise { - const cs = await getSuite(suite); - const credential: Credential = { - credentialType: "basic", - identity: new TextEncoder().encode(identity), - }; - const { publicPackage, privatePackage } = await tsGenerateKeyPackage( - credential, - defaultCapabilities(), - defaultLifetime, - [], - cs, - ); - const encoded = bytesToBase64( - encodeMlsMessage({ - version: "mls10", - wireformat: "mls_key_package", - keyPackage: publicPackage, - }), - ); - return { public: publicPackage, private: privatePackage, encoded }; -} - -export async function verifyKeyPackage( - pkg: - | string - | { - credential: { publicKey: string; identity?: string }; - signature: string; - } - & Record, - expectedIdentity?: string, -): Promise { - if (typeof pkg === "string") { - const decoded = decodeMlsMessage(b64ToBytes(pkg), 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_key_package") { - return false; - } - if (expectedIdentity) { - try { - // KeyPackage stores credential inside the leafNode per ts-mls types - const kp = decoded.keyPackage as { - leafNode?: { - credential?: { credentialType?: string; identity?: Uint8Array }; - }; - } | undefined; - const leafCred = kp?.leafNode?.credential as { - credentialType?: string; - identity?: Uint8Array; - } | undefined; - if ( - !leafCred || leafCred.credentialType !== "basic" || !leafCred.identity - ) return false; - const id = new TextDecoder().decode(leafCred.identity); - if (id !== expectedIdentity) return false; - } catch { - return false; - } - } - return true; - } - try { - const { signature, ...body } = pkg; - const b = body as { credential?: { identity?: string } }; - if ( - expectedIdentity && - typeof b.credential?.identity === "string" && - b.credential.identity !== expectedIdentity - ) { - return false; - } - const data = new TextEncoder().encode(JSON.stringify(body)); - const pub = await crypto.subtle.importKey( - "raw", - b64ToBytes(pkg.credential.publicKey), - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["verify"], - ); - return await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - pub, - b64ToBytes(signature), - data, - ); - } catch { - return false; - } -} - -export async function verifyCommit( - state: StoredGroupState, - message: PublicMessage, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - if (!message.content.commit) return false; - try { - const cs = await getSuite(suite); - const cloned = structuredClone(state); - await processPublicMessage( - cloned, - message as unknown as never, - buildPskIndex(state, psks), - cs, - acceptAll, - ); - return true; - } catch { - return false; - } -} - -export async function verifyWelcome( - data: Uint8Array, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - try { - const decoded = decodeMlsMessage(data, 0)?.[0]; - if ( - !decoded || - decoded.wireformat !== "mls_welcome" || - !decoded.welcome?.ratchetTree - ) { - return false; - } - const cs = await getSuite(suite); - // joinGroup が失敗しないことを確認するためダミーの鍵ペアで参加を試みる - const kp = await generateKeyPair("verify", suite); - await joinGroup( - decoded.welcome, - kp.public, - kp.private, - buildPskIndex(undefined, psks), - cs, - ); - return true; - } catch { - return false; - } -} - -export function verifyGroupInfo( - data: Uint8Array, - _suite: CiphersuiteName = DEFAULT_SUITE, -): Promise { - try { - const decoded = decodeMlsMessage(data, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_group_info") { - return Promise.resolve(false); - } - // deep verification requires internal helpers not exported from package entrypoint. - // As a conservative check, validate structure and signer index. - if (!decoded.groupInfo || typeof decoded.groupInfo.signer !== "number") { - return Promise.resolve(false); - } - return Promise.resolve(true); - } catch { - return Promise.resolve(false); - } -} - -export async function verifyPrivateMessage( - state: StoredGroupState, - data: Uint8Array, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - try { - const cs = await getSuite(suite); - const decoded = decodeMlsMessage(data, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_private_message") { - return false; - } - const cloned = structuredClone(state); - await processPrivateMessage( - cloned, - decoded.privateMessage, - buildPskIndex(state, psks), - cs, - ); - return true; - } catch { - return false; - } -} - -export async function createMLSGroup( - identity: string, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise<{ - state: StoredGroupState; - keyPair: GeneratedKeyPair; - gid: Uint8Array; -}> { - const keyPair = await generateKeyPair(identity, suite); - const gid = new TextEncoder().encode(crypto.randomUUID()); - const cs = await getSuite(suite); - // 可能な限り拡張を使用しないため、拡張一覧は空とする - const state = await createGroup( - gid, - keyPair.public, - keyPair.private, - [], - cs, - ); - return { state, keyPair, gid }; -} - -export async function addMembers( - state: StoredGroupState, - addKeyPackages: RawKeyPackageInput[], - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise<{ - commit: Uint8Array; - welcomes: WelcomeEntry[]; - state: StoredGroupState; - evidences: RosterEvidence[]; -}> { - const cs = await getSuite(suite); - const proposals: Proposal[] = []; - for (const kp of addKeyPackages) { - const decoded = decodeMlsMessage(b64ToBytes(kp.content), 0)?.[0]; - if (decoded && decoded.wireformat === "mls_key_package") { - proposals.push({ - proposalType: "add", - add: { keyPackage: decoded.keyPackage }, - }); - } - } - const result = await createCommit( - state, - buildPskIndex(state, psks), - false, - proposals, - cs, - ); - state = result.newState; - const commit = encodeMlsMessage(result.commit); - const welcomes: WelcomeEntry[] = []; - const evidences: RosterEvidence[] = []; - if (result.welcome) { - const info = await createGroupInfoWithExternalPubAndRatchetTree( - state, - cs, - ); - result.welcome.ratchetTree = info.ratchetTree; - const welcomeBytes = encodeMlsMessage({ - version: "mls10", - wireformat: "mls_welcome", - welcome: result.welcome, - }); - for (const kp of addKeyPackages) { - welcomes.push({ - actor: kp.actor, - deviceId: kp.deviceId, - data: welcomeBytes, - }); - } - } - for (const kp of addKeyPackages) { - if ( - kp.actor && kp.url && kp.hash && kp.leafSignatureKeyFpr && kp.fetchedAt - ) { - evidences.push({ - type: "RosterEvidence", - actor: kp.actor, - keyPackageUrl: kp.url, - keyPackageHash: `sha256:${kp.hash}`, - leafSignatureKeyFpr: kp.leafSignatureKeyFpr, - fetchedAt: kp.fetchedAt, - etag: kp.etag, - }); - } - } - return { commit, welcomes, state, evidences }; -} - -export async function removeMembers( - state: StoredGroupState, - removeIndices: number[], - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise<{ commit: Uint8Array; state: StoredGroupState }> { - const cs = await getSuite(suite); - const proposals: Proposal[] = []; - for (const index of removeIndices) { - proposals.push({ - proposalType: "remove", - remove: { removed: index }, - }); - } - const result = await createCommit( - state, - buildPskIndex(state, psks), - false, - proposals, - cs, - ); - return { commit: encodeMlsMessage(result.commit), state: result.newState }; -} - -export async function updateKey( - state: StoredGroupState, - identity: string, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise<{ - commit: Uint8Array; - state: StoredGroupState; - keyPair: GeneratedKeyPair; -}> { - const cs = await getSuite(suite); - const keyPair = await generateKeyPair(identity, suite); - // ts-mls typings expect a specific `update` shape; cast to satisfy public API for now. - const proposals: Proposal[] = [{ - proposalType: "update", - update: ({ keyPackage: keyPair.public } as unknown), - } as unknown as Proposal]; - const result = await createCommit( - state, - buildPskIndex(state, psks), - false, - proposals, - cs, - ); - return { - commit: encodeMlsMessage(result.commit), - state: result.newState, - keyPair, - }; -} - -export async function joinWithWelcome( - welcome: Uint8Array, - keyPair: GeneratedKeyPair, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - const cs = await getSuite(suite); - const decoded = decodeMlsMessage(welcome, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_welcome") { - throw new Error("不正なWelcomeメッセージです"); - } - return await joinGroup( - decoded.welcome, - keyPair.public, - keyPair.private, - buildPskIndex(undefined, psks), - cs, - ); -} - -export async function encryptMessage( - state: StoredGroupState, - plaintext: Uint8Array | string, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise<{ message: Uint8Array; state: StoredGroupState }> { - const cs = await getSuite(suite); - const input = typeof plaintext === "string" - ? new TextEncoder().encode(plaintext) - : plaintext; - const { newState, privateMessage } = await createApplicationMessage( - state, - input, - cs, - ); - const message = encodeMlsMessage({ - version: "mls10", - wireformat: "mls_private_message", - privateMessage, - }); - return { message, state: newState }; -} - -// Join ACK を一度だけ付加してメッセージを暗号化 -const sentAck = new Set(); - -export async function encryptMessageWithAck( - state: StoredGroupState, - plaintext: Uint8Array | string, - roomId: string, - deviceId: string, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise<{ messages: Uint8Array[]; state: StoredGroupState }> { - let current = state; - const out: Uint8Array[] = []; - const key = `${roomId}:${deviceId}`; - if (!sentAck.has(key)) { - const ackBody = JSON.stringify({ type: "joinAck", roomId, deviceId }); - const ack = await encryptMessage(current, ackBody, suite); - out.push(ack.message); - current = ack.state; - sentAck.add(key); - } - const msg = await encryptMessage(current, plaintext, suite); - out.push(msg.message); - return { messages: out, state: msg.state }; -} - -export async function decryptMessage( - state: StoredGroupState, - data: Uint8Array, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise<{ plaintext: Uint8Array; state: StoredGroupState } | null> { - const cs = await getSuite(suite); - const decoded = decodeMlsMessage(data, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_private_message") { - return null; - } - try { - const res = await processPrivateMessage( - state, - decoded.privateMessage, - buildPskIndex(state, psks), - cs, - ); - if (res.kind !== "applicationMessage") { - return { plaintext: new Uint8Array(), state: res.newState }; - } - return { plaintext: res.message, state: res.newState }; - } catch (e) { - if (e instanceof Error && e.message.includes("Desired gen in the past")) { - // 過去に処理済みのメッセージは無視する - return null; - } - throw e; - } -} - -export async function exportGroupInfo( - state: StoredGroupState, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise { - const cs = await getSuite(suite); - const info = await createGroupInfoWithExternalPubAndRatchetTree( - state, - cs, - ); - return encodeMlsMessage({ - version: "mls10", - wireformat: "mls_group_info", - groupInfo: info, - }); -} - -export async function joinWithGroupInfo( - groupInfo: Uint8Array, - keyPair: GeneratedKeyPair, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise<{ commit: string; state: StoredGroupState }> { - const cs = await getSuite(suite); - const decoded = decodeMlsMessage(groupInfo, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_group_info") { - throw new Error("不正なGroupInfoです"); - } - const { publicMessage, newState } = await joinGroupExternal( - decoded.groupInfo, - keyPair.public, - keyPair.private, - false, - cs, - ); - const commitBytes = encodeMlsMessage({ - version: "mls10", - wireformat: "mls_public_message", - publicMessage, - }); - return { commit: encodePublicMessage(commitBytes), state: newState }; -} - -export async function processCommit( - state: StoredGroupState, - message: PublicMessage, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - if (!message.content.commit) { - throw new Error("不正なCommitメッセージです"); - } - const cs = await getSuite(suite); - const { newState } = await processPublicMessage( - state, - message as unknown as never, - buildPskIndex(state, psks), - cs, - acceptAll, - ); - return newState; -} - -export async function processProposal( - state: StoredGroupState, - message: PublicMessage, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - if (!message.content.proposal) { - throw new Error("不正なProposalメッセージです"); - } - const cs = await getSuite(suite); - const { newState } = await processPublicMessage( - state, - message as unknown as never, - buildPskIndex(state, psks), - cs, - acceptAll, - ); - return newState; -} - -export { addMembers as createCommitAndWelcomes }; diff --git a/app/client/src/components/e2ee/storage.ts b/app/client/src/components/e2ee/storage.ts deleted file mode 100644 index fe126281e..000000000 --- a/app/client/src/components/e2ee/storage.ts +++ /dev/null @@ -1,527 +0,0 @@ -import type { - GeneratedKeyPair, - RosterEvidence, - StoredGroupState, -} from "./mls_wrapper.ts"; -import { load as loadStore, type Store } from "@tauri-apps/plugin-store"; -import { isTauri } from "../../utils/config.ts"; -import type { ChatMessage } from "../chat/types.ts"; -import { - type ClientConfig, - defaultAuthenticationService, - defaultKeyPackageEqualityConfig, - defaultKeyRetentionConfig, - defaultLifetimeConfig, - defaultPaddingConfig, -} from "ts-mls"; - -const defaultClientConfig: ClientConfig = { - keyRetentionConfig: defaultKeyRetentionConfig, - lifetimeConfig: defaultLifetimeConfig, - keyPackageEqualityConfig: defaultKeyPackageEqualityConfig, - paddingConfig: defaultPaddingConfig, - authService: defaultAuthenticationService, -}; - -// 新実装に伴い保存形式を変更 -const DB_VERSION = 10; -const STORE_NAME = "mlsGroups"; -const KEY_STORE = "mlsKeyPairs"; -const CACHE_STORE = "cache"; -const EVIDENCE_STORE = "evidence"; -const KP_RECORD_STORE = "kpRecords"; - -const stores: Record = {}; - -async function openStore(accountId: string): Promise { - if (stores[accountId]) return stores[accountId]; - const store = await loadStore(`takos_${accountId}.json`); - const version = await store.get("version"); - if (version !== DB_VERSION) { - await store.clear(); - await store.set("version", DB_VERSION); - await store.save(); - } - stores[accountId] = store; - return store; -} - -function openDB(accountId: string): Promise { - const name = `takos_${accountId}`; - return new Promise((resolve, reject) => { - const req = indexedDB.open(name, DB_VERSION); - req.onupgradeneeded = (ev) => { - const db = req.result; - const oldVersion = (ev as IDBVersionChangeEvent).oldVersion ?? 0; - if (oldVersion < DB_VERSION) { - if (db.objectStoreNames.contains(STORE_NAME)) { - db.deleteObjectStore(STORE_NAME); - } - if (db.objectStoreNames.contains(KEY_STORE)) { - db.deleteObjectStore(KEY_STORE); - } - if (db.objectStoreNames.contains(EVIDENCE_STORE)) { - db.deleteObjectStore(EVIDENCE_STORE); - } - if (db.objectStoreNames.contains(KP_RECORD_STORE)) { - db.deleteObjectStore(KP_RECORD_STORE); - } - } - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - if (!db.objectStoreNames.contains(KEY_STORE)) { - db.createObjectStore(KEY_STORE, { autoIncrement: true }); - } - if (!db.objectStoreNames.contains(CACHE_STORE)) { - db.createObjectStore(CACHE_STORE); - } - if (!db.objectStoreNames.contains(EVIDENCE_STORE)) { - db.createObjectStore(EVIDENCE_STORE); - } - if (!db.objectStoreNames.contains(KP_RECORD_STORE)) { - db.createObjectStore(KP_RECORD_STORE); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -// StoredGroupState をシリアライズ/デシリアライズするユーティリティ -function serializeGroupState(state: StoredGroupState): ArrayBuffer { - const replacer = (_key: string, value: unknown): unknown => { - if (typeof value === "bigint") { - return { $type: "bigint", data: value.toString() }; - } - if (value instanceof Map) { - return { $type: "Map", data: Array.from(value.entries()) }; - } - if (ArrayBuffer.isView(value)) { - return { - $type: value.constructor.name, - data: Array.from(value as Uint8Array), - }; - } - return value; - }; - const plain = { ...state, clientConfig: undefined } as Record< - string, - unknown - >; - const json = JSON.stringify(plain, replacer); - return new TextEncoder().encode(json).buffer; -} - -function deserializeGroupState(buf: ArrayBuffer): StoredGroupState { - const reviver = (_key: string, value: unknown): unknown => { - if (value && typeof value === "object" && "$type" in value) { - const v = value as { $type: string; data: unknown }; - switch (v.$type) { - case "bigint": - return BigInt(v.data as string); - case "Map": - return new Map(v.data as Iterable); - default: { - const ctor = (globalThis as Record)[v.$type] as - | { new (data: unknown): unknown } - | undefined; - if (ctor) { - return new ctor(v.data as unknown); - } - } - } - } - return value; - }; - const json = new TextDecoder().decode(new Uint8Array(buf)); - const obj = JSON.parse(json, reviver); - return { ...obj, clientConfig: defaultClientConfig } as StoredGroupState; -} - -export const loadMLSGroupStates = async ( - accountId: string, -): Promise> => { - if (isTauri()) { - const store = await openStore(accountId); - return await store.get>("groups") ?? {}; - } - const db = await openDB(accountId); - const tx = db.transaction(STORE_NAME, "readonly"); - const store = tx.objectStore(STORE_NAME); - return await new Promise((resolve, reject) => { - const req = store.openCursor(); - const result: Record = {}; - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - const buf = cursor.value as ArrayBuffer; - result[cursor.key as string] = deserializeGroupState(buf); - cursor.continue(); - } else { - resolve(result); - } - }; - req.onerror = () => reject(req.error); - }); -}; - -export const saveMLSGroupStates = async ( - accountId: string, - states: Record, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - await store.set("groups", states); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - store.clear(); - for (const [roomId, state] of Object.entries(states)) { - store.put(serializeGroupState(state), roomId); - } - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -export const loadMLSKeyPair = async ( - accountId: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - return await store.get("keyPair") ?? null; - } - const db = await openDB(accountId); - const tx = db.transaction(KEY_STORE, "readonly"); - const store = tx.objectStore(KEY_STORE); - return await new Promise((resolve, reject) => { - const req = store.openCursor(null, "prev"); - req.onsuccess = () => { - const cursor = req.result; - resolve(cursor ? (cursor.value as GeneratedKeyPair) : null); - }; - req.onerror = () => reject(req.error); - }); -}; - -export const saveMLSKeyPair = async ( - accountId: string, - pair: GeneratedKeyPair, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - await store.set("keyPair", pair); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(KEY_STORE, "readwrite"); - const store = tx.objectStore(KEY_STORE); - store.add(pair); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -export const loadRosterEvidence = async ( - accountId: string, - roomId: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const ev = await store.get>( - "evidence", - ) ?? {}; - return ev[roomId] ?? []; - } - const db = await openDB(accountId); - const tx = db.transaction(EVIDENCE_STORE, "readonly"); - const store = tx.objectStore(EVIDENCE_STORE); - return await new Promise((resolve, reject) => { - const req = store.get(roomId); - req.onsuccess = () => { - resolve((req.result as RosterEvidence[]) ?? []); - }; - req.onerror = () => reject(req.error); - }); -}; - -export const appendRosterEvidence = async ( - accountId: string, - roomId: string, - evidence: RosterEvidence[], -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const ev = await store.get>( - "evidence", - ) ?? {}; - const current = ev[roomId] ?? []; - ev[roomId] = current.concat(evidence); - await store.set("evidence", ev); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(EVIDENCE_STORE, "readwrite"); - const store = tx.objectStore(EVIDENCE_STORE); - const current: RosterEvidence[] = await new Promise((resolve, reject) => { - const req = store.get(roomId); - req.onsuccess = () => resolve((req.result as RosterEvidence[]) ?? []); - req.onerror = () => reject(req.error); - }); - store.put(current.concat(evidence), roomId); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -// すべての鍵ペアを取得(プール運用向け) -export const loadAllMLSKeyPairs = async ( - accountId: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - // tauri-store では一覧を保持していないため直近のみ - const last = await store.get("keyPair"); - return last ? [last] : []; - } - const db = await openDB(accountId); - const tx = db.transaction(KEY_STORE, "readonly"); - const store = tx.objectStore(KEY_STORE); - return await new Promise((resolve, reject) => { - const req = store.openCursor(); - const out: GeneratedKeyPair[] = []; - req.onsuccess = () => { - const cur = req.result; - if (cur) { - out.push(cur.value as GeneratedKeyPair); - cur.continue(); - } else { - resolve(out); - } - }; - req.onerror = () => reject(req.error); - }); -}; - -// 汎用キャッシュ(IndexedDB の CACHE_STORE を利用) -export const getCacheItem = async ( - accountId: string, - key: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const all = await store.get>("cache"); - return (all ?? {})[key] ?? null; - } - const db = await openDB(accountId); - const tx = db.transaction(CACHE_STORE, "readonly"); - const store = tx.objectStore(CACHE_STORE); - return await new Promise((resolve, reject) => { - const req = store.get(key); - req.onsuccess = () => resolve(req.result ?? null); - req.onerror = () => reject(req.error); - }); -}; - -export const setCacheItem = async ( - accountId: string, - key: string, - value: unknown, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const all = await store.get>("cache") ?? {}; - all[key] = value; - await store.set("cache", all); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(CACHE_STORE, "readwrite"); - const store = tx.objectStore(CACHE_STORE); - store.put(value, key); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -// KeyPackage 検証記録 -export interface KeyPackageRecord { - kpUrl: string; - actorId: string; - leafIndex: number; - credentialFingerprint: string; - time: string; - ktIncluded?: boolean; -} - -export const loadKeyPackageRecords = async ( - accountId: string, - roomId: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const rec = await store.get>( - "kpRecords", - ) ?? {}; - return rec[roomId] ?? []; - } - const db = await openDB(accountId); - const tx = db.transaction(KP_RECORD_STORE, "readonly"); - const store = tx.objectStore(KP_RECORD_STORE); - return await new Promise((resolve, reject) => { - const req = store.get(roomId); - req.onsuccess = () => { - resolve((req.result as KeyPackageRecord[]) ?? []); - }; - req.onerror = () => reject(req.error); - }); -}; - -export const appendKeyPackageRecords = async ( - accountId: string, - roomId: string, - records: KeyPackageRecord[], -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const rec = await store.get>( - "kpRecords", - ) ?? {}; - const current = rec[roomId] ?? []; - rec[roomId] = current.concat(records); - await store.set("kpRecords", rec); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(KP_RECORD_STORE, "readwrite"); - const store = tx.objectStore(KP_RECORD_STORE); - const current: KeyPackageRecord[] = await new Promise((resolve, reject) => { - const req = store.get(roomId); - req.onsuccess = () => resolve((req.result as KeyPackageRecord[]) ?? []); - req.onerror = () => reject(req.error); - }); - store.put(current.concat(records), roomId); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -// キャッシュの読み書き -export interface CacheEntry { - timestamp: number; - value: T; -} - -export const loadCacheEntry = async ( - accountId: string, - key: string, -): Promise | null> => { - if (isTauri()) { - const store = await openStore(accountId); - const cache = await store.get>>("cache") ?? {}; - return cache[key] ?? null; - } - const db = await openDB(accountId); - const tx = db.transaction(CACHE_STORE, "readonly"); - const store = tx.objectStore(CACHE_STORE); - return await new Promise((resolve, reject) => { - const req = store.get(key); - req.onsuccess = () => { - resolve(req.result as CacheEntry | null); - }; - req.onerror = () => reject(req.error); - }); -}; - -export const saveCacheEntry = async ( - accountId: string, - key: string, - value: T, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const cache = await store.get>>("cache") ?? {}; - cache[key] = { timestamp: Date.now(), value } as CacheEntry; - await store.set("cache", cache); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(CACHE_STORE, "readwrite"); - const store = tx.objectStore(CACHE_STORE); - store.put({ timestamp: Date.now(), value } as CacheEntry, key); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -// 復号済みメッセージの永続化(ブラウザ/tauri 両対応) -type SerializableChatMessage = Omit & { - timestamp: string; -}; - -function serializeMessages(list: ChatMessage[]): SerializableChatMessage[] { - return list.map((m) => ({ ...m, timestamp: m.timestamp.toISOString() })); -} - -function deserializeMessages(list: SerializableChatMessage[]): ChatMessage[] { - return list.map((m) => ({ ...m, timestamp: new Date(m.timestamp) })); -} - -export const loadDecryptedMessages = async ( - accountId: string, - roomId: string, -): Promise => { - const key = `roomMsgs:${roomId}`; - const entry = await loadCacheEntry(accountId, key); - if (!entry || !Array.isArray(entry.value)) return null; - try { - return deserializeMessages(entry.value); - } catch { - return null; - } -}; - -export const saveDecryptedMessages = async ( - accountId: string, - roomId: string, - messages: ChatMessage[], - opts?: { max?: number }, -): Promise => { - const key = `roomMsgs:${roomId}`; - const max = opts?.max ?? 500; - // 新しいものから最大 max 件を保存 - const trimmed = messages - .slice(-max) - .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - await saveCacheEntry(accountId, key, serializeMessages(trimmed)); -}; - -export const deleteMLSDatabase = async (accountId: string): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - await store.clear(); - await store.save(); - return; - } - await new Promise((resolve, reject) => { - const name = `takos_${accountId}`; - const req = indexedDB.deleteDatabase(name); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); -}; diff --git a/app/client/src/components/e2ee/useMLS.ts b/app/client/src/components/e2ee/useMLS.ts deleted file mode 100644 index 2c8f91af9..000000000 --- a/app/client/src/components/e2ee/useMLS.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { createSignal } from "solid-js"; -import { addKeyPackage } from "./api.ts"; -import { generateKeyPair } from "./mls_wrapper.ts"; -import { - bindingMessage, - type BindingStatus, - evaluateBinding, -} from "./binding.ts"; -import { loadKeyPackageRecords, loadRosterEvidence } from "./storage.ts"; - -export function useMLS(userName: string) { - const [status, setStatus] = createSignal(null); - const [error, setError] = createSignal(null); - const [bindingStatus, setBindingStatus] = createSignal( - null, - ); - const [bindingInfo, setBindingInfo] = createSignal< - { label: string; caution?: string } | null - >(null); - const [ktInfo, setKtInfo] = createSignal<{ included: boolean } | null>(null); - - const generateKeys = async () => { - try { - setStatus("鍵を生成中..."); - setError(null); - // BasicCredential.identity に Actor の URL を設定する - const actorId = - new URL(`/users/${userName}`, globalThis.location.origin).href; - const kp = await generateKeyPair(actorId); - await addKeyPackage(userName, { content: kp.encoded }); - setStatus("鍵を生成しました"); - } catch (err) { - console.error("鍵生成に失敗しました", err); - setStatus(null); - setError( - err instanceof Error ? err.message : "鍵生成に失敗しました", - ); - } - }; - - const assessBinding = async ( - accountId: string, - roomId: string, - credentialActor: string | undefined, - leafSignatureKeyFpr: string, - ktIncluded?: boolean, - ) => { - const evidences = await loadRosterEvidence(accountId, roomId); - const result = evaluateBinding( - credentialActor, - leafSignatureKeyFpr, - evidences, - ); - setBindingStatus(result); - setBindingInfo(bindingMessage(result)); - setKtInfo({ included: ktIncluded ?? false }); - }; - - const assessMemberBinding = async ( - accountId: string, - roomId: string, - credentialActor: string | undefined, - leafSignatureKeyFpr: string, - ) => { - const evidences = await loadRosterEvidence(accountId, roomId); - const result = evaluateBinding( - credentialActor, - leafSignatureKeyFpr, - evidences, - ); - const records = await loadKeyPackageRecords(accountId, roomId); - const rec = records.find((r) => - r.credentialFingerprint === leafSignatureKeyFpr && - (!credentialActor || r.actorId === credentialActor) - ); - return { - status: result, - info: bindingMessage(result), - kt: { included: rec?.ktIncluded ?? false }, - }; - }; - - return { - generateKeys, - status, - error, - bindingStatus, - bindingInfo, - ktInfo, - assessBinding, - assessMemberBinding, - }; -} diff --git a/app/client/src/components/microblog/api.ts b/app/client/src/components/microblog/api.ts index 882bb8a38..a80a9e78a 100644 --- a/app/client/src/components/microblog/api.ts +++ b/app/client/src/components/microblog/api.ts @@ -1,6 +1,5 @@ import type { ActivityPubObject, MicroblogPost } from "./types.ts"; import { apiFetch, getDomain } from "../../utils/config.ts"; -import { loadCacheEntry, saveCacheEntry } from "../e2ee/storage.ts"; /** * ActivityPub Object を取得 @@ -327,52 +326,33 @@ const userInfoCache = new Map => { +): UserInfo | null => { const mem = userInfoCache.get(identifier); if (mem && Date.now() - mem.timestamp < CACHE_DURATION) { return mem.userInfo; } - if (accountId) { - const entry = await loadCacheEntry( - accountId, - `userInfo:${identifier}`, - ); - if (entry && Date.now() - entry.timestamp < CACHE_DURATION) { - userInfoCache.set(identifier, { - userInfo: entry.value, - timestamp: entry.timestamp, - }); - return entry.value; - } - } return null; }; -export const setCachedUserInfo = async ( +export const setCachedUserInfo = ( identifier: string, userInfo: UserInfo, - accountId?: string, -) => { +): void => { userInfoCache.set(identifier, { userInfo, timestamp: Date.now(), }); - if (accountId) { - await saveCacheEntry(accountId, `userInfo:${identifier}`, userInfo); - } }; // 新しい共通ユーザー情報取得API export const fetchUserInfo = async ( identifier: string, - accountId?: string, ): Promise => { try { // まずキャッシュから確認 - const cached = await getCachedUserInfo(identifier, accountId); + const cached = await getCachedUserInfo(identifier); if (cached) { return cached; } @@ -387,7 +367,7 @@ export const fetchUserInfo = async ( const userInfo = await response.json(); // キャッシュに保存 - await setCachedUserInfo(identifier, userInfo, accountId); + await setCachedUserInfo(identifier, userInfo); return userInfo; } catch (error) { @@ -399,14 +379,13 @@ export const fetchUserInfo = async ( // バッチでユーザー情報を取得 export const fetchUserInfoBatch = async ( identifiers: string[], - accountId?: string, ): Promise => { try { const cachedMap: Record = {}; const uncached: string[] = []; for (const identifier of identifiers) { - const cachedInfo = await getCachedUserInfo(identifier, accountId); + const cachedInfo = await getCachedUserInfo(identifier); if (cachedInfo) { cachedMap[identifier] = cachedInfo; } else { @@ -429,7 +408,7 @@ export const fetchUserInfoBatch = async ( await Promise.all( fetchedInfos.map((info, index) => - setCachedUserInfo(uncached[index], info, accountId) + setCachedUserInfo(uncached[index], info) ), ); diff --git a/app/shared/db.ts b/app/shared/db.ts index 584494e37..3144014a7 100644 --- a/app/shared/db.ts +++ b/app/shared/db.ts @@ -9,12 +9,6 @@ export interface ListOpts { before?: Date; } -/** チャットルーム情報(MLS 状態は含まない) */ -export interface ChatroomInfo { - id: string; - // name, icon, members はサーバーでは保持しない -} - /** DB 抽象インターフェース */ export interface DB { getObject(id: string): Promise; @@ -35,32 +29,6 @@ export interface DB { removeFollower(id: string, follower: string): Promise; addFollowing(id: string, target: string): Promise; removeFollowing(id: string, target: string): Promise; - listChatrooms( - id: string, - ): Promise; - listChatroomsByMember( - member: string, - ): Promise; - addChatroom( - id: string, - room: ChatroomInfo, - ): Promise; - removeChatroom( - id: string, - roomId: string, - ): Promise; - findChatroom( - roomId: string, - ): Promise< - { - owner: string; - room: ChatroomInfo; - } | null - >; - updateChatroom( - owner: string, - room: ChatroomInfo, - ): Promise; updateSessionActivity( sessionId: string, date?: Date, @@ -98,6 +66,8 @@ export interface DB { filter: Record, sort?: Record, ): Promise; + saveDMMessage(from: string, to: string, content: string): Promise; + listDMsBetween(user1: string, user2: string): Promise; findObjects( filter: Record, sort?: Record, @@ -119,28 +89,6 @@ export interface DB { ): Promise; findAccountsByUserNames(usernames: string[]): Promise; countAccounts(): Promise; - createEncryptedMessage(data: { - roomId?: string; - from: string; - to: string[]; - content: string; - mediaType?: string; - encoding?: string; - }): Promise; - findEncryptedMessages( - condition: Record, - opts?: { before?: string; after?: string; limit?: number }, - ): Promise; - createHandshakeMessage(data: { - roomId?: string; - sender: string; - recipients: string[]; - message: string; - }): Promise; - findHandshakeMessages( - condition: Record, - opts?: { before?: string; after?: string; limit?: number }, - ): Promise; findEncryptedKeyPair( userName: string, deviceId: string, @@ -152,32 +100,6 @@ export interface DB { ): Promise; deleteEncryptedKeyPair(userName: string, deviceId: string): Promise; deleteEncryptedKeyPairsByUser(userName: string): Promise; - listKeyPackages(userName: string): Promise; - // Summary of key packages (count excluding lastResort, and whether lastResort exists) - summaryKeyPackages(userName: string): Promise<{ count: number; hasLastResort: boolean }>; - findKeyPackage(userName: string, id: string): Promise; - createKeyPackage( - userName: string, - content: string, - mediaType?: string, - encoding?: string, - groupInfo?: string, - expiresAt?: Date, - deviceId?: string, - version?: string, - cipherSuite?: number, - generator?: string | { id: string; type: string; name: string }, - id?: string, - lastResort?: boolean, - ): Promise; - markKeyPackageUsed(userName: string, id: string): Promise; - markKeyPackageUsedByRef( - userName: string, - keyPackageRef: string, - ): Promise; - cleanupKeyPackages(userName: string): Promise; - deleteKeyPackage(userName: string, id: string): Promise; - deleteKeyPackagesByUser(userName: string): Promise; savePendingInvite( roomId: string, userName: string,