From bd6c3a07a82ba11cf7b0423307229891675e7ed3 Mon Sep 17 00:00:00 2001 From: grothedev Date: Sun, 28 Dec 2025 21:41:07 -0500 Subject: phase 1 complete i guess --- .gitignore | 2 + README.md | 1 + config.ts | 2 +- deno.json | 10 +- deno.lock | 8 +- fileUtils.ts | 9 +- logger.ts | 141 +++++++++++++++++- main.ts | 72 +++++---- rateLimiter.ts | 2 +- static/css/gallery.css | 165 +++++++++++++++++++++ templates/partials/empty-files.html | 5 + templates/partials/empty-gallery.html | 5 + templates/partials/file-row.html | 9 ++ templates/partials/gallery-item.html | 9 ++ templates/scripts/chunked-upload.js | 7 +- templates/scripts/simple-upload.js | 7 +- templates/scripts/slideshow.js | 5 +- templates/styles/gallery.css | 165 --------------------- tests/fileUtils.test.ts | 112 ++++++++++++++ tests/integration.test.ts | 270 ++++++++++++++++++++++++++++++++++ tests/rateLimiter.test.ts | 106 +++++++++++++ views.ts | 85 +++++------ 22 files changed, 930 insertions(+), 267 deletions(-) create mode 100644 .gitignore create mode 100644 static/css/gallery.css create mode 100644 templates/partials/empty-files.html create mode 100644 templates/partials/empty-gallery.html create mode 100644 templates/partials/file-row.html create mode 100644 templates/partials/gallery-item.html delete mode 100644 templates/styles/gallery.css create mode 100644 tests/fileUtils.test.ts create mode 100644 tests/integration.test.ts create mode 100644 tests/rateLimiter.test.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a32f49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +logs +uploads/* diff --git a/README.md b/README.md index 1b0f2cf..fd021da 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.ts b/config.ts index 98ba25c..1eb61dd 100644 --- a/config.ts +++ b/config.ts @@ -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", diff --git a/deno.json b/deno.json index c3f04a8..d3237d8 100644 --- a/deno.json +++ b/deno.json @@ -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"] } } diff --git a/deno.lock b/deno.lock index f301ceb..aa6b1b2 100644 --- a/deno.lock +++ b/deno.lock @@ -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", diff --git a/logger.ts b/logger.ts index 5fbafa3..56192a1 100644 --- a/logger.ts +++ b/logger.ts @@ -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; +} class Logger { - private log(level: LogLevel, message: string, meta?: Record) { + 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) { 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(); + + 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) { + this.log(level, "server", message, meta); + } + + // Upload domain logs + upload(level: LogLevel, message: string, meta?: Record) { + this.log(level, "upload", message, meta); + } + + // Download domain logs + download(level: LogLevel, message: string, meta?: Record) { + this.log(level, "download", message, meta); + } + + // Security domain logs (rate limiting, IP tracking) + security(level: LogLevel, message: string, meta?: Record) { + this.log(level, "security", message, meta); + } + + // Cleanup domain logs + cleanup(level: LogLevel, message: string, meta?: Record) { + this.log(level, "cleanup", message, meta); + } + + // Request domain logs (HTTP requests) + request(level: LogLevel, message: string, meta?: Record) { + this.log(level, "request", message, meta); + } + + // Legacy methods for backward compatibility info(message: string, meta?: Record) { - this.log("INFO", message, meta); + this.log("INFO", "server", message, meta); } warn(message: string, meta?: Record) { - this.log("WARN", message, meta); + this.log("WARN", "server", message, meta); } error(message: string, meta?: Record) { - this.log("ERROR", message, meta); + this.log("ERROR", "server", message, meta); } debug(message: string, meta?: Record) { - 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(); +}); diff --git a/main.ts b/main.ts index af02c52..607669f 100644 --- a/main.ts +++ b/main.ts @@ -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/static/css/gallery.css b/static/css/gallery.css new file mode 100644 index 0000000..5ae5615 --- /dev/null +++ b/static/css/gallery.css @@ -0,0 +1,165 @@ +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 20px; + padding: 10px 0; +} +.gallery-item { + position: relative; + background: #f5f5f5; + border-radius: 4px; + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; + cursor: pointer; +} +.gallery-item:hover { + transform: translateY(-4px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} +.gallery-item a { + display: block; + position: relative; + text-decoration: none; +} +.video-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 48px; + color: white; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); + pointer-events: none; +} +.gallery-info { + padding: 10px; + background: white; +} +.gallery-filename { + font-size: 14px; + font-weight: 600; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} +.gallery-meta { + font-size: 12px; + color: #666; +} + +/* Slideshow Styles */ +.slideshow-modal { + display: none; + position: fixed; + z-index: 9999; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.95); +} +.slideshow-modal.active { + display: flex; + align-items: center; + justify-content: center; +} +.slideshow-close { + position: absolute; + top: 20px; + right: 40px; + color: #fff; + font-size: 50px; + font-weight: bold; + cursor: pointer; + z-index: 10001; + transition: color 0.3s; +} +.slideshow-close:hover { + color: #bbb; +} +.slideshow-content { + max-width: 90%; + max-height: 90%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +#slideshow-media-container { + display: flex; + align-items: center; + justify-content: center; + max-width: 100%; + max-height: 80vh; +} +#slideshow-media-container img, +#slideshow-media-container video { + max-width: 100%; + max-height: 80vh; + object-fit: contain; + border-radius: 4px; +} +.slideshow-info { + color: white; + text-align: center; + margin-top: 20px; + font-size: 16px; +} +#slideshow-filename { + font-weight: 600; + margin-bottom: 8px; +} +#slideshow-counter { + font-size: 14px; + color: #ccc; +} +.slideshow-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: rgba(255, 255, 255, 0.2); + color: white; + border: none; + font-size: 40px; + padding: 20px; + cursor: pointer; + transition: background-color 0.3s; + z-index: 10001; +} +.slideshow-nav:hover { + background-color: rgba(255, 255, 255, 0.4); +} +.slideshow-prev { + left: 20px; +} +.slideshow-next { + right: 20px; +} + +@media (max-width: 550px) { + .gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; + } + .gallery-item a img, + .gallery-item a video { + height: 150px !important; + } + .slideshow-close { + top: 10px; + right: 20px; + font-size: 40px; + } + .slideshow-nav { + font-size: 30px; + padding: 15px; + } + .slideshow-prev { + left: 10px; + } + .slideshow-next { + right: 10px; + } +} 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 @@ +
+
📭
+

No files uploaded yet

+

Upload some files to see them here!

+
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 @@ +
+
🖼️
+

No images or videos yet

+

Upload some media files to see them here!

+
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 @@ + + {{filename}} + {{size}} + {{uploaded}} + + {{view-button}} + Download + + 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 @@ + 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 => - `${window.location.origin}/download/${f}` - ).join(''); + const links = uploadedFiles.map(f => { + const encoded = encodeURIComponent(f); + return `${window.location.origin}/download/${encoded}`; + }).join(''); showResult('Upload successful!
' + 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 => - `${window.location.origin}/download/${f}` - ).join(''); + const links = data.files.map(f => { + const encoded = encodeURIComponent(f); + return `${window.location.origin}/download/${encoded}`; + }).join(''); showResult('Upload successful!
' + 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 = ``; + container.innerHTML = ``; } else { - container.innerHTML = `${file.name}`; + container.innerHTML = `${file.name}`; } document.getElementById('slideshow-filename').textContent = file.name; diff --git a/templates/styles/gallery.css b/templates/styles/gallery.css deleted file mode 100644 index 5ae5615..0000000 --- a/templates/styles/gallery.css +++ /dev/null @@ -1,165 +0,0 @@ -.gallery-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 20px; - padding: 10px 0; -} -.gallery-item { - position: relative; - background: #f5f5f5; - border-radius: 4px; - overflow: hidden; - transition: transform 0.2s, box-shadow 0.2s; - cursor: pointer; -} -.gallery-item:hover { - transform: translateY(-4px); - box-shadow: 0 4px 12px rgba(0,0,0,0.15); -} -.gallery-item a { - display: block; - position: relative; - text-decoration: none; -} -.video-overlay { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 48px; - color: white; - text-shadow: 0 2px 4px rgba(0,0,0,0.5); - pointer-events: none; -} -.gallery-info { - padding: 10px; - background: white; -} -.gallery-filename { - font-size: 14px; - font-weight: 600; - color: #333; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 4px; -} -.gallery-meta { - font-size: 12px; - color: #666; -} - -/* Slideshow Styles */ -.slideshow-modal { - display: none; - position: fixed; - z-index: 9999; - left: 0; - top: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.95); -} -.slideshow-modal.active { - display: flex; - align-items: center; - justify-content: center; -} -.slideshow-close { - position: absolute; - top: 20px; - right: 40px; - color: #fff; - font-size: 50px; - font-weight: bold; - cursor: pointer; - z-index: 10001; - transition: color 0.3s; -} -.slideshow-close:hover { - color: #bbb; -} -.slideshow-content { - max-width: 90%; - max-height: 90%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} -#slideshow-media-container { - display: flex; - align-items: center; - justify-content: center; - max-width: 100%; - max-height: 80vh; -} -#slideshow-media-container img, -#slideshow-media-container video { - max-width: 100%; - max-height: 80vh; - object-fit: contain; - border-radius: 4px; -} -.slideshow-info { - color: white; - text-align: center; - margin-top: 20px; - font-size: 16px; -} -#slideshow-filename { - font-weight: 600; - margin-bottom: 8px; -} -#slideshow-counter { - font-size: 14px; - color: #ccc; -} -.slideshow-nav { - position: absolute; - top: 50%; - transform: translateY(-50%); - background-color: rgba(255, 255, 255, 0.2); - color: white; - border: none; - font-size: 40px; - padding: 20px; - cursor: pointer; - transition: background-color 0.3s; - z-index: 10001; -} -.slideshow-nav:hover { - background-color: rgba(255, 255, 255, 0.4); -} -.slideshow-prev { - left: 20px; -} -.slideshow-next { - right: 20px; -} - -@media (max-width: 550px) { - .gallery-grid { - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 10px; - } - .gallery-item a img, - .gallery-item a video { - height: 150px !important; - } - .slideshow-close { - top: 10px; - right: 20px; - font-size: 40px; - } - .slideshow-nav { - font-size: 30px; - padding: 15px; - } - .slideshow-prev { - left: 10px; - } - .slideshow-next { - right: 10px; - } -} 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 { + 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(); + + 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(); + + 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(); + + 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); +}); diff --git a/views.ts b/views.ts index 4db06ce..ae46a9a 100644 --- a/views.ts +++ b/views.ts @@ -67,22 +67,25 @@ export async function renderUploadPage(chunked = false): Promise { export async function renderBrowsePage(files: Array<{ name: string; size: number; uploaded: Date }>): Promise { 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 ` - - ${file.name} - ${formatFileSize(file.size)} - ${file.uploaded.toLocaleString()} - - ${viewable ? `View` : ''} - Download - - - `; + const encodedName = encodeURIComponent(file.name); + + const viewButton = viewable + ? `View` + : ''; + + 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} - ` : ` -
-
📭
-

No files uploaded yet

-

Upload some files to see them here!

-
- `; + ` : 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 { 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 ` - - `; + const encodedName = encodeURIComponent(file.name); + + const media = isVideo + ? ` +
` + : `${file.name}`; + + 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 ? ` - - ` : ` -
-
🖼️
-

No images or videos yet

-

Upload some media files to see them here!

-
- `; + const galleryContent = mediaFiles.length > 0 + ? `` + : 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 = ``; + const styles = ''; const scripts = ``; return renderLayout("Gallery", content, scripts, styles); -- cgit v1.2.3