OAuth proxy server for blup image uploader

initial commit

evan.jarrett.net d118cbd2 737d8b84

verified
+16
.vscode/launch.json
··· 1 + { 2 + "version": "0.2.0", 3 + "configurations": [ 4 + { 5 + "name": "Wrangler", 6 + "type": "node", 7 + "request": "attach", 8 + "port": 9229, 9 + "resolveSourceMapLocations": null, 10 + "attachExistingChildren": false, 11 + "autoAttachChildProcesses": false, 12 + "localRoot": "${workspaceRoot}/src", 13 + "sourceMaps": true, 14 + } 15 + ] 16 + }
+4 -4
package-lock.json
··· 1 1 { 2 - "name": "jolly-sun-74a6", 3 - "version": "0.0.0", 2 + "name": "blupimgsblue", 3 + "version": "0.1.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 - "name": "jolly-sun-74a6", 9 - "version": "0.0.0", 8 + "name": "blupimgsblue", 9 + "version": "0.1.0", 10 10 "devDependencies": { 11 11 "@cloudflare/vitest-pool-workers": "^0.8.19", 12 12 "typescript": "^5.5.2",
+2 -2
package.json
··· 1 1 { 2 - "name": "jolly-sun-74a6", 3 - "version": "0.0.0", 2 + "name": "blupimgsblue", 3 + "version": "0.1.0", 4 4 "private": true, 5 5 "scripts": { 6 6 "deploy": "wrangler deploy",
+38 -30
public/index.html
··· 1 - <!doctype html> 1 + <!DOCTYPE html> 2 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>Hello, World!</title> 7 - </head> 8 - <body> 9 - <h1 id="heading"></h1> 10 - <p>This page comes from a static asset stored at `public/index.html` as configured in `wrangler.jsonc`.</p> 11 - <button id="button" type="button">Fetch a random UUID</button> 12 - <output id="random" for="button"></output> 13 - <script> 14 - fetch('/message') 15 - .then((resp) => resp.text()) 16 - .then((text) => { 17 - const h1 = document.getElementById('heading'); 18 - h1.textContent = text; 19 - }); 20 3 21 - const button = document.getElementById("button"); 22 - button.addEventListener("click", () => { 23 - fetch('/random') 24 - .then((resp) => resp.text()) 25 - .then((text) => { 26 - const random = document.getElementById('random'); 27 - random.textContent = text; 28 - }); 29 - }); 30 - </script> 31 - </body> 32 - </html> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>blup - Beautiful Image Uploads</title> 8 + <link rel="stylesheet" href="/styles.css"> 9 + </head> 10 + 11 + <body> 12 + <div class="container"> 13 + <div class="icon"> 14 + <div class="logo-icon">B</div> 15 + </div> 16 + 17 + <h1>blup</h1> 18 + 19 + <p>Upload images to the blue sky ☁️</p> 20 + <p>A fast CLI tool for uploading images to your AT Protocol PDS and getting instant CDN URLs through 21 + images.blue.</p> 22 + 23 + <div class="instructions"> 24 + <h3>Installation</h3> 25 + <p>Install the latest version from <a href="https://tangled.sh/@evan.jarrett.net/blup" 26 + class="link">tangled.sh</a>:</p> 27 + <code>go get https://tangled.sh/@evan.jarrett.net/blup@latest</code> 28 + 29 + <h3>Or browse the source</h3> 30 + <p>View the repository at <a href="https://tangled.sh/@evan.jarrett.net/blup" 31 + class="link">tangled.sh/@evan.jarrett.net/blup</a></p> 32 + </div> 33 + 34 + <div class="footer"> 35 + Built with 💙 for the AT Protocol ecosystem 36 + </div> 37 + </div> 38 + </body> 39 + 40 + </html>
+19
public/oauth/client-metadata.json
··· 1 + { 2 + "client_id": "https://blup.imgs.blue/oauth/client-metadata.json", 3 + "client_name": "Blup", 4 + "client_uri": "https://blup.imgs.blue", 5 + "grant_types": [ 6 + "authorization_code", 7 + "refresh_token" 8 + ], 9 + "scope": "atproto transition:generic", 10 + "response_types": [ 11 + "code" 12 + ], 13 + "redirect_uris": [ 14 + "https://blup.imgs.blue/oauth/callback" 15 + ], 16 + "dpop_bound_access_tokens": true, 17 + "token_endpoint_auth_method": "none", 18 + "application_type": "native" 19 + }
+47
public/oauth/success/index.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>Authorization Successful</title> 8 + <link rel="stylesheet" href="/styles.css"> 9 + </head> 10 + 11 + <body> 12 + <div class="container"> 13 + <div class="icon success-icon"> 14 + <div class="checkmark"></div> 15 + </div> 16 + 17 + <h1>Authorization Successful!</h1> 18 + 19 + <p>Your application has been successfully connected.</p> 20 + 21 + <div class="instructions"> 22 + <p><strong>You can now close this window</strong> and return to your terminal.</p> 23 + <p style="margin-top: 0.5rem; font-size: 0.95rem;"> 24 + Waiting for your CLI to confirm receipt 25 + <span class="spinner"></span> 26 + </p> 27 + </div> 28 + 29 + <div class="footer"> 30 + Secured with AT Protocol OAuth 31 + </div> 32 + </div> 33 + 34 + <script> 35 + // Optional: Try to close the window after a delay 36 + // This won't work unless the window was opened by script 37 + setTimeout(() => { 38 + window.close(); 39 + // If window.close() doesn't work, update the UI 40 + document.querySelector('.instructions').innerHTML = 41 + '<p><strong>✓ Authentication complete!</strong></p>' + 42 + '<p style="margin-top: 0.5rem;">You can safely close this window.</p>'; 43 + }, 3000); 44 + </script> 45 + </body> 46 + 47 + </html>`
+165
public/styles.css
··· 1 + * { 2 + margin: 0; 3 + padding: 0; 4 + box-sizing: border-box; 5 + } 6 + 7 + body { 8 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 9 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 10 + min-height: 100vh; 11 + display: flex; 12 + align-items: center; 13 + justify-content: center; 14 + color: #333; 15 + } 16 + 17 + .container { 18 + background: white; 19 + padding: 3rem; 20 + border-radius: 20px; 21 + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); 22 + text-align: center; 23 + max-width: 700px; 24 + width: 90%; 25 + animation: slideIn 0.4s ease-out; 26 + } 27 + 28 + @keyframes slideIn { 29 + from { 30 + opacity: 0; 31 + transform: translateY(20px); 32 + } 33 + 34 + to { 35 + opacity: 1; 36 + transform: translateY(0); 37 + } 38 + } 39 + 40 + .icon { 41 + width: 80px; 42 + height: 80px; 43 + margin: 0 auto 1.5rem; 44 + position: relative; 45 + } 46 + 47 + .success-icon .checkmark { 48 + width: 100%; 49 + height: 100%; 50 + border-radius: 50%; 51 + background: #4ade80; 52 + position: relative; 53 + animation: scaleIn 0.4s ease-out 0.2s both; 54 + } 55 + 56 + .logo-icon { 57 + width: 100%; 58 + height: 100%; 59 + border-radius: 50%; 60 + background: #6366f1; 61 + position: relative; 62 + animation: scaleIn 0.4s ease-out 0.2s both; 63 + display: flex; 64 + align-items: center; 65 + justify-content: center; 66 + font-size: 2rem; 67 + font-weight: bold; 68 + color: white; 69 + } 70 + 71 + @keyframes scaleIn { 72 + from { 73 + transform: scale(0); 74 + } 75 + 76 + to { 77 + transform: scale(1); 78 + } 79 + } 80 + 81 + .checkmark::after { 82 + content: ''; 83 + position: absolute; 84 + width: 30%; 85 + height: 50%; 86 + border: solid white; 87 + border-width: 0 5px 5px 0; 88 + left: 35%; 89 + top: 20%; 90 + transform: rotate(45deg); 91 + } 92 + 93 + h1 { 94 + color: #1f2937; 95 + font-size: 1.75rem; 96 + margin-bottom: 0.75rem; 97 + font-weight: 600; 98 + } 99 + 100 + p { 101 + color: #6b7280; 102 + font-size: 1.1rem; 103 + line-height: 1.6; 104 + margin-bottom: 1.5rem; 105 + } 106 + 107 + .instructions { 108 + background: #f3f4f6; 109 + padding: 1rem; 110 + border-radius: 10px; 111 + margin-top: 1.5rem; 112 + text-align: left; 113 + } 114 + 115 + .instructions h3 { 116 + color: #1f2937; 117 + font-size: 1.1rem; 118 + margin-bottom: 0.5rem; 119 + } 120 + 121 + .instructions code { 122 + background: #e5e7eb; 123 + padding: 0.2rem 0.4rem; 124 + border-radius: 4px; 125 + font-family: 'Courier New', monospace; 126 + font-size: 0.9rem; 127 + display: block; 128 + margin: 0.5rem 0; 129 + padding: 0.75rem; 130 + white-space: pre-wrap; 131 + } 132 + 133 + .footer { 134 + margin-top: 2rem; 135 + font-size: 0.9rem; 136 + color: #9ca3af; 137 + } 138 + 139 + .spinner { 140 + display: inline-block; 141 + width: 16px; 142 + height: 16px; 143 + border: 2px solid #e5e7eb; 144 + border-radius: 50%; 145 + border-top-color: #6366f1; 146 + animation: spin 1s ease-in-out infinite; 147 + margin-left: 0.5rem; 148 + vertical-align: middle; 149 + } 150 + 151 + @keyframes spin { 152 + to { 153 + transform: rotate(360deg); 154 + } 155 + } 156 + 157 + .link { 158 + color: #6366f1; 159 + text-decoration: none; 160 + font-weight: 500; 161 + } 162 + 163 + .link:hover { 164 + text-decoration: underline; 165 + }
+124 -19
src/index.ts
··· 1 - /** 2 - * Welcome to Cloudflare Workers! This is your first worker. 3 - * 4 - * - Run `npm run dev` in your terminal to start a development server 5 - * - Open a browser tab at http://localhost:8787/ to see your worker in action 6 - * - Run `npm run deploy` to publish your worker 7 - * 8 - * Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the 9 - * `Env` object can be regenerated with `npm run cf-typegen`. 10 - * 11 - * Learn more at https://developers.cloudflare.com/workers/ 12 - */ 1 + 2 + interface Env { 3 + OAUTH_SESSIONS: KVNamespace; 4 + ASSETS: Fetcher; 5 + } 13 6 14 7 export default { 15 - async fetch(request, env, ctx): Promise<Response> { 8 + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 16 9 const url = new URL(request.url); 17 10 switch (url.pathname) { 18 - case '/message': 19 - return new Response('Hello, World!'); 20 - case '/random': 21 - return new Response(crypto.randomUUID()); 22 - default: 23 - return new Response('Not Found', { status: 404 }); 11 + case '/oauth/callback': 12 + return handleOAuthCallback(request, env, ctx); 13 + case '/oauth/events': 14 + return handleSSE(request, env, ctx) 24 15 } 16 + return env.ASSETS.fetch(request); 25 17 }, 26 18 } satisfies ExportedHandler<Env>; 19 + 20 + async function handleSSE(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 21 + const url = new URL(request.url); 22 + const sessionId = url.searchParams.get('session'); 23 + 24 + if (!sessionId) { 25 + return new Response('Missing session ID', { status: 400 }); 26 + } 27 + 28 + // Create a TransformStream for SSE 29 + const { readable, writable } = new TransformStream(); 30 + const writer = writable.getWriter(); 31 + const encoder = new TextEncoder(); 32 + 33 + // SSE headers 34 + const headers = { 35 + 'Content-Type': 'text/event-stream', 36 + 'Cache-Control': 'no-cache', 37 + 'Connection': 'keep-alive', 38 + 'Access-Control-Allow-Origin': '*', // Adjust for your security needs 39 + }; 40 + 41 + // Start the SSE response 42 + const response = new Response(readable, { headers }); 43 + 44 + // Handle the async SSE logic 45 + (async () => { 46 + try { 47 + // Send initial connection message 48 + await writer.write(encoder.encode(': ping\n\n')); 49 + 50 + // Poll for auth data (max 5 minutes) 51 + const maxAttempts = 60; // 5 minutes with 5-second intervals 52 + let attempts = 0; 53 + 54 + while (attempts < maxAttempts) { 55 + // Check if auth data exists in KV 56 + const authData = await env.OAUTH_SESSIONS.get(sessionId); 57 + 58 + if (authData) { 59 + // Parse and send the auth data 60 + const data = JSON.parse(authData); 61 + 62 + // Send auth complete event 63 + await writer.write(encoder.encode( 64 + `event: auth-complete\ndata: ${authData}\n\n` 65 + )); 66 + 67 + // Delete the session immediately after sending 68 + await env.OAUTH_SESSIONS.delete(sessionId); 69 + 70 + // Send close event 71 + await writer.write(encoder.encode('event: close\ndata: {}\n\n')); 72 + break; 73 + } 74 + 75 + // Wait 5 seconds before next check 76 + await new Promise(resolve => setTimeout(resolve, 5000)); 77 + 78 + // Send keepalive 79 + await writer.write(encoder.encode(': keepalive\n\n')); 80 + 81 + attempts++; 82 + } 83 + 84 + // Timeout if no auth received 85 + if (attempts >= maxAttempts) { 86 + await writer.write(encoder.encode( 87 + 'event: timeout\ndata: {"error": "Authentication timeout"}\n\n' 88 + )); 89 + } 90 + 91 + } catch (error: unknown) { 92 + // Send error event 93 + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 94 + await writer.write(encoder.encode( 95 + `event: error\ndata: ${JSON.stringify({ error: errorMessage })}\n\n` 96 + )); 97 + } finally { 98 + await writer.close(); 99 + } 100 + })(); 101 + 102 + return response; 103 + } 104 + 105 + async function handleOAuthCallback(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 106 + const url = new URL(request.url); 107 + const code = url.searchParams.get('code'); 108 + const iss = url.searchParams.get('iss'); 109 + const state = url.searchParams.get('state'); 110 + 111 + if (!code || !state || !iss) { 112 + return new Response('Missing required parameters', { status: 400 }); 113 + } 114 + 115 + // Store auth data in KV 116 + await env.OAUTH_SESSIONS.put( 117 + state, 118 + JSON.stringify({ 119 + code, 120 + iss, 121 + state, 122 + timestamp: Date.now() 123 + }), 124 + { 125 + expirationTtl: 300 // 5 minutes 126 + } 127 + ); 128 + 129 + // Redirect to success page 130 + return Response.redirect(new URL('/oauth/success', request.url).toString(), 302); 131 + }
+16 -3
wrangler.jsonc
··· 4 4 */ 5 5 { 6 6 "$schema": "node_modules/wrangler/config-schema.json", 7 - "name": "jolly-sun-74a6", 7 + "name": "blupimgsblue", 8 8 "main": "src/index.ts", 9 9 "compatibility_date": "2025-06-28", 10 10 "compatibility_flags": [ 11 11 "global_fetch_strictly_public" 12 12 ], 13 13 "assets": { 14 - "directory": "./public" 14 + "directory": "./public", 15 + "binding": "ASSETS", 15 16 }, 16 17 "observability": { 17 18 "enabled": true 18 - } 19 + }, 20 + "kv_namespaces": [ 21 + { 22 + "binding": "OAUTH_SESSIONS", 23 + "id": "dcd7506b76584cf1b2eef1d0effbd45b" 24 + } 25 + ], 26 + "routes": [ 27 + { 28 + "pattern": "blup.imgs.blue", 29 + "custom_domain": true 30 + } 31 + ] 19 32 /** 20 33 * Smart Placement 21 34 * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement