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>
245 lines
7.5 KiB
TypeScript
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);
|
|
});
|