fix
This commit is contained in:
+10
-1
@@ -5,12 +5,15 @@ export interface Config {
|
||||
port: number;
|
||||
storageDir: string;
|
||||
maxFileSize: number;
|
||||
fileTtlMs: number;
|
||||
rateLimitEnabled: boolean;
|
||||
rateLimitMax: number;
|
||||
rateLimitWindowMs: number;
|
||||
trustProxy: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
|
||||
|
||||
const SIZE_UNITS: Record<string, number> = {
|
||||
B: 1,
|
||||
K: 1 << 10,
|
||||
@@ -49,9 +52,14 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
||||
}
|
||||
const host = env.HOST ?? "0.0.0.0";
|
||||
const storageDir = resolve(env.STORAGE_DIR ?? "./storage");
|
||||
const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "5GB");
|
||||
const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "10GB");
|
||||
if (maxFileSize < 1024) throw new Error("MAX_FILE_SIZE must be at least 1KB");
|
||||
|
||||
const fileTtlMs = Number(env.FILE_TTL_MS ?? String(DEFAULT_TTL_MS));
|
||||
if (!Number.isInteger(fileTtlMs) || fileTtlMs < 1) {
|
||||
throw new Error(`Invalid FILE_TTL_MS: ${env.FILE_TTL_MS}`);
|
||||
}
|
||||
|
||||
const rateLimitMax = Number(env.RATE_LIMIT_MAX ?? "300");
|
||||
if (!Number.isInteger(rateLimitMax) || rateLimitMax < 1) {
|
||||
throw new Error(`Invalid RATE_LIMIT_MAX: ${env.RATE_LIMIT_MAX}`);
|
||||
@@ -66,6 +74,7 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
||||
port,
|
||||
storageDir,
|
||||
maxFileSize,
|
||||
fileTtlMs,
|
||||
rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true),
|
||||
rateLimitMax,
|
||||
rateLimitWindowMs,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hasValidAuth, renderPasswordForm } from "../auth";
|
||||
import { notFound } from "../http";
|
||||
import { log } from "../log";
|
||||
import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security";
|
||||
import type { Storage } from "../storage";
|
||||
import { isExpired, type Storage } from "../storage";
|
||||
|
||||
export async function handleDownload(
|
||||
req: Request,
|
||||
@@ -17,6 +17,11 @@ export async function handleDownload(
|
||||
|
||||
const meta = await storage.readMetadata(id);
|
||||
if (!meta) return notFound();
|
||||
if (isExpired(meta)) {
|
||||
await storage.delete(id);
|
||||
log("info", `download rejected id=${id} expired`);
|
||||
return notFound();
|
||||
}
|
||||
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
|
||||
|
||||
if (!hasValidAuth(req, secret, rawKey)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function handleUpload(
|
||||
req: Request,
|
||||
storage: Storage,
|
||||
maxFileSize: number,
|
||||
ttlMs: number,
|
||||
): Promise<Response> {
|
||||
const contentType = req.headers.get("content-type");
|
||||
const boundary = contentType ? getBoundary(contentType) : null;
|
||||
@@ -73,6 +74,7 @@ export async function handleUpload(
|
||||
downloadKeyHash: hashSecret(key),
|
||||
passwordHash: hashPassword(password),
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
});
|
||||
|
||||
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
|
||||
|
||||
+13
-1
@@ -26,6 +26,17 @@ if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) {
|
||||
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" },
|
||||
@@ -97,7 +108,7 @@ async function route(req: Request, srv: Server<undefined>): Promise<Response> {
|
||||
}
|
||||
|
||||
if (path === "/api/upload" && method === "POST") {
|
||||
return handleUpload(req, storage, cfg.maxFileSize);
|
||||
return handleUpload(req, storage, cfg.maxFileSize, cfg.fileTtlMs);
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/auth/") && method === "POST") {
|
||||
@@ -145,6 +156,7 @@ log(
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.once(signal, () => {
|
||||
log("info", `received ${signal}, shutting down`);
|
||||
clearInterval(expiryTimer);
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface FileMeta {
|
||||
downloadKeyHash: string;
|
||||
passwordHash?: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export function isExpired(meta: FileMeta, now: number = Date.now()): boolean {
|
||||
if (!meta.expiresAt) return false;
|
||||
const expires = Date.parse(meta.expiresAt);
|
||||
return Number.isFinite(expires) && expires <= now;
|
||||
}
|
||||
|
||||
export class UploadTooLargeError extends Error {
|
||||
@@ -113,6 +120,21 @@ export class Storage {
|
||||
await rm(join(this.filesDir, `${id}.part`), { force: true });
|
||||
}
|
||||
|
||||
async expireExpired(now: number = Date.now()): Promise<number> {
|
||||
let removed = 0;
|
||||
for (const entry of await readdir(this.metadataDir)) {
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
const id = entry.slice(0, -".json".length);
|
||||
if (!ID_PATTERN.test(id)) continue;
|
||||
const meta = await this.readMetadata(id);
|
||||
if (meta && isExpired(meta, now)) {
|
||||
await this.delete(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> {
|
||||
let removedFiles = 0;
|
||||
let removedMetadata = 0;
|
||||
|
||||
Reference in New Issue
Block a user