image cache on cloudflare r2

feat: add basics

+2
.dev.vars.example
··· 1 + INDIKO_CLIENT_ID=your_client_id 2 + INDIKO_CLIENT_SECRET=your_client_secret
+7
.gitignore
··· 1 + node_modules/ 2 + dist/ 3 + .wrangler/ 4 + .dev.vars 5 + .env 6 + *.log 7 + bun.lock
+71
CRUSH.md
··· 1 + # L4 Development Memory 2 + 3 + ## Commands 4 + 5 + ### Development 6 + ```bash 7 + bun run dev # Start local development server 8 + bun run deploy # Deploy to Cloudflare 9 + bun run types # Generate TypeScript types 10 + ``` 11 + 12 + ### Wrangler 13 + ```bash 14 + wrangler r2 bucket create l4-images # Create R2 bucket 15 + wrangler kv:namespace create L4 # Create KV namespace 16 + wrangler secret put INDIKO_CLIENT_ID # Set secret 17 + wrangler secret put INDIKO_CLIENT_SECRET # Set secret 18 + ``` 19 + 20 + ### CLI 21 + ```bash 22 + cd cli && bun install && bun run build # Build CLI 23 + l4 config --api-key <key> --url <url> # Configure CLI 24 + l4 upload <file> # Upload image 25 + l4 list # List images 26 + ``` 27 + 28 + ## Project Structure 29 + 30 + - `/src/index.ts` - Main Worker entry point with auth, image serving, API key management 31 + - `/src/index.html` - Frontend application with upload UI, image grid, API key management 32 + - `/src/login.html` - Login page with Indiko OAuth 33 + - `/cli/` - CLI tool for uploading/managing images 34 + - `/wrangler.toml` - Cloudflare Workers configuration 35 + 36 + ## Key Features 37 + 38 + - **Indiko OAuth**: Session-based auth with admin/viewer roles 39 + - **API Keys**: Generate keys for CLI/programmatic access 40 + - **Image Transformations**: On-demand resize, format conversion (AVIF/WebP), quality adjustment 41 + - **R2 Storage**: Zero egress costs, S3-compatible 42 + - **Edge Caching**: Two-tier cache (Cloudflare edge + browser) 43 + - **Role-based Access**: Viewers can view, admins can upload/delete 44 + 45 + ## Image URL Pattern 46 + 47 + ``` 48 + /i/:key?w=800&h=600&f=webp&q=85&fit=scale-down 49 + ``` 50 + 51 + Parameters: 52 + - `w` - width 53 + - `h` - height 54 + - `f` - format (auto, webp, avif, jpeg) 55 + - `q` - quality (1-100) 56 + - `fit` - fit mode (scale-down, contain, cover, crop, pad) 57 + 58 + ## Environment Variables 59 + 60 + Production (secrets): 61 + - `INDIKO_CLIENT_ID` - Indiko OAuth client ID 62 + - `INDIKO_CLIENT_SECRET` - Indiko OAuth client secret 63 + 64 + Config (wrangler.toml): 65 + - `HOST` - Public URL of the service 66 + - `INDIKO_URL` - Indiko instance URL 67 + 68 + ## Bindings 69 + 70 + - `L4` - KV namespace for sessions, API keys, image metadata 71 + - `IMAGES` - R2 bucket for image storage
+10
LICENSE.md
··· 1 + # The O'Saasy License 2 + 3 + Copyright © `2025` `Kieran Klukas` 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 + 7 + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 + No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. 9 + 10 + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+168
README.md
··· 1 + # The L4 cache 2 + 3 + This is my own image cdn built on cloudflare r2 mainly so I can have fast optimized images on my blog. 4 + 5 + ## Docs 6 + 7 + ```bash 8 + bun install 9 + wrangler r2 bucket create l4-images 10 + wrangler kv namespace create L4 11 + ``` 12 + 13 + Update `wrangler.toml` with the KV namespace ID and set `HOST` to your domain as well as `INDIKO_URL` to your Indiko instance 14 + 15 + ### Production 16 + 17 + ```bash 18 + wrangler secret put INDIKO_CLIENT_ID 19 + wrangler secret put INDIKO_CLIENT_SECRET 20 + ``` 21 + 22 + ```bash 23 + bun run deploy 24 + ``` 25 + 26 + ## Development 27 + 28 + ```bash 29 + bun run dev 30 + ``` 31 + 32 + Create `.dev.vars` for local development: 33 + 34 + ```env 35 + INDIKO_CLIENT_ID=your_client_id 36 + INDIKO_CLIENT_SECRET=your_client_secret 37 + ``` 38 + 39 + ## CLI Usage 40 + 41 + ### Install CLI 42 + 43 + ```bash 44 + cd cli 45 + bun install 46 + bun run build 47 + npm link 48 + ``` 49 + 50 + ### Configure 51 + 52 + ```bash 53 + l4 config --api-key <your-api-key> --url https://l4.yourdomain.com 54 + ``` 55 + 56 + ### Upload Image 57 + 58 + ```bash 59 + l4 upload image.jpg 60 + l4 upload image.jpg --key custom-name.jpg 61 + ``` 62 + 63 + ### List Images 64 + 65 + ```bash 66 + l4 list 67 + l4 list --limit 50 68 + ``` 69 + 70 + ### Delete Image 71 + 72 + ```bash 73 + l4 delete image-key.jpg 74 + ``` 75 + 76 + ### Get Image URL 77 + 78 + ```bash 79 + l4 url image.jpg 80 + l4 url image.jpg --width 800 --format webp --quality 85 81 + ``` 82 + 83 + ## Image Transformations 84 + 85 + Images are served via `/i/:key` with optional query parameters: 86 + 87 + - `w` - Width (pixels) 88 + - `h` - Height (pixels) 89 + - `f` - Format (`auto`, `webp`, `avif`, `jpeg`) 90 + - `q` - Quality (1-100, default 85) 91 + - `fit` - Fit mode (`scale-down`, `contain`, `cover`, `crop`, `pad`) 92 + 93 + ### Examples 94 + 95 + ``` 96 + /i/photo.jpg?w=800&f=webp 97 + /i/photo.jpg?w=400&h=400&fit=cover&q=90 98 + /i/photo.jpg?f=auto 99 + ``` 100 + 101 + ## API Endpoints 102 + 103 + ### Authentication 104 + 105 + - `GET /login` - Login page 106 + - `GET /api/login` - Initiate OAuth 107 + - `GET /api/callback` - OAuth callback 108 + - `POST /api/logout` - Logout 109 + - `GET /api/me` - Get current user 110 + 111 + ### Images 112 + 113 + - `POST /api/upload` - Upload image (multipart/form-data) 114 + - `GET /api/images` - List images 115 + - `DELETE /api/images/:key` - Delete image 116 + - `GET /i/:key` - Serve image (public) 117 + 118 + ### API Keys 119 + 120 + - `GET /api/keys` - List API keys 121 + - `POST /api/keys` - Create API key 122 + - `DELETE /api/keys/:id` - Delete API key 123 + 124 + ## Architecture 125 + 126 + ``` 127 + ┌─────────────┐ 128 + │ Browser │ 129 + └──────┬──────┘ 130 + 131 + ├─── HTML/JS (Indiko OAuth) 132 + 133 + ├─── Upload/Manage Images 134 + 135 + v 136 + ┌─────────────────┐ 137 + │ Cloudflare │ 138 + │ Workers │ 139 + │ │ 140 + │ - Auth │ 141 + │ - Transform │ 142 + │ - Cache │ 143 + └────────┬────────┘ 144 + 145 + ├─── Session/Metadata 146 + v 147 + ┌────────┐ 148 + │ KV │ 149 + └────────┘ 150 + 151 + ├─── Original Images 152 + v 153 + ┌────────┐ 154 + │ R2 │ 155 + └────────┘ 156 + ``` 157 + 158 + <p align="center"> 159 + <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/main/.github/images/line-break.svg" /> 160 + </p> 161 + 162 + <p align="center"> 163 + <i><code>&copy 2025-present <a href="https://dunkirk.sh">Kieran Klukas</a></code></i> 164 + </p> 165 + 166 + <p align="center"> 167 + <a href="https://tangled.org/dunkirk.sh/l4/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=O'Saasy&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a> 168 + </p>
+20
cli/package.json
··· 1 + { 2 + "name": "@l4/cli", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "bin": { 6 + "l4": "./dist/index.js" 7 + }, 8 + "scripts": { 9 + "build": "bun build ./src/index.ts --outdir ./dist --target node --format esm", 10 + "dev": "bun run src/index.ts" 11 + }, 12 + "devDependencies": { 13 + "@types/bun": "latest" 14 + }, 15 + "dependencies": { 16 + "commander": "^12.1.0", 17 + "chalk": "^5.4.1", 18 + "ora": "^8.1.1" 19 + } 20 + }
+229
cli/src/index.ts
··· 1 + #!/usr/bin/env node 2 + import { Command } from "commander"; 3 + import chalk from "chalk"; 4 + import ora from "ora"; 5 + import { readFile, writeFile } from "fs/promises"; 6 + import { existsSync } from "fs"; 7 + import { homedir } from "os"; 8 + import { join } from "path"; 9 + 10 + const CONFIG_DIR = join(homedir(), ".config", "l4"); 11 + const CONFIG_FILE = join(CONFIG_DIR, "config.json"); 12 + 13 + interface Config { 14 + apiKey?: string; 15 + baseUrl?: string; 16 + } 17 + 18 + async function loadConfig(): Promise<Config> { 19 + if (!existsSync(CONFIG_FILE)) { 20 + return {}; 21 + } 22 + const data = await readFile(CONFIG_FILE, "utf-8"); 23 + return JSON.parse(data); 24 + } 25 + 26 + async function saveConfig(config: Config): Promise<void> { 27 + const { mkdir } = await import("fs/promises"); 28 + await mkdir(CONFIG_DIR, { recursive: true }); 29 + await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); 30 + } 31 + 32 + async function getConfig(): Promise<Config> { 33 + const config = await loadConfig(); 34 + if (!config.apiKey || !config.baseUrl) { 35 + console.error( 36 + chalk.red("Error: Not configured. Run 'l4 config' first."), 37 + ); 38 + process.exit(1); 39 + } 40 + return config; 41 + } 42 + 43 + const program = new Command(); 44 + 45 + program 46 + .name("l4") 47 + .description("CLI for L4 image cache") 48 + .version("1.0.0"); 49 + 50 + program 51 + .command("config") 52 + .description("Configure L4 CLI") 53 + .option("-k, --api-key <key>", "API key") 54 + .option("-u, --url <url>", "Base URL") 55 + .action(async (options) => { 56 + const config = await loadConfig(); 57 + 58 + if (options.apiKey) { 59 + config.apiKey = options.apiKey; 60 + } 61 + 62 + if (options.url) { 63 + config.baseUrl = options.url; 64 + } 65 + 66 + if (!options.apiKey && !options.url) { 67 + console.log(chalk.blue("Current configuration:")); 68 + console.log( 69 + `API Key: ${config.apiKey ? chalk.green("Set") : chalk.red("Not set")}`, 70 + ); 71 + console.log( 72 + `Base URL: ${config.baseUrl ? chalk.green(config.baseUrl) : chalk.red("Not set")}`, 73 + ); 74 + console.log( 75 + `\nRun ${chalk.cyan("l4 config --api-key <key> --url <url>")} to configure.`, 76 + ); 77 + return; 78 + } 79 + 80 + await saveConfig(config); 81 + console.log(chalk.green("✓ Configuration saved")); 82 + }); 83 + 84 + program 85 + .command("upload <file>") 86 + .description("Upload an image") 87 + .option("-k, --key <key>", "Custom key for the image") 88 + .action(async (filePath: string, options) => { 89 + const config = await getConfig(); 90 + const spinner = ora("Uploading image...").start(); 91 + 92 + try { 93 + const fileData = await readFile(filePath); 94 + const fileName = filePath.split("/").pop() || "image"; 95 + 96 + const formData = new FormData(); 97 + const blob = new Blob([fileData]); 98 + formData.append("file", blob, fileName); 99 + 100 + if (options.key) { 101 + formData.append("key", options.key); 102 + } 103 + 104 + const response = await fetch(`${config.baseUrl}/api/upload`, { 105 + method: "POST", 106 + headers: { 107 + Authorization: `Bearer ${config.apiKey}`, 108 + }, 109 + body: formData, 110 + }); 111 + 112 + if (!response.ok) { 113 + const error = await response.json(); 114 + throw new Error(error.error || "Upload failed"); 115 + } 116 + 117 + const result = await response.json(); 118 + spinner.succeed("Image uploaded successfully"); 119 + 120 + console.log(chalk.blue("\nImage URL:")); 121 + console.log(chalk.cyan(result.url)); 122 + console.log(chalk.gray(`Key: ${result.key}`)); 123 + } catch (error: any) { 124 + spinner.fail("Upload failed"); 125 + console.error(chalk.red(error.message)); 126 + process.exit(1); 127 + } 128 + }); 129 + 130 + program 131 + .command("list") 132 + .description("List all images") 133 + .option("-l, --limit <number>", "Number of images to list", "100") 134 + .action(async (options) => { 135 + const config = await getConfig(); 136 + const spinner = ora("Fetching images...").start(); 137 + 138 + try { 139 + const response = await fetch( 140 + `${config.baseUrl}/api/images?limit=${options.limit}`, 141 + { 142 + headers: { 143 + Authorization: `Bearer ${config.apiKey}`, 144 + }, 145 + }, 146 + ); 147 + 148 + if (!response.ok) { 149 + throw new Error("Failed to fetch images"); 150 + } 151 + 152 + const data = await response.json(); 153 + spinner.succeed(`Found ${data.images.length} images`); 154 + 155 + if (data.images.length === 0) { 156 + console.log(chalk.gray("No images found")); 157 + return; 158 + } 159 + 160 + console.log(); 161 + for (const image of data.images) { 162 + console.log(chalk.cyan(image.key)); 163 + console.log(chalk.gray(` ${image.originalName}`)); 164 + console.log( 165 + chalk.gray( 166 + ` ${(image.size / 1024).toFixed(2)} KB • ${new Date(image.uploadedAt).toLocaleDateString()}`, 167 + ), 168 + ); 169 + console.log(chalk.blue(` ${config.baseUrl}/i/${image.key}`)); 170 + console.log(); 171 + } 172 + 173 + if (data.hasMore) { 174 + console.log(chalk.yellow("More images available (use --limit to see more)")); 175 + } 176 + } catch (error: any) { 177 + spinner.fail("Failed to fetch images"); 178 + console.error(chalk.red(error.message)); 179 + process.exit(1); 180 + } 181 + }); 182 + 183 + program 184 + .command("delete <key>") 185 + .description("Delete an image") 186 + .action(async (key: string) => { 187 + const config = await getConfig(); 188 + const spinner = ora("Deleting image...").start(); 189 + 190 + try { 191 + const response = await fetch(`${config.baseUrl}/api/images/${key}`, { 192 + method: "DELETE", 193 + headers: { 194 + Authorization: `Bearer ${config.apiKey}`, 195 + }, 196 + }); 197 + 198 + if (!response.ok) { 199 + throw new Error("Failed to delete image"); 200 + } 201 + 202 + spinner.succeed("Image deleted successfully"); 203 + } catch (error: any) { 204 + spinner.fail("Delete failed"); 205 + console.error(chalk.red(error.message)); 206 + process.exit(1); 207 + } 208 + }); 209 + 210 + program 211 + .command("url <key>") 212 + .description("Get URL for an image") 213 + .option("-w, --width <width>", "Image width") 214 + .option("-h, --height <height>", "Image height") 215 + .option("-f, --format <format>", "Image format (auto, webp, avif, jpeg)") 216 + .option("-q, --quality <quality>", "Image quality (1-100)") 217 + .action(async (key: string, options) => { 218 + const config = await getConfig(); 219 + const url = new URL(`/i/${key}`, config.baseUrl); 220 + 221 + if (options.width) url.searchParams.set("w", options.width); 222 + if (options.height) url.searchParams.set("h", options.height); 223 + if (options.format) url.searchParams.set("f", options.format); 224 + if (options.quality) url.searchParams.set("q", options.quality); 225 + 226 + console.log(chalk.cyan(url.toString())); 227 + }); 228 + 229 + program.parse();
+4
l4 cache.svg
··· 1 + <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <rect width="512" height="512" fill="#694873"/> 3 + <path d="M244.878 329.25H254.328V387H95.0783V377.9L114.328 375.8C118.062 375.333 120.278 374.4 120.978 373C121.678 371.6 122.028 366.817 122.028 358.65V177.35C122.028 169.183 121.678 164.4 120.978 163C120.278 161.6 118.062 160.667 114.328 160.2L95.0783 158.45V149H179.428V158.45L160.178 160.2C156.445 160.667 154.228 161.6 153.528 163C152.828 164.4 152.478 169.183 152.478 177.35V375.8H215.828C221.428 375.8 225.395 375.683 227.728 375.45C230.062 374.983 231.578 374.167 232.278 373C233.212 371.6 234.028 369.5 234.728 366.7L244.878 329.25ZM365.155 358.65V325.4H264.005V314.2C275.905 301.6 286.172 289.35 294.805 277.45C303.439 265.317 310.905 252.833 317.205 240C323.505 227.167 328.989 213.4 333.655 198.7C338.555 183.767 342.989 167.2 346.955 149H384.055C374.722 181.9 361.072 211.417 343.105 237.55C325.139 263.683 303.672 289.233 278.705 314.2H365.155V259.25L395.605 254.35V314.2H422.555V325.4H395.605V358.65C395.605 366.817 395.955 371.6 396.655 373C397.355 374.4 399.572 375.333 403.305 375.8L422.555 377.9V387H331.205V377.9L357.455 375.8C361.189 375.567 363.405 374.75 364.105 373.35C364.805 371.717 365.155 366.817 365.155 358.65Z" fill="#85B79D"/> 4 + </svg>
+22
package.json
··· 1 + { 2 + "name": "l4", 3 + "module": "index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "wrangler dev", 8 + "deploy": "wrangler deploy", 9 + "types": "wrangler types" 10 + }, 11 + "devDependencies": { 12 + "@cloudflare/workers-types": "^4.20251211.0", 13 + "@types/bun": "latest", 14 + "wrangler": "^4.54.0" 15 + }, 16 + "peerDependencies": { 17 + "typescript": "^5" 18 + }, 19 + "dependencies": { 20 + "nanoid": "^5.1.6" 21 + } 22 + }
+818
src/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>l4 - image cache</title> 7 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>"> 8 + <style> 9 + :root { 10 + --evergreen: #16302b; 11 + --vintage-grape: #694873; 12 + --dusty-mauve: #8b728e; 13 + --muted-teal: #85b79d; 14 + --tea-green: #c0e5c8; 15 + --evergreen-dark: #0d1f1b; 16 + --input-bg: #1a3530; 17 + --border-color: #2a4540; 18 + --hover-bg: #243d38; 19 + } 20 + 21 + * { 22 + margin: 0; 23 + padding: 0; 24 + box-sizing: border-box; 25 + } 26 + 27 + html { 28 + background: var(--evergreen); 29 + } 30 + 31 + body { 32 + font-family: "Courier New", monospace; 33 + background: var(--evergreen); 34 + color: var(--tea-green); 35 + min-height: 100vh; 36 + padding: 2.5rem 1.25rem; 37 + display: flex; 38 + flex-direction: column; 39 + } 40 + 41 + main { 42 + max-width: 56.25rem; 43 + margin: 0 auto; 44 + flex: 1; 45 + width: 100%; 46 + } 47 + 48 + header { 49 + margin-bottom: 1.5rem; 50 + display: flex; 51 + justify-content: space-between; 52 + align-items: center; 53 + } 54 + 55 + h1 { 56 + font-size: 2.5rem; 57 + font-weight: 700; 58 + background: linear-gradient(135deg, var(--muted-teal), var(--tea-green)); 59 + -webkit-background-clip: text; 60 + -webkit-text-fill-color: transparent; 61 + background-clip: text; 62 + letter-spacing: -0.0625rem; 63 + } 64 + 65 + .user-info { 66 + display: flex; 67 + gap: 0.75rem; 68 + align-items: center; 69 + color: var(--muted-teal); 70 + font-size: 0.875rem; 71 + } 72 + 73 + .shortcut { 74 + color: var(--muted-teal); 75 + font-size: 0.6875rem; 76 + margin-bottom: 1rem; 77 + } 78 + 79 + .shortcut kbd { 80 + background: var(--input-bg); 81 + padding: 0.125rem 0.375rem; 82 + border: 0.0625rem solid var(--border-color); 83 + font-size: 0.6875rem; 84 + font-family: monospace; 85 + } 86 + 87 + .tabs { 88 + display: flex; 89 + gap: 0.5rem; 90 + margin-bottom: 1.5rem; 91 + border-bottom: 0.0625rem solid var(--border-color); 92 + } 93 + 94 + .tab { 95 + padding: 0.5rem 1rem; 96 + background: transparent; 97 + border: none; 98 + color: var(--muted-teal); 99 + cursor: pointer; 100 + font-family: "Courier New", monospace; 101 + font-size: 0.875rem; 102 + border-bottom: 0.125rem solid transparent; 103 + margin-bottom: -0.0625rem; 104 + transition: all 0.15s; 105 + } 106 + 107 + .tab:hover { 108 + color: var(--tea-green); 109 + } 110 + 111 + .tab:focus { 112 + outline: 0.0625rem solid var(--tea-green); 113 + outline-offset: 0; 114 + } 115 + 116 + .tab.active { 117 + color: var(--tea-green); 118 + border-bottom-color: var(--tea-green); 119 + } 120 + 121 + .tab-content { 122 + display: none; 123 + } 124 + 125 + .tab-content.active { 126 + display: block; 127 + } 128 + 129 + .upload-section { 130 + background: var(--input-bg); 131 + border: 0.125rem dashed var(--border-color); 132 + padding: 2rem; 133 + margin-bottom: 1.5rem; 134 + text-align: center; 135 + transition: border-color 0.15s; 136 + } 137 + 138 + .upload-section:hover, 139 + .upload-section.dragover { 140 + border-color: var(--muted-teal); 141 + } 142 + 143 + .upload-section h2 { 144 + font-size: 1.125rem; 145 + margin-bottom: 0.5rem; 146 + font-weight: 700; 147 + } 148 + 149 + .upload-section p { 150 + color: var(--muted-teal); 151 + font-size: 0.8125rem; 152 + margin-bottom: 1rem; 153 + } 154 + 155 + input[type="file"] { 156 + display: none; 157 + } 158 + 159 + .file-label { 160 + display: inline-block; 161 + padding: 0.75rem 1.5rem; 162 + background: var(--vintage-grape); 163 + color: var(--tea-green); 164 + cursor: pointer; 165 + font-family: "Courier New", monospace; 166 + font-size: 0.875rem; 167 + font-weight: 700; 168 + transition: background 0.15s; 169 + } 170 + 171 + .file-label:hover { 172 + background: var(--dusty-mauve); 173 + } 174 + 175 + button { 176 + padding: 0.5rem 1rem; 177 + background: var(--vintage-grape); 178 + color: var(--tea-green); 179 + border: none; 180 + font-size: 0.875rem; 181 + font-weight: 700; 182 + cursor: pointer; 183 + font-family: "Courier New", monospace; 184 + transition: background 0.15s; 185 + } 186 + 187 + button:hover { 188 + background: var(--dusty-mauve); 189 + } 190 + 191 + button:focus { 192 + outline: 0.0625rem solid var(--tea-green); 193 + outline-offset: 0; 194 + } 195 + 196 + button.danger { 197 + background: #8b2828; 198 + color: #ffb3b3; 199 + } 200 + 201 + button.danger:hover { 202 + background: #a03030; 203 + } 204 + 205 + .status-bar { 206 + background: var(--vintage-grape); 207 + color: var(--tea-green); 208 + padding: 0.75rem; 209 + margin-bottom: 1rem; 210 + font-size: 0.875rem; 211 + display: none; 212 + } 213 + 214 + .status-bar.show { 215 + display: block; 216 + } 217 + 218 + .images-grid { 219 + display: grid; 220 + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 221 + gap: 1rem; 222 + } 223 + 224 + .image-card { 225 + background: var(--input-bg); 226 + border: 0.0625rem solid var(--border-color); 227 + overflow: hidden; 228 + transition: border-color 0.15s; 229 + } 230 + 231 + .image-card:hover { 232 + border-color: var(--muted-teal); 233 + } 234 + 235 + .image-card:focus-within { 236 + border-color: var(--tea-green); 237 + } 238 + 239 + .image-preview { 240 + width: 100%; 241 + height: 180px; 242 + object-fit: cover; 243 + background: var(--evergreen-dark); 244 + display: block; 245 + } 246 + 247 + .image-info { 248 + padding: 0.75rem; 249 + } 250 + 251 + .image-url { 252 + font-size: 0.75rem; 253 + color: var(--muted-teal); 254 + word-break: break-all; 255 + margin-bottom: 0.5rem; 256 + } 257 + 258 + .image-actions { 259 + display: flex; 260 + gap: 0.5rem; 261 + } 262 + 263 + .image-actions button { 264 + flex: 1; 265 + padding: 0.5rem; 266 + font-size: 0.75rem; 267 + } 268 + 269 + .api-keys-list { 270 + background: var(--input-bg); 271 + border: 0.0625rem solid var(--border-color); 272 + } 273 + 274 + .api-key-item { 275 + padding: 1rem; 276 + border-bottom: 0.0625rem solid var(--border-color); 277 + display: flex; 278 + justify-content: space-between; 279 + align-items: center; 280 + transition: background 0.15s; 281 + } 282 + 283 + .api-key-item:hover { 284 + background: var(--hover-bg); 285 + } 286 + 287 + .api-key-item:last-child { 288 + border-bottom: none; 289 + } 290 + 291 + .api-key-item:focus-within { 292 + border-left: 0.125rem solid var(--tea-green); 293 + padding-left: calc(1rem - 0.0625rem); 294 + } 295 + 296 + .api-key-info h3 { 297 + font-size: 0.9375rem; 298 + margin-bottom: 0.25rem; 299 + font-weight: 700; 300 + } 301 + 302 + .api-key-meta { 303 + font-size: 0.75rem; 304 + color: var(--muted-teal); 305 + } 306 + 307 + .modal { 308 + display: none; 309 + position: fixed; 310 + top: 0; 311 + left: 0; 312 + right: 0; 313 + bottom: 0; 314 + background: rgba(13, 31, 27, 0.9); 315 + align-items: center; 316 + justify-content: center; 317 + z-index: 1000; 318 + } 319 + 320 + .modal.active { 321 + display: flex; 322 + } 323 + 324 + .modal-content { 325 + background: var(--evergreen); 326 + border: 0.0625rem solid var(--border-color); 327 + padding: 2rem; 328 + max-width: 500px; 329 + width: 90%; 330 + } 331 + 332 + .modal-content h2 { 333 + margin-bottom: 1.5rem; 334 + font-size: 1.5rem; 335 + font-weight: 700; 336 + } 337 + 338 + .form-group { 339 + margin-bottom: 1rem; 340 + } 341 + 342 + .form-group label { 343 + display: block; 344 + margin-bottom: 0.5rem; 345 + color: var(--muted-teal); 346 + font-size: 0.875rem; 347 + } 348 + 349 + .form-group input { 350 + width: 100%; 351 + padding: 0.75rem; 352 + background: var(--input-bg); 353 + border: 0.0625rem solid var(--border-color); 354 + color: var(--tea-green); 355 + font-size: 0.875rem; 356 + font-family: "Courier New", monospace; 357 + transition: border-color 0.15s; 358 + } 359 + 360 + .form-group input:focus { 361 + outline: none; 362 + border-color: var(--tea-green); 363 + } 364 + 365 + .form-actions { 366 + display: flex; 367 + gap: 0.5rem; 368 + margin-top: 1.5rem; 369 + } 370 + 371 + .form-actions button { 372 + flex: 1; 373 + } 374 + 375 + .loading { 376 + text-align: center; 377 + padding: 2rem; 378 + color: var(--muted-teal); 379 + font-size: 0.875rem; 380 + } 381 + 382 + footer { 383 + margin-top: 2rem; 384 + padding-top: 1rem; 385 + border-top: 0.0625rem solid var(--border-color); 386 + text-align: center; 387 + color: var(--muted-teal); 388 + font-size: 0.75rem; 389 + } 390 + </style> 391 + </head> 392 + <body> 393 + <main> 394 + <header> 395 + <div> 396 + <h1>l4</h1> 397 + </div> 398 + <div class="user-info"> 399 + <span id="userName"></span> 400 + <button onclick="logout()">logout</button> 401 + </div> 402 + </header> 403 + 404 + <div class="shortcut"> 405 + keyboard shortcuts: <kbd>1</kbd> images <kbd>2</kbd> api keys <kbd>u</kbd> upload <kbd>esc</kbd> close modal 406 + </div> 407 + 408 + <div class="status-bar" id="statusBar"></div> 409 + 410 + <div class="tabs"> 411 + <button class="tab active" onclick="switchTab('images')" data-key="1">images</button> 412 + <button class="tab" onclick="switchTab('api-keys')" data-key="2">api keys</button> 413 + </div> 414 + 415 + <div id="images-tab" class="tab-content active"> 416 + <div class="upload-section" id="uploadSection"> 417 + <h2>upload image</h2> 418 + <p>drag and drop or click to upload</p> 419 + <input type="file" id="fileInput" accept="image/*"> 420 + <label for="fileInput" class="file-label">choose file</label> 421 + </div> 422 + 423 + <div class="images-grid" id="imagesGrid"></div> 424 + </div> 425 + 426 + <div id="api-keys-tab" class="tab-content"> 427 + <div style="margin-bottom: 1rem;"> 428 + <button onclick="showCreateKeyModal()">create api key</button> 429 + </div> 430 + <div class="api-keys-list" id="apiKeysList"></div> 431 + </div> 432 + </main> 433 + 434 + <footer> 435 + powered by cloudflare r2 • zero egress costs 436 + </footer> 437 + 438 + <div class="modal" id="createKeyModal"> 439 + <div class="modal-content"> 440 + <h2>create api key</h2> 441 + <div class="form-group"> 442 + <label>key name</label> 443 + <input type="text" id="keyName" placeholder="my cli key" autofocus> 444 + </div> 445 + <div class="form-group"> 446 + <label>expires in (days)</label> 447 + <input type="number" id="keyExpiry" placeholder="30"> 448 + </div> 449 + <div class="form-actions"> 450 + <button onclick="closeModal()">cancel</button> 451 + <button onclick="createApiKey()">create</button> 452 + </div> 453 + </div> 454 + </div> 455 + 456 + <div class="modal" id="keyCreatedModal"> 457 + <div class="modal-content"> 458 + <h2>api key created</h2> 459 + <p style="margin-bottom: 1rem; color: var(--muted-teal);">save this key - you won't see it again</p> 460 + <div class="form-group"> 461 + <input type="text" id="newApiKey" readonly> 462 + </div> 463 + <div class="form-actions"> 464 + <button onclick="copyApiKey()">copy</button> 465 + <button onclick="closeKeyCreatedModal()">done</button> 466 + </div> 467 + </div> 468 + </div> 469 + 470 + <script> 471 + let token = null; 472 + let userRole = null; 473 + 474 + // Keyboard shortcuts 475 + document.addEventListener('keydown', (e) => { 476 + // Don't trigger if typing in input 477 + if (e.target.tagName === 'INPUT') return; 478 + 479 + // Tab switching 480 + if (e.key === '1') { 481 + e.preventDefault(); 482 + switchTab('images'); 483 + document.querySelector('.tab[data-key="1"]').focus(); 484 + } 485 + if (e.key === '2' && userRole === 'admin') { 486 + e.preventDefault(); 487 + switchTab('api-keys'); 488 + document.querySelector('.tab[data-key="2"]').focus(); 489 + } 490 + 491 + // Upload shortcut (admin only) 492 + if ((e.key === 'u' || e.key === 'U') && userRole === 'admin') { 493 + e.preventDefault(); 494 + document.getElementById('fileInput').click(); 495 + } 496 + 497 + // Close modal 498 + if (e.key === 'Escape') { 499 + closeModal(); 500 + closeKeyCreatedModal(); 501 + } 502 + 503 + // Create key (admin only) 504 + if (e.key === 'c' && userRole === 'admin' && document.getElementById('api-keys-tab').classList.contains('active')) { 505 + e.preventDefault(); 506 + showCreateKeyModal(); 507 + } 508 + }); 509 + 510 + function showStatus(message, duration = 3000) { 511 + const bar = document.getElementById('statusBar'); 512 + bar.textContent = message; 513 + bar.classList.add('show'); 514 + setTimeout(() => bar.classList.remove('show'), duration); 515 + } 516 + 517 + async function init() { 518 + const params = new URLSearchParams(window.location.search); 519 + token = params.get('token') || localStorage.getItem('l4_token'); 520 + 521 + if (!token) { 522 + window.location.href = '/login'; 523 + return; 524 + } 525 + 526 + localStorage.setItem('l4_token', token); 527 + window.history.replaceState({}, '', '/'); 528 + 529 + try { 530 + const response = await fetch('/api/me', { 531 + headers: { 'Authorization': `Bearer ${token}` } 532 + }); 533 + 534 + if (!response.ok) { 535 + throw new Error('Unauthorized'); 536 + } 537 + 538 + const user = await response.json(); 539 + userRole = user.role; 540 + document.getElementById('userName').textContent = user.profile?.name || 'user'; 541 + 542 + // Hide admin-only elements for viewers 543 + if (userRole === 'viewer') { 544 + document.getElementById('uploadSection').style.display = 'none'; 545 + document.querySelector('.tab[data-key="2"]').style.display = 'none'; 546 + document.querySelector('.shortcut').style.display = 'none'; 547 + } 548 + 549 + await loadImages(); 550 + if (userRole === 'admin') { 551 + await loadApiKeys(); 552 + } 553 + } catch (error) { 554 + localStorage.removeItem('l4_token'); 555 + window.location.href = '/login'; 556 + } 557 + } 558 + 559 + function switchTab(tab) { 560 + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); 561 + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); 562 + 563 + const activeTab = document.querySelector(`.tab[onclick*="${tab}"]`); 564 + if (activeTab) activeTab.classList.add('active'); 565 + 566 + const content = document.getElementById(`${tab}-tab`); 567 + if (content) content.classList.add('active'); 568 + 569 + if (tab === 'api-keys') { 570 + loadApiKeys(); 571 + } 572 + } 573 + 574 + const uploadSection = document.getElementById('uploadSection'); 575 + const fileInput = document.getElementById('fileInput'); 576 + 577 + uploadSection.addEventListener('dragover', (e) => { 578 + e.preventDefault(); 579 + uploadSection.classList.add('dragover'); 580 + }); 581 + 582 + uploadSection.addEventListener('dragleave', () => { 583 + uploadSection.classList.remove('dragover'); 584 + }); 585 + 586 + uploadSection.addEventListener('drop', async (e) => { 587 + e.preventDefault(); 588 + uploadSection.classList.remove('dragover'); 589 + 590 + const files = e.dataTransfer.files; 591 + if (files.length > 0) { 592 + await uploadFile(files[0]); 593 + } 594 + }); 595 + 596 + fileInput.addEventListener('change', async (e) => { 597 + if (e.target.files.length > 0) { 598 + await uploadFile(e.target.files[0]); 599 + } 600 + }); 601 + 602 + async function uploadFile(file) { 603 + const formData = new FormData(); 604 + formData.append('file', file); 605 + 606 + try { 607 + const response = await fetch('/api/upload', { 608 + method: 'POST', 609 + headers: { 'Authorization': `Bearer ${token}` }, 610 + body: formData 611 + }); 612 + 613 + if (!response.ok) { 614 + throw new Error('upload failed'); 615 + } 616 + 617 + showStatus('image uploaded'); 618 + await loadImages(); 619 + fileInput.value = ''; 620 + } catch (error) { 621 + showStatus('upload failed'); 622 + } 623 + } 624 + 625 + async function loadImages() { 626 + const grid = document.getElementById('imagesGrid'); 627 + grid.innerHTML = '<div class="loading">loading...</div>'; 628 + 629 + try { 630 + const response = await fetch('/api/images', { 631 + headers: { 'Authorization': `Bearer ${token}` } 632 + }); 633 + 634 + if (!response.ok) { 635 + throw new Error('failed to load images'); 636 + } 637 + 638 + const data = await response.json(); 639 + 640 + if (data.images.length === 0) { 641 + grid.innerHTML = '<div class="loading">no images</div>'; 642 + return; 643 + } 644 + 645 + grid.innerHTML = data.images.map(img => ` 646 + <div class="image-card" tabindex="0"> 647 + <img src="/i/${img.key}?w=400&f=webp" alt="${img.originalName}" class="image-preview"> 648 + <div class="image-info"> 649 + <div class="image-url">/i/${img.key}</div> 650 + <div class="image-actions"> 651 + <button onclick="copyUrl('/i/${img.key}')">copy</button> 652 + ${userRole === 'admin' ? `<button class="danger" onclick="deleteImage('${img.key}')">delete</button>` : ''} 653 + </div> 654 + </div> 655 + </div> 656 + `).join(''); 657 + } catch (error) { 658 + grid.innerHTML = '<div class="loading">failed to load</div>'; 659 + } 660 + } 661 + 662 + function copyUrl(path) { 663 + const url = window.location.origin + path; 664 + navigator.clipboard.writeText(url); 665 + showStatus('copied'); 666 + } 667 + 668 + async function deleteImage(key) { 669 + if (!confirm('delete this image?')) return; 670 + 671 + try { 672 + const response = await fetch(`/api/images/${key}`, { 673 + method: 'DELETE', 674 + headers: { 'Authorization': `Bearer ${token}` } 675 + }); 676 + 677 + if (response.ok) { 678 + showStatus('deleted'); 679 + await loadImages(); 680 + } 681 + } catch (error) { 682 + showStatus('delete failed'); 683 + } 684 + } 685 + 686 + async function loadApiKeys() { 687 + const list = document.getElementById('apiKeysList'); 688 + list.innerHTML = '<div class="loading">loading...</div>'; 689 + 690 + try { 691 + const response = await fetch('/api/keys', { 692 + headers: { 'Authorization': `Bearer ${token}` } 693 + }); 694 + 695 + if (!response.ok) { 696 + throw new Error('failed to load api keys'); 697 + } 698 + 699 + const data = await response.json(); 700 + 701 + if (data.keys.length === 0) { 702 + list.innerHTML = '<div class="loading">no api keys</div>'; 703 + return; 704 + } 705 + 706 + list.innerHTML = data.keys.map(key => { 707 + const created = new Date(key.createdAt).toLocaleDateString(); 708 + const expires = key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : 'never'; 709 + 710 + return ` 711 + <div class="api-key-item" tabindex="0"> 712 + <div class="api-key-info"> 713 + <h3>${key.name}</h3> 714 + <div class="api-key-meta"> 715 + created: ${created} • expires: ${expires} 716 + </div> 717 + </div> 718 + <button class="danger" onclick="deleteApiKey('${key.id}')">delete</button> 719 + </div> 720 + `; 721 + }).join(''); 722 + } catch (error) { 723 + list.innerHTML = '<div class="loading">failed to load</div>'; 724 + } 725 + } 726 + 727 + function showCreateKeyModal() { 728 + document.getElementById('createKeyModal').classList.add('active'); 729 + setTimeout(() => document.getElementById('keyName').focus(), 100); 730 + } 731 + 732 + function closeModal() { 733 + document.getElementById('createKeyModal').classList.remove('active'); 734 + document.getElementById('keyName').value = ''; 735 + document.getElementById('keyExpiry').value = ''; 736 + } 737 + 738 + async function createApiKey() { 739 + const name = document.getElementById('keyName').value; 740 + const expiresInDays = document.getElementById('keyExpiry').value; 741 + 742 + if (!name) { 743 + showStatus('enter a key name'); 744 + return; 745 + } 746 + 747 + try { 748 + const response = await fetch('/api/keys', { 749 + method: 'POST', 750 + headers: { 751 + 'Authorization': `Bearer ${token}`, 752 + 'Content-Type': 'application/json' 753 + }, 754 + body: JSON.stringify({ 755 + name, 756 + expiresInDays: expiresInDays ? parseInt(expiresInDays) : null 757 + }) 758 + }); 759 + 760 + if (!response.ok) { 761 + throw new Error('failed to create api key'); 762 + } 763 + 764 + const data = await response.json(); 765 + document.getElementById('newApiKey').value = data.apiKey; 766 + closeModal(); 767 + document.getElementById('keyCreatedModal').classList.add('active'); 768 + setTimeout(() => document.getElementById('newApiKey').select(), 100); 769 + await loadApiKeys(); 770 + } catch (error) { 771 + showStatus('failed to create key'); 772 + } 773 + } 774 + 775 + function copyApiKey() { 776 + const input = document.getElementById('newApiKey'); 777 + input.select(); 778 + navigator.clipboard.writeText(input.value); 779 + showStatus('copied'); 780 + } 781 + 782 + function closeKeyCreatedModal() { 783 + document.getElementById('keyCreatedModal').classList.remove('active'); 784 + document.getElementById('newApiKey').value = ''; 785 + } 786 + 787 + async function deleteApiKey(id) { 788 + if (!confirm('delete this api key?')) return; 789 + 790 + try { 791 + const response = await fetch(`/api/keys/${id}`, { 792 + method: 'DELETE', 793 + headers: { 'Authorization': `Bearer ${token}` } 794 + }); 795 + 796 + if (response.ok) { 797 + showStatus('deleted'); 798 + await loadApiKeys(); 799 + } 800 + } catch (error) { 801 + showStatus('delete failed'); 802 + } 803 + } 804 + 805 + function logout() { 806 + fetch('/api/logout', { 807 + method: 'POST', 808 + headers: { 'Authorization': `Bearer ${token}` } 809 + }).finally(() => { 810 + localStorage.removeItem('l4_token'); 811 + window.location.href = '/login'; 812 + }); 813 + } 814 + 815 + init(); 816 + </script> 817 + </body> 818 + </html>
+585
src/index.ts
··· 1 + import { nanoid } from "nanoid"; 2 + import indexHTML from "./index.html"; 3 + import loginHTML from "./login.html"; 4 + 5 + export default { 6 + async fetch( 7 + request: Request, 8 + env: Env, 9 + ctx: ExecutionContext, 10 + ): Promise<Response> { 11 + const url = new URL(request.url); 12 + 13 + // Public routes 14 + if (url.pathname === "/login" && request.method === "GET") { 15 + return new Response(loginHTML, { 16 + headers: { "Content-Type": "text/html" }, 17 + }); 18 + } 19 + 20 + // Serve images publicly (no auth required) 21 + if (url.pathname.startsWith("/i/")) { 22 + return handleImageRequest(request, env, ctx); 23 + } 24 + 25 + // OAuth initiation 26 + if (url.pathname === "/api/login" && request.method === "GET") { 27 + const state = nanoid(32); 28 + const codeVerifier = generateCodeVerifier(); 29 + const codeChallenge = await generateCodeChallenge(codeVerifier); 30 + 31 + await env.L4.put(`oauth:${state}`, JSON.stringify({ codeVerifier }), { 32 + expirationTtl: 600, 33 + }); 34 + 35 + const redirectUri = `${env.HOST}/api/callback`; 36 + const authUrl = new URL("/auth/authorize", env.INDIKO_URL); 37 + authUrl.searchParams.set("response_type", "code"); 38 + authUrl.searchParams.set("client_id", env.INDIKO_CLIENT_ID); 39 + authUrl.searchParams.set("redirect_uri", redirectUri); 40 + authUrl.searchParams.set("state", state); 41 + authUrl.searchParams.set("code_challenge", codeChallenge); 42 + authUrl.searchParams.set("code_challenge_method", "S256"); 43 + authUrl.searchParams.set("scope", "profile email"); 44 + 45 + return Response.redirect(authUrl.toString(), 302); 46 + } 47 + 48 + // OAuth callback 49 + if (url.pathname === "/api/callback" && request.method === "GET") { 50 + const code = url.searchParams.get("code"); 51 + const state = url.searchParams.get("state"); 52 + 53 + if (!code || !state) { 54 + return Response.redirect(`/login?error=missing_params`, 302); 55 + } 56 + 57 + const oauthData = await env.L4.get(`oauth:${state}`); 58 + if (!oauthData) { 59 + return Response.redirect(`/login?error=invalid_state`, 302); 60 + } 61 + 62 + const { codeVerifier } = JSON.parse(oauthData); 63 + await env.L4.delete(`oauth:${state}`); 64 + 65 + try { 66 + const redirectUri = `${env.HOST}/api/callback`; 67 + const tokenUrl = new URL("/auth/token", env.INDIKO_URL); 68 + const tokenBody = new URLSearchParams({ 69 + grant_type: "authorization_code", 70 + code, 71 + client_id: env.INDIKO_CLIENT_ID, 72 + client_secret: env.INDIKO_CLIENT_SECRET, 73 + redirect_uri: redirectUri, 74 + code_verifier: codeVerifier, 75 + }); 76 + 77 + const tokenResponse = await fetch(tokenUrl.toString(), { 78 + method: "POST", 79 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 80 + body: tokenBody.toString(), 81 + }); 82 + 83 + if (!tokenResponse.ok) { 84 + return Response.redirect(`/login?error=token_exchange_failed`, 302); 85 + } 86 + 87 + const tokenData = await tokenResponse.json(); 88 + 89 + if (tokenData.role !== "admin" && tokenData.role !== "viewer") { 90 + return Response.redirect(`/login?error=unauthorized_role`, 302); 91 + } 92 + 93 + const sessionToken = nanoid(32); 94 + const expiresAt = Date.now() + 24 * 60 * 60 * 1000; 95 + 96 + await env.L4.put( 97 + `session:${sessionToken}`, 98 + JSON.stringify({ 99 + expiresAt, 100 + profile: tokenData.profile, 101 + me: tokenData.me, 102 + role: tokenData.role, 103 + }), 104 + { expirationTtl: 86400 }, 105 + ); 106 + 107 + const redirectUrl = new URL("/", request.url); 108 + redirectUrl.searchParams.set("token", sessionToken); 109 + return Response.redirect(redirectUrl.toString(), 302); 110 + } catch (error) { 111 + return Response.redirect(`/login?error=unknown`, 302); 112 + } 113 + } 114 + 115 + // Logout 116 + if (url.pathname === "/api/logout" && request.method === "POST") { 117 + const authHeader = request.headers.get("Authorization"); 118 + if (authHeader && authHeader.startsWith("Bearer ")) { 119 + const token = authHeader.slice(7); 120 + await env.L4.delete(`session:${token}`); 121 + } 122 + return new Response(JSON.stringify({ success: true }), { 123 + headers: { "Content-Type": "application/json" }, 124 + }); 125 + } 126 + 127 + // Get current user 128 + if (url.pathname === "/api/me" && request.method === "GET") { 129 + const authHeader = request.headers.get("Authorization"); 130 + if (!authHeader || !authHeader.startsWith("Bearer ")) { 131 + return new Response(JSON.stringify({ error: "Unauthorized" }), { 132 + status: 401, 133 + headers: { "Content-Type": "application/json" }, 134 + }); 135 + } 136 + 137 + const token = authHeader.slice(7); 138 + const sessionData = await env.L4.get(`session:${token}`); 139 + 140 + if (!sessionData) { 141 + return new Response(JSON.stringify({ error: "Unauthorized" }), { 142 + status: 401, 143 + headers: { "Content-Type": "application/json" }, 144 + }); 145 + } 146 + 147 + const session = JSON.parse(sessionData); 148 + return new Response( 149 + JSON.stringify({ 150 + role: session.role, 151 + profile: session.profile, 152 + me: session.me, 153 + }), 154 + { headers: { "Content-Type": "application/json" } }, 155 + ); 156 + } 157 + 158 + // Auth required for management routes 159 + let userRole: string | null = null; 160 + if (url.pathname !== "/") { 161 + const authHeader = request.headers.get("Authorization"); 162 + if (!authHeader) { 163 + return new Response(JSON.stringify({ error: "Unauthorized" }), { 164 + status: 401, 165 + headers: { "Content-Type": "application/json" }, 166 + }); 167 + } 168 + 169 + if (authHeader.startsWith("Bearer ")) { 170 + const token = authHeader.slice(7); 171 + 172 + // Check if it's an API key 173 + const apiKeyData = await env.L4.get(`apikey:${token}`); 174 + if (apiKeyData) { 175 + const apiKey = JSON.parse(apiKeyData); 176 + if (apiKey.expiresAt && apiKey.expiresAt < Date.now()) { 177 + await env.L4.delete(`apikey:${token}`); 178 + return new Response(JSON.stringify({ error: "API key expired" }), { 179 + status: 401, 180 + headers: { "Content-Type": "application/json" }, 181 + }); 182 + } 183 + userRole = "admin"; // API keys have admin access 184 + } else { 185 + // Check session token 186 + const sessionData = await env.L4.get(`session:${token}`); 187 + if (!sessionData) { 188 + return new Response(JSON.stringify({ error: "Unauthorized" }), { 189 + status: 401, 190 + headers: { "Content-Type": "application/json" }, 191 + }); 192 + } 193 + 194 + const session = JSON.parse(sessionData); 195 + if (session.expiresAt < Date.now()) { 196 + await env.L4.delete(`session:${token}`); 197 + return new Response(JSON.stringify({ error: "Unauthorized" }), { 198 + status: 401, 199 + headers: { "Content-Type": "application/json" }, 200 + }); 201 + } 202 + userRole = session.role; 203 + } 204 + } else { 205 + return new Response(JSON.stringify({ error: "Unauthorized" }), { 206 + status: 401, 207 + headers: { "Content-Type": "application/json" }, 208 + }); 209 + } 210 + 211 + // Block write operations for viewers 212 + const isWriteOperation = 213 + (url.pathname === "/api/upload" && request.method === "POST") || 214 + (url.pathname.startsWith("/api/images/") && request.method === "DELETE") || 215 + (url.pathname === "/api/keys" && request.method === "POST") || 216 + (url.pathname.startsWith("/api/keys/") && request.method === "DELETE"); 217 + 218 + if (isWriteOperation && userRole === "viewer") { 219 + return new Response( 220 + JSON.stringify({ error: "Forbidden: View-only access" }), 221 + { status: 403, headers: { "Content-Type": "application/json" } }, 222 + ); 223 + } 224 + } 225 + 226 + // Main app 227 + if (url.pathname === "/" && request.method === "GET") { 228 + return new Response(indexHTML, { 229 + headers: { "Content-Type": "text/html" }, 230 + }); 231 + } 232 + 233 + // Upload image 234 + if (url.pathname === "/api/upload" && request.method === "POST") { 235 + return handleImageUpload(request, env); 236 + } 237 + 238 + // List images 239 + if (url.pathname === "/api/images" && request.method === "GET") { 240 + return handleListImages(request, env); 241 + } 242 + 243 + // Delete image 244 + if (url.pathname.startsWith("/api/images/") && request.method === "DELETE") { 245 + const imageKey = url.pathname.split("/")[3]; 246 + return handleDeleteImage(imageKey, env); 247 + } 248 + 249 + // API key management 250 + if (url.pathname === "/api/keys" && request.method === "GET") { 251 + return handleListApiKeys(env); 252 + } 253 + 254 + if (url.pathname === "/api/keys" && request.method === "POST") { 255 + return handleCreateApiKey(request, env); 256 + } 257 + 258 + if (url.pathname.startsWith("/api/keys/") && request.method === "DELETE") { 259 + const keyId = url.pathname.split("/")[3]; 260 + return handleDeleteApiKey(keyId, env); 261 + } 262 + 263 + return new Response("Not found", { status: 404 }); 264 + }, 265 + } satisfies ExportedHandler<Env>; 266 + 267 + async function handleImageRequest( 268 + request: Request, 269 + env: Env, 270 + ctx: ExecutionContext, 271 + ): Promise<Response> { 272 + const url = new URL(request.url); 273 + const imageKey = url.pathname.slice(3); // Remove /i/ 274 + 275 + if (!imageKey) { 276 + return new Response("Not found", { status: 404 }); 277 + } 278 + 279 + // Prevent infinite loops 280 + if (/image-resizing/.test(request.headers.get("via") || "")) { 281 + const object = await env.IMAGES.get(imageKey); 282 + if (!object) { 283 + return new Response("Not found", { status: 404 }); 284 + } 285 + return new Response(object.body, { 286 + headers: { 287 + "Content-Type": object.httpMetadata?.contentType || "application/octet-stream", 288 + "Cache-Control": "public, max-age=31536000", 289 + }, 290 + }); 291 + } 292 + 293 + // Parse transformation params 294 + const width = url.searchParams.get("w"); 295 + const height = url.searchParams.get("h"); 296 + const quality = url.searchParams.get("q") || "85"; 297 + const format = url.searchParams.get("f") || "auto"; 298 + const fit = url.searchParams.get("fit") || "scale-down"; 299 + 300 + // Check cache first 301 + const cacheKey = new Request(url.toString(), request); 302 + const cache = caches.default; 303 + let response = await cache.match(cacheKey); 304 + if (response) { 305 + return response; 306 + } 307 + 308 + // Fetch from R2 309 + const object = await env.IMAGES.get(imageKey); 310 + if (!object) { 311 + return new Response("Not found", { status: 404 }); 312 + } 313 + 314 + // Build image transformation options 315 + const imageOptions: any = { 316 + quality: parseInt(quality), 317 + format, 318 + fit, 319 + }; 320 + 321 + if (width) imageOptions.width = parseInt(width); 322 + if (height) imageOptions.height = parseInt(height); 323 + 324 + // Determine format based on Accept header if auto 325 + if (format === "auto") { 326 + const accept = request.headers.get("accept") || ""; 327 + if (/image\/avif/.test(accept)) { 328 + imageOptions.format = "avif"; 329 + } else if (/image\/webp/.test(accept)) { 330 + imageOptions.format = "webp"; 331 + } 332 + } 333 + 334 + // Fetch and transform 335 + const imageResponse = await fetch(request.url, { 336 + cf: { 337 + image: imageOptions, 338 + }, 339 + }); 340 + 341 + // Clone response with cache headers 342 + response = new Response(imageResponse.body, imageResponse); 343 + response.headers.set("Cache-Control", "public, max-age=31536000, s-maxage=86400"); 344 + response.headers.set("Vary", "Accept"); 345 + 346 + // Cache asynchronously 347 + ctx.waitUntil(cache.put(cacheKey, response.clone())); 348 + 349 + return response; 350 + } 351 + 352 + async function handleImageUpload( 353 + request: Request, 354 + env: Env, 355 + ): Promise<Response> { 356 + try { 357 + const formData = await request.formData(); 358 + const file = formData.get("file") as File; 359 + const customKey = formData.get("key") as string | null; 360 + 361 + if (!file) { 362 + return new Response(JSON.stringify({ error: "No file provided" }), { 363 + status: 400, 364 + headers: { "Content-Type": "application/json" }, 365 + }); 366 + } 367 + 368 + // Validate file type 369 + const contentType = file.type; 370 + const validTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif"]; 371 + if (!validTypes.includes(contentType)) { 372 + return new Response(JSON.stringify({ error: "Invalid file type" }), { 373 + status: 400, 374 + headers: { "Content-Type": "application/json" }, 375 + }); 376 + } 377 + 378 + // Generate key or use custom 379 + const extension = file.name.split(".").pop() || "jpg"; 380 + const imageKey = customKey || `${nanoid(12)}.${extension}`; 381 + 382 + // Upload to R2 383 + await env.IMAGES.put(imageKey, file.stream(), { 384 + httpMetadata: { 385 + contentType, 386 + }, 387 + customMetadata: { 388 + originalName: file.name, 389 + uploadedAt: new Date().toISOString(), 390 + }, 391 + }); 392 + 393 + // Store metadata in KV 394 + await env.L4.put( 395 + `img:${imageKey}`, 396 + JSON.stringify({ 397 + key: imageKey, 398 + originalName: file.name, 399 + contentType, 400 + size: file.size, 401 + uploadedAt: new Date().toISOString(), 402 + }), 403 + ); 404 + 405 + const imageUrl = `${new URL(request.url).origin}/i/${imageKey}`; 406 + 407 + return new Response( 408 + JSON.stringify({ 409 + success: true, 410 + key: imageKey, 411 + url: imageUrl, 412 + }), 413 + { headers: { "Content-Type": "application/json" } }, 414 + ); 415 + } catch (error) { 416 + return new Response(JSON.stringify({ error: "Upload failed" }), { 417 + status: 500, 418 + headers: { "Content-Type": "application/json" }, 419 + }); 420 + } 421 + } 422 + 423 + async function handleListImages( 424 + request: Request, 425 + env: Env, 426 + ): Promise<Response> { 427 + const url = new URL(request.url); 428 + const limit = parseInt(url.searchParams.get("limit") || "100"); 429 + const cursor = url.searchParams.get("cursor") || undefined; 430 + 431 + const listOptions: KVNamespaceListOptions = { 432 + prefix: "img:", 433 + limit: Math.min(limit, 1000), 434 + cursor, 435 + }; 436 + 437 + const list = await env.L4.list(listOptions); 438 + 439 + const images = await Promise.all( 440 + list.keys.map(async (key) => { 441 + const data = await env.L4.get(key.name); 442 + return data ? JSON.parse(data) : null; 443 + }), 444 + ); 445 + 446 + return new Response( 447 + JSON.stringify({ 448 + images: images.filter(Boolean), 449 + cursor: list.list_complete ? null : list.cursor, 450 + hasMore: !list.list_complete, 451 + }), 452 + { headers: { "Content-Type": "application/json" } }, 453 + ); 454 + } 455 + 456 + async function handleDeleteImage( 457 + imageKey: string, 458 + env: Env, 459 + ): Promise<Response> { 460 + try { 461 + // Delete from R2 462 + await env.IMAGES.delete(imageKey); 463 + 464 + // Delete metadata 465 + await env.L4.delete(`img:${imageKey}`); 466 + 467 + return new Response(JSON.stringify({ success: true }), { 468 + headers: { "Content-Type": "application/json" }, 469 + }); 470 + } catch (error) { 471 + return new Response(JSON.stringify({ error: "Delete failed" }), { 472 + status: 500, 473 + headers: { "Content-Type": "application/json" }, 474 + }); 475 + } 476 + } 477 + 478 + async function handleListApiKeys(env: Env): Promise<Response> { 479 + const list = await env.L4.list({ prefix: "apikey:" }); 480 + 481 + const keys = await Promise.all( 482 + list.keys.map(async (key) => { 483 + const data = await env.L4.get(key.name); 484 + if (!data) return null; 485 + const apiKey = JSON.parse(data); 486 + return { 487 + id: key.name.slice(7), 488 + name: apiKey.name, 489 + createdAt: apiKey.createdAt, 490 + expiresAt: apiKey.expiresAt, 491 + lastUsed: apiKey.lastUsed, 492 + }; 493 + }), 494 + ); 495 + 496 + return new Response( 497 + JSON.stringify({ keys: keys.filter(Boolean) }), 498 + { headers: { "Content-Type": "application/json" } }, 499 + ); 500 + } 501 + 502 + async function handleCreateApiKey( 503 + request: Request, 504 + env: Env, 505 + ): Promise<Response> { 506 + try { 507 + const { name, expiresInDays } = await request.json(); 508 + 509 + if (!name) { 510 + return new Response(JSON.stringify({ error: "Name is required" }), { 511 + status: 400, 512 + headers: { "Content-Type": "application/json" }, 513 + }); 514 + } 515 + 516 + const apiKey = nanoid(32); 517 + const expiresAt = expiresInDays 518 + ? Date.now() + expiresInDays * 24 * 60 * 60 * 1000 519 + : null; 520 + 521 + await env.L4.put( 522 + `apikey:${apiKey}`, 523 + JSON.stringify({ 524 + name, 525 + createdAt: Date.now(), 526 + expiresAt, 527 + lastUsed: null, 528 + }), 529 + ); 530 + 531 + return new Response( 532 + JSON.stringify({ 533 + success: true, 534 + apiKey, 535 + name, 536 + expiresAt, 537 + }), 538 + { headers: { "Content-Type": "application/json" } }, 539 + ); 540 + } catch (error) { 541 + return new Response(JSON.stringify({ error: "Invalid request" }), { 542 + status: 400, 543 + headers: { "Content-Type": "application/json" }, 544 + }); 545 + } 546 + } 547 + 548 + async function handleDeleteApiKey( 549 + keyId: string, 550 + env: Env, 551 + ): Promise<Response> { 552 + await env.L4.delete(`apikey:${keyId}`); 553 + 554 + return new Response(JSON.stringify({ success: true }), { 555 + headers: { "Content-Type": "application/json" }, 556 + }); 557 + } 558 + 559 + function generateCodeVerifier(): string { 560 + return nanoid(64); 561 + } 562 + 563 + async function generateCodeChallenge(verifier: string): Promise<string> { 564 + const encoder = new TextEncoder(); 565 + const data = encoder.encode(verifier); 566 + const hash = await crypto.subtle.digest("SHA-256", data); 567 + return base64UrlEncode(new Uint8Array(hash)); 568 + } 569 + 570 + function base64UrlEncode(buffer: Uint8Array): string { 571 + let binary = ""; 572 + for (let i = 0; i < buffer.byteLength; i++) { 573 + binary += String.fromCharCode(buffer[i]); 574 + } 575 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 576 + } 577 + 578 + interface Env { 579 + L4: KVNamespace; 580 + IMAGES: R2Bucket; 581 + HOST: string; 582 + INDIKO_URL: string; 583 + INDIKO_CLIENT_ID: string; 584 + INDIKO_CLIENT_SECRET: string; 585 + }
+156
src/login.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>login - l4</title> 7 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>"> 8 + <style> 9 + :root { 10 + --evergreen: #16302b; 11 + --vintage-grape: #694873; 12 + --dusty-mauve: #8b728e; 13 + --muted-teal: #85b79d; 14 + --tea-green: #c0e5c8; 15 + --evergreen-dark: #0d1f1b; 16 + --input-bg: #1a3530; 17 + --border-color: #2a4540; 18 + } 19 + 20 + * { 21 + margin: 0; 22 + padding: 0; 23 + box-sizing: border-box; 24 + } 25 + 26 + html { 27 + background: var(--evergreen); 28 + } 29 + 30 + body { 31 + font-family: "Courier New", monospace; 32 + background: var(--evergreen); 33 + color: var(--tea-green); 34 + display: flex; 35 + align-items: center; 36 + justify-content: center; 37 + min-height: 100vh; 38 + padding: 1.25rem; 39 + } 40 + 41 + .login-container { 42 + background: var(--input-bg); 43 + border: 0.0625rem solid var(--border-color); 44 + padding: 3rem; 45 + max-width: 450px; 46 + width: 100%; 47 + } 48 + 49 + h1 { 50 + font-size: 3rem; 51 + margin-bottom: 0.5rem; 52 + font-weight: 700; 53 + background: linear-gradient(135deg, var(--muted-teal), var(--tea-green)); 54 + -webkit-background-clip: text; 55 + -webkit-text-fill-color: transparent; 56 + background-clip: text; 57 + letter-spacing: -0.0625rem; 58 + } 59 + 60 + p { 61 + color: var(--muted-teal); 62 + margin-bottom: 2rem; 63 + font-size: 0.9375rem; 64 + } 65 + 66 + button { 67 + width: 100%; 68 + padding: 1rem; 69 + background: var(--vintage-grape); 70 + color: var(--tea-green); 71 + border: none; 72 + font-size: 1rem; 73 + font-weight: 700; 74 + cursor: pointer; 75 + font-family: "Courier New", monospace; 76 + transition: background 0.15s; 77 + } 78 + 79 + button:hover { 80 + background: var(--dusty-mauve); 81 + } 82 + 83 + button:focus { 84 + outline: 0.0625rem solid var(--tea-green); 85 + outline-offset: 0; 86 + } 87 + 88 + .error { 89 + background: #8b2828; 90 + border: 0.0625rem solid #a03030; 91 + padding: 1rem; 92 + margin-bottom: 1.5rem; 93 + color: #ffb3b3; 94 + font-size: 0.875rem; 95 + } 96 + 97 + .shortcut { 98 + color: var(--muted-teal); 99 + font-size: 0.75rem; 100 + margin-top: 1rem; 101 + text-align: center; 102 + } 103 + 104 + .shortcut kbd { 105 + background: var(--evergreen); 106 + padding: 0.125rem 0.375rem; 107 + border: 0.0625rem solid var(--border-color); 108 + font-size: 0.6875rem; 109 + } 110 + </style> 111 + </head> 112 + <body> 113 + <div class="login-container"> 114 + <h1>l4</h1> 115 + <p>high-performance image cache powered by cloudflare r2</p> 116 + 117 + <div id="error" class="error" style="display: none;"></div> 118 + 119 + <button onclick="login()" autofocus>login with indiko</button> 120 + 121 + <div class="shortcut"> 122 + press <kbd>enter</kbd> to login 123 + </div> 124 + </div> 125 + 126 + <script> 127 + const params = new URLSearchParams(window.location.search); 128 + const error = params.get('error'); 129 + 130 + if (error) { 131 + const errorDiv = document.getElementById('error'); 132 + const messages = { 133 + 'missing_params': 'missing authorization parameters', 134 + 'invalid_state': 'invalid state parameter', 135 + 'token_exchange_failed': 'failed to exchange authorization code', 136 + 'unauthorized_role': 'you need admin or viewer role to access this application', 137 + 'unknown': 'an unknown error occurred' 138 + }; 139 + 140 + errorDiv.textContent = messages[error] || messages.unknown; 141 + errorDiv.style.display = 'block'; 142 + } 143 + 144 + function login() { 145 + window.location.href = '/api/login'; 146 + } 147 + 148 + // Enter key to login 149 + document.addEventListener('keydown', (e) => { 150 + if (e.key === 'Enter') { 151 + login(); 152 + } 153 + }); 154 + </script> 155 + </body> 156 + </html>
+13
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ES2022", 5 + "lib": ["ES2022"], 6 + "types": ["@cloudflare/workers-types", "@types/bun"], 7 + "moduleResolution": "bundler", 8 + "resolveJsonModule": true, 9 + "allowSyntheticDefaultImports": true, 10 + "strict": true, 11 + "skipLibCheck": true 12 + } 13 + }
+32
wrangler.toml
··· 1 + name = "l4" 2 + main = "src/index.ts" 3 + compatibility_date = "2024-12-18" 4 + 5 + [observability] 6 + enabled = true 7 + 8 + [[r2_buckets]] 9 + binding = "IMAGES" 10 + bucket_name = "l4-images" 11 + 12 + [[kv_namespaces]] 13 + binding = "L4" 14 + id = "dee452afc1134a58aee704dfda12a703" 15 + 16 + [[rules]] 17 + type = "Text" 18 + globs = ["**/*.html"] 19 + fallthrough = false 20 + 21 + [vars] 22 + HOST = "https://l4.yourdomain.com" 23 + INDIKO_URL = "https://indiko.dunkirk.sh" 24 + 25 + # Environment variables 26 + # For local development, set in .dev.vars file: 27 + # INDIKO_CLIENT_ID=your_client_id 28 + # INDIKO_CLIENT_SECRET=your_client_secret 29 + # 30 + # For production, set as secrets: 31 + # wrangler secret put INDIKO_CLIENT_ID 32 + # wrangler secret put INDIKO_CLIENT_SECRET