import { Application, Router, Status, send } from "oak"; import { config } from "./config.ts"; import { logger } from "./logger.ts"; import { rateLimiter } from "./rateLimiter.ts"; import { ensureUploadDir, getUniqueFilename, listUploadedFiles, cleanupExpiredFiles, isAllowedFileType, getMimeType, isViewableInBrowser, } from "./fileUtils.ts"; import { renderUploadPage, renderBrowsePage, renderGalleryPage, renderError } from "./views.ts"; const app = new Application(); const router = new Router(); // Temporary storage for chunked uploads const chunkStorage = new Map(); function getClientIP(ctx: any): string { return ctx.request.ip || "unknown"; } // Static file serving router.get("/css/:filename", async (ctx) => { const filename = ctx.params.filename; try { await send(ctx, filename, { root: `${Deno.cwd()}/static/css`, }); } catch { ctx.response.status = Status.NotFound; } }); // Routes router.get("/", async (ctx) => { logger.request("INFO", "Page visit", { page: "/", ip: getClientIP(ctx) }); ctx.response.headers.set("Content-Type", "text/html"); ctx.response.body = await renderUploadPage(false); }); router.get("/chunked", async (ctx) => { logger.request("INFO", "Page visit", { page: "/chunked", ip: getClientIP(ctx) }); ctx.response.headers.set("Content-Type", "text/html"); ctx.response.body = await renderUploadPage(true); }); router.get("/browse", async (ctx) => { logger.request("INFO", "Page visit", { page: "/browse", ip: getClientIP(ctx) }); const files = await listUploadedFiles(); ctx.response.headers.set("Content-Type", "text/html"); ctx.response.body = await renderBrowsePage(files); }); router.get("/gallery", async (ctx) => { logger.request("INFO", "Page visit", { page: "/gallery", ip: getClientIP(ctx) }); const files = await listUploadedFiles(); ctx.response.headers.set("Content-Type", "text/html"); ctx.response.body = await renderGalleryPage(files); }); router.post("/upload", async (ctx) => { const ip = getClientIP(ctx); if (rateLimiter.isRateLimited(ip)) { logger.security("WARN", "Upload rejected: rate limit", { ip }); ctx.response.status = Status.TooManyRequests; ctx.response.body = { error: "Rate limit exceeded. Please try again later." }; return; } try { const body = ctx.request.body; const formData = await body.formData(); const uploadedFiles: string[] = []; const files = formData.getAll("files"); if (files.length === 0) { ctx.response.status = Status.BadRequest; ctx.response.body = { error: "No files provided" }; return; } for (const file of files) { if (!(file instanceof File)) { continue; } // Check file size if (file.size > config.maxFileSize) { const maxMB = Math.floor(config.maxFileSize / (1024 * 1024)); logger.upload("WARN", "File size exceeded", { filename: file.name, size: file.size, maxSize: config.maxFileSize, ip }); ctx.response.status = Status.BadRequest; ctx.response.body = { error: `File ${file.name} exceeds maximum size of ${maxMB}MB` }; return; } // Check file type if (!isAllowedFileType(file.type)) { logger.upload("WARN", "File type not allowed", { filename: file.name, type: file.type, ip }); ctx.response.status = Status.BadRequest; ctx.response.body = { error: `File type ${file.type} is not allowed` }; return; } // Get unique filename const filename = await getUniqueFilename(file.name); const filepath = `${config.uploadDir}/${filename}`; // Save file const arrayBuffer = await file.arrayBuffer(); await Deno.writeFile(filepath, new Uint8Array(arrayBuffer)); uploadedFiles.push(filename); logger.upload("INFO", "File uploaded successfully", { filename, size: file.size, ip }); } ctx.response.status = Status.OK; ctx.response.body = { files: uploadedFiles }; } catch (error) { logger.upload("ERROR", "Upload failed", { error: String(error), ip }); ctx.response.status = Status.InternalServerError; ctx.response.body = { error: "Upload failed" }; } }); router.post("/upload-chunk", async (ctx) => { const ip = getClientIP(ctx); try { const body = ctx.request.body; const formData = await body.formData(); const chunk = formData.get("chunk"); const filename = formData.get("filename") as string; const chunkIndex = parseInt(formData.get("chunkIndex") as string); const totalChunks = parseInt(formData.get("totalChunks") as string); if (!(chunk instanceof File) || !filename || isNaN(chunkIndex) || isNaN(totalChunks)) { ctx.response.status = Status.BadRequest; ctx.response.body = { error: "Invalid chunk data" }; return; } const storageKey = `${ip}:${filename}`; // Only apply rate limiting to the first chunk of each file if (chunkIndex === 0) { if (rateLimiter.isRateLimited(ip)) { logger.security("WARN", "Chunked upload rejected: rate limit", { ip, filename }); ctx.response.status = Status.TooManyRequests; ctx.response.body = { error: "Rate limit exceeded. Please try again later." }; return; } } // Initialize storage for this file if (!chunkStorage.has(storageKey)) { chunkStorage.set(storageKey, { chunks: [], totalChunks }); } const storage = chunkStorage.get(storageKey)!; // Store chunk const arrayBuffer = await chunk.arrayBuffer(); storage.chunks[chunkIndex] = new Uint8Array(arrayBuffer); // Check if all chunks received if (storage.chunks.filter(c => c).length === totalChunks) { // Combine all chunks const totalSize = storage.chunks.reduce((sum, chunk) => sum + chunk.length, 0); // Check total file size if (totalSize > config.maxFileSize) { chunkStorage.delete(storageKey); const maxMB = Math.floor(config.maxFileSize / (1024 * 1024)); logger.upload("WARN", "Chunked file size exceeded", { filename, size: totalSize, maxSize: config.maxFileSize, ip }); ctx.response.status = Status.BadRequest; ctx.response.body = { error: `File exceeds maximum size of ${maxMB}MB` }; return; } const completeFile = new Uint8Array(totalSize); let offset = 0; for (const chunk of storage.chunks) { completeFile.set(chunk, offset); offset += chunk.length; } // Get unique filename and save const uniqueFilename = await getUniqueFilename(filename); const filepath = `${config.uploadDir}/${uniqueFilename}`; await Deno.writeFile(filepath, completeFile); // Clean up chunkStorage.delete(storageKey); logger.upload("INFO", "Chunked file uploaded successfully", { filename: uniqueFilename, size: totalSize, chunks: totalChunks, ip }); } ctx.response.status = Status.OK; ctx.response.body = { success: true, chunkIndex, totalChunks }; } catch (error) { logger.upload("ERROR", "Chunk upload failed", { error: String(error), ip }); ctx.response.status = Status.InternalServerError; ctx.response.body = { error: "Chunk upload failed" }; } }); router.get("/download/:filename+", async (ctx) => { const encodedFilename = ctx.params.filename; const ip = getClientIP(ctx); if (!encodedFilename) { ctx.response.status = Status.BadRequest; ctx.response.headers.set("Content-Type", "text/html"); ctx.response.body = await renderError("Filename is required", 400); return; } // Decode the filename to handle special characters and spaces const filename = decodeURIComponent(encodedFilename); const filepath = `${config.uploadDir}/${filename}`; try { const fileInfo = await Deno.stat(filepath); if (!fileInfo.isFile) { logger.download("WARN", "File not found (not a file)", { filename, ip }); ctx.response.status = Status.NotFound; ctx.response.headers.set("Content-Type", "text/html"); ctx.response.body = await renderError("File not found", 404); return; } const file = await Deno.readFile(filepath); const mimeType = getMimeType(filename); const viewableInBrowser = isViewableInBrowser(mimeType); logger.download("INFO", viewableInBrowser ? "File viewed" : "File downloaded", { filename, mimeType, size: file.length, ip }); ctx.response.headers.set("Content-Type", mimeType); // Properly encode filename for Content-Disposition header // Use RFC 5987 encoding for non-ASCII characters const asciiFilename = filename.replace(/[^\x00-\x7F]/g, "_"); // Fallback for old browsers const encodedFilename = encodeURIComponent(filename); if (viewableInBrowser) { ctx.response.headers.set("Content-Disposition", `inline; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`); } else { ctx.response.headers.set("Content-Disposition", `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`); } ctx.response.body = file; } catch (error) { if (error instanceof Deno.errors.NotFound) { logger.download("WARN", "File not found", { filename, ip }); ctx.response.status = Status.NotFound; ctx.response.headers.set("Content-Type", "text/html"); ctx.response.body = await renderError("File not found", 404); } else { logger.download("ERROR", "Download failed", { filename, error: String(error), ip }); ctx.response.status = Status.InternalServerError; ctx.response.headers.set("Content-Type", "text/html"); ctx.response.body = await renderError("Failed to download file", 500); } } }); // Middleware app.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; logger.request("DEBUG", `${ctx.request.method} ${ctx.request.url.pathname}`, { duration: ms, status: ctx.response.status, ip: getClientIP(ctx) }); }); app.use(router.routes()); app.use(router.allowedMethods()); // Initialize await ensureUploadDir(); cleanupExpiredFiles(); logger.server("INFO", "Server starting", { port: config.port }); app.addEventListener("listen", ({ hostname, port, secure }) => { logger.server("INFO", "Server started", { url: `${secure ? "https" : "http"}://${hostname ?? "localhost"}:${port}`, }); }); await app.listen({ port: config.port });