diff options
| author | grothedev <grothedev@gmail.com> | 2025-12-28 15:41:41 -0500 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2025-12-28 15:41:41 -0500 |
| commit | 163be506b1e7102a7e96f79b7d919c3561095a38 (patch) | |
| tree | 5d689f37a7d129f83378107029c7beb777c01e5b | |
claude code deno fileupload project yeeee
| -rw-r--r-- | README.md | 91 | ||||
| -rw-r--r-- | config.ts | 36 | ||||
| -rw-r--r-- | deno.json | 9 | ||||
| -rw-r--r-- | deno.lock | 82 | ||||
| -rw-r--r-- | fileUtils.ts | 161 | ||||
| -rw-r--r-- | logger.ts | 27 | ||||
| -rw-r--r-- | main.ts | 277 | ||||
| -rw-r--r-- | rateLimiter.ts | 46 | ||||
| -rw-r--r-- | static/css/home.css | 153 | ||||
| -rwxr-xr-x | static/css/skeleton.css | 419 | ||||
| -rw-r--r-- | uploads/IMG_20251111_200030.jpg | bin | 0 -> 1132662 bytes | |||
| -rw-r--r-- | uploads/PXL_20250806_122816017.jpg | bin | 0 -> 3997008 bytes | |||
| -rw-r--r-- | uploads/PXL_20250816_171403961.jpg | bin | 0 -> 3262550 bytes | |||
| -rw-r--r-- | uploads/PXL_20250816_171419431.jpg | bin | 0 -> 3213745 bytes | |||
| -rw-r--r-- | uploads/Snapchat-1994178113.mp4 | bin | 0 -> 24211008 bytes | |||
| -rw-r--r-- | uploads/Snapchat-257701980.mp4 | bin | 0 -> 16855737 bytes | |||
| -rw-r--r-- | uploads/Snapchat-518753368.mp4 | bin | 0 -> 19823148 bytes | |||
| -rw-r--r-- | uploads/farout.jpg | bin | 0 -> 18380 bytes | |||
| -rw-r--r-- | views.ts | 354 | ||||
| -rw-r--r-- | views/home.blade.php | 32 | ||||
| -rw-r--r-- | views/template.blade.php | 29 |
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(); @@ -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 Binary files differnew file mode 100644 index 0000000..681d80d --- /dev/null +++ b/uploads/IMG_20251111_200030.jpg diff --git a/uploads/PXL_20250806_122816017.jpg b/uploads/PXL_20250806_122816017.jpg Binary files differnew file mode 100644 index 0000000..4237b94 --- /dev/null +++ b/uploads/PXL_20250806_122816017.jpg diff --git a/uploads/PXL_20250816_171403961.jpg b/uploads/PXL_20250816_171403961.jpg Binary files differnew file mode 100644 index 0000000..d24a91b --- /dev/null +++ b/uploads/PXL_20250816_171403961.jpg diff --git a/uploads/PXL_20250816_171419431.jpg b/uploads/PXL_20250816_171419431.jpg Binary files differnew file mode 100644 index 0000000..b4479c3 --- /dev/null +++ b/uploads/PXL_20250816_171419431.jpg diff --git a/uploads/Snapchat-1994178113.mp4 b/uploads/Snapchat-1994178113.mp4 Binary files differnew file mode 100644 index 0000000..bf8ed23 --- /dev/null +++ b/uploads/Snapchat-1994178113.mp4 diff --git a/uploads/Snapchat-257701980.mp4 b/uploads/Snapchat-257701980.mp4 Binary files differnew file mode 100644 index 0000000..6be624b --- /dev/null +++ b/uploads/Snapchat-257701980.mp4 diff --git a/uploads/Snapchat-518753368.mp4 b/uploads/Snapchat-518753368.mp4 Binary files differnew file mode 100644 index 0000000..0684e20 --- /dev/null +++ b/uploads/Snapchat-518753368.mp4 diff --git a/uploads/farout.jpg b/uploads/farout.jpg Binary files differnew file mode 100644 index 0000000..8e257e6 --- /dev/null +++ b/uploads/farout.jpg 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> |
