168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
import type { Server } from "bun";
|
|
import { loadConfig } from "./config";
|
|
import { json, jsonError, notFound, securityHeaders } from "./http";
|
|
import { log } from "./log";
|
|
import { RateLimiter } from "./ratelimit";
|
|
import { handleAuth } from "./routes/auth";
|
|
import { handleDelete } from "./routes/delete";
|
|
import { handleDownload } from "./routes/download";
|
|
import { handleUpload } from "./routes/upload";
|
|
import { KEY_PATTERN } from "./security";
|
|
import { Storage } from "./storage";
|
|
|
|
const cfg = loadConfig();
|
|
|
|
const storage = new Storage(cfg.storageDir);
|
|
await storage.init();
|
|
const secret = await storage.loadSecret();
|
|
const sweep = await storage.sweep();
|
|
if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) {
|
|
log(
|
|
"info",
|
|
`startup sweep removed ${sweep.removedFiles} orphaned file(s), ${sweep.removedMetadata} orphaned metadata record(s)`,
|
|
);
|
|
}
|
|
|
|
const limiter = new RateLimiter(cfg.rateLimitMax, cfg.rateLimitWindowMs);
|
|
const startedAt = Date.now();
|
|
|
|
const expiryTimer = setInterval(() => {
|
|
storage
|
|
.expireExpired()
|
|
.then((removed) => {
|
|
if (removed > 0) log("info", `expiry sweep removed ${removed} file(s)`);
|
|
})
|
|
.catch((err) => {
|
|
log("error", `expiry sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
});
|
|
}, 60 * 60 * 1000);
|
|
|
|
const STATIC_ROUTES: Record<string, { file: string; type: string }> = {
|
|
"/": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
|
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
|
"/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" },
|
|
"/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" },
|
|
};
|
|
|
|
const staticContent = new Map<string, { body: string; type: string }>();
|
|
for (const [route, info] of Object.entries(STATIC_ROUTES)) {
|
|
staticContent.set(route, {
|
|
body: await Bun.file(new URL(info.file, import.meta.url)).text(),
|
|
type: info.type,
|
|
});
|
|
}
|
|
|
|
const server = Bun.serve({
|
|
hostname: cfg.host,
|
|
port: cfg.port,
|
|
maxRequestBodySize: Infinity,
|
|
fetch(req, srv) {
|
|
return route(req, srv).catch((err) => {
|
|
log("error", `unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
return jsonError(500, "internal error");
|
|
});
|
|
},
|
|
});
|
|
|
|
function clientIp(req: Request, srv: Server<undefined>): string {
|
|
if (cfg.trustProxy) {
|
|
const xff = req.headers.get("x-forwarded-for");
|
|
if (xff) {
|
|
const first = xff.split(",")[0]?.trim();
|
|
if (first) return first;
|
|
}
|
|
const cf = req.headers.get("cf-connecting-ip");
|
|
if (cf) return cf;
|
|
}
|
|
return srv.requestIP(req)?.address ?? "unknown";
|
|
}
|
|
|
|
function serveStatic(path: string): Response {
|
|
const entry = staticContent.get(path);
|
|
if (!entry) return notFound();
|
|
return new Response(entry.body, {
|
|
headers: {
|
|
"content-type": entry.type,
|
|
"cache-control": "no-cache",
|
|
"content-security-policy":
|
|
"default-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
|
...securityHeaders(),
|
|
},
|
|
});
|
|
}
|
|
|
|
function isSecure(req: Request, url: URL): boolean {
|
|
if (cfg.trustProxy) return req.headers.get("x-forwarded-proto") === "https";
|
|
return url.protocol === "https:";
|
|
}
|
|
|
|
async function route(req: Request, srv: Server<undefined>): Promise<Response> {
|
|
const url = new URL(req.url);
|
|
const path = url.pathname;
|
|
const method = req.method;
|
|
|
|
if (method === "GET" && staticContent.has(path)) return serveStatic(path);
|
|
|
|
if (path === "/api/health" && method === "GET") {
|
|
return json({ status: "ok", uptimeSeconds: Math.round((Date.now() - startedAt) / 1000) });
|
|
}
|
|
|
|
if (path === "/api/upload" && method === "POST") {
|
|
return handleUpload(req, storage, cfg.maxFileSize, cfg.fileTtlMs, cfg.publicBaseUrl, cfg.maxStorageUsage);
|
|
}
|
|
|
|
if (path.startsWith("/api/auth/") && method === "POST") {
|
|
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
|
return jsonError(429, "too many requests");
|
|
}
|
|
return handleAuth(req, storage, path.slice("/api/auth/".length), secret, isSecure(req, url));
|
|
}
|
|
|
|
if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) {
|
|
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
|
return jsonError(429, "too many requests");
|
|
}
|
|
return handleDownload(req, storage, path.slice(3), secret);
|
|
}
|
|
|
|
const rootKey = path.slice(1);
|
|
if (
|
|
rootKey &&
|
|
!rootKey.includes("/") &&
|
|
KEY_PATTERN.test(rootKey) &&
|
|
(method === "GET" || method === "HEAD")
|
|
) {
|
|
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
|
return jsonError(429, "too many requests");
|
|
}
|
|
return handleDownload(req, storage, rootKey, secret);
|
|
}
|
|
|
|
if (path.startsWith("/api/files/") && method === "DELETE") {
|
|
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
|
|
return jsonError(429, "too many requests");
|
|
}
|
|
return handleDelete(req, storage, path.slice("/api/files/".length));
|
|
}
|
|
|
|
return notFound();
|
|
}
|
|
|
|
log(
|
|
"info",
|
|
`listening on http://${cfg.host}:${cfg.port} storage=${cfg.storageDir} maxUpload=${cfg.maxFileSize}`,
|
|
);
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
process.once(signal, () => {
|
|
log("info", `received ${signal}, shutting down`);
|
|
clearInterval(expiryTimer);
|
|
server.stop();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
process.on("unhandledRejection", (reason) => {
|
|
log("error", `unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
});
|