129 lines
3.5 KiB
TypeScript
129 lines
3.5 KiB
TypeScript
/**
|
|
* Backup API Routes — Hono router for encrypted backup operations.
|
|
*
|
|
* All endpoints require EncryptID JWT authentication.
|
|
* The server stores opaque ciphertext blobs it cannot decrypt.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import type { Context, Next } from "hono";
|
|
import {
|
|
verifyEncryptIDToken,
|
|
extractToken,
|
|
} from "@encryptid/sdk/server";
|
|
import type { EncryptIDClaims } from "@encryptid/sdk/server";
|
|
import {
|
|
putBackup,
|
|
getBackup,
|
|
listBackups,
|
|
deleteBackup,
|
|
deleteAllBackups,
|
|
getUsage,
|
|
} from "./backup-store";
|
|
|
|
const MAX_BLOB_SIZE = 10 * 1024 * 1024; // 10 MB per blob
|
|
|
|
type BackupEnv = {
|
|
Variables: {
|
|
userId: string;
|
|
claims: EncryptIDClaims;
|
|
};
|
|
};
|
|
|
|
const backupRouter = new Hono<BackupEnv>();
|
|
|
|
/** Auth middleware — extracts and verifies JWT, sets userId. */
|
|
backupRouter.use("*", async (c: Context<BackupEnv>, next: Next) => {
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) {
|
|
return c.json({ error: "Authentication required" }, 401);
|
|
}
|
|
let claims: EncryptIDClaims;
|
|
try {
|
|
claims = await verifyEncryptIDToken(token);
|
|
} catch {
|
|
return c.json({ error: "Invalid or expired token" }, 401);
|
|
}
|
|
c.set("userId", claims.sub);
|
|
c.set("claims", claims);
|
|
await next();
|
|
});
|
|
|
|
/** PUT /api/backup/:space/:docId — upload encrypted blob */
|
|
backupRouter.put("/:space/:docId", async (c) => {
|
|
const userId = c.get("userId");
|
|
const space = c.req.param("space");
|
|
const docId = decodeURIComponent(c.req.param("docId"));
|
|
|
|
const blob = await c.req.arrayBuffer();
|
|
if (blob.byteLength > MAX_BLOB_SIZE) {
|
|
return c.json({ error: `Blob too large (max ${MAX_BLOB_SIZE} bytes)` }, 413);
|
|
}
|
|
if (blob.byteLength === 0) {
|
|
return c.json({ error: "Empty blob" }, 400);
|
|
}
|
|
|
|
await putBackup(userId, space, docId, new Uint8Array(blob));
|
|
return c.json({ ok: true, size: blob.byteLength });
|
|
});
|
|
|
|
/** GET /api/backup/:space/:docId — download encrypted blob */
|
|
backupRouter.get("/:space/:docId", async (c) => {
|
|
const userId = c.get("userId");
|
|
const space = c.req.param("space");
|
|
const docId = decodeURIComponent(c.req.param("docId"));
|
|
|
|
const blob = await getBackup(userId, space, docId);
|
|
if (!blob) {
|
|
return c.json({ error: "Not found" }, 404);
|
|
}
|
|
|
|
const body = new Uint8Array(blob).buffer as ArrayBuffer;
|
|
return new Response(body, {
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
"Content-Length": blob.byteLength.toString(),
|
|
},
|
|
});
|
|
});
|
|
|
|
/** GET /api/backup/:space — list manifest for a space */
|
|
backupRouter.get("/:space", async (c) => {
|
|
const userId = c.get("userId");
|
|
const space = c.req.param("space");
|
|
|
|
const manifest = await listBackups(userId, space);
|
|
return c.json(manifest);
|
|
});
|
|
|
|
/** DELETE /api/backup/:space/:docId — delete specific backup */
|
|
backupRouter.delete("/:space/:docId", async (c) => {
|
|
const userId = c.get("userId");
|
|
const space = c.req.param("space");
|
|
const docId = decodeURIComponent(c.req.param("docId"));
|
|
|
|
const ok = await deleteBackup(userId, space, docId);
|
|
if (!ok) {
|
|
return c.json({ error: "Not found or delete failed" }, 404);
|
|
}
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
/** DELETE /api/backup/:space — delete all backups for a space */
|
|
backupRouter.delete("/:space", async (c) => {
|
|
const userId = c.get("userId");
|
|
const space = c.req.param("space");
|
|
|
|
await deleteAllBackups(userId, space);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
/** GET /api/backup/status — overall backup status for authenticated user */
|
|
backupRouter.get("/", async (c) => {
|
|
const userId = c.get("userId");
|
|
const usage = await getUsage(userId);
|
|
return c.json(usage);
|
|
});
|
|
|
|
export { backupRouter };
|