124 lines
3.3 KiB
TypeScript
124 lines
3.3 KiB
TypeScript
/**
|
|
* IPFS API Routes — Hono router mounted at /api/ipfs.
|
|
*
|
|
* GET /api/ipfs/status — IPFS node health + stats (authenticated)
|
|
* GET /api/ipfs/:cid — Proxy gateway fetch (avoids CORS for clients)
|
|
* POST /api/ipfs/pin — Pin arbitrary blob (authenticated, 50MB limit)
|
|
* DELETE /api/ipfs/:cid — Unpin a CID (authenticated)
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import type { Context, Next } from "hono";
|
|
import { verifyToken, extractToken } from "./auth";
|
|
import type { EncryptIDClaims } from "./auth";
|
|
import {
|
|
isIPFSEnabled,
|
|
pinToIPFS,
|
|
unpinFromIPFS,
|
|
fetchFromIPFS,
|
|
getIPFSStatus,
|
|
ipfsGatewayUrl,
|
|
} from "./ipfs";
|
|
|
|
const MAX_PIN_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
|
|
type IPFSEnv = {
|
|
Variables: {
|
|
userId: string;
|
|
claims: EncryptIDClaims;
|
|
};
|
|
};
|
|
|
|
export const ipfsRouter = new Hono<IPFSEnv>();
|
|
|
|
/** Auth middleware for mutating endpoints. */
|
|
async function requireAuth(c: Context<IPFSEnv>, next: Next) {
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
try {
|
|
const claims = await verifyToken(token);
|
|
c.set("userId", claims.sub);
|
|
c.set("claims", claims);
|
|
} catch {
|
|
return c.json({ error: "Invalid or expired token" }, 401);
|
|
}
|
|
await next();
|
|
}
|
|
|
|
/** GET /status — IPFS node health + stats */
|
|
ipfsRouter.get("/status", requireAuth, async (c) => {
|
|
if (!isIPFSEnabled()) {
|
|
return c.json({ enabled: false }, 503);
|
|
}
|
|
try {
|
|
const status = await getIPFSStatus();
|
|
return c.json({ enabled: true, ...status });
|
|
} catch (e: any) {
|
|
return c.json({ enabled: true, error: e.message }, 502);
|
|
}
|
|
});
|
|
|
|
/** POST /pin — Pin a blob to IPFS */
|
|
ipfsRouter.post("/pin", requireAuth, async (c) => {
|
|
if (!isIPFSEnabled()) {
|
|
return c.json({ error: "IPFS not enabled" }, 503);
|
|
}
|
|
|
|
const blob = await c.req.arrayBuffer();
|
|
if (blob.byteLength === 0) {
|
|
return c.json({ error: "Empty body" }, 400);
|
|
}
|
|
if (blob.byteLength > MAX_PIN_SIZE) {
|
|
return c.json({ error: `Blob too large (max ${MAX_PIN_SIZE} bytes)` }, 413);
|
|
}
|
|
|
|
const filename =
|
|
c.req.header("X-Filename") || `upload-${Date.now()}.bin`;
|
|
|
|
try {
|
|
const cid = await pinToIPFS(new Uint8Array(blob), filename);
|
|
return c.json({ cid, gateway: ipfsGatewayUrl(cid), size: blob.byteLength });
|
|
} catch (e: any) {
|
|
console.error("[ipfs/pin]", e.message);
|
|
return c.json({ error: "IPFS pin failed" }, 502);
|
|
}
|
|
});
|
|
|
|
/** DELETE /:cid — Unpin a CID */
|
|
ipfsRouter.delete("/:cid", requireAuth, async (c) => {
|
|
if (!isIPFSEnabled()) {
|
|
return c.json({ error: "IPFS not enabled" }, 503);
|
|
}
|
|
|
|
const cid = c.req.param("cid");
|
|
try {
|
|
await unpinFromIPFS(cid);
|
|
return c.json({ ok: true, cid });
|
|
} catch (e: any) {
|
|
console.error("[ipfs/unpin]", e.message);
|
|
return c.json({ error: "IPFS unpin failed" }, 502);
|
|
}
|
|
});
|
|
|
|
/** GET /:cid — Proxy gateway fetch (public, no auth needed) */
|
|
ipfsRouter.get("/:cid", async (c) => {
|
|
const cid = c.req.param("cid");
|
|
try {
|
|
const res = await fetchFromIPFS(cid);
|
|
if (!res.ok) {
|
|
return c.json({ error: "Not found on IPFS" }, 404);
|
|
}
|
|
const body = await res.arrayBuffer();
|
|
return new Response(body, {
|
|
headers: {
|
|
"Content-Type":
|
|
res.headers.get("Content-Type") || "application/octet-stream",
|
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
},
|
|
});
|
|
} catch (e: any) {
|
|
console.error("[ipfs/proxy]", e.message);
|
|
return c.json({ error: "IPFS fetch failed" }, 502);
|
|
}
|
|
});
|