import { inlinePayload } from './clipboardPayload.js'; import type { CompleteInput } from '@hop/shared'; import { randomUUID } from 'node:crypto'; import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm'; import { type AccountItem, type EmailRequest } from '@hop/shared'; import type { Service, RequestRow, Guest } from './service.js'; import type { User } from './auth.js'; import * as s from './db/schema.js'; import { fail, hash } from './security.js'; export class Accounts { constructor(readonly service: Service) {} verified(userId: string) { const user = this.service.auth.user(userId); if (!this.service.auth.serialize(user).verified) fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.'); return user; } notify(userId: string) { const io = this.service.io; if (!io) return; for (const socket of io.sockets.sockets.values()) { if (socket.data.accountId !== userId) continue; const session = this.service.db .select() .from(s.accountSessions) .where(eq(s.accountSessions.id, socket.data.accountSessionId)) .get(); const user = this.service.auth.user(userId); if ( !session || session.revokedAt || session.expiresAt <= Date.now() || !['pending', 'active'].includes(user.status) ) { socket.leave(`account:${userId}`); socket.emit('account:revoked', {}); } } io.to(`account:${userId}`).emit('inbox:updated', {}); } request(userId: string, id: string) { const row = this.service.db .select() .from(s.requests) .where( and( eq(s.requests.id, id), eq(s.requests.mode, 'email'), inArray(s.requests.kind, ['url', 'text', 'file']), or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)), ), ) .get(); if (!row) fail(404, 'REQUEST_NOT_FOUND', '전송 요청을 찾을 수 없습니다.'); return row!; } policy(senderId: string, recipientId: string) { const recipient = this.verified(recipientId); this.verified(senderId); if (senderId === recipientId) return 'allow'; const rule = this.service.db .select() .from(s.rules) .where(and(eq(s.rules.userId, recipientId), eq(s.rules.targetUserId, senderId))) .get(); if (rule?.action === 'block' || recipient.receivePolicy === 'none') return 'block'; if (rule?.action === 'allow') return 'allow'; if (recipient.receivePolicy === 'allowed') return 'block'; return 'request'; } canTransfer(row: RequestRow) { try { return ( row.mode === 'email' && row.status === 'accepted' && this.policy(row.senderUserId!, row.recipientUserId!) !== 'block' ); } catch { return false; } } idem( userId: string, operation: string, key: string | undefined, payload: unknown, fn: () => T, ): T { if (!key || !/^[A-Za-z0-9_-]{8,128}$/.test(key)) fail(400, 'IDEMPOTENCY_REQUIRED', '유효한 중복 방지 키가 필요합니다.'); const payloadHash = hash(JSON.stringify(payload)); return this.service.db.transaction(() => { const row = this.service.db .select() .from(s.idempotency) .where( and( eq(s.idempotency.userId, userId), eq(s.idempotency.operation, operation), eq(s.idempotency.key, key!), ), ) .get(); if (row) { if (row.payloadHash !== payloadHash) fail(409, 'IDEMPOTENCY_CONFLICT', '같은 요청 키의 내용이 달라졌습니다.'); return JSON.parse(row.response) as T; } const result = fn(); this.service.db .insert(s.idempotency) .values({ id: randomUUID(), userId, operation, key: key!, payloadHash, response: JSON.stringify(result), createdAt: Date.now(), }) .run(); return result; }); } create( userId: string, input: { email: string; kind: 'url' | 'text' | 'file'; byteSize: number; count: number; files?: { name: string; size: number; type: string }[]; }, ) { const { db, cfg, limiter } = this.service; this.verified(userId); if (input.kind === 'file') this.service.admin.assertNewUploads(); limiter.hit(`remote-target:${userId}`, 30, 3600000); const recipient = db .select() .from(s.users) .where( and( eq(s.users.email, input.email), eq(s.users.status, 'active'), sql`${s.users.verifiedAt} IS NOT NULL`, ), ) .get(); if (!recipient) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.'); let policy: string; try { policy = this.policy(userId, recipient!.id); } catch { policy = 'block'; } if (policy === 'block') fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.'); if ( input.count > cfg.maxItems || (input.kind === 'url' && input.byteSize > cfg.maxUrlBytes) || (input.kind === 'text' && input.byteSize > cfg.maxTextBytes) || input.files?.some((f) => f.size > this.service.admin.state.perFileBytes) ) fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.'); if (policy === 'request') { limiter.hit(`remote-new:${userId}`, 20, 86400000); const pending = db .select() .from(s.requests) .where( and( eq(s.requests.recipientUserId, recipient!.id), eq(s.requests.status, 'pending'), gt(s.requests.expiresAt, Date.now()), ), ) .all(); if (pending.length >= 50) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.'); } const now = Date.now(), id = randomUUID(); db.insert(s.requests) .values({ id, mode: 'email', senderUserId: userId, recipientUserId: recipient!.id, kind: input.kind, byteSize: input.byteSize, count: input.count, manifest: input.files ? JSON.stringify(input.files) : null, status: policy === 'allow' ? 'accepted' : 'pending', createdAt: now, expiresAt: now + cfg.emailRequestDays * 86400000, acceptedAt: policy === 'allow' ? now : null, startBy: policy === 'allow' ? now + cfg.uploadStartHours * 3600000 : null, }) .run(); db.insert(s.batches).values({ id: randomUUID(), requestId: id, status: 'waiting' }).run(); if (input.files) { this.service.storage.createManifest({ id: userId, kind: 'user' }, id, input.files); if (policy === 'allow') this.service.storage.reserve({ id: userId, kind: 'user' }, id); } return { requestId: id, requiresAcceptance: policy !== 'allow' }; } accept(userId: string, id: string, allowSender = false) { this.verified(userId); const row = this.request(userId, id); if (row.recipientUserId !== userId) fail(403, 'FORBIDDEN', '수신 계정만 수락할 수 있습니다.'); if ( row.status !== 'pending' || row.expiresAt <= Date.now() || this.policy(row.senderUserId!, userId) === 'block' ) fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.'); if (row.kind === 'file') this.service.admin.assertNewUploads(); if (row.kind === 'file') this.service.storage.reserve({ id: row.senderUserId!, kind: 'user' }, id); if (allowSender) this.rule(userId, row.senderUserId!, 'allow'); const now = Date.now(); this.service.db .update(s.requests) .set({ status: 'accepted', acceptedAt: now, startBy: now + this.service.cfg.uploadStartHours * 3600000, }) .where(eq(s.requests.id, id)) .run(); return { requestId: id }; } reject(userId: string, id: string) { this.verified(userId); const row = this.request(userId, id); if (row.recipientUserId !== userId || row.status !== 'pending') fail(409, 'REQUEST_STATE', '거절할 수 없는 요청입니다.'); this.service.db .update(s.requests) .set({ status: 'rejected', rejectedAt: Date.now() }) .where(eq(s.requests.id, id)) .run(); } cancel(userId: string, id: string) { const row = this.request(userId, id), batch = this.service.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!; if ( row.senderUserId !== userId || !['pending', 'accepted'].includes(row.status) || batch.status === 'stored' ) fail(409, 'REQUEST_STATE', '취소할 수 없는 요청입니다.'); this.service.storage.release(id); this.service.db .update(s.requests) .set({ status: 'cancelled' }) .where(eq(s.requests.id, id)) .run(); this.service.db .update(s.batches) .set({ status: 'failed' }) .where(eq(s.batches.id, batch.id)) .run(); } complete(userId: string, id: string, input: CompleteInput) { const row = this.request(userId, id); if ( row.senderUserId !== userId || !this.canTransfer(row) || !row.startBy || row.startBy <= Date.now() ) fail(409, 'REQUEST_STATE', '수락된 유효한 요청이 필요합니다.'); if (row.kind === 'file') fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.'); const batch = this.service.db .select() .from(s.batches) .where(eq(s.batches.requestId, id)) .get()!; if (batch.status !== 'waiting') fail(409, 'TRANSFER_STATE', '이미 처리된 전송입니다.'); const { values, bytes } = inlinePayload( row.kind, input, this.service.cfg.maxTextBytes, this.service.cfg.maxUrlBytes, ); if (bytes !== row.byteSize || values.length !== row.count) fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.'); const now = Date.now(); for (const value of values) { const itemId = randomUUID(); this.service.db .insert(s.items) .values({ id: itemId, batchId: batch.id, kind: row.kind, ...(row.kind === 'text' ? { text: value } : { url: value }), createdAt: now, expiresAt: now + this.service.cfg.urlRetentionDays * 86400000, }) .run(); this.createAccess(row, itemId); } this.service.db .update(s.batches) .set({ status: 'stored', storedAt: now }) .where(eq(s.batches.id, batch.id)) .run(); return { requestId: id, batchId: batch.id }; } createAccess(row: RequestRow, itemId: string) { if (row.mode === 'local') { for (const [guestId, role] of [ [row.senderGuestId!, 'sender'], [row.recipientGuestId!, 'recipient'], ] as const) this.service.db .insert(s.access) .values({ id: randomUUID(), itemId, guestId, role, read: false }) .run(); this.service.vault.receipt(row, itemId); } else if (row.senderUserId === row.recipientUserId) this.service.db .insert(s.access) .values({ id: randomUUID(), itemId, userId: row.senderUserId!, role: 'self', read: false, note: row.note, }) .run(); else for (const [userId, role] of [ [row.senderUserId!, 'sender'], [row.recipientUserId!, 'recipient'], ] as const) this.service.db .insert(s.access) .values({ id: randomUUID(), itemId, userId, role, read: false }) .run(); } listRequests(userId: string): EmailRequest[] { this.verified(userId); return this.service.db .select({ request: s.requests, batch: s.batches }) .from(s.requests) .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) .where( and( eq(s.requests.mode, 'email'), gt(s.requests.createdAt, Date.now() - 7 * 86400000), inArray(s.requests.kind, ['url', 'text', 'file']), or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)), ), ) .orderBy(desc(s.requests.createdAt), desc(s.requests.id)) .limit(100) .all() .map(({ request: r, batch: b }) => ({ id: r.id, batchId: b.id, kind: r.kind as 'url' | 'file', byteSize: r.byteSize, count: r.count, status: r.status, transferStatus: b.status, createdAt: r.createdAt, expiresAt: r.expiresAt, startBy: r.startBy, direction: r.senderUserId === userId ? 'sent' : 'received', sender: this.service.auth.serialize(this.service.auth.user(r.senderUserId!)), recipient: this.service.auth.serialize(this.service.auth.user(r.recipientUserId!)), files: r.kind === 'file' ? this.service.storage.fileRows(r.id).map((f) => ({ id: f.id, name: f.name, size: f.size, type: f.type, status: f.status, })) : undefined, })); } access(userId: string, id: string) { this.verified(userId); const row = this.service.db .select({ access: s.access, item: s.items }) .from(s.access) .innerJoin(s.items, eq(s.access.itemId, s.items.id)) .where( and( eq(s.access.id, id), eq(s.access.userId, userId), inArray(s.items.kind, ['url', 'text', 'file']), isNull(s.access.deletedAt), gt(s.items.expiresAt, Date.now()), ), ) .get(); if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.'); if (row!.item.fileId) this.service.storage.availability.require(row!.item.fileId); return row!; } read(userId: string, id: string, value: boolean) { const row = this.access(userId, id); this.service.db .update(s.access) .set({ read: value }) .where(and(eq(s.access.itemId, row.item.id), eq(s.access.role, row.access.role))) .run(); for (const access of this.service.db .select() .from(s.access) .where(eq(s.access.itemId, row.item.id)) .all()) { if (access.userId) this.notify(access.userId); if (access.guestId) this.service.presence.emit(access.guestId, 'access:updated', {}); } } list( userId: string, view: 'inbox' | 'vault' | 'sent' | 'favorites' | 'contents', query: { cursor?: string; limit: number; search?: string; kind?: string; read?: boolean }, ) { this.verified(userId); this.service.storage.availability.reconcile({ id: userId, kind: 'user' }); let after: { createdAt: number; id: string } | undefined; if (query.cursor) { try { after = JSON.parse(Buffer.from(query.cursor, 'base64url').toString()); if (!after || !Number.isSafeInteger(after.createdAt) || typeof after.id !== 'string') throw Error(); } catch { fail(400, 'INVALID_CURSOR', '목록 커서가 올바르지 않습니다.'); } } const rows = this.service.db .select({ access: s.access, item: s.items, request: s.requests }) .from(s.access) .innerJoin(s.items, eq(s.access.itemId, s.items.id)) .innerJoin(s.batches, eq(s.items.batchId, s.batches.id)) .innerJoin(s.requests, eq(s.batches.requestId, s.requests.id)) .where( and( eq(s.access.userId, userId), isNull(s.access.deletedAt), gt(s.items.expiresAt, Date.now()), view === 'contents' ? inArray(s.access.role, ['self', 'recipient']) : view === 'favorites' ? eq(s.access.favorite, true) : eq( s.access.role, view === 'inbox' ? 'recipient' : view === 'vault' ? 'self' : 'sender', ), inArray(s.items.kind, ['url', 'text', 'file']), query.kind ? eq(s.items.kind, query.kind as any) : undefined, query.read !== undefined ? eq(s.access.read, query.read) : undefined, query.search ? sql`(instr(lower(coalesce(${s.access.note},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.url},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.text},'')),lower(${query.search})) > 0 OR EXISTS (SELECT 1 FROM files f WHERE f.id = ${s.items.fileId} AND instr(lower(f.name),lower(${query.search})) > 0))` : undefined, after ? or( lt(s.items.createdAt, after.createdAt), and(eq(s.items.createdAt, after.createdAt), lt(s.access.id, after.id)), ) : undefined, ), ) .orderBy(desc(s.items.createdAt), desc(s.access.id)) .limit(query.limit + 1) .all(); const result: AccountItem[] = rows.slice(0, query.limit).map((r) => { const peerId = r.access.role === 'sender' ? r.request.recipientUserId : r.request.senderUserId; const peer = peerId ? this.service.auth.user(peerId) : null; const file = r.item.fileId ? this.service.db.select().from(s.files).where(eq(s.files.id, r.item.fileId)).get() : null; const recipient = this.service.db .select() .from(s.access) .where(and(eq(s.access.itemId, r.item.id), eq(s.access.role, 'recipient'))) .get(); return { accessId: r.access.id, id: r.item.id, batchId: r.item.batchId, kind: r.item.kind as 'url' | 'file', url: r.item.url, ...(r.item.kind === 'text' ? { text: r.item.text } : {}), role: r.access.role, read: r.access.read, favorite: r.access.favorite, note: r.access.note, file: file ? { id: file.id, name: file.name, size: file.size, type: file.type } : undefined, peerName: peer?.displayName ?? (r.request.senderGuestId ? this.service.device(r.request.senderGuestId).name : '익명 기기'), peerCode: '', senderEmail: peer?.email ?? undefined, senderName: peer?.displayName, createdAt: r.item.createdAt, expiresAt: r.item.expiresAt, recipientRead: recipient?.read ?? false, }; }); const last = result.at(-1); return { items: result, nextCursor: rows.length > query.limit && last ? Buffer.from(JSON.stringify({ createdAt: last.createdAt, id: last.accessId })).toString( 'base64url', ) : null, }; } vaultFiles(userId: string) { this.service.storage.availability.reconcile({ id: userId, kind: 'user' }); const rows = this.service.db .select({ file: s.files }) .from(s.access) .innerJoin(s.items, eq(s.access.itemId, s.items.id)) .innerJoin(s.files, eq(s.items.fileId, s.files.id)) .where( and( eq(s.access.userId, userId), inArray(s.access.role, ['self', 'recipient']), isNull(s.access.deletedAt), gt(s.items.expiresAt, Date.now()), eq(s.files.status, 'stored'), ), ) .all(); return new Map(rows.map(({ file }) => [file.id, file.size])); } used(userId: string) { return [...this.vaultFiles(userId).values()].reduce((sum, bytes) => sum + bytes, 0); } canStoreFile(userId: string, file: typeof s.files.$inferSelect) { const files = this.vaultFiles(userId); if (files.has(file.id)) return true; const reserved = this.service.db .select() .from(s.reservations) .where(and(eq(s.reservations.userId, userId), gt(s.reservations.expiresAt, Date.now()))) .all() .reduce((sum, row) => sum + row.bytes, 0); return ( [...files.values()].reduce((sum, bytes) => sum + bytes, 0) + reserved + file.size <= this.service.admin.state.accountBytes ); } ensureQuota(userId: string, bytes: number) { if (this.used(userId) + bytes > this.service.admin.state.accountBytes) fail(413, 'ACCOUNT_QUOTA', '계정 저장 용량을 초과했습니다.'); } favorite(userId: string, id: string, value: boolean) { this.service.db.transaction(() => { const row = this.access(userId, id); if (value && !row.access.favorite && row.item.fileId) { const file = this.service.db .select() .from(s.files) .where(eq(s.files.id, row.item.fileId)) .get()!; if (file.ownerUserId !== userId && row.access.role !== 'self') this.ensureQuota(userId, file.size); } this.service.db.update(s.access).set({ favorite: value }).where(eq(s.access.id, id)).run(); }); this.notify(userId); } move(g: Guest, userId: string, id: string) { this.verified(userId); return this.service.db.transaction(() => { const row = this.service.accessDetail(g, id); if (row.item.fileId) { const file = this.service.db .select() .from(s.files) .where(eq(s.files.id, row.item.fileId)) .get()!; this.ensureQuota(userId, file.size); if (file.ownerGuestId === g.id) this.service.db .update(s.files) .set({ ownerGuestId: null, ownerUserId: userId }) .where(eq(s.files.id, file.id)) .run(); } const existing = this.service.db .select() .from(s.access) .where( and( eq(s.access.itemId, row.item.id), eq(s.access.userId, userId), eq(s.access.role, 'self'), ), ) .get(); if (existing) this.service.db .update(s.access) .set({ deletedAt: null }) .where(eq(s.access.id, existing.id)) .run(); else this.service.db .insert(s.access) .values({ id: randomUUID(), itemId: row.item.id, userId, role: 'self', read: row.access.read, }) .run(); this.service.db .update(s.access) .set({ deletedAt: Date.now() }) .where(eq(s.access.id, row.access.id)) .run(); return { ok: true }; }); } rule(userId: string, target: string, action: 'allow' | 'block') { this.verified(userId); if (target === userId) fail(400, 'RULE_INVALID', '자기 계정에는 수신 규칙을 적용하지 않습니다.'); const candidate = this.service.db .select() .from(s.requests) .where(and(eq(s.requests.senderUserId, target), eq(s.requests.recipientUserId, userId))) .get(); const existing = this.service.db .select() .from(s.rules) .where(and(eq(s.rules.userId, userId), eq(s.rules.targetUserId, target))) .get(); if (!candidate && !existing) fail(404, 'SENDER_NOT_FOUND', '수신 관계가 있는 발신자만 관리할 수 있습니다.'); this.service.db .insert(s.rules) .values({ id: randomUUID(), userId, targetUserId: target, action }) .onConflictDoUpdate({ target: [s.rules.userId, s.rules.targetUserId], set: { action } }) .run(); if (action === 'block') { const affected = this.service.db .select({ request: s.requests, batch: s.batches }) .from(s.requests) .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) .where( and( eq(s.requests.senderUserId, target), eq(s.requests.recipientUserId, userId), inArray(s.requests.status, ['pending', 'accepted']), inArray(s.batches.status, ['waiting', 'uploading']), ), ) .all(); for (const { request } of affected) { this.service.storage.release(request.id); this.service.db .update(s.requests) .set({ status: request.status === 'pending' ? 'rejected' : 'cancelled', rejectedAt: Date.now(), }) .where(eq(s.requests.id, request.id)) .run(); } this.notify(target); } this.notify(userId); } }