image cache on cloudflare r2
at main 12 kB view raw
1import { nanoid } from "nanoid"; 2import sharp from "sharp"; 3import dashboard from "./dashboard.html"; 4import { 5 getStats, 6 getTopImages, 7 getTotalHits, 8 getTraffic, 9 getUniqueImages, 10 recordHit, 11} from "./stats"; 12 13// Configuration from env 14const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || ""; 15const PUBLIC_URL = process.env.PUBLIC_URL || "http://localhost:3000"; 16const AUTH_TOKEN = process.env.AUTH_TOKEN; 17 18// S3 configuration 19const S3_ACCESS_KEY_ID = 20 process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || ""; 21const S3_SECRET_ACCESS_KEY = 22 process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || ""; 23const S3_BUCKET = 24 process.env.S3_BUCKET || process.env.AWS_BUCKET || "l4-images"; 25const S3_ENDPOINT = process.env.S3_ENDPOINT || process.env.AWS_ENDPOINT || ""; 26const S3_REGION = process.env.S3_REGION || process.env.AWS_REGION || "auto"; 27 28// Slack configuration 29const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN || ""; 30const _SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET || ""; 31const ALLOWED_CHANNELS = 32 process.env.ALLOWED_CHANNELS?.split(",").map((c) => c.trim()) || []; 33 34// Create S3 client for R2 with explicit configuration 35const s3 = new Bun.S3Client({ 36 accessKeyId: S3_ACCESS_KEY_ID, 37 secretAccessKey: S3_SECRET_ACCESS_KEY, 38 endpoint: S3_ENDPOINT, 39 bucket: S3_BUCKET, 40 region: S3_REGION, 41}); 42 43async function optimizeImage( 44 buffer: Buffer, 45 mimeType: string, 46 preserveFormat = false, 47): Promise<{ buffer: Buffer; contentType: string; extension: string }> { 48 // Skip SVGs - just return as-is 49 if (mimeType === "image/svg+xml") { 50 return { buffer, contentType: mimeType, extension: "svg" }; 51 } 52 53 // If preserveFormat is true, keep original format 54 if (preserveFormat) { 55 const extension = mimeType.split("/")[1] || "jpg"; 56 return { buffer, contentType: mimeType, extension }; 57 } 58 59 // Convert to WebP with optimization (effort 4 = balanced speed/compression) 60 const optimized = await sharp(buffer) 61 .webp({ quality: 85, effort: 4 }) // effort: 0-6, 4 is faster than 6 with minimal quality loss 62 .toBuffer(); 63 64 return { buffer: optimized, contentType: "image/webp", extension: "webp" }; 65} 66 67async function uploadImageToR2( 68 buffer: Buffer, 69 contentType: string, 70): Promise<string> { 71 // Skip collision check - nanoid(12) has 4.7 quadrillion possibilities, collision is astronomically unlikely 72 const extension = contentType === "image/svg+xml" ? "svg" : "webp"; 73 const imageKey = `${nanoid(12)}.${extension}`; 74 75 // Upload to R2 using the S3 client 76 await s3.write(imageKey, buffer, { type: contentType }); 77 78 return imageKey; 79} 80 81// HTTP server for Slack events 82const server = Bun.serve({ 83 port: process.env.PORT || 3000, 84 85 routes: { 86 "/": { 87 GET(request) { 88 const accept = request.headers.get("Accept") || ""; 89 if (accept.includes("text/html")) { 90 const url = new URL(request.url); 91 return Response.redirect(`${url.origin}/dashboard`, 302); 92 } 93 94 const banner = ` 95 ██╗ ██╗ ██╗ 96 ██║ ██║ ██║ 97 ██║ ███████║ 98 ██║ ╚════██║ 99 ███████╗ ██║ 100 ╚══════╝ ╚═╝ 101 102 L4 Image CDN 103 104 Endpoints: 105 POST /upload Upload an image 106 GET /i/:key Fetch an image 107 GET /dashboard Stats dashboard 108 GET /health Health check 109`; 110 return new Response(banner, { 111 headers: { "Content-Type": "text/plain" }, 112 }); 113 }, 114 }, 115 116 "/slack/events": { 117 async POST(request) { 118 return handleSlackEvent(request); 119 }, 120 }, 121 122 "/upload": { 123 async POST(request) { 124 return handleUpload(request); 125 }, 126 }, 127 128 "/health": { 129 async GET(_request) { 130 return Response.json({ status: "ok" }); 131 }, 132 }, 133 134 "/dashboard": dashboard, 135 136 "/api/stats/overview": { 137 GET(request) { 138 const url = new URL(request.url); 139 const days = parseInt(url.searchParams.get("days") || "7", 10); 140 const safeDays = Math.min(Math.max(days, 1), 365); 141 142 return Response.json({ 143 totalHits: getTotalHits(safeDays), 144 uniqueImages: getUniqueImages(safeDays), 145 topImages: getTopImages(safeDays, 20), 146 }); 147 }, 148 }, 149 150 "/api/stats/traffic": { 151 GET(request) { 152 const url = new URL(request.url); 153 const startParam = url.searchParams.get("start"); 154 const endParam = url.searchParams.get("end"); 155 156 if (startParam && endParam) { 157 // Zoom mode: specific time range 158 const start = parseInt(startParam, 10); 159 const end = parseInt(endParam, 10); 160 const spanDays = (end - start) / 86400; 161 162 return Response.json( 163 getTraffic(spanDays, { startTime: start, endTime: end }), 164 ); 165 } 166 167 // Normal mode: last N days 168 const days = parseInt(url.searchParams.get("days") || "7", 10); 169 const safeDays = Math.min(Math.max(days, 1), 365); 170 171 return Response.json(getTraffic(safeDays)); 172 }, 173 }, 174 175 "/api/stats/image/:key": { 176 GET(request) { 177 const imageKey = request.params.key; 178 const url = new URL(request.url); 179 const days = parseInt(url.searchParams.get("days") || "30", 10); 180 const safeDays = Math.min(Math.max(days, 1), 365); 181 182 return Response.json(getStats(imageKey, safeDays)); 183 }, 184 }, 185 186 "/i/:key": { 187 async GET(request) { 188 const imageKey = request.params.key; 189 if (!imageKey) { 190 return new Response("Not found", { status: 404 }); 191 } 192 193 recordHit(imageKey); 194 195 if (!R2_PUBLIC_URL) { 196 return new Response("R2_PUBLIC_URL not configured", { status: 500 }); 197 } 198 199 return Response.redirect(`${R2_PUBLIC_URL}/${imageKey}`, 307); 200 }, 201 }, 202 }, 203 204 // Fallback for unmatched routes 205 async fetch(_request) { 206 return new Response("Not found", { status: 404 }); 207 }, 208 development: process.env?.NODE_ENV === "dev", 209}); 210 211async function handleUpload(request: Request) { 212 try { 213 // Check auth token 214 const authHeader = request.headers.get("Authorization"); 215 if (!AUTH_TOKEN || authHeader !== `Bearer ${AUTH_TOKEN}`) { 216 return new Response("Unauthorized", { status: 401 }); 217 } 218 219 // Parse multipart form data 220 const formData = await request.formData(); 221 const file = formData.get("file") as File; 222 223 if (!file) { 224 return Response.json( 225 { success: false, error: "No file provided" }, 226 { status: 400 }, 227 ); 228 } 229 230 // Check if preserveFormat is requested 231 const preserveFormat = formData.get("preserveFormat") === "true"; 232 233 // Read file buffer 234 const originalBuffer = Buffer.from(await file.arrayBuffer()); 235 const contentType = file.type || "image/jpeg"; 236 237 // Optimize image 238 const { buffer: optimizedBuffer, contentType: newContentType } = 239 await optimizeImage(originalBuffer, contentType, preserveFormat); 240 241 // Upload to R2 242 const imageKey = await uploadImageToR2(optimizedBuffer, newContentType); 243 const url = `${PUBLIC_URL}/i/${imageKey}`; 244 245 return Response.json({ success: true, url }); 246 } catch (error) { 247 console.error("Error handling upload:", error); 248 return Response.json( 249 { success: false, error: "Upload failed" }, 250 { status: 500 }, 251 ); 252 } 253} 254 255async function handleSlackEvent(request: Request) { 256 try { 257 const body = await request.text(); 258 const payload = JSON.parse(body); 259 260 // URL verification challenge 261 if (payload.type === "url_verification") { 262 return new Response(JSON.stringify({ challenge: payload.challenge }), { 263 headers: { "Content-Type": "application/json" }, 264 }); 265 } 266 267 // Handle file message events 268 if ( 269 payload.type === "event_callback" && 270 payload.event?.type === "message" 271 ) { 272 const event = payload.event; 273 274 // Check for files 275 if (!event.files || event.files.length === 0) { 276 return new Response("OK", { status: 200 }); 277 } 278 279 // Check if channel is allowed 280 if ( 281 ALLOWED_CHANNELS.length > 0 && 282 !ALLOWED_CHANNELS.includes(event.channel) 283 ) { 284 return new Response("OK", { status: 200 }); 285 } 286 287 // Process files in background (don't await - return 200 immediately) 288 processSlackFiles(event).catch(console.error); 289 290 return new Response("OK", { status: 200 }); 291 } 292 293 return new Response("OK", { status: 200 }); 294 } catch (error) { 295 console.error("Error handling Slack event:", error); 296 return new Response("Internal Server Error", { status: 500 }); 297 } 298} 299 300interface SlackFile { 301 url_private: string; 302 name: string; 303 mimetype: string; 304} 305 306interface SlackMessageEvent { 307 text?: string; 308 files?: SlackFile[]; 309 channel: string; 310 ts: string; 311} 312 313async function processSlackFiles(event: SlackMessageEvent) { 314 try { 315 // Check if message text contains "preserve" 316 const preserveFormat = 317 event.text?.toLowerCase().includes("preserve") ?? false; 318 319 // React with loading emoji (don't await - do it in parallel with downloads) 320 const loadingReaction = callSlackAPI("reactions.add", { 321 channel: event.channel, 322 timestamp: event.ts, 323 name: "spinny_fox", 324 }); 325 326 // Process all files in parallel 327 const filePromises = (event.files || []).map(async (file) => { 328 try { 329 console.log(`Processing file: ${file.name}`); 330 331 // Download file from Slack 332 const fileResponse = await fetch(file.url_private, { 333 headers: { 334 Authorization: `Bearer ${SLACK_BOT_TOKEN}`, 335 }, 336 }); 337 338 if (!fileResponse.ok) { 339 throw new Error("Failed to download file from Slack"); 340 } 341 342 const originalBuffer = Buffer.from(await fileResponse.arrayBuffer()); 343 const contentType = file.mimetype || "image/jpeg"; 344 345 console.log(`Downloaded ${file.name} (${originalBuffer.length} bytes)`); 346 347 // Optimize image (preserve format if message says "preserve") 348 const { buffer: optimizedBuffer, contentType: newContentType } = 349 await optimizeImage(originalBuffer, contentType, preserveFormat); 350 351 const savings = ( 352 (1 - optimizedBuffer.length / originalBuffer.length) * 353 100 354 ).toFixed(1); 355 if (preserveFormat) { 356 console.log( 357 `Uploaded: ${originalBuffer.length} bytes (format preserved)`, 358 ); 359 } else { 360 console.log( 361 `Optimized: ${originalBuffer.length}${optimizedBuffer.length} bytes (${savings}% reduction)`, 362 ); 363 } 364 365 // Upload to R2 366 const imageKey = await uploadImageToR2(optimizedBuffer, newContentType); 367 console.log(`Uploaded to R2: ${imageKey}`); 368 369 return `${PUBLIC_URL}/i/${imageKey}`; 370 } catch (error) { 371 console.error(`Error processing file ${file.name}:`, error); 372 return null; 373 } 374 }); 375 376 // Wait for all files to complete 377 const results = await Promise.all(filePromises); 378 const urls = results.filter((url): url is string => url !== null); 379 380 // Ensure loading reaction is done 381 await loadingReaction; 382 383 // Do all Slack API calls in parallel 384 const apiCalls: Promise<unknown>[] = [ 385 // Remove loading reaction 386 callSlackAPI("reactions.remove", { 387 channel: event.channel, 388 timestamp: event.ts, 389 name: "spinny_fox", 390 }), 391 ]; 392 393 if (urls.length > 0) { 394 apiCalls.push( 395 // Add success reaction 396 callSlackAPI("reactions.add", { 397 channel: event.channel, 398 timestamp: event.ts, 399 name: "yay-still", 400 }), 401 // Post URLs in thread 402 callSlackAPI("chat.postMessage", { 403 channel: event.channel, 404 thread_ts: event.ts, 405 text: urls.join("\n"), 406 }), 407 ); 408 } else { 409 apiCalls.push( 410 // Add error reaction 411 callSlackAPI("reactions.add", { 412 channel: event.channel, 413 timestamp: event.ts, 414 name: "rac-concern", 415 }), 416 ); 417 } 418 419 await Promise.all(apiCalls); 420 } catch (error) { 421 console.error("Error processing Slack files:", error); 422 423 // Add error reaction 424 await callSlackAPI("reactions.add", { 425 channel: event.channel, 426 timestamp: event.ts, 427 name: "rac-concern", 428 }).catch(console.error); 429 } 430} 431 432async function callSlackAPI(method: string, params: Record<string, unknown>) { 433 const response = await fetch(`https://slack.com/api/${method}`, { 434 method: "POST", 435 headers: { 436 "Content-Type": "application/json", 437 Authorization: `Bearer ${SLACK_BOT_TOKEN}`, 438 }, 439 body: JSON.stringify(params), 440 }); 441 442 const data = await response.json(); 443 if (!data.ok) { 444 throw new Error(`Slack API error: ${data.error}`); 445 } 446 447 return data; 448} 449 450console.log(`L4 Image CDN started on port ${server.port}`); 451console.log(`- S3 Bucket: ${S3_BUCKET}`); 452console.log(`- S3 Endpoint: ${S3_ENDPOINT}`); 453console.log(`- S3 Region: ${S3_REGION}`); 454console.log(`- R2 Public URL: ${R2_PUBLIC_URL}`); 455console.log(`- Public URL: ${PUBLIC_URL}`); 456console.log(`- Slack events: ${PUBLIC_URL}/slack/events`);