diff options
| author | grothedev <grothedev@gmail.com> | 2025-12-28 21:41:07 -0500 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2025-12-28 21:41:07 -0500 |
| commit | bd6c3a07a82ba11cf7b0423307229891675e7ed3 (patch) | |
| tree | fce602bc4038f0f79e12f9fb296e3d220915da23 | |
| parent | f978ad7db04ced4cbcf04a82bf6f0cc3f4ce66a3 (diff) | |
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | config.ts | 2 | ||||
| -rw-r--r-- | deno.json | 10 | ||||
| -rw-r--r-- | deno.lock | 8 | ||||
| -rw-r--r-- | fileUtils.ts | 9 | ||||
| -rw-r--r-- | logger.ts | 141 | ||||
| -rw-r--r-- | main.ts | 72 | ||||
| -rw-r--r-- | rateLimiter.ts | 2 | ||||
| -rw-r--r-- | static/css/gallery.css (renamed from templates/styles/gallery.css) | 0 | ||||
| -rw-r--r-- | templates/partials/empty-files.html | 5 | ||||
| -rw-r--r-- | templates/partials/empty-gallery.html | 5 | ||||
| -rw-r--r-- | templates/partials/file-row.html | 9 | ||||
| -rw-r--r-- | templates/partials/gallery-item.html | 9 | ||||
| -rw-r--r-- | templates/scripts/chunked-upload.js | 7 | ||||
| -rw-r--r-- | templates/scripts/simple-upload.js | 7 | ||||
| -rw-r--r-- | templates/scripts/slideshow.js | 5 | ||||
| -rw-r--r-- | tests/fileUtils.test.ts | 112 | ||||
| -rw-r--r-- | tests/integration.test.ts | 270 | ||||
| -rw-r--r-- | tests/rateLimiter.test.ts | 106 | ||||
| -rw-r--r-- | views.ts | 85 |
21 files changed, 765 insertions, 102 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a32f49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +logs +uploads/* @@ -62,6 +62,7 @@ Edit `config.ts` to customize: ## Phase 2 (Planned) +- https using letsencrypt key defined at a path defined in config. - Database integration to store file metadata - File model and controller - IP address tracking and association with uploads @@ -4,7 +4,7 @@ export const config = { // File upload settings uploadDir: "./uploads", - maxFileSize: 100 * 1024 * 1024, // 100MB in bytes + maxFileSize: 512 * 1024 * 1024, // 512MB in bytes allowedFileTypes: [ // Images "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", @@ -1,9 +1,15 @@ { "tasks": { "dev": "deno run --allow-net --allow-read --allow-write --allow-env --watch main.ts", - "start": "deno run --allow-net --allow-read --allow-write --allow-env main.ts" + "start": "deno run --allow-net --allow-read --allow-write --allow-env main.ts", + "test": "deno test --allow-net --allow-read --allow-write --allow-env tests/", + "test:watch": "deno test --allow-net --allow-read --allow-write --allow-env --watch tests/" }, "imports": { - "oak": "jsr:@oak/oak@^17.1.3" + "oak": "jsr:@oak/oak@^17.1.3", + "@std/assert": "jsr:@std/assert@^1" + }, + "compilerOptions": { + "lib": ["deno.window"] } } @@ -39,7 +39,10 @@ ] }, "@std/assert@1.0.16": { - "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532" + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", + "dependencies": [ + "jsr:@std/internal" + ] }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" @@ -76,7 +79,8 @@ }, "workspace": { "dependencies": [ - "jsr:@oak/oak@^17.1.3" + "jsr:@oak/oak@^17.1.3", + "jsr:@std/assert@1" ] } } diff --git a/fileUtils.ts b/fileUtils.ts index 6360a95..ab74223 100644 --- a/fileUtils.ts +++ b/fileUtils.ts @@ -48,7 +48,7 @@ export async function listUploadedFiles() { } } catch (error) { if (!(error instanceof Deno.errors.NotFound)) { - logger.error("Error listing files", { error: String(error) }); + logger.server("ERROR", "Error listing files", { error: String(error) }); } } @@ -71,16 +71,16 @@ export async function cleanupExpiredFiles() { if (age > config.fileExpiration) { await Deno.remove(filepath); cleaned++; - logger.info("Expired file removed", { filename: entry.name, age }); + logger.cleanup("INFO", "Expired file removed", { filename: entry.name, ageMs: age }); } } } } catch (error) { - logger.error("Error during file cleanup", { error: String(error) }); + logger.cleanup("ERROR", "Error during file cleanup", { error: String(error) }); } if (cleaned > 0) { - logger.info("File cleanup completed", { filesRemoved: cleaned }); + logger.cleanup("INFO", "File cleanup completed", { filesRemoved: cleaned }); } } @@ -119,7 +119,6 @@ export function getMimeType(filename: string): string { // Videos "mp4": "video/mp4", "webm": "video/webm", - "ogg": "video/ogg", "ogv": "video/ogg", "avi": "video/x-msvideo", "mov": "video/quicktime", @@ -1,27 +1,154 @@ type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG"; +type LogDomain = "server" | "upload" | "download" | "security" | "cleanup" | "request"; + +interface LogEntry { + timestamp: string; + level: LogLevel; + domain: LogDomain; + message: string; + meta?: Record<string, unknown>; +} class Logger { - private log(level: LogLevel, message: string, meta?: Record<string, unknown>) { + private logDir = "./logs"; + private currentDate = ""; + private logBuffer: string[] = []; + private flushInterval: number; + + constructor() { + this.ensureLogDir(); + // Flush logs every 5 seconds + this.flushInterval = setInterval(() => this.flush(), 5000); + } + + private async ensureLogDir() { + try { + await Deno.mkdir(this.logDir, { recursive: true }); + } catch (error) { + if (!(error instanceof Deno.errors.AlreadyExists)) { + console.error("Failed to create log directory:", error); + } + } + } + + private getDateString(): string { + const now = new Date(); + return now.toISOString().split('T')[0]; // YYYY-MM-DD + } + + private formatLogEntry(entry: LogEntry): string { + const metaStr = entry.meta ? ` ${JSON.stringify(entry.meta)}` : ""; + return `[${entry.timestamp}] [${entry.level}] [${entry.domain}] ${entry.message}${metaStr}\n`; + } + + private async writeToFile(domain: LogDomain, content: string) { + const date = this.getDateString(); + const filename = `${this.logDir}/${domain}-${date}.log`; + + try { + await Deno.writeTextFile(filename, content, { append: true }); + } catch (error) { + console.error(`Failed to write to log file ${filename}:`, error); + } + } + + private log(level: LogLevel, domain: LogDomain, message: string, meta?: Record<string, unknown>) { const timestamp = new Date().toISOString(); - const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; - console.log(`[${timestamp}] [${level}] ${message}${metaStr}`); + const entry: LogEntry = { timestamp, level, domain, message, meta }; + + // Write to console + const formatted = this.formatLogEntry(entry); + console.log(formatted.trim()); + + // Buffer for file writing + this.logBuffer.push(JSON.stringify({ ...entry, file: domain })); } + private async flush() { + if (this.logBuffer.length === 0) return; + + const entries = [...this.logBuffer]; + this.logBuffer = []; + + // Group entries by domain + const byDomain = new Map<LogDomain, LogEntry[]>(); + + for (const entryStr of entries) { + try { + const entry = JSON.parse(entryStr); + const domain = entry.file as LogDomain; + if (!byDomain.has(domain)) { + byDomain.set(domain, []); + } + byDomain.get(domain)!.push(entry); + } catch (error) { + console.error("Failed to parse log entry:", error); + } + } + + // Write to files + for (const [domain, domainEntries] of byDomain) { + const content = domainEntries.map(e => this.formatLogEntry(e)).join(''); + await this.writeToFile(domain, content); + } + } + + // Server domain logs + server(level: LogLevel, message: string, meta?: Record<string, unknown>) { + this.log(level, "server", message, meta); + } + + // Upload domain logs + upload(level: LogLevel, message: string, meta?: Record<string, unknown>) { + this.log(level, "upload", message, meta); + } + + // Download domain logs + download(level: LogLevel, message: string, meta?: Record<string, unknown>) { + this.log(level, "download", message, meta); + } + + // Security domain logs (rate limiting, IP tracking) + security(level: LogLevel, message: string, meta?: Record<string, unknown>) { + this.log(level, "security", message, meta); + } + + // Cleanup domain logs + cleanup(level: LogLevel, message: string, meta?: Record<string, unknown>) { + this.log(level, "cleanup", message, meta); + } + + // Request domain logs (HTTP requests) + request(level: LogLevel, message: string, meta?: Record<string, unknown>) { + this.log(level, "request", message, meta); + } + + // Legacy methods for backward compatibility info(message: string, meta?: Record<string, unknown>) { - this.log("INFO", message, meta); + this.log("INFO", "server", message, meta); } warn(message: string, meta?: Record<string, unknown>) { - this.log("WARN", message, meta); + this.log("WARN", "server", message, meta); } error(message: string, meta?: Record<string, unknown>) { - this.log("ERROR", message, meta); + this.log("ERROR", "server", message, meta); } debug(message: string, meta?: Record<string, unknown>) { - this.log("DEBUG", message, meta); + this.log("DEBUG", "server", message, meta); + } + + async shutdown() { + clearInterval(this.flushInterval); + await this.flush(); } } export const logger = new Logger(); + +// Ensure logs are flushed on exit +addEventListener("unload", () => { + logger.shutdown(); +}); @@ -37,26 +37,26 @@ router.get("/css/:filename", async (ctx) => { // Routes router.get("/", async (ctx) => { - logger.info("Page visit", { page: "/", ip: getClientIP(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.info("Page visit", { page: "/chunked", ip: getClientIP(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.info("Page visit", { page: "/browse", ip: getClientIP(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.info("Page visit", { page: "/gallery", ip: getClientIP(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); @@ -66,7 +66,7 @@ router.post("/upload", async (ctx) => { const ip = getClientIP(ctx); if (rateLimiter.isRateLimited(ip)) { - logger.warn("Upload rejected: rate limit", { 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; @@ -93,6 +93,7 @@ router.post("/upload", async (ctx) => { // 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; @@ -100,6 +101,7 @@ router.post("/upload", async (ctx) => { // 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; @@ -114,13 +116,13 @@ router.post("/upload", async (ctx) => { await Deno.writeFile(filepath, new Uint8Array(arrayBuffer)); uploadedFiles.push(filename); - logger.info("File uploaded", { filename, size: file.size, ip }); + logger.upload("INFO", "File uploaded successfully", { filename, size: file.size, ip }); } ctx.response.status = Status.OK; ctx.response.body = { files: uploadedFiles }; } catch (error) { - logger.error("Upload error", { error: String(error), ip }); + logger.upload("ERROR", "Upload failed", { error: String(error), ip }); ctx.response.status = Status.InternalServerError; ctx.response.body = { error: "Upload failed" }; } @@ -129,13 +131,6 @@ router.post("/upload", async (ctx) => { router.post("/upload-chunk", async (ctx) => { const ip = getClientIP(ctx); - if (rateLimiter.isRateLimited(ip)) { - logger.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(); @@ -153,6 +148,16 @@ router.post("/upload-chunk", async (ctx) => { 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 }); @@ -173,6 +178,7 @@ router.post("/upload-chunk", async (ctx) => { 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; @@ -193,35 +199,38 @@ router.post("/upload-chunk", async (ctx) => { // Clean up chunkStorage.delete(storageKey); - logger.info("Chunked file uploaded", { filename: uniqueFilename, size: totalSize, chunks: totalChunks, ip }); + 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.error("Chunk upload error", { error: String(error), ip }); + 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 filename = ctx.params.filename; +router.get("/download/:filename+", async (ctx) => { + const encodedFilename = ctx.params.filename; const ip = getClientIP(ctx); - if (!filename) { + 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); @@ -232,25 +241,30 @@ router.get("/download/:filename", async (ctx) => { const mimeType = getMimeType(filename); const viewableInBrowser = isViewableInBrowser(mimeType); - logger.info(viewableInBrowser ? "File viewed" : "File downloaded", { filename, mimeType, ip }); + 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="${filename}"`); + ctx.response.headers.set("Content-Disposition", `inline; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`); } else { - ctx.response.headers.set("Content-Disposition", `attachment; filename="${filename}"`); + 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.warn("File not found", { filename, ip }); + 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.error("Download error", { filename, error: String(error), ip }); + 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); @@ -263,7 +277,11 @@ app.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; - logger.debug(`${ctx.request.method} ${ctx.request.url.pathname}`, { duration: `${ms}ms`, status: ctx.response.status }); + logger.request("DEBUG", `${ctx.request.method} ${ctx.request.url.pathname}`, { + duration: ms, + status: ctx.response.status, + ip: getClientIP(ctx) + }); }); app.use(router.routes()); @@ -273,10 +291,10 @@ app.use(router.allowedMethods()); await ensureUploadDir(); cleanupExpiredFiles(); -logger.info("Server starting", { port: config.port }); +logger.server("INFO", "Server starting", { port: config.port }); app.addEventListener("listen", ({ hostname, port, secure }) => { - logger.info("Server started", { + logger.server("INFO", "Server started", { url: `${secure ? "https" : "http"}://${hostname ?? "localhost"}:${port}`, }); }); diff --git a/rateLimiter.ts b/rateLimiter.ts index b24abd8..239ef88 100644 --- a/rateLimiter.ts +++ b/rateLimiter.ts @@ -22,7 +22,7 @@ class RateLimiter { } if (entry.count >= config.rateLimit.maxUploads) { - logger.warn("Rate limit exceeded", { ip, count: entry.count }); + logger.security("WARN", "Rate limit exceeded", { ip, count: entry.count }); return true; } diff --git a/templates/styles/gallery.css b/static/css/gallery.css index 5ae5615..5ae5615 100644 --- a/templates/styles/gallery.css +++ b/static/css/gallery.css diff --git a/templates/partials/empty-files.html b/templates/partials/empty-files.html new file mode 100644 index 0000000..29f9ca1 --- /dev/null +++ b/templates/partials/empty-files.html @@ -0,0 +1,5 @@ +<div style="text-align: center; padding: 60px 20px; color: #666;"> + <div style="font-size: 64px; margin-bottom: 20px;">📭</div> + <h3>No files uploaded yet</h3> + <p>Upload some files to see them here!</p> +</div> diff --git a/templates/partials/empty-gallery.html b/templates/partials/empty-gallery.html new file mode 100644 index 0000000..dcafdc0 --- /dev/null +++ b/templates/partials/empty-gallery.html @@ -0,0 +1,5 @@ +<div style="text-align: center; padding: 60px 20px; color: #666;"> + <div style="font-size: 64px; margin-bottom: 20px;">🖼️</div> + <h3>No images or videos yet</h3> + <p>Upload some media files to see them here!</p> +</div> diff --git a/templates/partials/file-row.html b/templates/partials/file-row.html new file mode 100644 index 0000000..75ce50e --- /dev/null +++ b/templates/partials/file-row.html @@ -0,0 +1,9 @@ +<tr> + <td><a href="/download/{{encoded-name}}" target="_blank">{{filename}}</a></td> + <td>{{size}}</td> + <td>{{uploaded}}</td> + <td> + {{view-button}} + <a href="/download/{{encoded-name}}" download class="button">Download</a> + </td> +</tr> diff --git a/templates/partials/gallery-item.html b/templates/partials/gallery-item.html new file mode 100644 index 0000000..8694791 --- /dev/null +++ b/templates/partials/gallery-item.html @@ -0,0 +1,9 @@ +<div class="gallery-item" data-index="{{index}}"> + <a href="#" onclick="openSlideshow({{index}}); return false;"> + {{media}} + </a> + <div class="gallery-info"> + <div class="gallery-filename" title="{{filename}}">{{filename}}</div> + <div class="gallery-meta">{{size}} • {{uploaded}}</div> + </div> +</div> diff --git a/templates/scripts/chunked-upload.js b/templates/scripts/chunked-upload.js index 980c61c..a4ac7bc 100644 --- a/templates/scripts/chunked-upload.js +++ b/templates/scripts/chunked-upload.js @@ -39,9 +39,10 @@ form.addEventListener('submit', async (e) => { uploadedFiles.push(filename); } - const links = uploadedFiles.map(f => - `<a href="/download/${f}" target="_blank" style="display: block; margin: 5px 0;">${window.location.origin}/download/${f}</a>` - ).join(''); + const links = uploadedFiles.map(f => { + const encoded = encodeURIComponent(f); + return `<a href="/download/${encoded}" target="_blank" style="display: block; margin: 5px 0;">${window.location.origin}/download/${encoded}</a>`; + }).join(''); showResult('Upload successful!<br>' + links, 'success'); form.reset(); fileInfo.textContent = 'Max size: {{max-size}}MB per file'; diff --git a/templates/scripts/simple-upload.js b/templates/scripts/simple-upload.js index c3a0def..48653a1 100644 --- a/templates/scripts/simple-upload.js +++ b/templates/scripts/simple-upload.js @@ -38,9 +38,10 @@ form.addEventListener('submit', async (e) => { const data = await response.json(); if (response.ok) { - const links = data.files.map(f => - `<a href="/download/${f}" target="_blank" style="display: block; margin: 5px 0;">${window.location.origin}/download/${f}</a>` - ).join(''); + const links = data.files.map(f => { + const encoded = encodeURIComponent(f); + return `<a href="/download/${encoded}" target="_blank" style="display: block; margin: 5px 0;">${window.location.origin}/download/${encoded}</a>`; + }).join(''); showResult('Upload successful!<br>' + links, 'success'); form.reset(); fileInfo.textContent = 'Max size: {{max-size}}MB per file'; diff --git a/templates/scripts/slideshow.js b/templates/scripts/slideshow.js index ccf7c32..3ab2ada 100644 --- a/templates/scripts/slideshow.js +++ b/templates/scripts/slideshow.js @@ -33,11 +33,12 @@ function changeSlide(direction) { function showSlide(index) { const file = mediaFiles[index]; const container = document.getElementById('slideshow-media-container'); + const encodedName = encodeURIComponent(file.name); if (file.isVideo) { - container.innerHTML = `<video src="/download/${file.name}" controls autoplay style="max-width: 100%; max-height: 80vh;"></video>`; + container.innerHTML = `<video src="/download/${encodedName}" controls autoplay style="max-width: 100%; max-height: 80vh;"></video>`; } else { - container.innerHTML = `<img src="/download/${file.name}" alt="${file.name}" style="max-width: 100%; max-height: 80vh;">`; + container.innerHTML = `<img src="/download/${encodedName}" alt="${file.name}" style="max-width: 100%; max-height: 80vh;">`; } document.getElementById('slideshow-filename').textContent = file.name; diff --git a/tests/fileUtils.test.ts b/tests/fileUtils.test.ts new file mode 100644 index 0000000..15bfa3d --- /dev/null +++ b/tests/fileUtils.test.ts @@ -0,0 +1,112 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { + getUniqueFilename, + getMimeType, + isViewableInBrowser, + formatFileSize, + isAllowedFileType, +} from "../fileUtils.ts"; + +Deno.test("getMimeType - returns correct MIME type for images", () => { + assertEquals(getMimeType("photo.jpg"), "image/jpeg"); + assertEquals(getMimeType("image.png"), "image/png"); + assertEquals(getMimeType("graphic.gif"), "image/gif"); + assertEquals(getMimeType("pic.webp"), "image/webp"); +}); + +Deno.test("getMimeType - returns correct MIME type for videos", () => { + assertEquals(getMimeType("video.mp4"), "video/mp4"); + assertEquals(getMimeType("clip.webm"), "video/webm"); + assertEquals(getMimeType("movie.avi"), "video/x-msvideo"); +}); + +Deno.test("getMimeType - returns correct MIME type for documents", () => { + assertEquals(getMimeType("document.pdf"), "application/pdf"); + assertEquals(getMimeType("readme.txt"), "text/plain"); + assertEquals(getMimeType("archive.zip"), "application/zip"); +}); + +Deno.test("getMimeType - handles uppercase extensions", () => { + assertEquals(getMimeType("PHOTO.JPG"), "image/jpeg"); + assertEquals(getMimeType("Video.MP4"), "video/mp4"); +}); + +Deno.test("getMimeType - returns octet-stream for unknown types", () => { + assertEquals(getMimeType("file.xyz"), "application/octet-stream"); + assertEquals(getMimeType("noextension"), "application/octet-stream"); +}); + +Deno.test("isViewableInBrowser - images are viewable", () => { + assertEquals(isViewableInBrowser("image/jpeg"), true); + assertEquals(isViewableInBrowser("image/png"), true); + assertEquals(isViewableInBrowser("image/gif"), true); +}); + +Deno.test("isViewableInBrowser - videos are viewable", () => { + assertEquals(isViewableInBrowser("video/mp4"), true); + assertEquals(isViewableInBrowser("video/webm"), true); +}); + +Deno.test("isViewableInBrowser - PDFs and text are viewable", () => { + assertEquals(isViewableInBrowser("application/pdf"), true); + assertEquals(isViewableInBrowser("text/plain"), true); +}); + +Deno.test("isViewableInBrowser - archives are not viewable", () => { + assertEquals(isViewableInBrowser("application/zip"), false); + assertEquals(isViewableInBrowser("application/octet-stream"), false); +}); + +Deno.test("formatFileSize - formats bytes correctly", () => { + assertEquals(formatFileSize(0), "0 Bytes"); + assertEquals(formatFileSize(500), "500 Bytes"); + assertEquals(formatFileSize(1024), "1 KB"); + assertEquals(formatFileSize(1536), "1.5 KB"); + assertEquals(formatFileSize(1048576), "1 MB"); + assertEquals(formatFileSize(1073741824), "1 GB"); +}); + +Deno.test("isAllowedFileType - allows configured types", () => { + assertEquals(isAllowedFileType("image/jpeg"), true); + assertEquals(isAllowedFileType("application/pdf"), true); +}); + +Deno.test("getUniqueFilename - returns same name if file doesn't exist", async () => { + const testDir = await Deno.makeTempDir(); + + try { + // Save original config + const { config } = await import("../config.ts"); + const originalUploadDir = config.uploadDir; + config.uploadDir = testDir; + + const filename = await getUniqueFilename("test.txt"); + assertEquals(filename, "test.txt"); + + // Restore + config.uploadDir = originalUploadDir; + } finally { + await Deno.remove(testDir, { recursive: true }); + } +}); + +Deno.test("getUniqueFilename - appends epoch if file exists", async () => { + const testDir = await Deno.makeTempDir(); + + try { + // Create a test file + await Deno.writeTextFile(`${testDir}/existing.txt`, "test"); + + const { config } = await import("../config.ts"); + const originalUploadDir = config.uploadDir; + config.uploadDir = testDir; + + const filename = await getUniqueFilename("existing.txt"); + assertEquals(filename.startsWith("existing_"), true); + assertEquals(filename.endsWith(".txt"), true); + + config.uploadDir = originalUploadDir; + } finally { + await Deno.remove(testDir, { recursive: true }); + } +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..666115a --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,270 @@ +import { assertEquals, assertExists } from "@std/assert"; + +const BASE_URL = "http://localhost:8000"; + +// Helper to check if server is running +async function isServerRunning(): Promise<boolean> { + try { + const response = await fetch(BASE_URL); + // Consume the response body to prevent resource leak + await response.body?.cancel(); + return response.ok; + } catch { + return false; + } +} + +Deno.test({ + name: "GET / - returns upload page", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + const response = await fetch(BASE_URL); + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-type"), "text/html"); + + const html = await response.text(); + assertExists(html.includes("File Upload")); + }, +}); + +Deno.test({ + name: "GET /browse - returns browse page", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + const response = await fetch(`${BASE_URL}/browse`); + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-type"), "text/html"); + + const html = await response.text(); + assertExists(html.includes("Browse Files")); + }, +}); + +Deno.test({ + name: "GET /gallery - returns gallery page", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + const response = await fetch(`${BASE_URL}/gallery`); + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-type"), "text/html"); + + const html = await response.text(); + assertExists(html.includes("Gallery")); + }, +}); + +Deno.test({ + name: "POST /upload - uploads a file", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + const formData = new FormData(); + const testFile = new File(["test content"], "test.txt", { type: "text/plain" }); + formData.append("files", testFile); + + const response = await fetch(`${BASE_URL}/upload`, { + method: "POST", + body: formData, + }); + + if (response.status === 429) { + console.log("⚠️ Rate limit exceeded, skipping test"); + await response.body?.cancel(); + return; + } + + assertEquals(response.status, 200); + const data = await response.json(); + assertExists(data.files); + assertEquals(Array.isArray(data.files), true); + assertEquals(data.files.length, 1); + }, +}); + +Deno.test({ + name: "POST /upload - rejects file exceeding size limit", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + // Create a file larger than the configured max size (512MB) + // For testing, we'll just check if the validation logic works + const formData = new FormData(); + + // Create a large blob (10MB for testing purposes) + const largeContent = new Uint8Array(10 * 1024 * 1024); + const largeFile = new File([largeContent], "large.bin", { type: "application/octet-stream" }); + formData.append("files", largeFile); + + const response = await fetch(`${BASE_URL}/upload`, { + method: "POST", + body: formData, + }); + + if (response.status === 429) { + console.log("⚠️ Rate limit exceeded, skipping test"); + await response.body?.cancel(); + return; + } + + // Should succeed because 10MB < 512MB limit + assertEquals(response.status, 200); + await response.body?.cancel(); + }, +}); + +Deno.test({ + name: "GET /download/:filename - downloads uploaded file", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + // First upload a file + const formData = new FormData(); + const testContent = "download test content"; + const testFile = new File([testContent], "downloadtest.txt", { type: "text/plain" }); + formData.append("files", testFile); + + const uploadResponse = await fetch(`${BASE_URL}/upload`, { + method: "POST", + body: formData, + }); + + if (uploadResponse.status === 429) { + console.log("⚠️ Rate limit exceeded, skipping test"); + await uploadResponse.body?.cancel(); + return; + } + + const uploadData = await uploadResponse.json(); + const filename = uploadData.files[0]; + + // Now download it + const downloadResponse = await fetch(`${BASE_URL}/download/${encodeURIComponent(filename)}`); + assertEquals(downloadResponse.status, 200); + + const content = await downloadResponse.text(); + assertEquals(content, testContent); + }, +}); + +Deno.test({ + name: "GET /download/:filename - handles spaces in filename", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + // Upload a file with spaces + const formData = new FormData(); + const testContent = "space test content"; + const testFile = new File([testContent], "test file with spaces.txt", { type: "text/plain" }); + formData.append("files", testFile); + + const uploadResponse = await fetch(`${BASE_URL}/upload`, { + method: "POST", + body: formData, + }); + + if (uploadResponse.status === 429) { + console.log("⚠️ Rate limit exceeded, skipping test"); + await uploadResponse.body?.cancel(); + return; + } + + const uploadData = await uploadResponse.json(); + const filename = uploadData.files[0]; + + // Download with encoded filename + const downloadResponse = await fetch(`${BASE_URL}/download/${encodeURIComponent(filename)}`); + assertEquals(downloadResponse.status, 200); + + const content = await downloadResponse.text(); + assertEquals(content, testContent); + }, +}); + +Deno.test({ + name: "GET /download/nonexistent - returns 404", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + const response = await fetch(`${BASE_URL}/download/nonexistent.txt`); + assertEquals(response.status, 404); + // Consume response body to prevent leak + await response.body?.cancel(); + }, +}); + +Deno.test({ + name: "POST /upload-chunk - uploads file in chunks", + async fn() { + if (!await isServerRunning()) { + console.log("⚠️ Server not running, skipping integration test"); + return; + } + + const content = "chunked upload test content"; + const filename = "chunked-test.txt"; + const blob = new Blob([content]); + const chunkSize = 10; // Small chunk size for testing + const chunks = Math.ceil(blob.size / chunkSize); + + // Upload chunks + for (let i = 0; i < chunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, blob.size); + const chunk = blob.slice(start, end); + + const formData = new FormData(); + formData.append("chunk", new File([chunk], "chunk")); + formData.append("filename", filename); + formData.append("chunkIndex", String(i)); + formData.append("totalChunks", String(chunks)); + + const response = await fetch(`${BASE_URL}/upload-chunk`, { + method: "POST", + body: formData, + }); + + if (response.status === 429) { + console.log("⚠️ Rate limit exceeded, skipping test"); + await response.body?.cancel(); + return; + } + + assertEquals(response.status, 200); + await response.body?.cancel(); + } + + // Verify the file was assembled correctly + const downloadResponse = await fetch(`${BASE_URL}/download/${encodeURIComponent(filename)}`); + assertEquals(downloadResponse.status, 200); + + const downloadedContent = await downloadResponse.text(); + assertEquals(downloadedContent, content); + }, +}); diff --git a/tests/rateLimiter.test.ts b/tests/rateLimiter.test.ts new file mode 100644 index 0000000..2645deb --- /dev/null +++ b/tests/rateLimiter.test.ts @@ -0,0 +1,106 @@ +import { assertEquals } from "@std/assert"; +import { config } from "../config.ts"; + +Deno.test("RateLimiter - allows first request", () => { + // Create a new rate limiter instance for testing + class TestRateLimiter { + private storage = new Map<string, { count: number; resetTime: number }>(); + + isRateLimited(ip: string): boolean { + const now = Date.now(); + const entry = this.storage.get(ip); + + if (!entry || now > entry.resetTime) { + this.storage.set(ip, { + count: 1, + resetTime: now + config.rateLimit.windowMs, + }); + return false; + } + + if (entry.count >= config.rateLimit.maxUploads) { + return true; + } + + entry.count++; + return false; + } + } + + const limiter = new TestRateLimiter(); + assertEquals(limiter.isRateLimited("127.0.0.1"), false); +}); + +Deno.test("RateLimiter - allows requests within limit", () => { + class TestRateLimiter { + private storage = new Map<string, { count: number; resetTime: number }>(); + + isRateLimited(ip: string): boolean { + const now = Date.now(); + const entry = this.storage.get(ip); + + if (!entry || now > entry.resetTime) { + this.storage.set(ip, { + count: 1, + resetTime: now + config.rateLimit.windowMs, + }); + return false; + } + + if (entry.count >= config.rateLimit.maxUploads) { + return true; + } + + entry.count++; + return false; + } + } + + const limiter = new TestRateLimiter(); + const ip = "192.168.1.1"; + + // Make requests up to the limit + for (let i = 0; i < config.rateLimit.maxUploads; i++) { + assertEquals(limiter.isRateLimited(ip), false, `Request ${i + 1} should be allowed`); + } + + // Next request should be blocked + assertEquals(limiter.isRateLimited(ip), true, "Request beyond limit should be blocked"); +}); + +Deno.test("RateLimiter - different IPs have separate limits", () => { + class TestRateLimiter { + private storage = new Map<string, { count: number; resetTime: number }>(); + + isRateLimited(ip: string): boolean { + const now = Date.now(); + const entry = this.storage.get(ip); + + if (!entry || now > entry.resetTime) { + this.storage.set(ip, { + count: 1, + resetTime: now + config.rateLimit.windowMs, + }); + return false; + } + + if (entry.count >= config.rateLimit.maxUploads) { + return true; + } + + entry.count++; + return false; + } + } + + const limiter = new TestRateLimiter(); + + // Max out first IP + for (let i = 0; i < config.rateLimit.maxUploads; i++) { + limiter.isRateLimited("10.0.0.1"); + } + assertEquals(limiter.isRateLimited("10.0.0.1"), true); + + // Second IP should still be allowed + assertEquals(limiter.isRateLimited("10.0.0.2"), false); +}); @@ -67,22 +67,25 @@ export async function renderUploadPage(chunked = false): Promise<string> { export async function renderBrowsePage(files: Array<{ name: string; size: number; uploaded: Date }>): Promise<string> { const browseTemplate = await loadTemplate("./templates/browse.html"); + const fileRowTemplate = await loadTemplate("./templates/partials/file-row.html"); + const emptyFilesTemplate = await loadTemplate("./templates/partials/empty-files.html"); const fileRows = files.map(file => { const mimeType = getMimeType(file.name); const viewable = isViewableInBrowser(mimeType); - - return ` - <tr> - <td><a href="/download/${file.name}" target="_blank">${file.name}</a></td> - <td>${formatFileSize(file.size)}</td> - <td>${file.uploaded.toLocaleString()}</td> - <td> - ${viewable ? `<a href="/download/${file.name}" target="_blank" class="button" style="margin-right: 10px; background-color: #28a745; color: white; border-color: #28a745;">View</a>` : ''} - <a href="/download/${file.name}" download class="button">Download</a> - </td> - </tr> - `; + const encodedName = encodeURIComponent(file.name); + + const viewButton = viewable + ? `<a href="/download/${encodedName}" target="_blank" class="button" style="margin-right: 10px; background-color: #28a745; color: white; border-color: #28a745;">View</a>` + : ''; + + return replaceVariables(fileRowTemplate, { + "encoded-name": encodedName, + "filename": file.name, + "size": formatFileSize(file.size), + "uploaded": file.uploaded.toLocaleString(), + "view-button": viewButton, + }); }).join(''); const filesContent = files.length > 0 ? ` @@ -99,13 +102,7 @@ export async function renderBrowsePage(files: Array<{ name: string; size: number ${fileRows} </tbody> </table> - ` : ` - <div style="text-align: center; padding: 60px 20px; color: #666;"> - <div style="font-size: 64px; margin-bottom: 20px;">📭</div> - <h3>No files uploaded yet</h3> - <p>Upload some files to see them here!</p> - </div> - `; + ` : emptyFilesTemplate; const content = replaceVariables(browseTemplate, { "files-content": filesContent, @@ -116,7 +113,8 @@ export async function renderBrowsePage(files: Array<{ name: string; size: number export async function renderGalleryPage(files: Array<{ name: string; size: number; uploaded: Date }>): Promise<string> { const galleryTemplate = await loadTemplate("./templates/gallery.html"); - const galleryCss = await loadTemplate("./templates/styles/gallery.css"); + const galleryItemTemplate = await loadTemplate("./templates/partials/gallery-item.html"); + const emptyGalleryTemplate = await loadTemplate("./templates/partials/empty-gallery.html"); // Filter only images and videos const mediaFiles = files.filter(file => { @@ -127,36 +125,25 @@ export async function renderGalleryPage(files: Array<{ name: string; size: numbe const galleryItems = mediaFiles.map((file, index) => { const mimeType = getMimeType(file.name); const isVideo = mimeType.startsWith("video/"); - - return ` - <div class="gallery-item" data-index="${index}"> - <a href="#" onclick="openSlideshow(${index}); return false;"> - ${isVideo ? ` - <video src="/download/${file.name}" style="width: 100%; height: 200px; object-fit: cover; border-radius: 4px;"></video> - <div class="video-overlay">▶</div> - ` : ` - <img src="/download/${file.name}" alt="${file.name}" style="width: 100%; height: 200px; object-fit: cover; border-radius: 4px;"> - `} - </a> - <div class="gallery-info"> - <div class="gallery-filename" title="${file.name}">${file.name}</div> - <div class="gallery-meta">${formatFileSize(file.size)} • ${file.uploaded.toLocaleDateString()}</div> - </div> - </div> - `; + const encodedName = encodeURIComponent(file.name); + + const media = isVideo + ? `<video src="/download/${encodedName}" style="width: 100%; height: 200px; object-fit: cover; border-radius: 4px;"></video> + <div class="video-overlay">▶</div>` + : `<img src="/download/${encodedName}" alt="${file.name}" style="width: 100%; height: 200px; object-fit: cover; border-radius: 4px;">`; + + return replaceVariables(galleryItemTemplate, { + "index": String(index), + "media": media, + "filename": file.name, + "size": formatFileSize(file.size), + "uploaded": file.uploaded.toLocaleDateString(), + }); }).join(''); - const galleryContent = mediaFiles.length > 0 ? ` - <div class="gallery-grid"> - ${galleryItems} - </div> - ` : ` - <div style="text-align: center; padding: 60px 20px; color: #666;"> - <div style="font-size: 64px; margin-bottom: 20px;">🖼️</div> - <h3>No images or videos yet</h3> - <p>Upload some media files to see them here!</p> - </div> - `; + const galleryContent = mediaFiles.length > 0 + ? `<div class="gallery-grid">\n${galleryItems}\n</div>` + : emptyGalleryTemplate; const content = replaceVariables(galleryTemplate, { "gallery-content": galleryContent, @@ -174,7 +161,7 @@ export async function renderGalleryPage(files: Array<{ name: string; size: numbe "media-files-json": mediaFilesJson, }); - const styles = `<style>\n${galleryCss}\n</style>`; + const styles = '<link rel="stylesheet" href="/css/gallery.css">'; const scripts = `<script>\n${scriptContent}\n</script>`; return renderLayout("Gallery", content, scripts, styles); |
