Monorepo for Aesthetic.Computer aesthetic.computer
at main 457 lines 17 kB view raw
1// Media 2 3export const config = { path: "/media/*" }; 4 5export default async function handleRequest(request) { 6 const url = new URL(request.url); 7 const path = url.pathname.split("/"); 8 let newUrl; 9 10 if (path[1] === "media") { 11 const resourcePath = path.slice(2).join("/"); 12 13 // Handle /media/tapes/CODE, /media/paintings/CODE, or /media/pieces/SLUG routes 14 if (path[2] === "tapes" && path[3]) { 15 // Strip .zip extension if present 16 const code = path[3].replace(/\.zip$/, ''); 17 return await handleTapeCodeRequest(code); 18 } 19 20 if (path[2] === "paintings" && path[3]) { 21 return await handlePaintingCodeRequest(path[3]); 22 } 23 24 if (path[2] === "pieces" && path[3]) { 25 return await handlePieceSlugRequest(path[3]); 26 } 27 28 if (!path[2]?.includes("@") && !path[2]?.match(/^ac[a-z0-9]+$/i)) { 29 // No @ prefix and not a user code (acXXXXX format) - treat as direct file path 30 const extension = resourcePath.split(".").pop()?.toLowerCase(); 31 32 // Special handling for .mp4 tape files 33 if (extension === "mp4" && resourcePath.match(/^[^/]+\/[^/]+-\d+\.mp4$/)) { 34 return await handleTapeMp4Request(resourcePath); 35 } 36 37 const baseUrl = 38 extension === "mjs" 39 ? "https://user-aesthetic-computer.sfo3.digitaloceanspaces.com" 40 : "https://user.aesthetic.computer"; 41 42 newUrl = `${baseUrl}/${resourcePath}`; 43 // Properly encode the URL, especially the pipe character in Auth0 user IDs 44 const response = await fetch(newUrl.split('/').map((part, i) => i < 3 ? part : encodeURIComponent(part)).join('/')); 45 46 // Determine Content-Type based on file extension 47 let contentType = response.headers.get("Content-Type"); 48 49 // Override Content-Type based on extension if needed 50 if (extension === "png") contentType = "image/png"; 51 else if (extension === "jpg" || extension === "jpeg") contentType = "image/jpeg"; 52 else if (extension === "gif") contentType = "image/gif"; 53 else if (extension === "webp") contentType = "image/webp"; 54 else if (extension === "zip") contentType = "application/zip"; 55 else if (extension === "mp4") contentType = "video/mp4"; 56 else if (extension === "json") contentType = "application/json"; 57 58 const moddedResponse = new Response(response.body, { 59 headers: { ...response.headers }, 60 status: response.status, 61 statusText: response.statusText, 62 }); 63 moddedResponse.headers.set("Access-Control-Allow-Origin", "*"); 64 moddedResponse.headers.set("Content-Type", contentType); 65 moddedResponse.headers.set("Content-Disposition", "inline"); 66 return moddedResponse; 67 } else { 68 // Handle both @username and acXXXXX user code formats 69 const userIdentifier = path[2]; 70 const userId = await queryUserID(userIdentifier, request); 71 72 if (!userId) { 73 return new Response(`User not found: ${userIdentifier}`, { status: 404 }); 74 } 75 76 const newPath = `${userId}/${path.slice(3).join("/")}`; 77 78 if (newPath.split("/").pop().split(".")[1]?.length > 0) { 79 if (newPath.split(".").pop() === "mjs") { 80 newUrl = `https://user-aesthetic-computer.sfo3.digitaloceanspaces.com/${newPath}`; 81 } else { 82 newUrl = `https://user.aesthetic.computer/${newPath}`; 83 } 84 // TODO: How can I ensure that Allow-Origin * can be here? 85 // Properly encode the URL, especially the pipe character in Auth0 user IDs 86 const response = await fetch(newUrl.split('/').map((part, i) => i < 3 ? part : encodeURIComponent(part)).join('/')); 87 // Create a new Response object using the fetched response's body 88 89 // Determine Content-Type based on file extension 90 const extension = newPath.split(".").pop()?.toLowerCase(); 91 let contentType = response.headers.get("Content-Type"); 92 93 // Override Content-Type based on extension if needed 94 if (extension === "png") contentType = "image/png"; 95 else if (extension === "jpg" || extension === "jpeg") contentType = "image/jpeg"; 96 else if (extension === "gif") contentType = "image/gif"; 97 else if (extension === "webp") contentType = "image/webp"; 98 else if (extension === "zip") contentType = "application/zip"; 99 else if (extension === "mp4") contentType = "video/mp4"; 100 else if (extension === "json") contentType = "application/json"; 101 102 const moddedResponse = new Response(response.body, { 103 // Copy all the fetched response's headers 104 headers: { ...response.headers }, 105 status: response.status, 106 statusText: response.statusText, 107 }); 108 // // Set the Access-Control-Allow-Origin header to * 109 moddedResponse.headers.set("Access-Control-Allow-Origin", "*"); 110 moddedResponse.headers.set("Content-Type", contentType); 111 moddedResponse.headers.set("Content-Disposition", "inline"); 112 return moddedResponse; 113 // return fetch(encodeURI(newUrl)); 114 } else { 115 const path = newPath.replace("/media", ""); 116 newUrl = `/media-collection?for=${path}`; 117 return new URL(newUrl, request.url); 118 } 119 } 120 } else { 121 return new Response("💾 Not a `media` path.", { status: 500 }); 122 } 123} 124 125async function queryUserID(userIdentifier, request) { 126 // Use the same host as the incoming request 127 const requestUrl = new URL(request.url); 128 const host = `${requestUrl.protocol}//${requestUrl.host}`; 129 130 // Determine if it's a user code (acXXXXX) or handle (@username) 131 let url; 132 if (userIdentifier.match(/^ac[a-z0-9]+$/i)) { 133 // User code format - query by code 134 url = `${host}/user?code=${encodeURIComponent(userIdentifier)}`; 135 } else { 136 // Handle format (with or without @) 137 url = `${host}/user?from=${encodeURIComponent(userIdentifier)}`; 138 } 139 140 try { 141 const res = await fetch(url); 142 if (res.ok) { 143 const json = await res.json(); 144 return json.sub; 145 } else { 146 console.error(`Error: ${res.status} ${res.statusText}`); 147 console.error( 148 `Response headers: ${JSON.stringify( 149 Array.from(res.headers.entries()), 150 )}`, 151 ); 152 return null; 153 } 154 } catch (error) { 155 console.error(`Fetch failed: ${error}`); 156 return null; 157 } 158} 159 160/** 161 * Handle tape code requests like /media/tapes/A6LwKTML 162 * @param {string} code - Tape code 163 * @returns {Response} 164 */ 165async function handleTapeCodeRequest(code) { 166 const host = Deno.env.get("CONTEXT") === "dev" 167 ? "https://localhost:8888" 168 : "https://aesthetic.computer"; 169 170 try { 171 // Query tape by code 172 const res = await fetch(`${host}/.netlify/functions/get-tape?code=${encodeURIComponent(code)}`); 173 174 if (!res.ok) { 175 return new Response(`Tape not found: ${code}`, { status: 404 }); 176 } 177 178 const tape = await res.json(); 179 180 // Redirect to the ZIP file 181 const bucket = tape.bucket || "art-aesthetic-computer"; 182 const key = tape.user ? `${tape.user}/${tape.slug}.zip` : `${tape.slug}.zip`; 183 const zipUrl = `https://${bucket}.sfo3.digitaloceanspaces.com/${key}`; 184 185 return Response.redirect(zipUrl, 302); 186 187 } catch (error) { 188 console.error(`Error fetching tape by code:`, error); 189 return new Response("Error fetching tape", { status: 500 }); 190 } 191} 192 193/** 194 * Handle painting code requests like /media/paintings/ABC123, /media/paintings/ABC123.png, or /media/paintings/ABC123.zip 195 * @param {string} codeWithExtension - Painting code with optional .png or .zip extension 196 * @returns {Response} 197 */ 198async function handlePaintingCodeRequest(codeWithExtension) { 199 const host = Deno.env.get("CONTEXT") === "dev" 200 ? "https://localhost:8888" 201 : "https://aesthetic.computer"; 202 203 // Check if requesting the recording ZIP or image PNG 204 const isZipRequest = codeWithExtension.endsWith('.zip'); 205 const isPngRequest = codeWithExtension.endsWith('.png'); 206 207 // Strip extension to get the code 208 let code = codeWithExtension; 209 if (isZipRequest) { 210 code = codeWithExtension.slice(0, -4); // Remove .zip 211 } else if (isPngRequest) { 212 code = codeWithExtension.slice(0, -4); // Remove .png 213 } 214 215 try { 216 // Query painting by code (try code first, then slug if it looks like a timestamp) 217 let res = await fetch(`${host}/.netlify/functions/get-painting?code=${encodeURIComponent(code)}`); 218 219 // If not found and looks like a timestamp, try querying by slug 220 if (!res.ok && code.match(/^\d{4}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,3}$/)) { 221 res = await fetch(`${host}/.netlify/functions/get-painting?slug=${encodeURIComponent(code)}`); 222 } 223 224 if (!res.ok) { 225 return new Response(`Painting not found: ${code}`, { status: 404 }); 226 } 227 228 const painting = await res.json(); 229 230 // If user painting, we need the handle to construct the authenticated URL 231 let userHandle = null; 232 if (painting.user) { 233 // Look up handle from @handles collection via existing handle endpoint 234 const handleRes = await fetch(`${host}/.netlify/functions/handle?for=${encodeURIComponent(painting.user)}`); 235 if (handleRes.ok) { 236 const handleData = await handleRes.json(); 237 userHandle = handleData.handle; 238 } 239 } 240 241 if (isZipRequest) { 242 // Return the recording ZIP 243 const bucket = painting.bucket || (painting.user ? "user-aesthetic-computer" : "art-aesthetic-computer"); 244 let recordingSlug; 245 246 if (painting.slug.includes(':')) { 247 // Anonymous painting: combined slug format (imageSlug:recordingSlug) 248 [, recordingSlug] = painting.slug.split(':'); 249 } else { 250 // User painting: same slug as image, just different extension 251 recordingSlug = painting.slug; 252 } 253 254 const key = painting.user ? `${painting.user}/${recordingSlug}.zip` : `${recordingSlug}.zip`; 255 const zipUrl = `https://${bucket}.sfo3.digitaloceanspaces.com/${encodeURIComponent(painting.user)}/${recordingSlug}.zip`; 256 257 // Let DigitalOcean Spaces return 404 if the file doesn't exist 258 return Response.redirect(zipUrl, 302); 259 } else { 260 // Return the PNG file (whether .png extension was provided or not) 261 // Extract image slug from combined slug if present (imageSlug:recordingSlug) 262 const imageSlug = painting.slug.includes(':') ? painting.slug.split(':')[0] : painting.slug; 263 const bucket = painting.bucket || (painting.user ? "user-aesthetic-computer" : "art-aesthetic-computer"); 264 265 if (painting.user && userHandle) { 266 // User painting - redirect to authenticated endpoint which handles private buckets 267 const redirectUrl = `${host}/media/@${userHandle}/painting/${imageSlug}.png`; 268 return Response.redirect(redirectUrl, 302); 269 } else if (painting.user && !userHandle) { 270 return new Response(`User not found for painting: ${code}`, { status: 404 }); 271 } else { 272 // Anonymous painting - direct redirect to public bucket 273 const pngUrl = `https://${bucket}.sfo3.digitaloceanspaces.com/${imageSlug}.png`; 274 return Response.redirect(pngUrl, 302); 275 } 276 } 277 278 } catch (error) { 279 console.error(`Error fetching painting by code:`, error); 280 return new Response("Error fetching painting", { status: 500 }); 281 } 282} 283 284/** 285 * Handle piece slug requests like /media/pieces/SLUG or /media/pieces/SLUG.mjs 286 * @param {string} slugWithExtension - Piece slug with optional .mjs extension 287 * @returns {Response} 288 */ 289async function handlePieceSlugRequest(slugWithExtension) { 290 // Strip .mjs extension if present 291 const slug = slugWithExtension.endsWith('.mjs') 292 ? slugWithExtension.slice(0, -4) 293 : slugWithExtension; 294 295 // Pieces are stored in art-aesthetic-computer bucket (anonymous) or user bucket 296 // For now, try art bucket first (anonymous pieces) 297 const artBucket = "art-aesthetic-computer"; 298 const mjsUrl = `https://${artBucket}.sfo3.digitaloceanspaces.com/${slug}.mjs`; 299 300 try { 301 // Try to fetch the piece from DigitalOcean Spaces 302 const response = await fetch(mjsUrl); 303 304 if (response.ok) { 305 // Return the .mjs file with proper headers 306 const moddedResponse = new Response(response.body, { 307 headers: { ...response.headers }, 308 status: response.status, 309 statusText: response.statusText, 310 }); 311 moddedResponse.headers.set("Access-Control-Allow-Origin", "*"); 312 moddedResponse.headers.set("Content-Type", "application/javascript"); 313 moddedResponse.headers.set("Content-Disposition", "inline"); 314 return moddedResponse; 315 } else { 316 return new Response(`Piece not found: ${slug}`, { status: 404 }); 317 } 318 } catch (error) { 319 console.error(`Error fetching piece by slug:`, error); 320 return new Response("Error fetching piece", { status: 500 }); 321 } 322} 323 324/** 325 * Handle tape MP4 requests with conversion status checking 326 * @param {string} resourcePath - Path like "userId/tape-slug.mp4" 327 * @returns {Response} 328 */ 329async function handleTapeMp4Request(resourcePath) { 330 // Extract slug from path (remove .mp4 extension) 331 const pathParts = resourcePath.split("/"); 332 const filename = pathParts[pathParts.length - 1]; 333 const slug = filename.replace(/\.mp4$/, ""); 334 335 // Query MongoDB for tape status 336 const host = Deno.env.get("CONTEXT") === "dev" 337 ? "https://localhost:8888" 338 : "https://aesthetic.computer"; 339 340 try { 341 const res = await fetch(`${host}/.netlify/functions/get-tape-status?slug=${encodeURIComponent(slug)}`); 342 343 if (!res.ok) { 344 return new Response("Tape not found", { status: 404 }); 345 } 346 347 const tape = await res.json(); 348 349 // Check MP4 conversion status 350 if (tape.mp4Status === "complete" && tape.mp4) { 351 // MP4 is ready - redirect to actual file 352 return Response.redirect(tape.mp4, 302); 353 } else if (tape.mp4Status === "processing") { 354 // MP4 is still processing - return JSON status 355 const acceptHeader = Deno.env.get("HTTP_ACCEPT") || ""; 356 357 if (acceptHeader.includes("application/json")) { 358 return new Response(JSON.stringify({ 359 status: "processing", 360 message: "MP4 conversion in progress", 361 slug: tape.slug, 362 code: tape.code, 363 }), { 364 status: 202, 365 headers: { 366 "Content-Type": "application/json", 367 "Access-Control-Allow-Origin": "*", 368 }, 369 }); 370 } else { 371 // Return HTML for browser requests 372 return new Response(` 373<!DOCTYPE html> 374<html> 375<head> 376 <meta charset="utf-8"> 377 <meta name="viewport" content="width=device-width, initial-scale=1"> 378 <title>Converting Tape...</title> 379 <style> 380 body { 381 font-family: system-ui, -apple-system, sans-serif; 382 display: flex; 383 align-items: center; 384 justify-content: center; 385 min-height: 100vh; 386 margin: 0; 387 background: #0a0a0a; 388 color: #fff; 389 } 390 .container { 391 text-align: center; 392 padding: 2rem; 393 } 394 .spinner { 395 width: 50px; 396 height: 50px; 397 border: 4px solid rgba(255,255,255,0.1); 398 border-top-color: #fff; 399 border-radius: 50%; 400 animation: spin 1s linear infinite; 401 margin: 0 auto 1rem; 402 } 403 @keyframes spin { 404 to { transform: rotate(360deg); } 405 } 406 h1 { margin: 0 0 0.5rem; font-size: 1.5rem; } 407 p { margin: 0; opacity: 0.7; } 408 code { 409 background: rgba(255,255,255,0.1); 410 padding: 0.2rem 0.5rem; 411 border-radius: 4px; 412 font-family: 'Monaco', monospace; 413 } 414 </style> 415 <script> 416 // Auto-refresh every 5 seconds 417 setTimeout(() => location.reload(), 5000); 418 </script> 419</head> 420<body> 421 <div class="container"> 422 <div class="spinner"></div> 423 <h1>🎬 Converting Tape to MP4</h1> 424 <p>Tape <code>${tape.code}</code> is being processed...</p> 425 <p style="margin-top: 1rem; font-size: 0.9rem;">This page will auto-refresh.</p> 426 </div> 427</body> 428</html> 429 `, { 430 status: 202, 431 headers: { 432 "Content-Type": "text/html; charset=utf-8", 433 "Access-Control-Allow-Origin": "*", 434 "Refresh": "5", // Auto-refresh header as backup 435 }, 436 }); 437 } 438 } else { 439 // MP4 not started yet - return pending status 440 return new Response(JSON.stringify({ 441 status: "pending", 442 message: "MP4 conversion not started", 443 slug: tape.slug, 444 code: tape.code, 445 }), { 446 status: 202, 447 headers: { 448 "Content-Type": "application/json", 449 "Access-Control-Allow-Origin": "*", 450 }, 451 }); 452 } 453 } catch (error) { 454 console.error(`Error checking tape status:`, error); 455 return new Response("Error checking tape status", { status: 500 }); 456 } 457}