blazing fast link redirects on cloudflare kv hop.dunkirk.sh/u/tacy
at main 14 kB view raw
1import { nanoid } from "nanoid"; 2import notFoundHTML from "./404.html"; 3import indexHTML from "./index.html"; 4import loginHTML from "./login.html"; 5 6export default { 7 async fetch( 8 request: Request, 9 env: Env, 10 ctx: ExecutionContext, 11 ): Promise<Response> { 12 const url = new URL(request.url); 13 14 // Public routes that don't require auth 15 if (url.pathname === "/login" && request.method === "GET") { 16 return new Response(loginHTML, { 17 headers: { "Content-Type": "text/html" }, 18 }); 19 } 20 21 const isRedirect = url.pathname.startsWith("/h/"); 22 if (isRedirect) { 23 const shortCode = url.pathname.slice(3); 24 const targetUrl = await env.HOP.get(shortCode); 25 26 if (targetUrl) { 27 return Response.redirect(targetUrl, 302); 28 } 29 30 return new Response(notFoundHTML, { 31 status: 404, 32 headers: { "Content-Type": "text/html" }, 33 }); 34 } 35 36 // OAuth initiation endpoint 37 if (url.pathname === "/api/login" && request.method === "GET") { 38 const state = nanoid(32); 39 const codeVerifier = generateCodeVerifier(); 40 const codeChallenge = await generateCodeChallenge(codeVerifier); 41 42 // Store state and verifier in KV 43 await env.HOP.put(`oauth:${state}`, JSON.stringify({ codeVerifier }), { 44 expirationTtl: 600, // 10 minutes 45 }); 46 47 // Build redirect URI from HOST env var or request origin 48 const redirectUri = env.HOST 49 ? `${env.HOST}/api/callback` 50 : new URL("/api/callback", request.url).toString(); 51 52 const authUrl = new URL("/auth/authorize", env.INDIKO_URL); 53 authUrl.searchParams.set("response_type", "code"); 54 authUrl.searchParams.set("client_id", env.INDIKO_CLIENT_ID); 55 authUrl.searchParams.set("redirect_uri", redirectUri); 56 authUrl.searchParams.set("state", state); 57 authUrl.searchParams.set("code_challenge", codeChallenge); 58 authUrl.searchParams.set("code_challenge_method", "S256"); 59 authUrl.searchParams.set("scope", "profile email"); 60 61 return Response.redirect(authUrl.toString(), 302); 62 } 63 64 // OAuth callback endpoint 65 if (url.pathname === "/api/callback" && request.method === "GET") { 66 const code = url.searchParams.get("code"); 67 const state = url.searchParams.get("state"); 68 69 if (!code || !state) { 70 return Response.redirect( 71 new URL("/login?error=missing_params", request.url).toString(), 72 302, 73 ); 74 } 75 76 // Retrieve and verify state 77 const oauthData = await env.HOP.get(`oauth:${state}`); 78 if (!oauthData) { 79 return Response.redirect( 80 new URL("/login?error=invalid_state", request.url).toString(), 81 302, 82 ); 83 } 84 85 const { codeVerifier } = JSON.parse(oauthData); 86 await env.HOP.delete(`oauth:${state}`); 87 88 // Exchange code for token 89 try { 90 // Build redirect URI from HOST env var or request origin 91 const redirectUri = env.HOST 92 ? `${env.HOST}/api/callback` 93 : new URL("/api/callback", request.url).toString(); 94 95 const tokenUrl = new URL("/auth/token", env.INDIKO_URL); 96 const tokenBody = new URLSearchParams({ 97 grant_type: "authorization_code", 98 code, 99 client_id: env.INDIKO_CLIENT_ID, 100 client_secret: env.INDIKO_CLIENT_SECRET, 101 redirect_uri: redirectUri, 102 code_verifier: codeVerifier, 103 }); 104 105 const tokenResponse = await fetch(tokenUrl.toString(), { 106 method: "POST", 107 headers: { 108 "Content-Type": "application/x-www-form-urlencoded", 109 }, 110 body: tokenBody.toString(), 111 }); 112 113 if (!tokenResponse.ok) { 114 const errorText = await tokenResponse.text(); 115 console.error( 116 "Token exchange failed:", 117 tokenResponse.status, 118 errorText, 119 ); 120 return Response.redirect( 121 new URL( 122 "/login?error=token_exchange_failed", 123 request.url, 124 ).toString(), 125 302, 126 ); 127 } 128 129 const tokenData = await tokenResponse.json(); 130 131 // Check if user has admin or viewer role 132 if (tokenData.role !== "admin" && tokenData.role !== "viewer") { 133 return Response.redirect( 134 new URL("/login?error=unauthorized_role", request.url).toString(), 135 302, 136 ); 137 } 138 139 // Generate session token 140 const sessionToken = nanoid(32); 141 const expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours 142 143 // Store session with user profile 144 await env.HOP.put( 145 `session:${sessionToken}`, 146 JSON.stringify({ 147 expiresAt, 148 profile: tokenData.profile, 149 me: tokenData.me, 150 role: tokenData.role, 151 }), 152 { expirationTtl: 86400 }, // 24 hours 153 ); 154 155 // Redirect to main app with session token 156 const redirectUrl = new URL("/", request.url); 157 redirectUrl.searchParams.set("token", sessionToken); 158 return Response.redirect(redirectUrl.toString(), 302); 159 } catch (error) { 160 return Response.redirect( 161 new URL("/login?error=unknown", request.url).toString(), 162 302, 163 ); 164 } 165 } 166 167 // Logout endpoint 168 if (url.pathname === "/api/logout" && request.method === "POST") { 169 const authHeader = request.headers.get("Authorization"); 170 if (authHeader && authHeader.startsWith("Bearer ")) { 171 const token = authHeader.slice(7); 172 await env.HOP.delete(`session:${token}`); 173 } 174 return new Response(JSON.stringify({ success: true }), { 175 headers: { "Content-Type": "application/json" }, 176 }); 177 } 178 179 // Get current user info endpoint 180 if (url.pathname === "/api/me" && request.method === "GET") { 181 const authHeader = request.headers.get("Authorization"); 182 if (!authHeader || !authHeader.startsWith("Bearer ")) { 183 return new Response(JSON.stringify({ error: "Unauthorized" }), { 184 status: 401, 185 headers: { "Content-Type": "application/json" }, 186 }); 187 } 188 189 const token = authHeader.slice(7); 190 const sessionData = await env.HOP.get(`session:${token}`); 191 192 if (!sessionData) { 193 return new Response(JSON.stringify({ error: "Unauthorized" }), { 194 status: 401, 195 headers: { "Content-Type": "application/json" }, 196 }); 197 } 198 199 const session = JSON.parse(sessionData); 200 return new Response( 201 JSON.stringify({ 202 role: session.role, 203 profile: session.profile, 204 me: session.me, 205 }), 206 { 207 headers: { "Content-Type": "application/json" }, 208 }, 209 ); 210 } 211 212 // Check auth for all other routes (except / which needs to load first) 213 let userRole: string | null = null; 214 if (url.pathname !== "/") { 215 const authHeader = request.headers.get("Authorization"); 216 if (!authHeader) { 217 return new Response(JSON.stringify({ error: "Unauthorized" }), { 218 status: 401, 219 headers: { "Content-Type": "application/json" }, 220 }); 221 } 222 223 // Check for API key authentication 224 if (authHeader.startsWith("Bearer ")) { 225 const token = authHeader.slice(7); 226 227 // Check if it's an API key 228 if (token === env.API_KEY) { 229 // Valid API key, treat as admin 230 userRole = "admin"; 231 } else { 232 // Check if it's a session token 233 const sessionData = await env.HOP.get(`session:${token}`); 234 235 if (!sessionData) { 236 return new Response(JSON.stringify({ error: "Unauthorized" }), { 237 status: 401, 238 headers: { "Content-Type": "application/json" }, 239 }); 240 } 241 242 const session = JSON.parse(sessionData); 243 if (session.expiresAt < Date.now()) { 244 await env.HOP.delete(`session:${token}`); 245 return new Response(JSON.stringify({ error: "Unauthorized" }), { 246 status: 401, 247 headers: { "Content-Type": "application/json" }, 248 }); 249 } 250 userRole = session.role; 251 } 252 } else { 253 return new Response(JSON.stringify({ error: "Unauthorized" }), { 254 status: 401, 255 headers: { "Content-Type": "application/json" }, 256 }); 257 } 258 259 // Block write operations for viewers 260 const isWriteOperation = 261 (url.pathname === "/api/shorten" && request.method === "POST") || 262 (url.pathname.startsWith("/api/urls/") && 263 (request.method === "PUT" || request.method === "DELETE")); 264 265 if (isWriteOperation && userRole === "viewer") { 266 return new Response( 267 JSON.stringify({ error: "Forbidden: View-only access" }), 268 { 269 status: 403, 270 headers: { "Content-Type": "application/json" }, 271 }, 272 ); 273 } 274 } 275 276 if (url.pathname === "/" && request.method === "GET") { 277 // Inject redirect base into HTML 278 // If REDIRECT_BASE is not set, use local origin with /h path 279 const redirectBase = 280 env.REDIRECT_BASE || `${new URL(request.url).origin}/h`; 281 const html = indexHTML.replace( 282 "<!-- REDIRECT_BASE -->", 283 `<script>window.REDIRECT_BASE = ${JSON.stringify(redirectBase)};</script>`, 284 ); 285 return new Response(html, { 286 headers: { "Content-Type": "text/html" }, 287 }); 288 } 289 290 if (url.pathname === "/api/urls" && request.method === "GET") { 291 const searchParams = url.searchParams; 292 const limit = parseInt(searchParams.get("limit") || "100"); 293 const cursor = searchParams.get("cursor") || undefined; 294 const search = searchParams.get("search") || ""; 295 296 const listOptions: KVNamespaceListOptions = { 297 limit: Math.min(limit, 1000), 298 cursor, 299 }; 300 301 const list = await env.HOP.list(listOptions); 302 303 // Clean up expired sessions in background 304 const now = Date.now(); 305 const sessionKeys = list.keys.filter((key) => 306 key.name.startsWith("session:"), 307 ); 308 for (const key of sessionKeys) { 309 const sessionData = await env.HOP.get(key.name); 310 if (sessionData) { 311 try { 312 const session = JSON.parse(sessionData); 313 if (session.expiresAt < now) { 314 ctx.waitUntil(env.HOP.delete(key.name)); 315 } 316 } catch (e) { 317 // Invalid session data, delete it 318 ctx.waitUntil(env.HOP.delete(key.name)); 319 } 320 } 321 } 322 323 let urls = await Promise.all( 324 list.keys 325 .filter( 326 (key) => 327 !key.name.startsWith("session:") && 328 !key.name.startsWith("oauth:"), 329 ) 330 .map(async (key) => ({ 331 shortCode: key.name, 332 url: await env.HOP.get(key.name), 333 created: key.metadata?.created || Date.now(), 334 })), 335 ); 336 337 // Filter by search term if provided 338 if (search) { 339 const searchLower = search.toLowerCase(); 340 urls = urls.filter( 341 (item) => 342 item.shortCode.toLowerCase().includes(searchLower) || 343 item.url?.toLowerCase().includes(searchLower), 344 ); 345 } 346 347 return new Response( 348 JSON.stringify({ 349 urls, 350 cursor: list.list_complete ? null : list.cursor, 351 hasMore: !list.list_complete, 352 }), 353 { 354 headers: { "Content-Type": "application/json" }, 355 }, 356 ); 357 } 358 359 if (url.pathname.startsWith("/api/urls/") && request.method === "PUT") { 360 const shortCode = url.pathname.split("/")[3]; 361 try { 362 const { url: newUrl } = await request.json(); 363 364 if (!newUrl) { 365 return new Response(JSON.stringify({ error: "URL is required" }), { 366 status: 400, 367 headers: { "Content-Type": "application/json" }, 368 }); 369 } 370 371 const existing = await env.HOP.get(shortCode); 372 if (!existing) { 373 return new Response( 374 JSON.stringify({ error: "Short URL not found" }), 375 { 376 status: 404, 377 headers: { "Content-Type": "application/json" }, 378 }, 379 ); 380 } 381 382 const metadata = await env.HOP.getWithMetadata(shortCode); 383 await env.HOP.put(shortCode, newUrl, { 384 metadata: metadata.metadata || { created: Date.now() }, 385 }); 386 387 return new Response(JSON.stringify({ shortCode, url: newUrl }), { 388 headers: { "Content-Type": "application/json" }, 389 }); 390 } catch (error) { 391 return new Response(JSON.stringify({ error: "Invalid request" }), { 392 status: 400, 393 headers: { "Content-Type": "application/json" }, 394 }); 395 } 396 } 397 398 if (url.pathname.startsWith("/api/urls/") && request.method === "DELETE") { 399 const shortCode = url.pathname.split("/")[3]; 400 401 const existing = await env.HOP.get(shortCode); 402 if (!existing) { 403 return new Response(JSON.stringify({ error: "Short URL not found" }), { 404 status: 404, 405 headers: { "Content-Type": "application/json" }, 406 }); 407 } 408 409 await env.HOP.delete(shortCode); 410 411 return new Response(JSON.stringify({ success: true }), { 412 headers: { "Content-Type": "application/json" }, 413 }); 414 } 415 416 if (url.pathname === "/api/shorten" && request.method === "POST") { 417 try { 418 const { url: targetUrl, slug } = await request.json(); 419 420 if (!targetUrl) { 421 return new Response(JSON.stringify({ error: "URL is required" }), { 422 status: 400, 423 headers: { "Content-Type": "application/json" }, 424 }); 425 } 426 427 const shortCode = slug || generateShortCode(); 428 const existing = await env.HOP.get(shortCode); 429 430 if (existing) { 431 return new Response( 432 JSON.stringify({ error: "Slug already exists" }), 433 { 434 status: 409, 435 headers: { "Content-Type": "application/json" }, 436 }, 437 ); 438 } 439 440 await env.HOP.put(shortCode, targetUrl, { 441 metadata: { created: Date.now() }, 442 }); 443 444 return new Response(JSON.stringify({ shortCode, url: targetUrl }), { 445 headers: { "Content-Type": "application/json" }, 446 }); 447 } catch (error) { 448 return new Response(JSON.stringify({ error: "Invalid request" }), { 449 status: 400, 450 headers: { "Content-Type": "application/json" }, 451 }); 452 } 453 } 454 455 return new Response("Not found", { status: 404 }); 456 }, 457} satisfies ExportedHandler<Env>; 458 459function generateShortCode(): string { 460 return nanoid(6); 461} 462 463async function generateSessionToken(): Promise<string> { 464 return nanoid(32); 465} 466 467function generateCodeVerifier(): string { 468 return nanoid(64); 469} 470 471async function generateCodeChallenge(verifier: string): Promise<string> { 472 const encoder = new TextEncoder(); 473 const data = encoder.encode(verifier); 474 const hash = await crypto.subtle.digest("SHA-256", data); 475 return base64UrlEncode(new Uint8Array(hash)); 476} 477 478function base64UrlEncode(buffer: Uint8Array): string { 479 let binary = ""; 480 for (let i = 0; i < buffer.byteLength; i++) { 481 binary += String.fromCharCode(buffer[i]); 482 } 483 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 484} 485 486interface Env { 487 HOP: KVNamespace; 488 API_KEY: string; 489 HOST?: string; 490 REDIRECT_BASE?: string; 491 INDIKO_URL: string; 492 INDIKO_CLIENT_ID: string; 493 INDIKO_CLIENT_SECRET: string; 494}