import { createHash, randomUUID } from "node:crypto"; import { Hono } from "hono"; import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm"; import { db } from "../db/client.js"; import { transfers, users } from "../db/schema.js"; import { resolveSession } from "./session.js"; import { rateLimit } from "./middleware.js"; import { deleteObject, presignDownload, presignUpload } from "../storage/s3.js"; export const transferRoutes = new Hono(); const UPLOAD_TTL_SECONDS = 15 * 60; const DOWNLOAD_TTL_SECONDS = 10 * 60; const DEFAULT_EXPIRY_DAYS = 7; const MAX_EXPIRY_DAYS = 30; const MAX_METADATA_LEN = 8_192; const MAX_SIZE_BYTES_FREE = 2 * 1024 * 1024 * 1024; const MAX_MAX_DOWNLOADS = 100; transferRoutes.use("/transfers", rateLimit(30)); transferRoutes.use("/transfers/*", rateLimit(60)); function storageKey(id: string): string { return `t/${id.slice(0, 2)}/${id}`; } function hashEmail(email: string): string { return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); } function isValidUuid(v: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); } /** * POST /api/transfers * Create a transfer. Body: * { * sizeBytes: number, // ciphertext size * encryptedMetadata: string, // base64 AEAD-sealed JSON {name, mime, size} * recipientEmail?: string, // hashed before persist; used for /inbox routing * maxDownloads?: number, // default 1 * expiresInDays?: number // default 7, cap 30 * } * Returns: { transferId, uploadUrl, storageKey, expiresAt } */ transferRoutes.post("/transfers", async (c) => { const user = await resolveSession(c); const senderDeviceId = c.req.header("x-device-id") ?? null; let body: any; try { body = await c.req.json(); } catch { return c.json({ error: "invalid_body" }, 400); } const sizeBytes = Number(body.sizeBytes); if (!Number.isInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > MAX_SIZE_BYTES_FREE) { return c.json({ error: "invalid_size" }, 400); } const encryptedMetadata = typeof body.encryptedMetadata === "string" ? body.encryptedMetadata : ""; if (!encryptedMetadata || encryptedMetadata.length > MAX_METADATA_LEN) { return c.json({ error: "invalid_metadata" }, 400); } const maxDownloads = Number.isInteger(body.maxDownloads) ? Math.max(1, Math.min(MAX_MAX_DOWNLOADS, body.maxDownloads)) : 1; const expiresInDays = Number.isInteger(body.expiresInDays) ? Math.max(1, Math.min(MAX_EXPIRY_DAYS, body.expiresInDays)) : DEFAULT_EXPIRY_DAYS; const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); let recipientUserId: string | null = null; let recipientEmailHash: string | null = null; if (typeof body.recipientEmail === "string" && body.recipientEmail.trim()) { const email = body.recipientEmail.trim().toLowerCase(); recipientEmailHash = hashEmail(email); const match = await db .select({ id: users.id }) .from(users) .where(eq(users.email, email)) .limit(1); if (match.length > 0) recipientUserId = match[0].id; } const id = randomUUID(); const key = storageKey(id); const [row] = await db .insert(transfers) .values({ id, storageKey: key, senderUserId: user?.id ?? null, senderDeviceId, recipientUserId, recipientEmailHash, encryptedMetadata, sizeBytes, maxDownloads, expiresAt, }) .returning(); const uploadUrl = await presignUpload(key, sizeBytes, UPLOAD_TTL_SECONDS); return c.json( { transferId: row.id, uploadUrl, expiresAt: row.expiresAt, }, 201, ); }); /** * GET /api/transfers/:id * Head-style: returns metadata + a presigned download URL. Does NOT yet * bump the download counter — that's what POST /consume is for, so the * recipient client can poll metadata before committing. */ transferRoutes.get("/transfers/:id", async (c) => { const id = c.req.param("id"); if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404); const [row] = await db.select().from(transfers).where(eq(transfers.id, id)).limit(1); if (!row || row.deletedAt) return c.json({ error: "not_found" }, 404); if (row.expiresAt < new Date()) return c.json({ error: "expired" }, 410); if (row.downloadCount >= row.maxDownloads) return c.json({ error: "consumed" }, 410); return c.json({ transferId: row.id, encryptedMetadata: row.encryptedMetadata, sizeBytes: row.sizeBytes, maxDownloads: row.maxDownloads, downloadCount: row.downloadCount, expiresAt: row.expiresAt, }); }); /** * POST /api/transfers/:id/consume * Atomically increments downloadCount and returns a presigned GET URL. * Prevents two recipients from concurrently claiming the last slot. */ transferRoutes.post("/transfers/:id/consume", async (c) => { const id = c.req.param("id"); if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404); const [row] = await db .update(transfers) .set({ downloadCount: sql`${transfers.downloadCount} + 1`, firstDownloadAt: sql`coalesce(${transfers.firstDownloadAt}, now())`, }) .where( and( eq(transfers.id, id), isNull(transfers.deletedAt), gt(transfers.expiresAt, new Date()), sql`${transfers.downloadCount} < ${transfers.maxDownloads}`, ), ) .returning(); if (!row) return c.json({ error: "not_available" }, 410); const downloadUrl = await presignDownload(row.storageKey, DOWNLOAD_TTL_SECONDS); return c.json({ downloadUrl, expiresInSeconds: DOWNLOAD_TTL_SECONDS }); }); /** * GET /api/transfers * List the authenticated user's inbox (things sent TO them) and outbox * (things they sent). Signed-in only. */ transferRoutes.get("/transfers", async (c) => { const user = await resolveSession(c); if (!user) return c.json({ error: "unauthenticated" }, 401); const rows = await db .select({ id: transfers.id, sizeBytes: transfers.sizeBytes, encryptedMetadata: transfers.encryptedMetadata, createdAt: transfers.createdAt, expiresAt: transfers.expiresAt, maxDownloads: transfers.maxDownloads, downloadCount: transfers.downloadCount, firstDownloadAt: transfers.firstDownloadAt, senderUserId: transfers.senderUserId, recipientUserId: transfers.recipientUserId, }) .from(transfers) .where( and( isNull(transfers.deletedAt), or( eq(transfers.senderUserId, user.id), eq(transfers.recipientUserId, user.id), ), ), ) .orderBy(desc(transfers.createdAt)) .limit(50); return c.json({ transfers: rows.map((r) => ({ ...r, direction: r.senderUserId === user.id ? "sent" : "received", })), }); }); /** * DELETE /api/transfers/:id * Sender can revoke. Marks deleted, purges the blob asynchronously. */ transferRoutes.delete("/transfers/:id", async (c) => { const user = await resolveSession(c); if (!user) return c.json({ error: "unauthenticated" }, 401); const id = c.req.param("id"); if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404); const [row] = await db .update(transfers) .set({ deletedAt: new Date() }) .where(and(eq(transfers.id, id), eq(transfers.senderUserId, user.id), isNull(transfers.deletedAt))) .returning(); if (!row) return c.json({ error: "not_found" }, 404); deleteObject(row.storageKey).catch((err) => console.error("[transfers] delete blob failed:", row.storageKey, err), ); return c.body(null, 204); });