Monorepo for Aesthetic.Computer
aesthetic.computer
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}