a cache for slack profile pictures and emojis
1import { cors } from "@elysiajs/cors";
2import { cron } from "@elysiajs/cron";
3import { html } from "@elysiajs/html";
4import { swagger } from "@elysiajs/swagger";
5import * as Sentry from "@sentry/bun";
6import { logger } from "@tqman/nice-logger";
7import { Elysia, t } from "elysia";
8import { version } from "../package.json";
9import { getEmojiUrl } from "../utils/emojiHelper";
10import { SlackCache } from "./cache";
11import dashboard from "./dashboard.html" with { type: "text" };
12import type { SlackUser } from "./slack";
13import { SlackWrapper } from "./slackWrapper";
14
15if (process.env.SENTRY_DSN) {
16 console.log("Sentry DSN provided, error monitoring is enabled");
17 Sentry.init({
18 environment: process.env.NODE_ENV,
19 dsn: process.env.SENTRY_DSN,
20 tracesSampleRate: 0.5,
21 ignoreErrors: [
22 // Ignore all 404-related errors
23 "Not Found",
24 "404",
25 "user_not_found",
26 "emoji_not_found",
27 ],
28 });
29} else {
30 console.warn("Sentry DSN not provided, error monitoring is disabled");
31}
32
33const slackApp = new SlackWrapper();
34
35const cache = new SlackCache(
36 process.env.DATABASE_PATH ?? "./data/cachet.db",
37 25,
38 async () => {
39 console.log("Fetching emojis from Slack");
40 const emojis = await slackApp.getEmojiList();
41 const emojiEntries = Object.entries(emojis)
42 .map(([name, url]) => {
43 if (typeof url === "string" && url.startsWith("alias:")) {
44 const aliasName = url.substring(6); // Remove 'alias:' prefix
45 const aliasUrl = emojis[aliasName] ?? getEmojiUrl(aliasName) ?? null;
46
47 if (aliasUrl === null) {
48 console.warn(`Could not find alias for ${aliasName}`);
49 return;
50 }
51
52 return {
53 name,
54 imageUrl: aliasUrl === null ? getEmojiUrl(aliasName) : aliasUrl,
55 alias: aliasName,
56 };
57 }
58 return {
59 name,
60 imageUrl: url,
61 alias: null,
62 };
63 })
64 .filter(
65 (
66 entry,
67 ): entry is { name: string; imageUrl: string; alias: string | null } =>
68 entry !== undefined,
69 );
70
71 console.log("Batch inserting emojis");
72
73 await cache.batchInsertEmojis(emojiEntries);
74
75 console.log("Finished batch inserting emojis");
76 },
77);
78
79const app = new Elysia()
80 .use(html())
81 .use(
82 logger({
83 mode: "combined",
84 }),
85 )
86 .use(
87 cors({
88 origin: true,
89 }),
90 )
91 .derive(({ headers }) => ({
92 startTime: Date.now(),
93 userAgent: headers["user-agent"],
94 ipAddress: headers["x-forwarded-for"] || headers["x-real-ip"] || "unknown",
95 }))
96 .onAfterHandle(async ({ request, set, startTime, userAgent, ipAddress }) => {
97 const responseTime = Date.now() - startTime;
98 const endpoint = new URL(request.url).pathname;
99
100 // Don't track favicon or swagger requests
101 if (endpoint !== "/favicon.ico" && !endpoint.startsWith("/swagger")) {
102 await cache.recordRequest(
103 endpoint,
104 request.method,
105 (set.status as number) || 200,
106 userAgent,
107 ipAddress,
108 responseTime,
109 );
110 }
111 })
112 .use(
113 cron({
114 name: "heartbeat",
115 pattern: "0 0 * * *",
116 async run() {
117 await cache.purgeAll();
118 },
119 }),
120 )
121 .use(
122 cron({
123 name: "purgeSpecificUserCache",
124 pattern: "5 * * * *", // Run at 5 minutes after each hour
125 async run() {
126 const userId = "U062UG485EE";
127 console.log(`Purging cache for user ${userId}`);
128 const result = await cache.purgeUserCache(userId);
129 console.log(
130 `Cache purge for user ${userId}: ${result ? "successful" : "no cache entry found"}`,
131 );
132 },
133 }),
134 )
135 .use(
136 swagger({
137 exclude: ["/", "favicon.ico"],
138 documentation: {
139 info: {
140 version: version,
141 title: "Cachet",
142 description:
143 "Hi 👋\n\nThis is a pretty simple API that acts as a middleman caching layer between slack and the outside world. There may be authentication in the future, but for now, it's just a simple cache.\n\nThe `/r` endpoints are redirects to the actual image URLs, so you can use them as direct image links.",
144 contact: {
145 name: "Kieran Klukas",
146 email: "me@dunkirk.sh",
147 },
148 license: {
149 name: "AGPL 3.0",
150 url: "https://github.com/taciturnaxoltol/cachet/blob/master/LICENSE.md",
151 },
152 },
153 tags: [
154 {
155 name: "The Cache!",
156 description: "*must be read in an ominous voice*",
157 },
158 {
159 name: "Status",
160 description: "*Rather boring status endpoints :(*",
161 },
162 ],
163 },
164 }),
165 )
166 .onError(({ code, error, request, set }) => {
167 if (error instanceof Error)
168 console.error(
169 `\x1b[31m x\x1b[0m unhandled error: \x1b[31m${error.message}\x1b[0m`,
170 );
171
172 // Don't send 404 errors to Sentry
173 const is404 =
174 set.status === 404 ||
175 (error instanceof Error &&
176 (error.message === "Not Found" ||
177 error.message === "user_not_found" ||
178 error.message === "emoji_not_found"));
179
180 if (!is404) {
181 Sentry.withScope((scope) => {
182 scope.setExtra("url", request.url);
183 scope.setExtra("code", code);
184 Sentry.captureException(error);
185 });
186 }
187
188 if (code === "VALIDATION") {
189 return error.message;
190 }
191 })
192 .get("/", ({ redirect, headers }) => {
193 // check if its a browser
194
195 if (
196 headers["user-agent"]?.toLowerCase().includes("mozilla") ||
197 headers["user-agent"]?.toLowerCase().includes("chrome") ||
198 headers["user-agent"]?.toLowerCase().includes("safari")
199 ) {
200 return redirect("/dashboard", 302);
201 }
202
203 return "Hello World from Cachet 😊\n\n---\nSee /swagger for docs\nSee /dashboard for analytics\n---";
204 })
205 .get("/favicon.ico", Bun.file("./favicon.ico"))
206 .get("/dashboard", () => dashboard)
207 .get(
208 "/health",
209 async ({ error }) => {
210 const slackConnection = await slackApp.testAuth();
211
212 const databaseConnection = await cache.healthCheck();
213
214 if (!slackConnection || !databaseConnection)
215 return error(500, {
216 http: false,
217 slack: slackConnection,
218 database: databaseConnection,
219 });
220
221 return {
222 http: true,
223 slack: true,
224 database: true,
225 };
226 },
227 {
228 tags: ["Status"],
229 response: {
230 200: t.Object({
231 http: t.Boolean(),
232 slack: t.Boolean(),
233 database: t.Boolean(),
234 }),
235 500: t.Object({
236 http: t.Boolean({
237 default: false,
238 }),
239 slack: t.Boolean({
240 default: false,
241 }),
242 database: t.Boolean({
243 default: false,
244 }),
245 }),
246 },
247 },
248 )
249 .get(
250 "/users/:user",
251 async ({ params, error, request }) => {
252 const user = await cache.getUser(params.user);
253
254 // if not found then check slack first
255 if (!user || !user.imageUrl) {
256 let slackUser: SlackUser;
257 try {
258 slackUser = await slackApp.getUserInfo(params.user);
259 } catch (e) {
260 if (e instanceof Error && e.message === "user_not_found")
261 return error(404, { message: "User not found" });
262
263 Sentry.withScope((scope) => {
264 scope.setExtra("url", request.url);
265 scope.setExtra("user", params.user);
266 Sentry.captureException(e);
267 });
268
269 if (e instanceof Error)
270 console.warn(
271 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
272 );
273
274 return error(500, {
275 message: `Error fetching user from Slack: ${e}`,
276 });
277 }
278
279 const displayName =
280 slackUser.profile.display_name_normalized ||
281 slackUser.profile.real_name_normalized;
282
283 await cache.insertUser(
284 slackUser.id,
285 displayName,
286 slackUser.profile.pronouns,
287 slackUser.profile.image_512,
288 );
289
290 return {
291 id: slackUser.id,
292 expiration: new Date().toISOString(),
293 user: slackUser.id,
294 displayName: displayName,
295 pronouns: slackUser.profile.pronouns || null,
296 image: slackUser.profile.image_512,
297 };
298 }
299
300 return {
301 id: user.id,
302 expiration: user.expiration.toISOString(),
303 user: user.userId,
304 displayName: user.displayName,
305 pronouns: user.pronouns,
306 image: user.imageUrl,
307 };
308 },
309 {
310 tags: ["The Cache!"],
311 params: t.Object({
312 user: t.String(),
313 }),
314 response: {
315 404: t.Object({
316 message: t.String({
317 default: "User not found",
318 }),
319 }),
320 500: t.Object({
321 message: t.String({
322 default: "Error fetching user from Slack",
323 }),
324 }),
325 200: t.Object({
326 id: t.String({
327 default: "90750e24-c2f0-4c52-8681-e6176da6e7ab",
328 }),
329 expiration: t.String({
330 default: new Date().toISOString(),
331 }),
332 user: t.String({
333 default: "U12345678",
334 }),
335 displayName: t.String({
336 default: "krn",
337 }),
338 pronouns: t.Nullable(t.String({ default: "possibly/blank" })),
339 image: t.String({
340 default:
341 "https://avatars.slack-edge.com/2024-11-30/8105375749571_53898493372773a01a1f_original.jpg",
342 }),
343 }),
344 },
345 },
346 )
347 .get(
348 "/users/:user/r",
349 async ({ params, error, redirect, request }) => {
350 const user = await cache.getUser(params.user);
351
352 // if not found then check slack first
353 if (!user || !user.imageUrl) {
354 let slackUser: SlackUser;
355 try {
356 slackUser = await slackApp.getUserInfo(params.user.toUpperCase());
357 } catch (e) {
358 if (e instanceof Error && e.message === "user_not_found") {
359 console.warn(
360 `\x1b[38;5;214m ⚠️ WARN\x1b[0m user not found: \x1b[38;5;208m${params.user}\x1b[0m`,
361 );
362
363 return redirect(
364 "https://api.dicebear.com/9.x/thumbs/svg?seed={username_hash}",
365 307,
366 );
367 }
368
369 Sentry.withScope((scope) => {
370 scope.setExtra("url", request.url);
371 scope.setExtra("user", params.user);
372 Sentry.captureException(e);
373 });
374
375 if (e instanceof Error)
376 console.warn(
377 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
378 );
379
380 return error(500, {
381 message: `Error fetching user from Slack: ${e}`,
382 });
383 }
384
385 await cache.insertUser(
386 slackUser.id,
387 slackUser.profile.display_name_normalized ||
388 slackUser.profile.real_name_normalized,
389 slackUser.profile.pronouns,
390 slackUser.profile.image_512,
391 );
392
393 return redirect(slackUser.profile.image_512, 302);
394 }
395
396 return redirect(user.imageUrl, 302);
397 },
398 {
399 tags: ["The Cache!"],
400 query: t.Object({
401 r: t.Optional(t.String()),
402 }),
403 params: t.Object({
404 user: t.String(),
405 }),
406 },
407 )
408 .get(
409 "/emojis",
410 async () => {
411 const emojis = await cache.listEmojis();
412
413 return emojis.map((emoji) => ({
414 id: emoji.id,
415 expiration: emoji.expiration.toISOString(),
416 name: emoji.name,
417 ...(emoji.alias ? { alias: emoji.alias } : {}),
418 image: emoji.imageUrl,
419 }));
420 },
421 {
422 tags: ["The Cache!"],
423 response: {
424 200: t.Array(
425 t.Object({
426 id: t.String({
427 default: "5427fe70-686f-4684-9da5-95d9ef4c1090",
428 }),
429 expiration: t.String({
430 default: new Date().toISOString(),
431 }),
432 name: t.String({
433 default: "blahaj-heart",
434 }),
435 alias: t.Optional(
436 t.String({
437 default: "blobhaj-heart",
438 }),
439 ),
440 image: t.String({
441 default:
442 "https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png",
443 }),
444 }),
445 ),
446 },
447 },
448 )
449 .get(
450 "/emojis/:emoji",
451 async ({ params, error }) => {
452 const emoji = await cache.getEmoji(params.emoji);
453
454 if (!emoji) return error(404, { message: "Emoji not found" });
455
456 return {
457 id: emoji.id,
458 expiration: emoji.expiration.toISOString(),
459 name: emoji.name,
460 ...(emoji.alias ? { alias: emoji.alias } : {}),
461 image: emoji.imageUrl,
462 };
463 },
464 {
465 tags: ["The Cache!"],
466 params: t.Object({
467 emoji: t.String(),
468 }),
469 response: {
470 404: t.Object({
471 message: t.String({
472 default: "Emoji not found",
473 }),
474 }),
475 200: t.Object({
476 id: t.String({
477 default: "9ed0a560-928d-409c-89fc-10fe156299da",
478 }),
479 expiration: t.String({
480 default: new Date().toISOString(),
481 }),
482 name: t.String({
483 default: "orphmoji-yay",
484 }),
485 image: t.String({
486 default:
487 "https://emoji.slack-edge.com/T0266FRGM/orphmoji-yay/23a37f4af47092d3.png",
488 }),
489 }),
490 },
491 },
492 )
493 .get(
494 "/emojis/:emoji/r",
495 async ({ params, error, redirect }) => {
496 const emoji = await cache.getEmoji(params.emoji);
497
498 if (!emoji) return error(404, { message: "Emoji not found" });
499
500 return redirect(emoji.imageUrl, 302);
501 },
502 {
503 tags: ["The Cache!"],
504 params: t.Object({
505 emoji: t.String(),
506 }),
507 },
508 )
509 .post(
510 "/reset",
511 async ({ headers, set }) => {
512 if (headers.authorization !== `Bearer ${process.env.BEARER_TOKEN}`) {
513 set.status = 401;
514 return "Unauthorized";
515 }
516
517 return await cache.purgeAll();
518 },
519 {
520 tags: ["The Cache!"],
521 headers: t.Object({
522 authorization: t.String({
523 default: "Bearer <token>",
524 }),
525 }),
526 response: {
527 200: t.Object({
528 message: t.String(),
529 users: t.Number(),
530 emojis: t.Number(),
531 }),
532 401: t.String({ default: "Unauthorized" }),
533 },
534 },
535 )
536 .post(
537 "/users/:user/purge",
538 async ({ headers, params, set }) => {
539 if (headers.authorization !== `Bearer ${process.env.BEARER_TOKEN}`) {
540 set.status = 401;
541 return "Unauthorized";
542 }
543
544 const success = await cache.purgeUserCache(params.user);
545
546 return {
547 message: success ? "User cache purged" : "User not found in cache",
548 userId: params.user,
549 success,
550 };
551 },
552 {
553 tags: ["The Cache!"],
554 headers: t.Object({
555 authorization: t.String({
556 default: "Bearer <token>",
557 }),
558 }),
559 params: t.Object({
560 user: t.String(),
561 }),
562 response: {
563 200: t.Object({
564 message: t.String(),
565 userId: t.String(),
566 success: t.Boolean(),
567 }),
568 401: t.String({ default: "Unauthorized" }),
569 },
570 },
571 )
572 .get(
573 "/stats",
574 async ({ query }) => {
575 const days = query.days ? parseInt(query.days) : 7;
576 const analytics = await cache.getAnalytics(days);
577
578 return analytics;
579 },
580 {
581 tags: ["Status"],
582 query: t.Object({
583 days: t.Optional(
584 t.String({ description: "Number of days to look back (default: 7)" }),
585 ),
586 }),
587 response: {
588 200: t.Object({
589 totalRequests: t.Number(),
590 requestsByEndpoint: t.Array(
591 t.Object({
592 endpoint: t.String(),
593 count: t.Number(),
594 averageResponseTime: t.Number(),
595 }),
596 ),
597 requestsByStatus: t.Array(
598 t.Object({
599 status: t.Number(),
600 count: t.Number(),
601 averageResponseTime: t.Number(),
602 }),
603 ),
604 requestsByDay: t.Array(
605 t.Object({
606 date: t.String(),
607 count: t.Number(),
608 averageResponseTime: t.Number(),
609 }),
610 ),
611 averageResponseTime: t.Nullable(t.Number()),
612 topUserAgents: t.Array(
613 t.Object({
614 userAgent: t.String(),
615 count: t.Number(),
616 }),
617 ),
618 latencyAnalytics: t.Object({
619 percentiles: t.Object({
620 p50: t.Nullable(t.Number()),
621 p75: t.Nullable(t.Number()),
622 p90: t.Nullable(t.Number()),
623 p95: t.Nullable(t.Number()),
624 p99: t.Nullable(t.Number()),
625 }),
626 distribution: t.Array(
627 t.Object({
628 range: t.String(),
629 count: t.Number(),
630 percentage: t.Number(),
631 }),
632 ),
633 slowestEndpoints: t.Array(
634 t.Object({
635 endpoint: t.String(),
636 averageResponseTime: t.Number(),
637 count: t.Number(),
638 }),
639 ),
640 latencyOverTime: t.Array(
641 t.Object({
642 time: t.String(),
643 averageResponseTime: t.Number(),
644 p95: t.Nullable(t.Number()),
645 count: t.Number(),
646 }),
647 ),
648 }),
649 performanceMetrics: t.Object({
650 uptime: t.Number(),
651 errorRate: t.Number(),
652 throughput: t.Number(),
653 apdex: t.Number(),
654 cachehitRate: t.Number(),
655 }),
656 peakTraffic: t.Object({
657 peakHour: t.String(),
658 peakRequests: t.Number(),
659 peakDay: t.String(),
660 peakDayRequests: t.Number(),
661 }),
662 dashboardMetrics: t.Object({
663 statsRequests: t.Number(),
664 totalWithStats: t.Number(),
665 }),
666 trafficOverview: t.Array(
667 t.Object({
668 time: t.String(),
669 routes: t.Record(t.String(), t.Number()),
670 total: t.Number(),
671 }),
672 ),
673 }),
674 },
675 },
676 )
677 .listen(process.env.PORT ?? 3000);
678
679console.log(
680 `\n---\n\n🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port} on v${version}@${process.env.NODE_ENV}\n\n---\n`,
681);