summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorgrothedev <grothedev@gmail.com>2025-12-28 21:41:07 -0500
committergrothedev <grothedev@gmail.com>2025-12-28 21:41:07 -0500
commitbd6c3a07a82ba11cf7b0423307229891675e7ed3 (patch)
treefce602bc4038f0f79e12f9fb296e3d220915da23
parentf978ad7db04ced4cbcf04a82bf6f0cc3f4ce66a3 (diff)
phase 1 complete i guessHEADmain
-rw-r--r--.gitignore2
-rw-r--r--README.md1
-rw-r--r--config.ts2
-rw-r--r--deno.json10
-rw-r--r--deno.lock8
-rw-r--r--fileUtils.ts9
-rw-r--r--logger.ts141
-rw-r--r--main.ts72
-rw-r--r--rateLimiter.ts2
-rw-r--r--static/css/gallery.css (renamed from templates/styles/gallery.css)0
-rw-r--r--templates/partials/empty-files.html5
-rw-r--r--templates/partials/empty-gallery.html5
-rw-r--r--templates/partials/file-row.html9
-rw-r--r--templates/partials/gallery-item.html9
-rw-r--r--templates/scripts/chunked-upload.js7
-rw-r--r--templates/scripts/simple-upload.js7
-rw-r--r--templates/scripts/slideshow.js5
-rw-r--r--tests/fileUtils.test.ts112
-rw-r--r--tests/integration.test.ts270
-rw-r--r--tests/rateLimiter.test.ts106
-rw-r--r--views.ts85
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/*
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<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();
+});
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/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);
+});
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<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);