anydrop/server/src/http/transfers.ts
ordinarthur 0b639dfc3c feat: encrypted cloud relay (Phase 2)
Adds a "Via AnyDrop" flow for senders who need to reach someone not
present on the mesh. The file is sealed client-side (XChaCha20-Poly1305),
uploaded directly to an in-cluster MinIO bucket via a presigned PUT, and
handed off to the recipient as a URL whose fragment carries the key.
The server only ever sees ciphertext, opaque metadata blobs, and sizes.

- server: transfers table (drizzle migration), /api/transfers CRUD +
  consume endpoint, presigned PUT/GET via @aws-sdk/client-s3, cleanup
  loop that purges expired + exhausted blobs.
- web: @noble/ciphers sealFile/openFile, high-level sendCloud/receive
  helpers, CloudSharePanel on Home, /r/:id receive page, /inbox page
  for signed-in users (sent + received tabs).
- k8s: MinIO StatefulSet with bucket-init initContainer, S3 env vars
  on the server Deployment (credentials pulled from minio-credentials
  Secret).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 11:09:58 +02:00

245 lines
7.5 KiB
TypeScript

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);
});