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);