blazing fast link redirects on cloudflare kv hop.dunkirk.sh/u/tacy

feat: add auth

dunkirk.sh 3373c34d aa9f37e6

verified
+385 -21
+44 -1
src/index.html
··· 442 442 <section class="form-section"> 443 443 <div class="shortcut"> 444 444 <kbd>tab</kbd> navigate • <kbd>enter</kbd> submit • 445 - <kbd>esc</kbd> clear • <kbd>cmd+k</kbd> focus 445 + <kbd>esc</kbd> clear • <kbd>cmd+k</kbd> focus • <kbd>cmd+l</kbd> logout 446 446 </div> 447 447 <form id="shortenForm"> 448 448 <div class="form-row"> ··· 474 474 </footer> 475 475 476 476 <script> 477 + // Check for session token 478 + const token = localStorage.getItem('hop_session'); 479 + if (!token) { 480 + window.location.href = '/login'; 481 + } 482 + 483 + // Add auth header to all API requests 484 + const originalFetch = window.fetch; 485 + window.fetch = function(...args) { 486 + const [url, config] = args; 487 + if (typeof url === 'string' && url.startsWith('/api/')) { 488 + const token = localStorage.getItem('hop_session'); 489 + if (token) { 490 + args[1] = { 491 + ...config, 492 + headers: { 493 + ...(config?.headers || {}), 494 + 'Authorization': `Bearer ${token}`, 495 + }, 496 + }; 497 + } 498 + } 499 + return originalFetch.apply(this, args).then(async (response) => { 500 + if (response.status === 302 || response.status === 401) { 501 + localStorage.removeItem('hop_session'); 502 + window.location.href = '/login'; 503 + } 504 + return response; 505 + }); 506 + }; 507 + 477 508 const isReload = 478 509 performance.navigation.type === 1 || 479 510 performance.getEntriesByType("navigation")[0]?.type === "reload"; ··· 516 547 slugInput.style.borderColor = "var(--border-color)"; 517 548 urlInput.focus(); 518 549 } 550 + if (e.key === "l" && (e.metaKey || e.ctrlKey)) { 551 + e.preventDefault(); 552 + logout(); 553 + } 519 554 if ( 520 555 (e.key === "c" || e.key === "C") && 521 556 document.getElementById("shortUrlInput") ··· 526 561 } 527 562 } 528 563 }); 564 + 565 + async function logout() { 566 + try { 567 + await fetch('/api/logout', { method: 'POST' }); 568 + } catch (e) {} 569 + localStorage.removeItem('hop_session'); 570 + window.location.href = '/login'; 571 + } 529 572 530 573 slugInput.addEventListener("input", (e) => { 531 574 clearTimeout(debounceTimer);
+127 -19
src/index.ts
··· 1 1 import { nanoid } from 'nanoid'; 2 2 import indexHTML from './index.html'; 3 + import loginHTML from './login.html'; 3 4 4 5 export default { 5 6 async fetch( ··· 9 10 ): Promise<Response> { 10 11 const url = new URL(request.url); 11 12 13 + // Public routes that don't require auth 14 + if (url.pathname === '/login' && request.method === 'GET') { 15 + return new Response(loginHTML, { 16 + headers: { 'Content-Type': 'text/html' }, 17 + }); 18 + } 19 + 20 + const isRedirect = url.pathname !== '/' && !url.pathname.startsWith('/api/'); 21 + if (isRedirect) { 22 + const shortCode = url.pathname.slice(1); 23 + const targetUrl = await env.HOP.get(shortCode); 24 + 25 + if (targetUrl) { 26 + return Response.redirect(targetUrl, 302); 27 + } 28 + 29 + return new Response('Short URL not found', { 30 + status: 404, 31 + headers: { 'Content-Type': 'text/plain' }, 32 + }); 33 + } 34 + 35 + // Login endpoint 36 + if (url.pathname === '/api/login' && request.method === 'POST') { 37 + try { 38 + const { password } = await request.json(); 39 + 40 + if (password !== env.AUTH_PASSWORD) { 41 + return new Response(JSON.stringify({ error: 'Invalid password' }), { 42 + status: 401, 43 + headers: { 'Content-Type': 'application/json' }, 44 + }); 45 + } 46 + 47 + // Generate session token 48 + const token = await generateSessionToken(); 49 + const expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours 50 + 51 + // Store session in KV 52 + await env.HOP.put(`session:${token}`, JSON.stringify({ expiresAt }), { 53 + expirationTtl: 86400, // 24 hours 54 + }); 55 + 56 + return new Response(JSON.stringify({ token }), { 57 + headers: { 'Content-Type': 'application/json' }, 58 + }); 59 + } catch (error) { 60 + return new Response(JSON.stringify({ error: 'Invalid request' }), { 61 + status: 400, 62 + headers: { 'Content-Type': 'application/json' }, 63 + }); 64 + } 65 + } 66 + 67 + // Logout endpoint 68 + if (url.pathname === '/api/logout' && request.method === 'POST') { 69 + const authHeader = request.headers.get('Authorization'); 70 + if (authHeader && authHeader.startsWith('Bearer ')) { 71 + const token = authHeader.slice(7); 72 + await env.HOP.delete(`session:${token}`); 73 + } 74 + return new Response(JSON.stringify({ success: true }), { 75 + headers: { 'Content-Type': 'application/json' }, 76 + }); 77 + } 78 + 79 + // Check auth for all other routes (except / which needs to load first) 80 + if (url.pathname !== '/') { 81 + const authHeader = request.headers.get('Authorization'); 82 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 83 + return new Response(JSON.stringify({ error: 'Unauthorized' }), { 84 + status: 401, 85 + headers: { 'Content-Type': 'application/json' }, 86 + }); 87 + } 88 + 89 + const token = authHeader.slice(7); 90 + const sessionData = await env.HOP.get(`session:${token}`); 91 + 92 + if (!sessionData) { 93 + return new Response(JSON.stringify({ error: 'Unauthorized' }), { 94 + status: 401, 95 + headers: { 'Content-Type': 'application/json' }, 96 + }); 97 + } 98 + 99 + const session = JSON.parse(sessionData); 100 + if (session.expiresAt < Date.now()) { 101 + await env.HOP.delete(`session:${token}`); 102 + return new Response(JSON.stringify({ error: 'Unauthorized' }), { 103 + status: 401, 104 + headers: { 'Content-Type': 'application/json' }, 105 + }); 106 + } 107 + } 108 + 12 109 if (url.pathname === '/' && request.method === 'GET') { 13 110 return new Response(indexHTML, { 14 111 headers: { 'Content-Type': 'text/html' }, ··· 28 125 29 126 const list = await env.HOP.list(listOptions); 30 127 128 + // Clean up expired sessions in background 129 + const now = Date.now(); 130 + const sessionKeys = list.keys.filter(key => key.name.startsWith('session:')); 131 + for (const key of sessionKeys) { 132 + const sessionData = await env.HOP.get(key.name); 133 + if (sessionData) { 134 + try { 135 + const session = JSON.parse(sessionData); 136 + if (session.expiresAt < now) { 137 + ctx.waitUntil(env.HOP.delete(key.name)); 138 + } 139 + } catch (e) { 140 + // Invalid session data, delete it 141 + ctx.waitUntil(env.HOP.delete(key.name)); 142 + } 143 + } 144 + } 145 + 31 146 let urls = await Promise.all( 32 - list.keys.map(async (key) => ({ 33 - shortCode: key.name, 34 - url: await env.HOP.get(key.name), 35 - created: key.metadata?.created || Date.now(), 36 - })) 147 + list.keys 148 + .filter(key => !key.name.startsWith('session:')) 149 + .map(async (key) => ({ 150 + shortCode: key.name, 151 + url: await env.HOP.get(key.name), 152 + created: key.metadata?.created || Date.now(), 153 + })) 37 154 ); 38 155 39 156 // Filter by search term if provided ··· 144 261 } 145 262 } 146 263 147 - const shortCode = url.pathname.slice(1); 148 - if (shortCode && !shortCode.startsWith('api/')) { 149 - const targetUrl = await env.HOP.get(shortCode); 150 - 151 - if (targetUrl) { 152 - return Response.redirect(targetUrl, 302); 153 - } 154 - 155 - return new Response('Short URL not found', { 156 - status: 404, 157 - headers: { 'Content-Type': 'text/plain' }, 158 - }); 159 - } 160 - 161 264 return new Response('Not found', { status: 404 }); 162 265 }, 163 266 } satisfies ExportedHandler<Env>; ··· 166 269 return nanoid(6); 167 270 } 168 271 272 + async function generateSessionToken(): Promise<string> { 273 + return nanoid(32); 274 + } 275 + 169 276 interface Env { 170 277 HOP: KVNamespace; 278 + AUTH_PASSWORD: string; 171 279 }
+210
src/login.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>login // hop</title> 8 + <link rel="icon" 9 + 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>" /> 10 + <style> 11 + :root { 12 + --bg-primary: oklch(23.17% 0.0113 115.0); 13 + --text-primary: oklch(0.77 0.05 177.41); 14 + --text-muted: oklch(61.43% 0.0603 149.4); 15 + --accent-primary: oklch(82.83% 0.0539 158.7); 16 + --accent-bright: oklch(0.81 0.05 164.12); 17 + --input-bg: oklch(28.59% 0.0107 114.8); 18 + --border-color: oklch(34.93% 0.0102 114.7); 19 + --hover-bg: oklch(40.99% 0.0098 114.6); 20 + --error-bg: oklch(58.72% 0.1531 28.0); 21 + --error-text: oklch(73.36% 0.1635 27.9); 22 + } 23 + 24 + * { 25 + margin: 0; 26 + padding: 0; 27 + box-sizing: border-box; 28 + } 29 + 30 + html { 31 + background: var(--bg-primary); 32 + } 33 + 34 + body { 35 + font-family: "Courier New", monospace; 36 + background: var(--bg-primary); 37 + color: var(--text-primary); 38 + min-height: 100vh; 39 + display: flex; 40 + align-items: center; 41 + justify-content: center; 42 + padding: 1.25rem; 43 + } 44 + 45 + .login-container { 46 + max-width: 25rem; 47 + width: 100%; 48 + } 49 + 50 + h1 { 51 + font-size: 2.5rem; 52 + margin-bottom: 0.5rem; 53 + font-weight: 700; 54 + background: linear-gradient(135deg, var(--text-muted), var(--accent-primary), var(--accent-bright)); 55 + -webkit-background-clip: text; 56 + -webkit-text-fill-color: transparent; 57 + background-clip: text; 58 + letter-spacing: -0.0625rem; 59 + text-align: center; 60 + } 61 + 62 + .subtitle { 63 + color: var(--text-muted); 64 + margin-bottom: 2rem; 65 + font-size: 0.8125rem; 66 + font-family: monospace; 67 + text-align: center; 68 + } 69 + 70 + form { 71 + display: flex; 72 + flex-direction: column; 73 + gap: 0.75rem; 74 + } 75 + 76 + input { 77 + width: 100%; 78 + padding: 0.75rem 0.875rem; 79 + background: var(--input-bg); 80 + border: 0.0625rem solid var(--border-color); 81 + border-radius: 0.25rem; 82 + color: var(--text-primary); 83 + font-size: 0.875rem; 84 + font-family: "Courier New", monospace; 85 + transition: border-color 0.15s; 86 + } 87 + 88 + input:focus { 89 + outline: none; 90 + border-color: var(--text-muted); 91 + background: #2d2e28; 92 + } 93 + 94 + input::placeholder { 95 + color: var(--hover-bg); 96 + } 97 + 98 + button { 99 + width: 100%; 100 + padding: 0.75rem; 101 + background: var(--text-muted); 102 + color: var(--bg-primary); 103 + border: none; 104 + border-radius: 0.25rem; 105 + font-size: 0.875rem; 106 + font-weight: 700; 107 + cursor: pointer; 108 + font-family: "Courier New", monospace; 109 + transition: background 0.15s; 110 + margin-top: 0.25rem; 111 + } 112 + 113 + button:hover { 114 + background: #7a9e80; 115 + } 116 + 117 + button:active { 118 + background: #5a7f61; 119 + } 120 + 121 + button:disabled { 122 + opacity: 0.4; 123 + cursor: not-allowed; 124 + } 125 + 126 + button:focus { 127 + outline: 0.125rem solid var(--accent-primary); 128 + outline-offset: 0.125rem; 129 + } 130 + 131 + .error { 132 + color: var(--error-text); 133 + font-size: 0.8125rem; 134 + text-align: center; 135 + margin-top: 0.75rem; 136 + display: none; 137 + } 138 + 139 + .error.show { 140 + display: block; 141 + } 142 + </style> 143 + </head> 144 + 145 + <body style="background-color: var(--bg-primary)"> 146 + <div class="login-container"> 147 + <h1>🛎️ hop</h1> 148 + <p class="subtitle">// admin login</p> 149 + <form id="loginForm"> 150 + <input type="password" id="password" placeholder="password" required autofocus /> 151 + <button type="submit">login</button> 152 + </form> 153 + <div id="error" class="error"></div> 154 + </div> 155 + 156 + <script> 157 + const form = document.getElementById('loginForm'); 158 + const error = document.getElementById('error'); 159 + const passwordInput = document.getElementById('password'); 160 + 161 + document.addEventListener('keydown', (e) => { 162 + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { 163 + e.preventDefault(); 164 + passwordInput.focus(); 165 + passwordInput.select(); 166 + } 167 + }); 168 + 169 + form.addEventListener('submit', async (e) => { 170 + e.preventDefault(); 171 + 172 + const password = passwordInput.value; 173 + const submitBtn = form.querySelector('button'); 174 + 175 + submitBtn.disabled = true; 176 + submitBtn.textContent = 'logging in...'; 177 + error.classList.remove('show'); 178 + 179 + try { 180 + const response = await fetch('/api/login', { 181 + method: 'POST', 182 + headers: { 'Content-Type': 'application/json' }, 183 + body: JSON.stringify({ password }), 184 + }); 185 + 186 + const data = await response.json(); 187 + 188 + if (!response.ok) { 189 + throw new Error(data.error || 'Login failed'); 190 + } 191 + 192 + // Store session token 193 + localStorage.setItem('hop_session', data.token); 194 + 195 + // Redirect to main app 196 + window.location.href = '/'; 197 + } catch (err) { 198 + error.textContent = err.message; 199 + error.classList.add('show'); 200 + passwordInput.value = ''; 201 + passwordInput.focus(); 202 + } finally { 203 + submitBtn.disabled = false; 204 + submitBtn.textContent = 'login'; 205 + } 206 + }); 207 + </script> 208 + </body> 209 + 210 + </html>
+4 -1
wrangler.toml
··· 12 12 binding = "HOP" 13 13 id = "ae7cd39a622b466d876b8410d22d1397" 14 14 15 - [rules] 15 + [vars] 16 + AUTH_PASSWORD = "changeme" 17 + 16 18 [[rules]] 17 19 type = "Text" 18 20 globs = ["**/*.html"] 21 + fallthrough = false