summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorgrothedev <grothedev@gmail.com>2025-12-28 15:41:41 -0500
committergrothedev <grothedev@gmail.com>2025-12-28 15:41:41 -0500
commit163be506b1e7102a7e96f79b7d919c3561095a38 (patch)
tree5d689f37a7d129f83378107029c7beb777c01e5b
claude code deno fileupload project yeeee
-rw-r--r--README.md91
-rw-r--r--config.ts36
-rw-r--r--deno.json9
-rw-r--r--deno.lock82
-rw-r--r--fileUtils.ts161
-rw-r--r--logger.ts27
-rw-r--r--main.ts277
-rw-r--r--rateLimiter.ts46
-rw-r--r--static/css/home.css153
-rwxr-xr-xstatic/css/skeleton.css419
-rw-r--r--uploads/IMG_20251111_200030.jpgbin0 -> 1132662 bytes
-rw-r--r--uploads/PXL_20250806_122816017.jpgbin0 -> 3997008 bytes
-rw-r--r--uploads/PXL_20250816_171403961.jpgbin0 -> 3262550 bytes
-rw-r--r--uploads/PXL_20250816_171419431.jpgbin0 -> 3213745 bytes
-rw-r--r--uploads/Snapchat-1994178113.mp4bin0 -> 24211008 bytes
-rw-r--r--uploads/Snapchat-257701980.mp4bin0 -> 16855737 bytes
-rw-r--r--uploads/Snapchat-518753368.mp4bin0 -> 19823148 bytes
-rw-r--r--uploads/farout.jpgbin0 -> 18380 bytes
-rw-r--r--views.ts354
-rw-r--r--views/home.blade.php32
-rw-r--r--views/template.blade.php29
21 files changed, 1716 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1b0f2cf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,91 @@
+# File Upload Application (Deno)
+
+A basic file uploading website demonstration, built with Deno and Oak.
+
+## Features (Phase 1) ✓
+
+- **Dual Upload Modes**:
+ - Simple upload page (no JavaScript required)
+ - JavaScript version with chunked uploads for large files
+- **Multiple File Support**: Upload one or multiple files at once
+- **File Sharing**: After upload, get shareable URLs for downloaded files
+- **File Browser**: Browse all uploaded files with size and timestamp info
+- **Error Handling**: Appropriate error reporting and comprehensive logging
+- **Configurable Limits**:
+ - File size limit (default: 100MB) - defined in `config.ts`
+ - File type restrictions (configurable in `config.ts`)
+ - File expiration (default: 7 days)
+- **Rate Limiting**: Per IP address (default: 10 uploads per 15 minutes)
+- **Smart Naming**: Files keep original names unless collision occurs, then epoch seconds are appended
+- **Auto Cleanup**: Expired files are automatically removed
+
+## Getting Started
+
+### Prerequisites
+
+- Deno 2.0+ installed ([https://deno.land](https://deno.land))
+
+### Running the Server
+
+Development mode (with auto-reload):
+```bash
+deno task dev
+```
+
+Production mode:
+```bash
+deno task start
+```
+
+The server will start at `http://localhost:8000`
+
+## Configuration
+
+Edit `config.ts` to customize:
+
+- **Port**: Server port (default: 8000)
+- **Upload Directory**: Where files are stored (default: ./uploads)
+- **Max File Size**: Maximum file size in bytes (default: 100MB)
+- **Allowed File Types**: Array of MIME types (default: all types allowed)
+- **File Expiration**: Time before files are deleted in ms (default: 7 days)
+- **Rate Limiting**: Window and max uploads per IP
+- **Chunk Size**: Size of chunks for chunked uploads (default: 1MB)
+
+## API Endpoints
+
+- `GET /` - Simple upload page
+- `GET /chunked` - Chunked upload page
+- `GET /browse` - Browse all uploaded files
+- `POST /upload` - Upload files (multipart/form-data)
+- `POST /upload-chunk` - Upload file chunks
+- `GET /download/:filename` - Download a file
+
+## Phase 2 (Planned)
+
+- Database integration to store file metadata
+- File model and controller
+- IP address tracking and association with uploads
+- Page visit tracking by IP
+- Enhanced analytics and reporting
+
+## Project Structure
+
+```
+.
+├── main.ts # Main server and routes
+├── config.ts # Configuration settings
+├── logger.ts # Logging utility
+├── rateLimiter.ts # Rate limiting logic
+├── fileUtils.ts # File handling utilities
+├── views.ts # HTML template rendering
+├── deno.json # Deno configuration
+└── uploads/ # Uploaded files directory (auto-created)
+```
+
+## Security Features
+
+- Rate limiting per IP address
+- File size validation
+- File type validation
+- Automatic file expiration
+- Comprehensive logging of all operations
diff --git a/config.ts b/config.ts
new file mode 100644
index 0000000..98ba25c
--- /dev/null
+++ b/config.ts
@@ -0,0 +1,36 @@
+export const config = {
+ // Server settings
+ port: 8000,
+
+ // File upload settings
+ uploadDir: "./uploads",
+ maxFileSize: 100 * 1024 * 1024, // 100MB in bytes
+ allowedFileTypes: [
+ // Images
+ "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
+ // Documents
+ "application/pdf", "text/plain",
+ "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ // Archives
+ "application/zip", "application/x-tar", "application/gzip",
+ // Video
+ "video/mp4", "video/mpeg", "video/webm",
+ // Audio
+ "audio/mpeg", "audio/wav", "audio/webm",
+ // Allow all types (comment out specific types above to restrict)
+ "*/*"
+ ],
+
+ // File expiration in milliseconds (0 = never expire)
+ fileExpiration: 7 * 24 * 60 * 60 * 1000, // 7 days
+
+ // Rate limiting
+ rateLimit: {
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ maxUploads: 10, // max uploads per window per IP
+ },
+
+ // Chunked upload settings
+ chunkSize: 1024 * 1024, // 1MB chunks
+};
diff --git a/deno.json b/deno.json
new file mode 100644
index 0000000..c3f04a8
--- /dev/null
+++ b/deno.json
@@ -0,0 +1,9 @@
+{
+ "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"
+ },
+ "imports": {
+ "oak": "jsr:@oak/oak@^17.1.3"
+ }
+}
diff --git a/deno.lock b/deno.lock
new file mode 100644
index 0000000..f301ceb
--- /dev/null
+++ b/deno.lock
@@ -0,0 +1,82 @@
+{
+ "version": "5",
+ "specifiers": {
+ "jsr:@oak/commons@1": "1.0.1",
+ "jsr:@oak/oak@^17.1.3": "17.2.0",
+ "jsr:@std/assert@1": "1.0.16",
+ "jsr:@std/bytes@1": "1.0.6",
+ "jsr:@std/crypto@1": "1.0.5",
+ "jsr:@std/encoding@1": "1.0.10",
+ "jsr:@std/encoding@^1.0.10": "1.0.10",
+ "jsr:@std/http@1": "1.0.23",
+ "jsr:@std/internal@^1.0.12": "1.0.12",
+ "jsr:@std/media-types@1": "1.1.0",
+ "jsr:@std/path@1": "1.1.4",
+ "npm:path-to-regexp@^6.3.0": "6.3.0"
+ },
+ "jsr": {
+ "@oak/commons@1.0.1": {
+ "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c",
+ "dependencies": [
+ "jsr:@std/assert",
+ "jsr:@std/bytes",
+ "jsr:@std/crypto",
+ "jsr:@std/encoding@1",
+ "jsr:@std/http",
+ "jsr:@std/media-types"
+ ]
+ },
+ "@oak/oak@17.2.0": {
+ "integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1",
+ "dependencies": [
+ "jsr:@oak/commons",
+ "jsr:@std/assert",
+ "jsr:@std/bytes",
+ "jsr:@std/http",
+ "jsr:@std/media-types",
+ "jsr:@std/path",
+ "npm:path-to-regexp"
+ ]
+ },
+ "@std/assert@1.0.16": {
+ "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532"
+ },
+ "@std/bytes@1.0.6": {
+ "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
+ },
+ "@std/crypto@1.0.5": {
+ "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40"
+ },
+ "@std/encoding@1.0.10": {
+ "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
+ },
+ "@std/http@1.0.23": {
+ "integrity": "6634e9e034c589bf35101c1b5ee5bbf052a5987abca20f903e58bdba85c80dee",
+ "dependencies": [
+ "jsr:@std/encoding@^1.0.10"
+ ]
+ },
+ "@std/internal@1.0.12": {
+ "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
+ },
+ "@std/media-types@1.1.0": {
+ "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
+ },
+ "@std/path@1.1.4": {
+ "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
+ "dependencies": [
+ "jsr:@std/internal"
+ ]
+ }
+ },
+ "npm": {
+ "path-to-regexp@6.3.0": {
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="
+ }
+ },
+ "workspace": {
+ "dependencies": [
+ "jsr:@oak/oak@^17.1.3"
+ ]
+ }
+}
diff --git a/fileUtils.ts b/fileUtils.ts
new file mode 100644
index 0000000..6360a95
--- /dev/null
+++ b/fileUtils.ts
@@ -0,0 +1,161 @@
+import { config } from "./config.ts";
+import { logger } from "./logger.ts";
+
+export async function ensureUploadDir() {
+ try {
+ await Deno.mkdir(config.uploadDir, { recursive: true });
+ } catch (error) {
+ if (!(error instanceof Deno.errors.AlreadyExists)) {
+ throw error;
+ }
+ }
+}
+
+export async function getUniqueFilename(filename: string): Promise<string> {
+ const filepath = `${config.uploadDir}/${filename}`;
+
+ try {
+ await Deno.stat(filepath);
+ // File exists, append epoch seconds
+ const epoch = Math.floor(Date.now() / 1000);
+ const parts = filename.split(".");
+ if (parts.length > 1) {
+ const ext = parts.pop();
+ return `${parts.join(".")}_${epoch}.${ext}`;
+ }
+ return `${filename}_${epoch}`;
+ } catch (error) {
+ if (error instanceof Deno.errors.NotFound) {
+ return filename;
+ }
+ throw error;
+ }
+}
+
+export async function listUploadedFiles() {
+ const files: Array<{ name: string; size: number; uploaded: Date }> = [];
+
+ try {
+ for await (const entry of Deno.readDir(config.uploadDir)) {
+ if (entry.isFile) {
+ const stat = await Deno.stat(`${config.uploadDir}/${entry.name}`);
+ files.push({
+ name: entry.name,
+ size: stat.size,
+ uploaded: stat.mtime || new Date(),
+ });
+ }
+ }
+ } catch (error) {
+ if (!(error instanceof Deno.errors.NotFound)) {
+ logger.error("Error listing files", { error: String(error) });
+ }
+ }
+
+ return files.sort((a, b) => b.uploaded.getTime() - a.uploaded.getTime());
+}
+
+export async function cleanupExpiredFiles() {
+ if (config.fileExpiration === 0) return;
+
+ const now = Date.now();
+ let cleaned = 0;
+
+ try {
+ for await (const entry of Deno.readDir(config.uploadDir)) {
+ if (entry.isFile) {
+ const filepath = `${config.uploadDir}/${entry.name}`;
+ const stat = await Deno.stat(filepath);
+ const age = now - (stat.mtime?.getTime() || 0);
+
+ if (age > config.fileExpiration) {
+ await Deno.remove(filepath);
+ cleaned++;
+ logger.info("Expired file removed", { filename: entry.name, age });
+ }
+ }
+ }
+ } catch (error) {
+ logger.error("Error during file cleanup", { error: String(error) });
+ }
+
+ if (cleaned > 0) {
+ logger.info("File cleanup completed", { filesRemoved: cleaned });
+ }
+}
+
+// Run cleanup every hour
+setInterval(() => cleanupExpiredFiles(), 60 * 60 * 1000);
+
+export function isAllowedFileType(contentType: string): boolean {
+ if (config.allowedFileTypes.includes("*/*")) {
+ return true;
+ }
+ return config.allowedFileTypes.includes(contentType);
+}
+
+export function formatFileSize(bytes: number): string {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+}
+
+export function getMimeType(filename: string): string {
+ const ext = filename.split(".").pop()?.toLowerCase();
+
+ const mimeTypes: Record<string, string> = {
+ // Images
+ "jpg": "image/jpeg",
+ "jpeg": "image/jpeg",
+ "png": "image/png",
+ "gif": "image/gif",
+ "webp": "image/webp",
+ "svg": "image/svg+xml",
+ "bmp": "image/bmp",
+ "ico": "image/x-icon",
+
+ // Videos
+ "mp4": "video/mp4",
+ "webm": "video/webm",
+ "ogg": "video/ogg",
+ "ogv": "video/ogg",
+ "avi": "video/x-msvideo",
+ "mov": "video/quicktime",
+ "mkv": "video/x-matroska",
+ "m4v": "video/x-m4v",
+ "mpeg": "video/mpeg",
+ "mpg": "video/mpeg",
+
+ // Audio
+ "mp3": "audio/mpeg",
+ "wav": "audio/wav",
+ "ogg": "audio/ogg",
+ "m4a": "audio/mp4",
+ "flac": "audio/flac",
+
+ // Documents
+ "pdf": "application/pdf",
+ "txt": "text/plain",
+ "html": "text/html",
+ "css": "text/css",
+ "js": "application/javascript",
+ "json": "application/json",
+
+ // Archives
+ "zip": "application/zip",
+ "tar": "application/x-tar",
+ "gz": "application/gzip",
+ };
+
+ return mimeTypes[ext || ""] || "application/octet-stream";
+}
+
+export function isViewableInBrowser(mimeType: string): boolean {
+ return mimeType.startsWith("image/") ||
+ mimeType.startsWith("video/") ||
+ mimeType.startsWith("audio/") ||
+ mimeType === "application/pdf" ||
+ mimeType === "text/plain";
+}
diff --git a/logger.ts b/logger.ts
new file mode 100644
index 0000000..5fbafa3
--- /dev/null
+++ b/logger.ts
@@ -0,0 +1,27 @@
+type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG";
+
+class Logger {
+ private log(level: LogLevel, message: string, meta?: Record<string, unknown>) {
+ const timestamp = new Date().toISOString();
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
+ console.log(`[${timestamp}] [${level}] ${message}${metaStr}`);
+ }
+
+ info(message: string, meta?: Record<string, unknown>) {
+ this.log("INFO", message, meta);
+ }
+
+ warn(message: string, meta?: Record<string, unknown>) {
+ this.log("WARN", message, meta);
+ }
+
+ error(message: string, meta?: Record<string, unknown>) {
+ this.log("ERROR", message, meta);
+ }
+
+ debug(message: string, meta?: Record<string, unknown>) {
+ this.log("DEBUG", message, meta);
+ }
+}
+
+export const logger = new Logger();
diff --git a/main.ts b/main.ts
new file mode 100644
index 0000000..fdfe5f0
--- /dev/null
+++ b/main.ts
@@ -0,0 +1,277 @@
+import { Application, Router, Status, send } from "oak";
+import { config } from "./config.ts";
+import { logger } from "./logger.ts";
+import { rateLimiter } from "./rateLimiter.ts";
+import {
+ ensureUploadDir,
+ getUniqueFilename,
+ listUploadedFiles,
+ cleanupExpiredFiles,
+ isAllowedFileType,
+ getMimeType,
+ isViewableInBrowser,
+} from "./fileUtils.ts";
+import { renderUploadPage, renderBrowsePage, renderError } from "./views.ts";
+
+const app = new Application();
+const router = new Router();
+
+// Temporary storage for chunked uploads
+const chunkStorage = new Map<string, { chunks: Uint8Array[]; totalChunks: number }>();
+
+function getClientIP(ctx: any): string {
+ return ctx.request.ip || "unknown";
+}
+
+// Static file serving
+router.get("/css/:filename", async (ctx) => {
+ const filename = ctx.params.filename;
+ try {
+ await send(ctx, filename, {
+ root: `${Deno.cwd()}/static/css`,
+ });
+ } catch {
+ ctx.response.status = Status.NotFound;
+ }
+});
+
+// Routes
+router.get("/", (ctx) => {
+ logger.info("Page visit", { page: "/", ip: getClientIP(ctx) });
+ ctx.response.headers.set("Content-Type", "text/html");
+ ctx.response.body = renderUploadPage(false);
+});
+
+router.get("/chunked", (ctx) => {
+ logger.info("Page visit", { page: "/chunked", ip: getClientIP(ctx) });
+ ctx.response.headers.set("Content-Type", "text/html");
+ ctx.response.body = renderUploadPage(true);
+});
+
+router.get("/browse", async (ctx) => {
+ logger.info("Page visit", { page: "/browse", ip: getClientIP(ctx) });
+ const files = await listUploadedFiles();
+ ctx.response.headers.set("Content-Type", "text/html");
+ ctx.response.body = renderBrowsePage(files);
+});
+
+router.post("/upload", 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();
+ const uploadedFiles: string[] = [];
+
+ const files = formData.getAll("files");
+
+ if (files.length === 0) {
+ ctx.response.status = Status.BadRequest;
+ ctx.response.body = { error: "No files provided" };
+ return;
+ }
+
+ for (const file of files) {
+ if (!(file instanceof File)) {
+ continue;
+ }
+
+ // Check file size
+ if (file.size > config.maxFileSize) {
+ const maxMB = Math.floor(config.maxFileSize / (1024 * 1024));
+ ctx.response.status = Status.BadRequest;
+ ctx.response.body = { error: `File ${file.name} exceeds maximum size of ${maxMB}MB` };
+ return;
+ }
+
+ // Check file type
+ if (!isAllowedFileType(file.type)) {
+ ctx.response.status = Status.BadRequest;
+ ctx.response.body = { error: `File type ${file.type} is not allowed` };
+ return;
+ }
+
+ // Get unique filename
+ const filename = await getUniqueFilename(file.name);
+ const filepath = `${config.uploadDir}/${filename}`;
+
+ // Save file
+ const arrayBuffer = await file.arrayBuffer();
+ await Deno.writeFile(filepath, new Uint8Array(arrayBuffer));
+
+ uploadedFiles.push(filename);
+ logger.info("File uploaded", { 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 });
+ ctx.response.status = Status.InternalServerError;
+ ctx.response.body = { error: "Upload failed" };
+ }
+});
+
+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();
+
+ const chunk = formData.get("chunk");
+ const filename = formData.get("filename") as string;
+ const chunkIndex = parseInt(formData.get("chunkIndex") as string);
+ const totalChunks = parseInt(formData.get("totalChunks") as string);
+
+ if (!(chunk instanceof File) || !filename || isNaN(chunkIndex) || isNaN(totalChunks)) {
+ ctx.response.status = Status.BadRequest;
+ ctx.response.body = { error: "Invalid chunk data" };
+ return;
+ }
+
+ const storageKey = `${ip}:${filename}`;
+
+ // Initialize storage for this file
+ if (!chunkStorage.has(storageKey)) {
+ chunkStorage.set(storageKey, { chunks: [], totalChunks });
+ }
+
+ const storage = chunkStorage.get(storageKey)!;
+
+ // Store chunk
+ const arrayBuffer = await chunk.arrayBuffer();
+ storage.chunks[chunkIndex] = new Uint8Array(arrayBuffer);
+
+ // Check if all chunks received
+ if (storage.chunks.filter(c => c).length === totalChunks) {
+ // Combine all chunks
+ const totalSize = storage.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
+
+ // Check total file size
+ if (totalSize > config.maxFileSize) {
+ chunkStorage.delete(storageKey);
+ const maxMB = Math.floor(config.maxFileSize / (1024 * 1024));
+ ctx.response.status = Status.BadRequest;
+ ctx.response.body = { error: `File exceeds maximum size of ${maxMB}MB` };
+ return;
+ }
+
+ const completeFile = new Uint8Array(totalSize);
+ let offset = 0;
+ for (const chunk of storage.chunks) {
+ completeFile.set(chunk, offset);
+ offset += chunk.length;
+ }
+
+ // Get unique filename and save
+ const uniqueFilename = await getUniqueFilename(filename);
+ const filepath = `${config.uploadDir}/${uniqueFilename}`;
+ await Deno.writeFile(filepath, completeFile);
+
+ // Clean up
+ chunkStorage.delete(storageKey);
+
+ logger.info("Chunked file uploaded", { 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 });
+ ctx.response.status = Status.InternalServerError;
+ ctx.response.body = { error: "Chunk upload failed" };
+ }
+});
+
+router.get("/download/:filename", async (ctx) => {
+ const filename = ctx.params.filename;
+ const ip = getClientIP(ctx);
+
+ if (!filename) {
+ ctx.response.status = Status.BadRequest;
+ ctx.response.headers.set("Content-Type", "text/html");
+ ctx.response.body = renderError("Filename is required", 400);
+ return;
+ }
+
+ const filepath = `${config.uploadDir}/${filename}`;
+
+ try {
+ const fileInfo = await Deno.stat(filepath);
+
+ if (!fileInfo.isFile) {
+ ctx.response.status = Status.NotFound;
+ ctx.response.headers.set("Content-Type", "text/html");
+ ctx.response.body = renderError("File not found", 404);
+ return;
+ }
+
+ const file = await Deno.readFile(filepath);
+ const mimeType = getMimeType(filename);
+ const viewableInBrowser = isViewableInBrowser(mimeType);
+
+ logger.info(viewableInBrowser ? "File viewed" : "File downloaded", { filename, mimeType, ip });
+
+ ctx.response.headers.set("Content-Type", mimeType);
+
+ if (viewableInBrowser) {
+ ctx.response.headers.set("Content-Disposition", `inline; filename="${filename}"`);
+ } else {
+ ctx.response.headers.set("Content-Disposition", `attachment; filename="${filename}"`);
+ }
+
+ ctx.response.body = file;
+ } catch (error) {
+ if (error instanceof Deno.errors.NotFound) {
+ logger.warn("File not found", { filename, ip });
+ ctx.response.status = Status.NotFound;
+ ctx.response.headers.set("Content-Type", "text/html");
+ ctx.response.body = renderError("File not found", 404);
+ } else {
+ logger.error("Download error", { filename, error: String(error), ip });
+ ctx.response.status = Status.InternalServerError;
+ ctx.response.headers.set("Content-Type", "text/html");
+ ctx.response.body = renderError("Failed to download file", 500);
+ }
+ }
+});
+
+// Middleware
+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 });
+});
+
+app.use(router.routes());
+app.use(router.allowedMethods());
+
+// Initialize
+await ensureUploadDir();
+cleanupExpiredFiles();
+
+logger.info("Server starting", { port: config.port });
+
+app.addEventListener("listen", ({ hostname, port, secure }) => {
+ logger.info("Server started", {
+ url: `${secure ? "https" : "http"}://${hostname ?? "localhost"}:${port}`,
+ });
+});
+
+await app.listen({ port: config.port });
diff --git a/rateLimiter.ts b/rateLimiter.ts
new file mode 100644
index 0000000..b24abd8
--- /dev/null
+++ b/rateLimiter.ts
@@ -0,0 +1,46 @@
+import { config } from "./config.ts";
+import { logger } from "./logger.ts";
+
+interface RateLimitEntry {
+ count: number;
+ resetTime: number;
+}
+
+class RateLimiter {
+ private storage = new Map<string, RateLimitEntry>();
+
+ 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) {
+ logger.warn("Rate limit exceeded", { ip, count: entry.count });
+ return true;
+ }
+
+ entry.count++;
+ return false;
+ }
+
+ cleanup() {
+ const now = Date.now();
+ for (const [ip, entry] of this.storage.entries()) {
+ if (now > entry.resetTime) {
+ this.storage.delete(ip);
+ }
+ }
+ }
+}
+
+export const rateLimiter = new RateLimiter();
+
+// Cleanup expired entries every 5 minutes
+setInterval(() => rateLimiter.cleanup(), 5 * 60 * 1000);
diff --git a/static/css/home.css b/static/css/home.css
new file mode 100644
index 0000000..66339e8
--- /dev/null
+++ b/static/css/home.css
@@ -0,0 +1,153 @@
+#bg {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: -1;
+}
+body {
+ font-family: Arial, sans-serif;
+ margin: 0;
+ padding: 0;
+ background-color: #888888;
+}
+header {
+ background-color: #333;
+ color: #dfdfdf;
+ padding: 1rem;
+ text-align: center;
+}
+main {
+ max-width: 800px;
+ margin: 2rem auto;
+ padding: 1rem;
+ background-color: #cecece;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+ z-index: 1;
+}
+section {
+ margin-bottom: 2rem;
+ z-index: 1;
+ position: relative;
+}
+section h2 {
+ margin-top: 0;
+}
+section p {
+ margin: 0.5rem 0;
+}
+section a {
+ color: #3f6d87;
+ text-decoration: none;
+}
+section li {
+ list-style-type: none;
+ margin: 1rem 1rem;
+}
+section a:hover {
+ text-decoration: underline;
+}
+.status {
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+
+.widget {
+ margin: 20px 0;
+ padding: 15px;
+ border: 1px dotted #555;
+ border-radius: 5px;
+}
+
+.widget-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+
+.search-container {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 15px;
+}
+
+.search-input {
+ flex: 1;
+ padding: 8px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+}
+
+.sort-container {
+ display: flex;
+ gap: 5px;
+ margin-bottom: 15px;
+ flex-wrap: wrap;
+}
+
+.hn-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.hn-item {
+ padding: 10px 0;
+ border-bottom: 1px solid #eee;
+}
+
+.hn-item:last-child {
+ border-bottom: none;
+}
+
+.hn-title {
+ margin-bottom: 5px;
+}
+
+.hn-title a {
+ color: #000;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.hn-title a:hover {
+ text-decoration: underline;
+}
+
+.hn-domain {
+ color: #5a5a5a;
+ font-size: 0.9em;
+ margin-left: 5px;
+}
+
+.hn-meta {
+ color: #525252;
+ font-size: 0.85em;
+}
+
+.hn-meta a {
+ color: #828282;
+ text-decoration: none;
+}
+
+.hn-meta a:hover {
+ text-decoration: underline;
+}
+
+.hn-author {
+ color: #000;
+}
+
+.loading, .error, .no-results {
+ text-align: center;
+ font-size: .8em;
+ padding: 20px;
+ color: #666;
+}
+
+.error {
+ color: #d00;
+}
+
diff --git a/static/css/skeleton.css b/static/css/skeleton.css
new file mode 100755
index 0000000..c3fda1c
--- /dev/null
+++ b/static/css/skeleton.css
@@ -0,0 +1,419 @@
+/*
+* Skeleton V2.0.4
+* Copyright 2014, Dave Gamache
+* www.getskeleton.com
+* Free to use under the MIT license.
+* http://www.opensource.org/licenses/mit-license.php
+* 12/29/2014
+*/
+
+
+/* Table of contents
+––––––––––––––––––––––––––––––––––––––––––––––––––
+- Grid
+- Base Styles
+- Typography
+- Links
+- Buttons
+- Forms
+- Lists
+- Code
+- Tables
+- Spacing
+- Utilities
+- Clearing
+- Media Queries
+*/
+
+
+/* Grid
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.container {
+ position: relative;
+ width: 100%;
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 0 20px;
+ box-sizing: border-box; }
+.column,
+.columns {
+ width: 100%;
+ float: left;
+ box-sizing: border-box; }
+
+/* For devices larger than 400px */
+@media (min-width: 400px) {
+ .container {
+ width: 85%;
+ padding: 0; }
+}
+
+/* For devices larger than 550px */
+@media (min-width: 550px) {
+ .container {
+ width: 80%; }
+ .column,
+ .columns {
+ margin-left: 4%; }
+ .column:first-child,
+ .columns:first-child {
+ margin-left: 0; }
+
+ .one.column,
+ .col1,
+ .one.columns { width: 4.66666666667%; }
+ .two.columns { width: 13.3333333333%; }
+ .three.columns { width: 22%; }
+ .four.columns { width: 30.6666666667%; }
+ .five.columns { width: 39.3333333333%; }
+ .six.columns { width: 48%; }
+ .seven.columns { width: 56.6666666667%; }
+ .eight.columns { width: 65.3333333333%; }
+ .nine.columns { width: 74.0%; }
+ .ten.columns { width: 82.6666666667%; }
+ .eleven.columns { width: 91.3333333333%; }
+ .twelve.columns { width: 100%; margin-left: 0; }
+
+ .one-third.column { width: 30.6666666667%; }
+ .two-thirds.column { width: 65.3333333333%; }
+
+ .one-half.column { width: 48%; }
+
+ /* Offsets */
+ .offset-by-one.column,
+ .offset-by-one.columns { margin-left: 8.66666666667%; }
+ .offset-by-two.column,
+ .offset-by-two.columns { margin-left: 17.3333333333%; }
+ .offset-by-three.column,
+ .offset-by-three.columns { margin-left: 26%; }
+ .offset-by-four.column,
+ .offset-by-four.columns { margin-left: 34.6666666667%; }
+ .offset-by-five.column,
+ .offset-by-five.columns { margin-left: 43.3333333333%; }
+ .offset-by-six.column,
+ .offset-by-six.columns { margin-left: 52%; }
+ .offset-by-seven.column,
+ .offset-by-seven.columns { margin-left: 60.6666666667%; }
+ .offset-by-eight.column,
+ .offset-by-eight.columns { margin-left: 69.3333333333%; }
+ .offset-by-nine.column,
+ .offset-by-nine.columns { margin-left: 78.0%; }
+ .offset-by-ten.column,
+ .offset-by-ten.columns { margin-left: 86.6666666667%; }
+ .offset-by-eleven.column,
+ .offset-by-eleven.columns { margin-left: 95.3333333333%; }
+
+ .offset-by-one-third.column,
+ .offset-by-one-third.columns { margin-left: 34.6666666667%; }
+ .offset-by-two-thirds.column,
+ .offset-by-two-thirds.columns { margin-left: 69.3333333333%; }
+
+ .offset-by-one-half.column,
+ .offset-by-one-half.columns { margin-left: 52%; }
+
+}
+
+
+/* Base Styles
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+/* NOTE
+html is set to 62.5% so that all the REM measurements throughout Skeleton
+are based on 10px sizing. So basically 1.5rem = 15px :) */
+html {
+ font-size: 62.5%; }
+body {
+ font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */
+ line-height: 1.6;
+ font-weight: 400;
+ font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
+ color: #222; }
+
+
+/* Typography
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+h1, h2, h3, h4, h5, h6 {
+ margin-top: 0;
+ margin-bottom: 2rem;
+ font-weight: 300; }
+h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;}
+h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; }
+h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; }
+h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; }
+h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; }
+h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; }
+
+/* Larger than phablet */
+@media (min-width: 550px) {
+ h1 { font-size: 5.0rem; }
+ h2 { font-size: 4.2rem; }
+ h3 { font-size: 3.6rem; }
+ h4 { font-size: 3.0rem; }
+ h5 { font-size: 2.4rem; }
+ h6 { font-size: 1.5rem; }
+}
+
+p {
+ margin-top: 0; }
+
+
+/* Links
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+a {
+ color: #1EAEDB; }
+a:hover {
+ color: #0FA0CE; }
+
+
+/* Buttons
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.button,
+button,
+input[type="submit"],
+input[type="reset"],
+input[type="button"] {
+ display: inline-block;
+ height: 38px;
+ padding: 0 30px;
+ color: #555;
+ text-align: center;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 38px;
+ letter-spacing: .1rem;
+ text-transform: uppercase;
+ text-decoration: none;
+ white-space: nowrap;
+ background-color: transparent;
+ border-radius: 4px;
+ border: 1px solid #bbb;
+ cursor: pointer;
+ box-sizing: border-box; }
+.button:hover,
+button:hover,
+input[type="submit"]:hover,
+input[type="reset"]:hover,
+input[type="button"]:hover,
+.button:focus,
+button:focus,
+input[type="submit"]:focus,
+input[type="reset"]:focus,
+input[type="button"]:focus {
+ color: #333;
+ border-color: #888;
+ outline: 0; }
+.button.button-primary,
+button.button-primary,
+input[type="submit"].button-primary,
+input[type="reset"].button-primary,
+input[type="button"].button-primary {
+ color: #FFF;
+ background-color: #33C3F0;
+ border-color: #33C3F0; }
+.button.button-primary:hover,
+button.button-primary:hover,
+input[type="submit"].button-primary:hover,
+input[type="reset"].button-primary:hover,
+input[type="button"].button-primary:hover,
+.button.button-primary:focus,
+button.button-primary:focus,
+input[type="submit"].button-primary:focus,
+input[type="reset"].button-primary:focus,
+input[type="button"].button-primary:focus {
+ color: #FFF;
+ background-color: #1EAEDB;
+ border-color: #1EAEDB; }
+
+
+/* Forms
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+input[type="email"],
+input[type="number"],
+input[type="search"],
+input[type="text"],
+input[type="tel"],
+input[type="url"],
+input[type="password"],
+textarea,
+select {
+ height: 38px;
+ padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
+ background-color: #fff;
+ border: 1px solid #D1D1D1;
+ border-radius: 4px;
+ box-shadow: none;
+ box-sizing: border-box; }
+/* Removes awkward default styles on some inputs for iOS */
+input[type="email"],
+input[type="number"],
+input[type="search"],
+input[type="text"],
+input[type="tel"],
+input[type="url"],
+input[type="password"],
+textarea {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none; }
+textarea {
+ min-height: 65px;
+ padding-top: 6px;
+ padding-bottom: 6px; }
+input[type="email"]:focus,
+input[type="number"]:focus,
+input[type="search"]:focus,
+input[type="text"]:focus,
+input[type="tel"]:focus,
+input[type="url"]:focus,
+input[type="password"]:focus,
+textarea:focus,
+select:focus {
+ border: 1px solid #33C3F0;
+ outline: 0; }
+label,
+legend {
+ display: block;
+ margin-bottom: .5rem;
+ font-weight: 600; }
+fieldset {
+ padding: 0;
+ border-width: 0; }
+input[type="checkbox"],
+input[type="radio"] {
+ display: inline; }
+label > .label-body {
+ display: inline-block;
+ margin-left: .5rem;
+ font-weight: normal; }
+
+
+/* Lists
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+ul {
+ list-style: circle inside; }
+ol {
+ list-style: decimal inside; }
+ol, ul {
+ padding-left: 0;
+ margin-top: 0; }
+ul ul,
+ul ol,
+ol ol,
+ol ul {
+ margin: 1.5rem 0 1.5rem 3rem;
+ font-size: 90%; }
+li {
+ margin-bottom: 1rem; }
+
+
+/* Code
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+code {
+ padding: .2rem .5rem;
+ margin: 0 .2rem;
+ font-size: 90%;
+ white-space: nowrap;
+ background: #F1F1F1;
+ border: 1px solid #E1E1E1;
+ border-radius: 4px; }
+pre > code {
+ display: block;
+ padding: 1rem 1.5rem;
+ white-space: pre; }
+
+
+/* Tables
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+th,
+td {
+ padding: 12px 15px;
+ text-align: left;
+ border-bottom: 1px solid #E1E1E1; }
+th:first-child,
+td:first-child {
+ padding-left: 0; }
+th:last-child,
+td:last-child {
+ padding-right: 0; }
+
+
+/* Spacing
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+button,
+.button {
+ margin-bottom: 1rem; }
+input,
+textarea,
+select,
+fieldset {
+ margin-bottom: 1.5rem; }
+pre,
+blockquote,
+dl,
+figure,
+table,
+p,
+ul,
+ol,
+form {
+ margin-bottom: 2.5rem; }
+
+
+/* Utilities
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.u-full-width {
+ width: 100%;
+ box-sizing: border-box; }
+.u-max-full-width {
+ max-width: 100%;
+ box-sizing: border-box; }
+.u-pull-right {
+ float: right; }
+.u-pull-left {
+ float: left; }
+
+
+/* Misc
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+hr {
+ margin-top: 3rem;
+ margin-bottom: 3.5rem;
+ border-width: 0;
+ border-top: 1px solid #E1E1E1; }
+
+
+/* Clearing
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+
+/* Self Clearing Goodness */
+.container:after,
+.row:after,
+.u-cf {
+ content: "";
+ display: table;
+ clear: both; }
+
+
+/* Media Queries
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+/*
+Note: The best way to structure the use of media queries is to create the queries
+near the relevant code. For example, if you wanted to change the styles for buttons
+on small devices, paste the mobile query code up in the buttons section and style it
+there.
+*/
+
+
+/* Larger than mobile */
+@media (min-width: 400px) {}
+
+/* Larger than phablet (also point when grid becomes active) */
+@media (min-width: 550px) {}
+
+/* Larger than tablet */
+@media (min-width: 750px) {}
+
+/* Larger than desktop */
+@media (min-width: 1000px) {}
+
+/* Larger than Desktop HD */
+@media (min-width: 1200px) {}
diff --git a/uploads/IMG_20251111_200030.jpg b/uploads/IMG_20251111_200030.jpg
new file mode 100644
index 0000000..681d80d
--- /dev/null
+++ b/uploads/IMG_20251111_200030.jpg
Binary files differ
diff --git a/uploads/PXL_20250806_122816017.jpg b/uploads/PXL_20250806_122816017.jpg
new file mode 100644
index 0000000..4237b94
--- /dev/null
+++ b/uploads/PXL_20250806_122816017.jpg
Binary files differ
diff --git a/uploads/PXL_20250816_171403961.jpg b/uploads/PXL_20250816_171403961.jpg
new file mode 100644
index 0000000..d24a91b
--- /dev/null
+++ b/uploads/PXL_20250816_171403961.jpg
Binary files differ
diff --git a/uploads/PXL_20250816_171419431.jpg b/uploads/PXL_20250816_171419431.jpg
new file mode 100644
index 0000000..b4479c3
--- /dev/null
+++ b/uploads/PXL_20250816_171419431.jpg
Binary files differ
diff --git a/uploads/Snapchat-1994178113.mp4 b/uploads/Snapchat-1994178113.mp4
new file mode 100644
index 0000000..bf8ed23
--- /dev/null
+++ b/uploads/Snapchat-1994178113.mp4
Binary files differ
diff --git a/uploads/Snapchat-257701980.mp4 b/uploads/Snapchat-257701980.mp4
new file mode 100644
index 0000000..6be624b
--- /dev/null
+++ b/uploads/Snapchat-257701980.mp4
Binary files differ
diff --git a/uploads/Snapchat-518753368.mp4 b/uploads/Snapchat-518753368.mp4
new file mode 100644
index 0000000..0684e20
--- /dev/null
+++ b/uploads/Snapchat-518753368.mp4
Binary files differ
diff --git a/uploads/farout.jpg b/uploads/farout.jpg
new file mode 100644
index 0000000..8e257e6
--- /dev/null
+++ b/uploads/farout.jpg
Binary files differ
diff --git a/views.ts b/views.ts
new file mode 100644
index 0000000..ede9724
--- /dev/null
+++ b/views.ts
@@ -0,0 +1,354 @@
+import { config } from "./config.ts";
+import { formatFileSize, getMimeType, isViewableInBrowser } from "./fileUtils.ts";
+
+function renderTemplate(title: string, content: string, scripts = "") {
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>${title}</title>
+ <link rel="stylesheet" href="/css/skeleton.css">
+ <link rel="stylesheet" href="/css/home.css">
+</head>
+<body>
+ <header>
+ <h2>File Upload</h2>
+ <section class="status">
+ <p> </p>
+ </section>
+ </header>
+ <main>
+ <center>- - -</center>
+ ${content}
+ <center>- - -</center>
+ </main>
+ ${scripts}
+</body>
+</html>`;
+}
+
+export function renderUploadPage(chunked = false) {
+ const maxSizeMB = Math.floor(config.maxFileSize / (1024 * 1024));
+ const expirationDays = Math.floor(config.fileExpiration / (24 * 60 * 60 * 1000));
+
+ const content = `
+<section>
+ <h5>Shared Files</h5>
+ <p>Welcome to the file sharing service</p>
+ <center><a href="/browse">Browse Uploaded Files</a></center>
+</section>
+
+<section id="fileupload${chunked ? '-js' : '-nojs'}">
+ <div class="widget">
+ <h4>File Upload ${chunked ? '(Chunked)' : ''}</h4>
+ <h6>
+ ${chunked ? '<a href="/">Simple Upload</a>' : '<a href="/chunked">Chunked Upload</a>'}
+ | <a href="/browse">Browse Files</a>
+ </h6>
+ <form id="uploadForm" ${chunked ? '' : 'action="/upload" method="POST" enctype="multipart/form-data"'}>
+ <input type="file" id="fileInput" name="files" multiple ${chunked ? '' : 'required'}>
+ <div class="file-info" id="fileInfo" style="margin: 10px 0; color: #666;">
+ Max size: ${maxSizeMB}MB per file
+ </div>
+ <button type="submit" id="uploadBtn">Upload</button>
+ </form>
+
+ <div class="progress" id="progress" style="display: none; margin-top: 15px;">
+ <div style="width: 100%; height: 30px; background: #f0f0f0; border-radius: 4px; overflow: hidden;">
+ <div id="progressFill" style="height: 100%; background: #33C3F0; width: 0%; display: flex; align-items: center; justify-content: center; color: white; font-size: 12px; font-weight: 600; transition: width 0.3s;">0%</div>
+ </div>
+ </div>
+
+ <div class="result" id="result" style="display: none; margin-top: 15px; padding: 15px; border-radius: 4px;"></div>
+
+ <div style="background: #e7f3ff; padding: 15px; border-radius: 4px; margin-top: 15px; font-size: 14px; color: #004085;">
+ <strong>Info:</strong> Files expire after ${expirationDays} days.
+ Rate limit: ${config.rateLimit.maxUploads} uploads per ${config.rateLimit.windowMs / 60000} minutes.
+ </div>
+ </div>
+</section>
+
+<footer>
+ <p>Last updated December 28th, 2025</p>
+</footer>`;
+
+ const scripts = chunked ? getChunkedUploadScript() : getSimpleUploadScript();
+
+ return renderTemplate("File Upload", content, scripts);
+}
+
+function getSimpleUploadScript() {
+ return `<script>
+ const fileInput = document.getElementById('fileInput');
+ const fileInfo = document.getElementById('fileInfo');
+ const form = document.getElementById('uploadForm');
+ const result = document.getElementById('result');
+ const uploadBtn = document.getElementById('uploadBtn');
+
+ fileInput.addEventListener('change', (e) => {
+ const files = e.target.files;
+ if (files.length > 0) {
+ const names = Array.from(files).map(f => f.name).join(', ');
+ fileInfo.textContent = \`Selected: \${files.length} file(s) - \${names}\`;
+ }
+ });
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const files = fileInput.files;
+
+ if (files.length === 0) {
+ showResult('Please select at least one file', 'error');
+ return;
+ }
+
+ uploadBtn.disabled = true;
+ uploadBtn.textContent = 'Uploading...';
+
+ const formData = new FormData();
+ for (let i = 0; i < files.length; i++) {
+ formData.append('files', files[i]);
+ }
+
+ try {
+ const response = await fetch('/upload', {
+ method: 'POST',
+ body: formData
+ });
+
+ 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('');
+ showResult('Upload successful!<br>' + links, 'success');
+ form.reset();
+ fileInfo.textContent = 'Max size: ${Math.floor(config.maxFileSize / (1024 * 1024))}MB per file';
+ } else {
+ showResult('Error: ' + data.error, 'error');
+ }
+ } catch (error) {
+ showResult('Upload failed: ' + error.message, 'error');
+ } finally {
+ uploadBtn.disabled = false;
+ uploadBtn.textContent = 'Upload';
+ }
+ });
+
+ function showResult(message, type) {
+ result.innerHTML = message;
+ result.style.display = 'block';
+ if (type === 'success') {
+ result.style.background = '#d4edda';
+ result.style.color = '#155724';
+ result.style.border = '1px solid #c3e6cb';
+ } else {
+ result.style.background = '#f8d7da';
+ result.style.color = '#721c24';
+ result.style.border = '1px solid #f5c6cb';
+ }
+ }
+ </script>`;
+}
+
+function getChunkedUploadScript() {
+ return `<script>
+ const fileInput = document.getElementById('fileInput');
+ const fileInfo = document.getElementById('fileInfo');
+ const form = document.getElementById('uploadForm');
+ const result = document.getElementById('result');
+ const progress = document.getElementById('progress');
+ const progressFill = document.getElementById('progressFill');
+ const uploadBtn = document.getElementById('uploadBtn');
+ const CHUNK_SIZE = ${config.chunkSize};
+
+ fileInput.addEventListener('change', (e) => {
+ const files = e.target.files;
+ if (files.length > 0) {
+ const names = Array.from(files).map(f => f.name).join(', ');
+ const totalSize = Array.from(files).reduce((sum, f) => sum + f.size, 0);
+ fileInfo.textContent = \`Selected: \${files.length} file(s) - \${formatBytes(totalSize)}\`;
+ }
+ });
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const files = fileInput.files;
+
+ if (files.length === 0) {
+ showResult('Please select at least one file', 'error');
+ return;
+ }
+
+ uploadBtn.disabled = true;
+ uploadBtn.textContent = 'Uploading...';
+ progress.style.display = 'block';
+ result.style.display = 'none';
+
+ try {
+ const uploadedFiles = [];
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const filename = await uploadFileChunked(file, i, files.length);
+ 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('');
+ showResult('Upload successful!<br>' + links, 'success');
+ form.reset();
+ fileInfo.textContent = 'Max size: ${Math.floor(config.maxFileSize / (1024 * 1024))}MB per file';
+ } catch (error) {
+ showResult('Upload failed: ' + error.message, 'error');
+ } finally {
+ uploadBtn.disabled = false;
+ uploadBtn.textContent = 'Upload';
+ progress.style.display = 'none';
+ }
+ });
+
+ async function uploadFileChunked(file, fileIndex, totalFiles) {
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+
+ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
+ const start = chunkIndex * CHUNK_SIZE;
+ const end = Math.min(start + CHUNK_SIZE, file.size);
+ const chunk = file.slice(start, end);
+
+ const formData = new FormData();
+ formData.append('chunk', chunk);
+ formData.append('filename', file.name);
+ formData.append('chunkIndex', chunkIndex);
+ formData.append('totalChunks', totalChunks);
+
+ const response = await fetch('/upload-chunk', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Upload failed');
+ }
+
+ const overallProgress = ((fileIndex * totalChunks + chunkIndex + 1) / (totalFiles * totalChunks)) * 100;
+ updateProgress(overallProgress);
+ }
+
+ return file.name;
+ }
+
+ function updateProgress(percent) {
+ const rounded = Math.round(percent);
+ progressFill.style.width = rounded + '%';
+ progressFill.textContent = rounded + '%';
+ }
+
+ function showResult(message, type) {
+ result.innerHTML = message;
+ result.style.display = 'block';
+ if (type === 'success') {
+ result.style.background = '#d4edda';
+ result.style.color = '#155724';
+ result.style.border = '1px solid #c3e6cb';
+ } else {
+ result.style.background = '#f8d7da';
+ result.style.color = '#721c24';
+ result.style.border = '1px solid #f5c6cb';
+ }
+ }
+
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
+ }
+ </script>`;
+}
+
+export function renderBrowsePage(files: Array<{ name: string; size: number; uploaded: Date }>) {
+ 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>
+ `;
+ }).join('');
+
+ const content = `
+<section>
+ <h5>Browse Files</h5>
+ <h6>
+ <a href="/">Simple Upload</a> |
+ <a href="/chunked">Chunked Upload</a> |
+ <a href="/browse">Browse Files</a>
+ </h6>
+</section>
+
+<section>
+ <div class="widget">
+ ${files.length > 0 ? `
+ <table style="width: 100%; border-collapse: collapse;">
+ <thead>
+ <tr>
+ <th>Filename</th>
+ <th>Size</th>
+ <th>Uploaded</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${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>
+ `}
+ </div>
+</section>
+
+<footer>
+ <p>Last updated December 28th, 2025</p>
+</footer>`;
+
+ return renderTemplate("Browse Files", content);
+}
+
+export function renderError(error: string, status = 400) {
+ const content = `
+<section>
+ <div class="widget" style="text-align: center;">
+ <div style="font-size: 64px; margin-bottom: 20px;">⚠️</div>
+ <h3>Error ${status}</h3>
+ <p>${error}</p>
+ <p style="margin-top: 30px;">
+ <a href="/">← Back to Home</a>
+ </p>
+ </div>
+</section>
+
+<footer>
+ <p>Last updated December 28th, 2025</p>
+</footer>`;
+
+ return renderTemplate("Error", content);
+}
diff --git a/views/home.blade.php b/views/home.blade.php
new file mode 100644
index 0000000..b5e4a3d
--- /dev/null
+++ b/views/home.blade.php
@@ -0,0 +1,32 @@
+@extends('template')
+@section('scripts')
+<script type = "module" src = "/js/main.js"></script>
+@endsection
+@section('content')
+
+<section>
+ <h5>Shared Files</h5>
+ <p>Welcome</p>
+ <center> . . . </center>
+</section>
+<section id = "fileupload-js">
+</section>
+<section id = "fileupload-nojs">
+ <div class = "widget" >
+ <h4>File Upload</h4>
+ <h6><a href = "/f">Browse Uploaded Files</a></h6>
+ <form action="/f" method="POST" enctype="multipart/form-data">
+ @csrf
+ @error('f') {{ $message }} @enderror
+ <input multiple name="f[]" type="file">
+ <input hidden name = "response_format" value = "html" />
+ <button type = "submit">Upload</button>
+ </form>
+ </div>
+</section>
+
+<footer>
+ <p>Last updated December 28th, 2025</p>
+</footer>
+@endsection
+</html>
diff --git a/views/template.blade.php b/views/template.blade.php
new file mode 100644
index 0000000..f122d48
--- /dev/null
+++ b/views/template.blade.php
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>TG</title>
+ <link rel="stylesheet" href="css/home.css">
+ @yield('head')
+</head>
+<body>
+ <header>
+ <h2>.</h2>
+ <section class = "status">
+ <p> </p>
+ </section>
+ </header>
+ <main>
+ <center>- - -</center>
+ @yield('content')
+ <center>- - -</center>
+ <footer>
+ </footer>
+ </main>
+</body>
+<script src="https://unpkg.com/htmx.org@2.0.1"></script>
+<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
+<script src = "https://code.jquery.com/jquery-3.7.1.min.js"></script>
+@yield('scripts')
+</html>