blazing fast link redirects on cloudflare kv
hop.dunkirk.sh/u/tacy
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}