image cache on cloudflare r2
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`);