a cache for slack profile pictures and emojis
at v0.3.2 20 kB view raw
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}