a cache for slack profile pictures and emojis
1import { serve } from "bun";
2import * as Sentry from "@sentry/bun";
3import { SlackCache } from "./cache";
4import { SlackWrapper } from "./slackWrapper";
5import { getEmojiUrl } from "../utils/emojiHelper";
6import type { SlackUser } from "./slack";
7import swaggerSpec from "./swagger";
8import dashboard from "./dashboard.html";
9import swagger from "./swagger.html";
10
11// Initialize Sentry if DSN is provided
12if (process.env.SENTRY_DSN) {
13 console.log("Sentry DSN provided, error monitoring is enabled");
14 Sentry.init({
15 environment: process.env.NODE_ENV,
16 dsn: process.env.SENTRY_DSN,
17 tracesSampleRate: 0.5,
18 ignoreErrors: [
19 // Ignore all 404-related errors
20 "Not Found",
21 "404",
22 "user_not_found",
23 "emoji_not_found",
24 ],
25 });
26} else {
27 console.warn("Sentry DSN not provided, error monitoring is disabled");
28}
29
30// Initialize SlackWrapper and Cache
31const slackApp = new SlackWrapper();
32const cache = new SlackCache(
33 process.env.DATABASE_PATH ?? "./data/cachet.db",
34 25,
35 async () => {
36 console.log("Fetching emojis from Slack");
37 const emojis = await slackApp.getEmojiList();
38 const emojiEntries = Object.entries(emojis)
39 .map(([name, url]) => {
40 if (typeof url === "string" && url.startsWith("alias:")) {
41 const aliasName = url.substring(6); // Remove 'alias:' prefix
42 const aliasUrl = emojis[aliasName] ?? getEmojiUrl(aliasName) ?? null;
43
44 if (aliasUrl === null) {
45 console.warn(`Could not find alias for ${aliasName}`);
46 return;
47 }
48
49 return {
50 name,
51 imageUrl: aliasUrl === null ? getEmojiUrl(aliasName) : aliasUrl,
52 alias: aliasName,
53 };
54 }
55 return {
56 name,
57 imageUrl: url,
58 alias: null,
59 };
60 })
61 .filter(
62 (
63 entry,
64 ): entry is { name: string; imageUrl: string; alias: string | null } =>
65 entry !== undefined,
66 );
67
68 console.log("Batch inserting emojis");
69 await cache.batchInsertEmojis(emojiEntries);
70 console.log("Finished batch inserting emojis");
71 },
72);
73
74// Setup cron jobs
75setupCronJobs();
76
77// Start the server
78const server = serve({
79 routes: {
80 // HTML routes
81 "/dashboard": dashboard,
82 "/swagger": swagger,
83 "/swagger.json": async (request) => {
84 return Response.json(swaggerSpec);
85 },
86 "/favicon.ico": async (request) => {
87 return new Response(Bun.file("./favicon.ico"));
88 },
89
90 // Root route - redirect to dashboard for browsers
91 "/": async (request) => {
92 const startTime = Date.now();
93 const recordAnalytics = async (statusCode: number) => {
94 const userAgent = request.headers.get("user-agent") || "";
95 const ipAddress =
96 request.headers.get("x-forwarded-for") ||
97 request.headers.get("x-real-ip") ||
98 "unknown";
99
100 await cache.recordRequest(
101 "/",
102 request.method,
103 statusCode,
104 userAgent,
105 ipAddress,
106 Date.now() - startTime,
107 );
108 };
109
110 const userAgent = request.headers.get("user-agent") || "";
111 if (
112 userAgent.toLowerCase().includes("mozilla") ||
113 userAgent.toLowerCase().includes("chrome") ||
114 userAgent.toLowerCase().includes("safari")
115 ) {
116 recordAnalytics(302);
117 return new Response(null, {
118 status: 302,
119 headers: { Location: "/dashboard" },
120 });
121 }
122
123 recordAnalytics(200);
124 return new Response(
125 "Hello World from Cachet 😊\n\n---\nSee /swagger for docs\nSee /dashboard for analytics\n---",
126 );
127 },
128
129 // Health check endpoint
130 "/health": {
131 async GET(request) {
132 const startTime = Date.now();
133 const recordAnalytics = async (statusCode: number) => {
134 const userAgent = request.headers.get("user-agent") || "";
135 const ipAddress =
136 request.headers.get("x-forwarded-for") ||
137 request.headers.get("x-real-ip") ||
138 "unknown";
139
140 await cache.recordRequest(
141 "/health",
142 "GET",
143 statusCode,
144 userAgent,
145 ipAddress,
146 Date.now() - startTime,
147 );
148 };
149
150 return handleHealthCheck(request, recordAnalytics);
151 },
152 },
153
154 // User endpoints
155 "/users/:id": {
156 async GET(request) {
157 const startTime = Date.now();
158 const recordAnalytics = async (statusCode: number) => {
159 const userAgent = request.headers.get("user-agent") || "";
160 const ipAddress =
161 request.headers.get("x-forwarded-for") ||
162 request.headers.get("x-real-ip") ||
163 "unknown";
164
165 await cache.recordRequest(
166 request.url,
167 "GET",
168 statusCode,
169 userAgent,
170 ipAddress,
171 Date.now() - startTime,
172 );
173 };
174
175 return handleGetUser(request, recordAnalytics);
176 },
177 },
178
179 "/users/:id/r": {
180 async GET(request) {
181 const startTime = Date.now();
182 const recordAnalytics = async (statusCode: number) => {
183 const userAgent = request.headers.get("user-agent") || "";
184 const ipAddress =
185 request.headers.get("x-forwarded-for") ||
186 request.headers.get("x-real-ip") ||
187 "unknown";
188
189 await cache.recordRequest(
190 request.url,
191 "GET",
192 statusCode,
193 userAgent,
194 ipAddress,
195 Date.now() - startTime,
196 );
197 };
198
199 return handleUserRedirect(request, recordAnalytics);
200 },
201 },
202
203 "/users/:id/purge": {
204 async POST(request) {
205 const startTime = Date.now();
206 const recordAnalytics = async (statusCode: number) => {
207 const userAgent = request.headers.get("user-agent") || "";
208 const ipAddress =
209 request.headers.get("x-forwarded-for") ||
210 request.headers.get("x-real-ip") ||
211 "unknown";
212
213 await cache.recordRequest(
214 request.url,
215 "POST",
216 statusCode,
217 userAgent,
218 ipAddress,
219 Date.now() - startTime,
220 );
221 };
222
223 return handlePurgeUser(request, recordAnalytics);
224 },
225 },
226
227 // Emoji endpoints
228 "/emojis": {
229 async GET(request) {
230 const startTime = Date.now();
231 const recordAnalytics = async (statusCode: number) => {
232 const userAgent = request.headers.get("user-agent") || "";
233 const ipAddress =
234 request.headers.get("x-forwarded-for") ||
235 request.headers.get("x-real-ip") ||
236 "unknown";
237
238 await cache.recordRequest(
239 "/emojis",
240 "GET",
241 statusCode,
242 userAgent,
243 ipAddress,
244 Date.now() - startTime,
245 );
246 };
247
248 return handleListEmojis(request, recordAnalytics);
249 },
250 },
251
252 "/emojis/:name": {
253 async GET(request) {
254 const startTime = Date.now();
255 const recordAnalytics = async (statusCode: number) => {
256 const userAgent = request.headers.get("user-agent") || "";
257 const ipAddress =
258 request.headers.get("x-forwarded-for") ||
259 request.headers.get("x-real-ip") ||
260 "unknown";
261
262 await cache.recordRequest(
263 request.url,
264 "GET",
265 statusCode,
266 userAgent,
267 ipAddress,
268 Date.now() - startTime,
269 );
270 };
271
272 return handleGetEmoji(request, recordAnalytics);
273 },
274 },
275
276 "/emojis/:name/r": {
277 async GET(request) {
278 const startTime = Date.now();
279 const recordAnalytics = async (statusCode: number) => {
280 const userAgent = request.headers.get("user-agent") || "";
281 const ipAddress =
282 request.headers.get("x-forwarded-for") ||
283 request.headers.get("x-real-ip") ||
284 "unknown";
285
286 await cache.recordRequest(
287 request.url,
288 "GET",
289 statusCode,
290 userAgent,
291 ipAddress,
292 Date.now() - startTime,
293 );
294 };
295
296 return handleEmojiRedirect(request, recordAnalytics);
297 },
298 },
299
300 // Reset cache endpoint
301 "/reset": {
302 async POST(request) {
303 const startTime = Date.now();
304 const recordAnalytics = async (statusCode: number) => {
305 const userAgent = request.headers.get("user-agent") || "";
306 const ipAddress =
307 request.headers.get("x-forwarded-for") ||
308 request.headers.get("x-real-ip") ||
309 "unknown";
310
311 await cache.recordRequest(
312 "/reset",
313 "POST",
314 statusCode,
315 userAgent,
316 ipAddress,
317 Date.now() - startTime,
318 );
319 };
320
321 return handleResetCache(request, recordAnalytics);
322 },
323 },
324
325 // Stats endpoint
326 "/stats": {
327 async GET(request) {
328 const startTime = Date.now();
329 const recordAnalytics = async (statusCode: number) => {
330 const userAgent = request.headers.get("user-agent") || "";
331 const ipAddress =
332 request.headers.get("x-forwarded-for") ||
333 request.headers.get("x-real-ip") ||
334 "unknown";
335
336 await cache.recordRequest(
337 "/stats",
338 "GET",
339 statusCode,
340 userAgent,
341 ipAddress,
342 Date.now() - startTime,
343 );
344 };
345
346 return handleGetStats(request, recordAnalytics);
347 },
348 },
349 },
350
351 // Enable development mode for hot reloading
352 development: {
353 hmr: true,
354 console: true,
355 },
356
357 // Fallback fetch handler for unmatched routes and error handling
358 async fetch(request) {
359 const url = new URL(request.url);
360 const path = url.pathname;
361 const method = request.method;
362 const startTime = Date.now();
363
364 // Record request analytics (except for favicon and swagger)
365 const recordAnalytics = async (statusCode: number) => {
366 if (path !== "/favicon.ico" && !path.startsWith("/swagger")) {
367 const userAgent = request.headers.get("user-agent") || "";
368 const ipAddress =
369 request.headers.get("x-forwarded-for") ||
370 request.headers.get("x-real-ip") ||
371 "unknown";
372
373 await cache.recordRequest(
374 path,
375 method,
376 statusCode,
377 userAgent,
378 ipAddress,
379 Date.now() - startTime,
380 );
381 }
382 };
383
384 try {
385 // Not found
386 recordAnalytics(404);
387 return new Response("Not Found", { status: 404 });
388 } catch (error) {
389 console.error(
390 `\x1b[31m x\x1b[0m unhandled error: \x1b[31m${error instanceof Error ? error.message : String(error)}\x1b[0m`,
391 );
392
393 // Don't send 404 errors to Sentry
394 const is404 =
395 error instanceof Error &&
396 (error.message === "Not Found" ||
397 error.message === "user_not_found" ||
398 error.message === "emoji_not_found");
399
400 if (!is404 && error instanceof Error) {
401 Sentry.withScope((scope) => {
402 scope.setExtra("url", request.url);
403 Sentry.captureException(error);
404 });
405 }
406
407 recordAnalytics(500);
408 return new Response("Internal Server Error", { status: 500 });
409 }
410 },
411
412 port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
413});
414
415console.log(
416 `\n---\n\n🐰 Bun server is running at ${server.url} on ${process.env.NODE_ENV}\n\n---\n`,
417);
418
419// Handler functions
420async function handleHealthCheck(
421 request: Request,
422 recordAnalytics: (statusCode: number) => Promise<void>,
423) {
424 const slackConnection = await slackApp.testAuth();
425 const databaseConnection = await cache.healthCheck();
426
427 if (!slackConnection || !databaseConnection) {
428 await recordAnalytics(500);
429 return Response.json(
430 {
431 http: false,
432 slack: slackConnection,
433 database: databaseConnection,
434 },
435 { status: 500 },
436 );
437 }
438
439 await recordAnalytics(200);
440 return Response.json({
441 http: true,
442 slack: true,
443 database: true,
444 });
445}
446
447async function handleGetUser(
448 request: Request,
449 recordAnalytics: (statusCode: number) => Promise<void>,
450) {
451 const url = new URL(request.url);
452 const userId = url.pathname.split("/").pop() || "";
453 const user = await cache.getUser(userId);
454
455 // If not found then check slack first
456 if (!user || !user.imageUrl) {
457 let slackUser: SlackUser;
458 try {
459 slackUser = await slackApp.getUserInfo(userId);
460 } catch (e) {
461 if (e instanceof Error && e.message === "user_not_found") {
462 await recordAnalytics(404);
463 return Response.json({ message: "User not found" }, { status: 404 });
464 }
465
466 Sentry.withScope((scope) => {
467 scope.setExtra("url", request.url);
468 scope.setExtra("user", userId);
469 Sentry.captureException(e);
470 });
471
472 if (e instanceof Error)
473 console.warn(
474 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
475 );
476
477 await recordAnalytics(500);
478 return Response.json(
479 { message: `Error fetching user from Slack: ${e}` },
480 { status: 500 },
481 );
482 }
483
484 const displayName =
485 slackUser.profile.display_name_normalized ||
486 slackUser.profile.real_name_normalized;
487
488 await cache.insertUser(
489 slackUser.id,
490 displayName,
491 slackUser.profile.pronouns,
492 slackUser.profile.image_512,
493 );
494
495 await recordAnalytics(200);
496 return Response.json({
497 id: slackUser.id,
498 expiration: new Date().toISOString(),
499 user: slackUser.id,
500 displayName: displayName,
501 pronouns: slackUser.profile.pronouns || null,
502 image: slackUser.profile.image_512,
503 });
504 }
505
506 await recordAnalytics(200);
507 return Response.json({
508 id: user.id,
509 expiration: user.expiration.toISOString(),
510 user: user.userId,
511 displayName: user.displayName,
512 pronouns: user.pronouns,
513 image: user.imageUrl,
514 });
515}
516
517async function handleUserRedirect(
518 request: Request,
519 recordAnalytics: (statusCode: number) => Promise<void>,
520) {
521 const url = new URL(request.url);
522 const parts = url.pathname.split("/");
523 const userId = parts[2] || "";
524 const user = await cache.getUser(userId);
525
526 // If not found then check slack first
527 if (!user || !user.imageUrl) {
528 let slackUser: SlackUser;
529 try {
530 slackUser = await slackApp.getUserInfo(userId.toUpperCase());
531 } catch (e) {
532 if (e instanceof Error && e.message === "user_not_found") {
533 console.warn(
534 `\x1b[38;5;214m ⚠️ WARN\x1b[0m user not found: \x1b[38;5;208m${userId}\x1b[0m`,
535 );
536
537 await recordAnalytics(307);
538 return new Response(null, {
539 status: 307,
540 headers: {
541 Location:
542 "https://api.dicebear.com/9.x/thumbs/svg?seed={username_hash}",
543 },
544 });
545 }
546
547 Sentry.withScope((scope) => {
548 scope.setExtra("url", request.url);
549 scope.setExtra("user", userId);
550 Sentry.captureException(e);
551 });
552
553 if (e instanceof Error)
554 console.warn(
555 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
556 );
557
558 await recordAnalytics(500);
559 return Response.json(
560 { message: `Error fetching user from Slack: ${e}` },
561 { status: 500 },
562 );
563 }
564
565 await cache.insertUser(
566 slackUser.id,
567 slackUser.profile.display_name_normalized ||
568 slackUser.profile.real_name_normalized,
569 slackUser.profile.pronouns,
570 slackUser.profile.image_512,
571 );
572
573 await recordAnalytics(302);
574 return new Response(null, {
575 status: 302,
576 headers: { Location: slackUser.profile.image_512 },
577 });
578 }
579
580 await recordAnalytics(302);
581 return new Response(null, {
582 status: 302,
583 headers: { Location: user.imageUrl },
584 });
585}
586
587async function handleListEmojis(
588 request: Request,
589 recordAnalytics: (statusCode: number) => Promise<void>,
590) {
591 const emojis = await cache.listEmojis();
592
593 await recordAnalytics(200);
594 return Response.json(
595 emojis.map((emoji) => ({
596 id: emoji.id,
597 expiration: emoji.expiration.toISOString(),
598 name: emoji.name,
599 ...(emoji.alias ? { alias: emoji.alias } : {}),
600 image: emoji.imageUrl,
601 })),
602 );
603}
604
605async function handleGetEmoji(
606 request: Request,
607 recordAnalytics: (statusCode: number) => Promise<void>,
608) {
609 const url = new URL(request.url);
610 const emojiName = url.pathname.split("/").pop() || "";
611 const emoji = await cache.getEmoji(emojiName);
612
613 if (!emoji) {
614 await recordAnalytics(404);
615 return Response.json({ message: "Emoji not found" }, { status: 404 });
616 }
617
618 await recordAnalytics(200);
619 return Response.json({
620 id: emoji.id,
621 expiration: emoji.expiration.toISOString(),
622 name: emoji.name,
623 ...(emoji.alias ? { alias: emoji.alias } : {}),
624 image: emoji.imageUrl,
625 });
626}
627
628async function handleEmojiRedirect(
629 request: Request,
630 recordAnalytics: (statusCode: number) => Promise<void>,
631) {
632 const url = new URL(request.url);
633 const parts = url.pathname.split("/");
634 const emojiName = parts[2] || "";
635 const emoji = await cache.getEmoji(emojiName);
636
637 if (!emoji) {
638 await recordAnalytics(404);
639 return Response.json({ message: "Emoji not found" }, { status: 404 });
640 }
641
642 await recordAnalytics(302);
643 return new Response(null, {
644 status: 302,
645 headers: { Location: emoji.imageUrl },
646 });
647}
648
649async function handleResetCache(
650 request: Request,
651 recordAnalytics: (statusCode: number) => Promise<void>,
652) {
653 const authHeader = request.headers.get("authorization") || "";
654
655 if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
656 await recordAnalytics(401);
657 return new Response("Unauthorized", { status: 401 });
658 }
659
660 const result = await cache.purgeAll();
661 await recordAnalytics(200);
662 return Response.json(result);
663}
664
665async function handlePurgeUser(
666 request: Request,
667 recordAnalytics: (statusCode: number) => Promise<void>,
668) {
669 const authHeader = request.headers.get("authorization") || "";
670
671 if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
672 await recordAnalytics(401);
673 return new Response("Unauthorized", { status: 401 });
674 }
675
676 const url = new URL(request.url);
677 const parts = url.pathname.split("/");
678 const userId = parts[2] || "";
679 const success = await cache.purgeUserCache(userId);
680
681 await recordAnalytics(200);
682 return Response.json({
683 message: success ? "User cache purged" : "User not found in cache",
684 userId: userId,
685 success,
686 });
687}
688
689async function handleGetStats(
690 request: Request,
691 recordAnalytics: (statusCode: number) => Promise<void>,
692) {
693 const url = new URL(request.url);
694 const params = new URLSearchParams(url.search);
695 const days = params.get("days") ? parseInt(params.get("days")!) : 7;
696 const analytics = await cache.getAnalytics(days);
697
698 await recordAnalytics(200);
699 return Response.json(analytics);
700}
701
702// Setup cron jobs for cache maintenance
703function setupCronJobs() {
704 // Daily purge of all expired items
705 const dailyPurge = setInterval(async () => {
706 const now = new Date();
707 if (now.getHours() === 0 && now.getMinutes() === 0) {
708 await cache.purgeAll();
709 }
710 }, 60 * 1000); // Check every minute
711
712 // Hourly purge of specific user cache
713 const hourlyUserPurge = setInterval(async () => {
714 const now = new Date();
715 if (now.getMinutes() === 5) {
716 const userId = "U062UG485EE";
717 console.log(`Purging cache for user ${userId}`);
718 const result = await cache.purgeUserCache(userId);
719 console.log(
720 `Cache purge for user ${userId}: ${result ? "successful" : "no cache entry found"}`,
721 );
722 }
723 }, 60 * 1000); // Check every minute
724
725 // Clean up on process exit
726 process.on("exit", () => {
727 clearInterval(dailyPurge);
728 clearInterval(hourlyUserPurge);
729 });
730}