a cache for slack profile pictures and emojis

feat: move to a typed routes system

dunkirk.sh f44b7d71 ec4ce6eb

verified
+24 -2
src/cache.ts
··· 618 618 } 619 619 620 620 /** 621 + * Get all emojis from the cache 622 + * @returns Array of all non-expired emojis 623 + */ 624 + async getAllEmojis(): Promise<Emoji[]> { 625 + const results = this.db 626 + .query("SELECT * FROM emojis WHERE expiration > ?") 627 + .all(Date.now()) as Emoji[]; 628 + 629 + return results.map(result => ({ 630 + type: "emoji", 631 + id: result.id, 632 + name: result.name, 633 + alias: result.alias || null, 634 + imageUrl: result.imageUrl, 635 + expiration: new Date(result.expiration), 636 + })); 637 + } 638 + 639 + /** 621 640 * Records a request for analytics 622 641 * @param endpoint The endpoint that was accessed 623 642 * @param method HTTP method ··· 1564 1583 1565 1584 // Clean up old cache entries (keep only last 5) 1566 1585 if (this.analyticsCache.size > 5) { 1567 - const oldestKey = Array.from(this.analyticsCache.keys())[0]; 1568 - this.analyticsCache.delete(oldestKey); 1586 + const keys = Array.from(this.analyticsCache.keys()); 1587 + const oldestKey = keys[0]; 1588 + if (oldestKey) { 1589 + this.analyticsCache.delete(oldestKey); 1590 + } 1569 1591 } 1570 1592 1571 1593 return result;
+295
src/handlers/index.ts
··· 1 + /** 2 + * All route handler functions extracted for reuse 3 + */ 4 + 5 + import * as Sentry from "@sentry/bun"; 6 + import type { SlackUser } from "../slack"; 7 + import type { RouteHandlerWithAnalytics } from "../lib/analytics-wrapper"; 8 + 9 + // These will be injected by the route system 10 + let cache: any; 11 + let slackApp: any; 12 + 13 + export function injectDependencies(cacheInstance: any, slackInstance: any) { 14 + cache = cacheInstance; 15 + slackApp = slackInstance; 16 + } 17 + 18 + export const handleHealthCheck: RouteHandlerWithAnalytics = async ( 19 + request, 20 + recordAnalytics, 21 + ) => { 22 + const isHealthy = await cache.healthCheck(); 23 + if (isHealthy) { 24 + await recordAnalytics(200); 25 + return Response.json({ 26 + status: "healthy", 27 + cache: true, 28 + uptime: process.uptime(), 29 + }); 30 + } else { 31 + await recordAnalytics(503); 32 + return Response.json( 33 + { status: "unhealthy", error: "Cache connection failed" }, 34 + { status: 503 }, 35 + ); 36 + } 37 + }; 38 + 39 + export const handleGetUser: RouteHandlerWithAnalytics = async ( 40 + request, 41 + recordAnalytics, 42 + ) => { 43 + const url = new URL(request.url); 44 + const userId = url.pathname.split("/").pop() || ""; 45 + const user = await cache.getUser(userId); 46 + 47 + if (!user || !user.imageUrl) { 48 + let slackUser: SlackUser; 49 + try { 50 + slackUser = await slackApp.getUserInfo(userId); 51 + } catch (e) { 52 + if (e instanceof Error && e.message === "user_not_found") { 53 + await recordAnalytics(404); 54 + return Response.json({ message: "User not found" }, { status: 404 }); 55 + } 56 + 57 + Sentry.withScope((scope) => { 58 + scope.setExtra("url", request.url); 59 + scope.setExtra("user", userId); 60 + Sentry.captureException(e); 61 + }); 62 + 63 + await recordAnalytics(500); 64 + return Response.json( 65 + { message: "Internal server error" }, 66 + { status: 500 }, 67 + ); 68 + } 69 + 70 + await cache.insertUser( 71 + slackUser.id, 72 + slackUser.real_name || slackUser.name || "Unknown", 73 + slackUser.profile?.pronouns || "", 74 + slackUser.profile?.image_512 || slackUser.profile?.image_192 || "", 75 + ); 76 + 77 + await recordAnalytics(200); 78 + return Response.json({ 79 + id: slackUser.id, 80 + userId: slackUser.id, 81 + displayName: slackUser.real_name || slackUser.name || "Unknown", 82 + pronouns: slackUser.profile?.pronouns || "", 83 + imageUrl: slackUser.profile?.image_512 || slackUser.profile?.image_192 || "", 84 + }); 85 + } 86 + 87 + await recordAnalytics(200); 88 + return Response.json(user); 89 + }; 90 + 91 + export const handleUserRedirect: RouteHandlerWithAnalytics = async ( 92 + request, 93 + recordAnalytics, 94 + ) => { 95 + const url = new URL(request.url); 96 + const parts = url.pathname.split("/"); 97 + const userId = parts[2] || ""; 98 + const user = await cache.getUser(userId); 99 + 100 + if (!user || !user.imageUrl) { 101 + let slackUser: SlackUser; 102 + try { 103 + slackUser = await slackApp.getUserInfo(userId.toUpperCase()); 104 + } catch (e) { 105 + if (e instanceof Error && e.message === "user_not_found") { 106 + console.warn(`⚠️ WARN user not found: ${userId}`); 107 + 108 + await recordAnalytics(307); 109 + return new Response(null, { 110 + status: 307, 111 + headers: { 112 + Location: "https://ca.slack-edge.com/T0266FRGM-U0266FRGP-g28a1f281330-512", 113 + }, 114 + }); 115 + } 116 + 117 + Sentry.withScope((scope) => { 118 + scope.setExtra("url", request.url); 119 + scope.setExtra("user", userId); 120 + Sentry.captureException(e); 121 + }); 122 + 123 + await recordAnalytics(500); 124 + return Response.json( 125 + { message: "Internal server error" }, 126 + { status: 500 }, 127 + ); 128 + } 129 + 130 + await cache.insertUser( 131 + slackUser.id, 132 + slackUser.real_name || slackUser.name || "Unknown", 133 + slackUser.profile?.pronouns || "", 134 + slackUser.profile?.image_512 || slackUser.profile?.image_192 || "", 135 + ); 136 + 137 + await recordAnalytics(302); 138 + return new Response(null, { 139 + status: 302, 140 + headers: { 141 + Location: slackUser.profile?.image_512 || slackUser.profile?.image_192 || "", 142 + }, 143 + }); 144 + } 145 + 146 + await recordAnalytics(302); 147 + return new Response(null, { 148 + status: 302, 149 + headers: { Location: user.imageUrl }, 150 + }); 151 + }; 152 + 153 + export const handlePurgeUser: RouteHandlerWithAnalytics = async ( 154 + request, 155 + recordAnalytics, 156 + ) => { 157 + const authHeader = request.headers.get("authorization") || ""; 158 + if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) { 159 + await recordAnalytics(401); 160 + return new Response("Unauthorized", { status: 401 }); 161 + } 162 + 163 + const url = new URL(request.url); 164 + const userId = url.pathname.split("/")[2] || ""; 165 + const result = await cache.purgeUserCache(userId); 166 + 167 + await recordAnalytics(200); 168 + return Response.json({ 169 + message: "User cache purged", 170 + userId, 171 + success: result, 172 + }); 173 + }; 174 + 175 + export const handleListEmojis: RouteHandlerWithAnalytics = async ( 176 + request, 177 + recordAnalytics, 178 + ) => { 179 + const emojis = await cache.getAllEmojis(); 180 + await recordAnalytics(200); 181 + return Response.json(emojis); 182 + }; 183 + 184 + export const handleGetEmoji: RouteHandlerWithAnalytics = async ( 185 + request, 186 + recordAnalytics, 187 + ) => { 188 + const url = new URL(request.url); 189 + const emojiName = url.pathname.split("/").pop() || ""; 190 + const emoji = await cache.getEmoji(emojiName); 191 + 192 + if (!emoji) { 193 + await recordAnalytics(404); 194 + return Response.json({ message: "Emoji not found" }, { status: 404 }); 195 + } 196 + 197 + await recordAnalytics(200); 198 + return Response.json(emoji); 199 + }; 200 + 201 + export const handleEmojiRedirect: RouteHandlerWithAnalytics = async ( 202 + request, 203 + recordAnalytics, 204 + ) => { 205 + const url = new URL(request.url); 206 + const parts = url.pathname.split("/"); 207 + const emojiName = parts[2] || ""; 208 + const emoji = await cache.getEmoji(emojiName); 209 + 210 + if (!emoji) { 211 + await recordAnalytics(404); 212 + return Response.json({ message: "Emoji not found" }, { status: 404 }); 213 + } 214 + 215 + await recordAnalytics(302); 216 + return new Response(null, { 217 + status: 302, 218 + headers: { Location: emoji.imageUrl }, 219 + }); 220 + }; 221 + 222 + export const handleResetCache: RouteHandlerWithAnalytics = async ( 223 + request, 224 + recordAnalytics, 225 + ) => { 226 + const authHeader = request.headers.get("authorization") || ""; 227 + if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) { 228 + await recordAnalytics(401); 229 + return new Response("Unauthorized", { status: 401 }); 230 + } 231 + const result = await cache.purgeAll(); 232 + await recordAnalytics(200); 233 + return Response.json(result); 234 + }; 235 + 236 + export const handleGetEssentialStats: RouteHandlerWithAnalytics = async ( 237 + request, 238 + recordAnalytics, 239 + ) => { 240 + const url = new URL(request.url); 241 + const params = new URLSearchParams(url.search); 242 + const days = params.get("days") ? parseInt(params.get("days")!) : 7; 243 + 244 + const stats = await cache.getEssentialStats(days); 245 + await recordAnalytics(200); 246 + return Response.json(stats); 247 + }; 248 + 249 + export const handleGetChartData: RouteHandlerWithAnalytics = async ( 250 + request, 251 + recordAnalytics, 252 + ) => { 253 + const url = new URL(request.url); 254 + const params = new URLSearchParams(url.search); 255 + const days = params.get("days") ? parseInt(params.get("days")!) : 7; 256 + 257 + const chartData = await cache.getChartData(days); 258 + await recordAnalytics(200); 259 + return Response.json(chartData); 260 + }; 261 + 262 + export const handleGetUserAgents: RouteHandlerWithAnalytics = async ( 263 + request, 264 + recordAnalytics, 265 + ) => { 266 + const url = new URL(request.url); 267 + const params = new URLSearchParams(url.search); 268 + const days = params.get("days") ? parseInt(params.get("days")!) : 7; 269 + 270 + const userAgents = await cache.getUserAgents(days); 271 + await recordAnalytics(200); 272 + return Response.json(userAgents); 273 + }; 274 + 275 + export const handleGetStats: RouteHandlerWithAnalytics = async ( 276 + request, 277 + recordAnalytics, 278 + ) => { 279 + const url = new URL(request.url); 280 + const params = new URLSearchParams(url.search); 281 + const days = params.get("days") ? parseInt(params.get("days")!) : 7; 282 + 283 + const [essentialStats, chartData, userAgents] = await Promise.all([ 284 + cache.getEssentialStats(days), 285 + cache.getChartData(days), 286 + cache.getUserAgents(days), 287 + ]); 288 + 289 + await recordAnalytics(200); 290 + return Response.json({ 291 + ...essentialStats, 292 + chartData, 293 + userAgents, 294 + }); 295 + };
+44 -765
src/index.ts
··· 3 3 import { SlackCache } from "./cache"; 4 4 import { SlackWrapper } from "./slackWrapper"; 5 5 import { getEmojiUrl } from "../utils/emojiHelper"; 6 - import type { SlackUser } from "./slack"; 7 - import swaggerSpec from "./swagger"; 6 + import { createApiRoutes } from "./routes/api-routes"; 7 + import { buildRoutes, getSwaggerSpec } from "./lib/route-builder"; 8 8 import dashboard from "./dashboard.html"; 9 9 import swagger from "./swagger.html"; 10 10 ··· 15 15 environment: process.env.NODE_ENV, 16 16 dsn: process.env.SENTRY_DSN, 17 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 - ], 18 + ignoreErrors: ["Not Found", "404", "user_not_found", "emoji_not_found"], 25 19 }); 26 20 } else { 27 21 console.warn("Sentry DSN not provided, error monitoring is disabled"); ··· 38 32 const emojiEntries = Object.entries(emojis) 39 33 .map(([name, url]) => { 40 34 if (typeof url === "string" && url.startsWith("alias:")) { 41 - const aliasName = url.substring(6); // Remove 'alias:' prefix 35 + const aliasName = url.substring(6); 42 36 const aliasUrl = emojis[aliasName] ?? getEmojiUrl(aliasName) ?? null; 43 37 44 38 if (aliasUrl === null) { ··· 74 68 // Inject SlackWrapper into cache for background user updates 75 69 cache.setSlackWrapper(slackApp); 76 70 77 - // Cache maintenance is now handled automatically by cache.ts scheduled tasks 78 - 79 - // Start the server 80 - const server = serve({ 81 - routes: { 82 - // HTML routes 83 - "/dashboard": dashboard, 84 - "/swagger": swagger, 85 - "/swagger.json": async (request) => { 86 - return Response.json(swaggerSpec); 87 - }, 88 - "/favicon.ico": async (request) => { 89 - return new Response(Bun.file("./favicon.ico")); 90 - }, 91 - 92 - // Root route - redirect to dashboard for browsers 93 - "/": async (request) => { 94 - const startTime = Date.now(); 95 - const recordAnalytics = async (statusCode: number) => { 96 - const userAgent = request.headers.get("user-agent") || ""; 97 - const ipAddress = 98 - request.headers.get("x-forwarded-for") || 99 - request.headers.get("x-real-ip") || 100 - "unknown"; 101 - 102 - await cache.recordRequest( 103 - "/", 104 - request.method, 105 - statusCode, 106 - userAgent, 107 - ipAddress, 108 - Date.now() - startTime, 109 - ); 110 - }; 111 - 112 - const userAgent = request.headers.get("user-agent") || ""; 113 - if ( 114 - userAgent.toLowerCase().includes("mozilla") || 115 - userAgent.toLowerCase().includes("chrome") || 116 - userAgent.toLowerCase().includes("safari") 117 - ) { 118 - recordAnalytics(302); 119 - return new Response(null, { 120 - status: 302, 121 - headers: { Location: "/dashboard" }, 122 - }); 123 - } 71 + // Create the typed API routes with injected dependencies 72 + const apiRoutes = createApiRoutes(cache, slackApp); 124 73 125 - recordAnalytics(200); 126 - return new Response( 127 - "Hello World from Cachet 😊\n\n---\nSee /swagger for docs\nSee /dashboard for analytics\n---", 128 - ); 129 - }, 74 + // Build Bun-compatible routes and generate Swagger 75 + const typedRoutes = buildRoutes(apiRoutes); 76 + const generatedSwagger = getSwaggerSpec(); 130 77 131 - // Health check endpoint 132 - "/health": { 133 - async GET(request) { 134 - const startTime = Date.now(); 135 - const recordAnalytics = async (statusCode: number) => { 136 - const userAgent = request.headers.get("user-agent") || ""; 137 - const ipAddress = 138 - request.headers.get("x-forwarded-for") || 139 - request.headers.get("x-real-ip") || 140 - "unknown"; 141 - 142 - await cache.recordRequest( 143 - "/health", 144 - "GET", 145 - statusCode, 146 - userAgent, 147 - ipAddress, 148 - Date.now() - startTime, 149 - ); 150 - }; 151 - 152 - return handleHealthCheck(request, recordAnalytics); 153 - }, 154 - }, 155 - 156 - // User endpoints 157 - "/users/:id": { 158 - async GET(request) { 159 - const startTime = Date.now(); 160 - const recordAnalytics = async (statusCode: number) => { 161 - const userAgent = request.headers.get("user-agent") || ""; 162 - const ipAddress = 163 - request.headers.get("x-forwarded-for") || 164 - request.headers.get("x-real-ip") || 165 - "unknown"; 166 - 167 - await cache.recordRequest( 168 - request.url, 169 - "GET", 170 - statusCode, 171 - userAgent, 172 - ipAddress, 173 - Date.now() - startTime, 174 - ); 175 - }; 176 - 177 - return handleGetUser(request, recordAnalytics); 178 - }, 179 - }, 180 - 181 - "/users/:id/r": { 182 - async GET(request) { 183 - const startTime = Date.now(); 184 - const recordAnalytics = async (statusCode: number) => { 185 - const userAgent = request.headers.get("user-agent") || ""; 186 - const ipAddress = 187 - request.headers.get("x-forwarded-for") || 188 - request.headers.get("x-real-ip") || 189 - "unknown"; 190 - 191 - await cache.recordRequest( 192 - request.url, 193 - "GET", 194 - statusCode, 195 - userAgent, 196 - ipAddress, 197 - Date.now() - startTime, 198 - ); 199 - }; 200 - 201 - return handleUserRedirect(request, recordAnalytics); 202 - }, 203 - }, 204 - 205 - "/users/:id/purge": { 206 - async POST(request) { 207 - const startTime = Date.now(); 208 - const recordAnalytics = async (statusCode: number) => { 209 - const userAgent = request.headers.get("user-agent") || ""; 210 - const ipAddress = 211 - request.headers.get("x-forwarded-for") || 212 - request.headers.get("x-real-ip") || 213 - "unknown"; 214 - 215 - await cache.recordRequest( 216 - request.url, 217 - "POST", 218 - statusCode, 219 - userAgent, 220 - ipAddress, 221 - Date.now() - startTime, 222 - ); 223 - }; 224 - 225 - return handlePurgeUser(request, recordAnalytics); 226 - }, 227 - }, 228 - 229 - // Emoji endpoints 230 - "/emojis": { 231 - async GET(request) { 232 - const startTime = Date.now(); 233 - const recordAnalytics = async (statusCode: number) => { 234 - const userAgent = request.headers.get("user-agent") || ""; 235 - const ipAddress = 236 - request.headers.get("x-forwarded-for") || 237 - request.headers.get("x-real-ip") || 238 - "unknown"; 239 - 240 - await cache.recordRequest( 241 - "/emojis", 242 - "GET", 243 - statusCode, 244 - userAgent, 245 - ipAddress, 246 - Date.now() - startTime, 247 - ); 248 - }; 249 - 250 - return handleListEmojis(request, recordAnalytics); 251 - }, 252 - }, 253 - 254 - "/emojis/:name": { 255 - async GET(request) { 256 - const startTime = Date.now(); 257 - const recordAnalytics = async (statusCode: number) => { 258 - const userAgent = request.headers.get("user-agent") || ""; 259 - const ipAddress = 260 - request.headers.get("x-forwarded-for") || 261 - request.headers.get("x-real-ip") || 262 - "unknown"; 263 - 264 - await cache.recordRequest( 265 - request.url, 266 - "GET", 267 - statusCode, 268 - userAgent, 269 - ipAddress, 270 - Date.now() - startTime, 271 - ); 272 - }; 273 - 274 - return handleGetEmoji(request, recordAnalytics); 275 - }, 276 - }, 277 - 278 - "/emojis/:name/r": { 279 - async GET(request) { 280 - const startTime = Date.now(); 281 - const recordAnalytics = async (statusCode: number) => { 282 - const userAgent = request.headers.get("user-agent") || ""; 283 - const ipAddress = 284 - request.headers.get("x-forwarded-for") || 285 - request.headers.get("x-real-ip") || 286 - "unknown"; 287 - 288 - await cache.recordRequest( 289 - request.url, 290 - "GET", 291 - statusCode, 292 - userAgent, 293 - ipAddress, 294 - Date.now() - startTime, 295 - ); 296 - }; 297 - 298 - return handleEmojiRedirect(request, recordAnalytics); 299 - }, 300 - }, 301 - 302 - // Reset cache endpoint 303 - "/reset": { 304 - async POST(request) { 305 - const startTime = Date.now(); 306 - const recordAnalytics = async (statusCode: number) => { 307 - const userAgent = request.headers.get("user-agent") || ""; 308 - const ipAddress = 309 - request.headers.get("x-forwarded-for") || 310 - request.headers.get("x-real-ip") || 311 - "unknown"; 312 - 313 - await cache.recordRequest( 314 - "/reset", 315 - "POST", 316 - statusCode, 317 - userAgent, 318 - ipAddress, 319 - Date.now() - startTime, 320 - ); 321 - }; 322 - 323 - return handleResetCache(request, recordAnalytics); 324 - }, 325 - }, 326 - 327 - // Fast essential stats endpoint - loads immediately 328 - "/api/stats/essential": { 329 - async GET(request) { 330 - const startTime = Date.now(); 331 - const recordAnalytics = async (statusCode: number) => { 332 - const userAgent = request.headers.get("user-agent") || ""; 333 - const ipAddress = 334 - request.headers.get("x-forwarded-for") || 335 - request.headers.get("x-real-ip") || 336 - "unknown"; 337 - 338 - await cache.recordRequest( 339 - "/api/stats/essential", 340 - "GET", 341 - statusCode, 342 - userAgent, 343 - ipAddress, 344 - Date.now() - startTime, 345 - ); 346 - }; 347 - 348 - return handleGetEssentialStats(request, recordAnalytics); 349 - }, 350 - }, 351 - 352 - // Chart data endpoint - loads after essential stats 353 - "/api/stats/charts": { 354 - async GET(request) { 355 - const startTime = Date.now(); 356 - const recordAnalytics = async (statusCode: number) => { 357 - const userAgent = request.headers.get("user-agent") || ""; 358 - const ipAddress = 359 - request.headers.get("x-forwarded-for") || 360 - request.headers.get("x-real-ip") || 361 - "unknown"; 362 - 363 - await cache.recordRequest( 364 - "/api/stats/charts", 365 - "GET", 366 - statusCode, 367 - userAgent, 368 - ipAddress, 369 - Date.now() - startTime, 370 - ); 371 - }; 372 - 373 - return handleGetChartData(request, recordAnalytics); 374 - }, 375 - }, 376 - 377 - // User agents endpoint - loads last 378 - "/api/stats/useragents": { 379 - async GET(request) { 380 - const startTime = Date.now(); 381 - const recordAnalytics = async (statusCode: number) => { 382 - const userAgent = request.headers.get("user-agent") || ""; 383 - const ipAddress = 384 - request.headers.get("x-forwarded-for") || 385 - request.headers.get("x-real-ip") || 386 - "unknown"; 387 - 388 - await cache.recordRequest( 389 - "/api/stats/useragents", 390 - "GET", 391 - statusCode, 392 - userAgent, 393 - ipAddress, 394 - Date.now() - startTime, 395 - ); 396 - }; 397 - 398 - return handleGetUserAgents(request, recordAnalytics); 399 - }, 400 - }, 401 - 402 - // Original stats endpoint (for backwards compatibility) 403 - "/stats": { 404 - async GET(request) { 405 - const startTime = Date.now(); 406 - const recordAnalytics = async (statusCode: number) => { 407 - const userAgent = request.headers.get("user-agent") || ""; 408 - const ipAddress = 409 - request.headers.get("x-forwarded-for") || 410 - request.headers.get("x-real-ip") || 411 - "unknown"; 412 - 413 - await cache.recordRequest( 414 - "/stats", 415 - "GET", 416 - statusCode, 417 - userAgent, 418 - ipAddress, 419 - Date.now() - startTime, 420 - ); 421 - }; 422 - 423 - return handleGetStats(request, recordAnalytics); 424 - }, 425 - }, 78 + // Legacy routes (non-API) 79 + const legacyRoutes = { 80 + "/dashboard": dashboard, 81 + "/swagger": swagger, 82 + "/swagger.json": async (request: Request) => { 83 + return Response.json(generatedSwagger); 426 84 }, 427 - 428 - // Enable development mode for hot reloading 429 - development: { 430 - hmr: true, 431 - console: true, 85 + "/favicon.ico": async (request: Request) => { 86 + return new Response(Bun.file("./favicon.ico")); 432 87 }, 433 88 434 - // Fallback fetch handler for unmatched routes and error handling 435 - async fetch(request) { 436 - const url = new URL(request.url); 437 - const path = url.pathname; 438 - const method = request.method; 439 - const startTime = Date.now(); 89 + // Root route - redirect to dashboard for browsers 90 + "/": async (request: Request) => { 91 + const userAgent = request.headers.get("user-agent") || ""; 440 92 441 - // Record request analytics (except for favicon and swagger) 442 - const recordAnalytics = async (statusCode: number) => { 443 - if (path !== "/favicon.ico" && !path.startsWith("/swagger")) { 444 - const userAgent = request.headers.get("user-agent") || ""; 445 - const ipAddress = 446 - request.headers.get("x-forwarded-for") || 447 - request.headers.get("x-real-ip") || 448 - "unknown"; 449 - 450 - await cache.recordRequest( 451 - path, 452 - method, 453 - statusCode, 454 - userAgent, 455 - ipAddress, 456 - Date.now() - startTime, 457 - ); 458 - } 459 - }; 460 - 461 - try { 462 - // Not found 463 - recordAnalytics(404); 464 - return new Response("Not Found", { status: 404 }); 465 - } catch (error) { 466 - console.error( 467 - `\x1b[31m x\x1b[0m unhandled error: \x1b[31m${error instanceof Error ? error.message : String(error)}\x1b[0m`, 468 - ); 469 - 470 - // Don't send 404 errors to Sentry 471 - const is404 = 472 - error instanceof Error && 473 - (error.message === "Not Found" || 474 - error.message === "user_not_found" || 475 - error.message === "emoji_not_found"); 476 - 477 - if (!is404 && error instanceof Error) { 478 - Sentry.withScope((scope) => { 479 - scope.setExtra("url", request.url); 480 - Sentry.captureException(error); 481 - }); 482 - } 483 - 484 - recordAnalytics(500); 485 - return new Response("Internal Server Error", { status: 500 }); 486 - } 487 - }, 488 - 489 - port: process.env.PORT ? parseInt(process.env.PORT) : 3000, 490 - }); 491 - 492 - console.log( 493 - `\n---\n\n🐰 Bun server is running at ${server.url} on ${process.env.NODE_ENV}\n\n---\n`, 494 - ); 495 - 496 - // Handler functions 497 - async function handleHealthCheck( 498 - request: Request, 499 - recordAnalytics: (statusCode: number) => Promise<void>, 500 - ) { 501 - const slackConnection = await slackApp.testAuth(); 502 - const databaseConnection = await cache.healthCheck(); 503 - 504 - if (!slackConnection || !databaseConnection) { 505 - await recordAnalytics(500); 506 - return Response.json( 507 - { 508 - http: false, 509 - slack: slackConnection, 510 - database: databaseConnection, 511 - }, 512 - { status: 500 }, 513 - ); 514 - } 515 - 516 - await recordAnalytics(200); 517 - return Response.json({ 518 - http: true, 519 - slack: true, 520 - database: true, 521 - }); 522 - } 523 - 524 - async function handleGetUser( 525 - request: Request, 526 - recordAnalytics: (statusCode: number) => Promise<void>, 527 - ) { 528 - const url = new URL(request.url); 529 - const userId = url.pathname.split("/").pop() || ""; 530 - const user = await cache.getUser(userId); 531 - 532 - // If not found then check slack first 533 - if (!user || !user.imageUrl) { 534 - let slackUser: SlackUser; 535 - try { 536 - slackUser = await slackApp.getUserInfo(userId); 537 - } catch (e) { 538 - if (e instanceof Error && e.message === "user_not_found") { 539 - await recordAnalytics(404); 540 - return Response.json({ message: "User not found" }, { status: 404 }); 541 - } 542 - 543 - Sentry.withScope((scope) => { 544 - scope.setExtra("url", request.url); 545 - scope.setExtra("user", userId); 546 - Sentry.captureException(e); 93 + if ( 94 + userAgent.toLowerCase().includes("mozilla") || 95 + userAgent.toLowerCase().includes("chrome") || 96 + userAgent.toLowerCase().includes("safari") 97 + ) { 98 + return new Response(null, { 99 + status: 302, 100 + headers: { Location: "/dashboard" }, 547 101 }); 548 - 549 - if (e instanceof Error) 550 - console.warn( 551 - `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`, 552 - ); 553 - 554 - await recordAnalytics(500); 555 - return Response.json( 556 - { message: `Error fetching user from Slack: ${e}` }, 557 - { status: 500 }, 558 - ); 559 102 } 560 103 561 - const displayName = 562 - slackUser.profile.display_name_normalized || 563 - slackUser.profile.real_name_normalized; 564 - 565 - await cache.insertUser( 566 - slackUser.id, 567 - displayName, 568 - slackUser.profile.pronouns, 569 - slackUser.profile.image_512, 104 + return new Response( 105 + "Hello World from Cachet 😊\n\n---\nSee /swagger for docs\nSee /dashboard for analytics\n---", 570 106 ); 571 - 572 - await recordAnalytics(200); 573 - return Response.json({ 574 - id: slackUser.id, 575 - expiration: new Date().toISOString(), 576 - user: slackUser.id, 577 - displayName: displayName, 578 - pronouns: slackUser.profile.pronouns || null, 579 - image: slackUser.profile.image_512, 580 - }); 581 - } 582 - 583 - await recordAnalytics(200); 584 - return Response.json({ 585 - id: user.id, 586 - expiration: user.expiration.toISOString(), 587 - user: user.userId, 588 - displayName: user.displayName, 589 - pronouns: user.pronouns, 590 - image: user.imageUrl, 591 - }); 592 - } 593 - 594 - async function handleUserRedirect( 595 - request: Request, 596 - recordAnalytics: (statusCode: number) => Promise<void>, 597 - ) { 598 - const url = new URL(request.url); 599 - const parts = url.pathname.split("/"); 600 - const userId = parts[2] || ""; 601 - const user = await cache.getUser(userId); 602 - 603 - // If not found then check slack first 604 - if (!user || !user.imageUrl) { 605 - let slackUser: SlackUser; 606 - try { 607 - slackUser = await slackApp.getUserInfo(userId.toUpperCase()); 608 - } catch (e) { 609 - if (e instanceof Error && e.message === "user_not_found") { 610 - console.warn( 611 - `\x1b[38;5;214m ⚠️ WARN\x1b[0m user not found: \x1b[38;5;208m${userId}\x1b[0m`, 612 - ); 613 - 614 - await recordAnalytics(307); 615 - return new Response(null, { 616 - status: 307, 617 - headers: { 618 - Location: 619 - "https://api.dicebear.com/9.x/thumbs/svg?seed={username_hash}", 620 - }, 621 - }); 622 - } 623 - 624 - Sentry.withScope((scope) => { 625 - scope.setExtra("url", request.url); 626 - scope.setExtra("user", userId); 627 - Sentry.captureException(e); 628 - }); 629 - 630 - if (e instanceof Error) 631 - console.warn( 632 - `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`, 633 - ); 634 - 635 - await recordAnalytics(500); 636 - return Response.json( 637 - { message: `Error fetching user from Slack: ${e}` }, 638 - { status: 500 }, 639 - ); 640 - } 641 - 642 - await cache.insertUser( 643 - slackUser.id, 644 - slackUser.profile.display_name_normalized || 645 - slackUser.profile.real_name_normalized, 646 - slackUser.profile.pronouns, 647 - slackUser.profile.image_512, 648 - ); 649 - 650 - await recordAnalytics(302); 651 - return new Response(null, { 652 - status: 302, 653 - headers: { Location: slackUser.profile.image_512 }, 654 - }); 655 - } 656 - 657 - await recordAnalytics(302); 658 - return new Response(null, { 659 - status: 302, 660 - headers: { Location: user.imageUrl }, 661 - }); 662 - } 663 - 664 - async function handleListEmojis( 665 - request: Request, 666 - recordAnalytics: (statusCode: number) => Promise<void>, 667 - ) { 668 - const emojis = await cache.listEmojis(); 669 - 670 - await recordAnalytics(200); 671 - return Response.json( 672 - emojis.map((emoji) => ({ 673 - id: emoji.id, 674 - expiration: emoji.expiration.toISOString(), 675 - name: emoji.name, 676 - ...(emoji.alias ? { alias: emoji.alias } : {}), 677 - image: emoji.imageUrl, 678 - })), 679 - ); 680 - } 681 - 682 - async function handleGetEmoji( 683 - request: Request, 684 - recordAnalytics: (statusCode: number) => Promise<void>, 685 - ) { 686 - const url = new URL(request.url); 687 - const emojiName = url.pathname.split("/").pop() || ""; 688 - const emoji = await cache.getEmoji(emojiName); 689 - 690 - if (!emoji) { 691 - const fallbackUrl = getEmojiUrl(emojiName); 692 - if (!fallbackUrl) { 693 - await recordAnalytics(404); 694 - return Response.json({ message: "Emoji not found" }, { status: 404 }); 695 - } 696 - 697 - await recordAnalytics(200); 698 - return Response.json({ 699 - id: null, 700 - expiration: new Date().toISOString(), 701 - name: emojiName, 702 - image: fallbackUrl, 703 - }); 704 - } 705 - 706 - await recordAnalytics(200); 707 - return Response.json({ 708 - id: emoji.id, 709 - expiration: emoji.expiration.toISOString(), 710 - name: emoji.name, 711 - ...(emoji.alias ? { alias: emoji.alias } : {}), 712 - image: emoji.imageUrl, 713 - }); 714 - } 715 - 716 - async function handleEmojiRedirect( 717 - request: Request, 718 - recordAnalytics: (statusCode: number) => Promise<void>, 719 - ) { 720 - const url = new URL(request.url); 721 - const parts = url.pathname.split("/"); 722 - const emojiName = parts[2] || ""; 723 - const emoji = await cache.getEmoji(emojiName); 724 - 725 - if (!emoji) { 726 - const fallbackUrl = getEmojiUrl(emojiName); 727 - if (!fallbackUrl) { 728 - await recordAnalytics(404); 729 - return Response.json({ message: "Emoji not found" }, { status: 404 }); 730 - } 731 - 732 - await recordAnalytics(302); 733 - return new Response(null, { 734 - status: 302, 735 - headers: { Location: fallbackUrl }, 736 - }); 737 - } 738 - 739 - await recordAnalytics(302); 740 - return new Response(null, { 741 - status: 302, 742 - headers: { Location: emoji.imageUrl }, 743 - }); 744 - } 745 - 746 - async function handleResetCache( 747 - request: Request, 748 - recordAnalytics: (statusCode: number) => Promise<void>, 749 - ) { 750 - const authHeader = request.headers.get("authorization") || ""; 751 - 752 - if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) { 753 - await recordAnalytics(401); 754 - return new Response("Unauthorized", { status: 401 }); 755 - } 107 + }, 108 + }; 756 109 757 - const result = await cache.purgeAll(); 758 - await recordAnalytics(200); 759 - return Response.json(result); 760 - } 110 + // Merge all routes 111 + const allRoutes = { 112 + ...legacyRoutes, 113 + ...typedRoutes, 114 + }; 761 115 762 - async function handlePurgeUser( 763 - request: Request, 764 - recordAnalytics: (statusCode: number) => Promise<void>, 765 - ) { 766 - const authHeader = request.headers.get("authorization") || ""; 116 + // Start the server 117 + const server = serve({ 118 + routes: allRoutes, 119 + port: process.env.PORT ? parseInt(process.env.PORT) : 3000, 120 + }); 767 121 768 - if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) { 769 - await recordAnalytics(401); 770 - return new Response("Unauthorized", { status: 401 }); 771 - } 122 + console.log(`🚀 Server running on http://localhost:${server.port}`); 772 123 773 - const url = new URL(request.url); 774 - const parts = url.pathname.split("/"); 775 - const userId = parts[2] || ""; 776 - const success = await cache.purgeUserCache(userId); 777 - 778 - await recordAnalytics(200); 779 - return Response.json({ 780 - message: success ? "User cache purged" : "User not found in cache", 781 - userId: userId, 782 - success, 783 - }); 784 - } 785 - 786 - async function handleGetStats( 787 - request: Request, 788 - recordAnalytics: (statusCode: number) => Promise<void>, 789 - ) { 790 - const url = new URL(request.url); 791 - const params = new URLSearchParams(url.search); 792 - const days = params.get("days") ? parseInt(params.get("days")!) : 7; 793 - const analytics = await cache.getAnalytics(days); 794 - 795 - await recordAnalytics(200); 796 - return Response.json(analytics); 797 - } 798 - 799 - // Fast essential stats - just the 3 key metrics 800 - async function handleGetEssentialStats( 801 - request: Request, 802 - recordAnalytics: (statusCode: number) => Promise<void>, 803 - ) { 804 - const url = new URL(request.url); 805 - const params = new URLSearchParams(url.search); 806 - const days = params.get("days") ? parseInt(params.get("days")!) : 7; 807 - 808 - const essentialStats = await cache.getEssentialStats(days); 809 - 810 - await recordAnalytics(200); 811 - return Response.json(essentialStats); 812 - } 813 - 814 - // Chart data - requests and latency over time 815 - async function handleGetChartData( 816 - request: Request, 817 - recordAnalytics: (statusCode: number) => Promise<void>, 818 - ) { 819 - const url = new URL(request.url); 820 - const params = new URLSearchParams(url.search); 821 - const days = params.get("days") ? parseInt(params.get("days")!) : 7; 822 - 823 - const chartData = await cache.getChartData(days); 824 - 825 - await recordAnalytics(200); 826 - return Response.json(chartData); 827 - } 828 - 829 - // User agents data - slowest loading part 830 - async function handleGetUserAgents( 831 - request: Request, 832 - recordAnalytics: (statusCode: number) => Promise<void>, 833 - ) { 834 - const url = new URL(request.url); 835 - const params = new URLSearchParams(url.search); 836 - const days = params.get("days") ? parseInt(params.get("days")!) : 7; 837 - 838 - const userAgents = await cache.getUserAgents(days); 839 - 840 - await recordAnalytics(200); 841 - return Response.json(userAgents); 842 - } 843 - 844 - // Cache maintenance is now handled by scheduled tasks in cache.ts 845 - // No aggressive daily purge needed - users will lazy load with longer TTL 124 + export { cache, slackApp };
+56
src/lib/analytics-wrapper.ts
··· 1 + /** 2 + * Analytics wrapper utility to eliminate boilerplate in route handlers 3 + */ 4 + 5 + // Cache will be injected by the route system 6 + 7 + export type AnalyticsRecorder = (statusCode: number) => Promise<void>; 8 + export type RouteHandlerWithAnalytics = (request: Request, recordAnalytics: AnalyticsRecorder) => Promise<Response> | Response; 9 + 10 + /** 11 + * Creates analytics wrapper with injected cache 12 + */ 13 + export function createAnalyticsWrapper(cache: any) { 14 + return function withAnalytics( 15 + path: string, 16 + method: string, 17 + handler: RouteHandlerWithAnalytics 18 + ) { 19 + return async (request: Request): Promise<Response> => { 20 + const startTime = Date.now(); 21 + 22 + const recordAnalytics: AnalyticsRecorder = async (statusCode: number) => { 23 + const userAgent = request.headers.get("user-agent") || ""; 24 + const ipAddress = 25 + request.headers.get("x-forwarded-for") || 26 + request.headers.get("x-real-ip") || 27 + "unknown"; 28 + 29 + // Use the actual request URL for dynamic paths, fallback to provided path 30 + const analyticsPath = path.includes(":") ? request.url : path; 31 + 32 + await cache.recordRequest( 33 + analyticsPath, 34 + method, 35 + statusCode, 36 + userAgent, 37 + ipAddress, 38 + Date.now() - startTime, 39 + ); 40 + }; 41 + 42 + return handler(request, recordAnalytics); 43 + }; 44 + }; 45 + } 46 + 47 + /** 48 + * Type-safe analytics wrapper that automatically infers path and method 49 + */ 50 + export function createAnalyticsHandler( 51 + path: string, 52 + method: string 53 + ) { 54 + return (handler: RouteHandlerWithAnalytics) => 55 + withAnalytics(path, method, handler); 56 + }
+56
src/lib/route-builder.ts
··· 1 + /** 2 + * Utility to build Bun-compatible routes from typed route definitions 3 + * and generate Swagger documentation 4 + */ 5 + 6 + import type { RouteDefinition } from "../types/routes"; 7 + import { swaggerGenerator } from "./swagger-generator"; 8 + 9 + /** 10 + * Convert typed routes to Bun server format and generate Swagger 11 + */ 12 + export function buildRoutes(typedRoutes: Record<string, RouteDefinition>) { 13 + // Generate Swagger from typed routes 14 + swaggerGenerator.addRoutes(typedRoutes); 15 + 16 + // Convert to Bun server format 17 + const bunRoutes: Record<string, any> = {}; 18 + 19 + Object.entries(typedRoutes).forEach(([path, routeConfig]) => { 20 + const bunRoute: Record<string, any> = {}; 21 + 22 + // Convert each HTTP method 23 + Object.entries(routeConfig).forEach(([method, typedRoute]) => { 24 + if (typedRoute && 'handler' in typedRoute) { 25 + bunRoute[method] = typedRoute.handler; 26 + } 27 + }); 28 + 29 + bunRoutes[path] = bunRoute; 30 + }); 31 + 32 + return bunRoutes; 33 + } 34 + 35 + /** 36 + * Get the generated Swagger specification 37 + */ 38 + export function getSwaggerSpec() { 39 + return swaggerGenerator.getSpec(); 40 + } 41 + 42 + /** 43 + * Merge typed routes with existing legacy routes 44 + * This allows gradual migration 45 + */ 46 + export function mergeRoutes( 47 + typedRoutes: Record<string, RouteDefinition>, 48 + legacyRoutes: Record<string, any> 49 + ) { 50 + const builtRoutes = buildRoutes(typedRoutes); 51 + 52 + return { 53 + ...legacyRoutes, 54 + ...builtRoutes, 55 + }; 56 + }
+203
src/lib/swagger-generator.ts
··· 1 + /** 2 + * Generates Swagger/OpenAPI specifications from typed route definitions 3 + */ 4 + 5 + import { version } from "../../package.json"; 6 + import type { RouteDefinition, RouteMetadata, RouteParam, HttpMethod } from "../types/routes"; 7 + 8 + interface SwaggerSpec { 9 + openapi: string; 10 + info: { 11 + title: string; 12 + version: string; 13 + description: string; 14 + contact: { 15 + name: string; 16 + email: string; 17 + }; 18 + license: { 19 + name: string; 20 + url: string; 21 + }; 22 + }; 23 + paths: Record<string, any>; 24 + components?: { 25 + securitySchemes?: any; 26 + }; 27 + } 28 + 29 + export class SwaggerGenerator { 30 + private spec: SwaggerSpec; 31 + 32 + constructor() { 33 + this.spec = { 34 + openapi: "3.0.0", 35 + info: { 36 + title: "Cachet", 37 + version: version, 38 + description: "A high-performance cache and proxy for Slack profile pictures and emojis with comprehensive analytics.", 39 + contact: { 40 + name: "Kieran Klukas", 41 + email: "me@dunkirk.sh", 42 + }, 43 + license: { 44 + name: "AGPL 3.0", 45 + url: "https://github.com/taciturnaxolotl/cachet/blob/main/LICENSE.md", 46 + }, 47 + }, 48 + paths: {}, 49 + components: { 50 + securitySchemes: { 51 + bearerAuth: { 52 + type: "http", 53 + scheme: "bearer", 54 + }, 55 + }, 56 + }, 57 + }; 58 + } 59 + 60 + /** 61 + * Add routes to the Swagger specification 62 + */ 63 + addRoutes(routes: Record<string, RouteDefinition | any>) { 64 + Object.entries(routes).forEach(([path, routeConfig]) => { 65 + // Skip non-API routes 66 + if (typeof routeConfig === 'function' || 67 + path.includes('dashboard') || 68 + path.includes('swagger') || 69 + path.includes('favicon')) { 70 + return; 71 + } 72 + 73 + this.addRoute(path, routeConfig); 74 + }); 75 + } 76 + 77 + /** 78 + * Add a single route to the specification 79 + */ 80 + private addRoute(path: string, routeConfig: RouteDefinition) { 81 + const swaggerPath = this.convertPathToSwagger(path); 82 + 83 + if (!this.spec.paths[swaggerPath]) { 84 + this.spec.paths[swaggerPath] = {}; 85 + } 86 + 87 + // Process each HTTP method 88 + Object.entries(routeConfig).forEach(([method, typedRoute]) => { 89 + if (typeof typedRoute === 'object' && 'handler' in typedRoute && 'metadata' in typedRoute) { 90 + const swaggerMethod = method.toLowerCase(); 91 + this.spec.paths[swaggerPath][swaggerMethod] = this.buildMethodSpec( 92 + method as HttpMethod, 93 + typedRoute.metadata 94 + ); 95 + } 96 + }); 97 + } 98 + 99 + /** 100 + * Convert Express-style path to Swagger format 101 + * /users/:id -> /users/{id} 102 + */ 103 + private convertPathToSwagger(path: string): string { 104 + return path.replace(/:([^/]+)/g, '{$1}'); 105 + } 106 + 107 + /** 108 + * Build Swagger specification for a single method 109 + */ 110 + private buildMethodSpec(method: HttpMethod, metadata: RouteMetadata) { 111 + const spec: any = { 112 + summary: metadata.summary, 113 + description: metadata.description, 114 + tags: metadata.tags || ['API'], 115 + responses: {}, 116 + }; 117 + 118 + // Add parameters 119 + if (metadata.parameters) { 120 + spec.parameters = []; 121 + 122 + // Path parameters 123 + if (metadata.parameters.path) { 124 + metadata.parameters.path.forEach(param => { 125 + spec.parameters.push(this.buildParameterSpec(param, 'path')); 126 + }); 127 + } 128 + 129 + // Query parameters 130 + if (metadata.parameters.query) { 131 + metadata.parameters.query.forEach(param => { 132 + spec.parameters.push(this.buildParameterSpec(param, 'query')); 133 + }); 134 + } 135 + 136 + // Request body 137 + if (metadata.parameters.body && ['POST', 'PUT', 'PATCH'].includes(method)) { 138 + spec.requestBody = { 139 + required: true, 140 + content: { 141 + 'application/json': { 142 + schema: metadata.parameters.body, 143 + }, 144 + }, 145 + }; 146 + } 147 + } 148 + 149 + // Add responses 150 + Object.entries(metadata.responses).forEach(([status, response]) => { 151 + spec.responses[status] = { 152 + description: response.description, 153 + ...(response.schema && { 154 + content: { 155 + 'application/json': { 156 + schema: response.schema, 157 + }, 158 + }, 159 + }), 160 + }; 161 + }); 162 + 163 + // Add security if required 164 + if (metadata.requiresAuth) { 165 + spec.security = [{ bearerAuth: [] }]; 166 + } 167 + 168 + return spec; 169 + } 170 + 171 + /** 172 + * Build parameter specification 173 + */ 174 + private buildParameterSpec(param: RouteParam, location: 'path' | 'query') { 175 + return { 176 + name: param.name, 177 + in: location, 178 + required: param.required, 179 + description: param.description, 180 + schema: { 181 + type: param.type, 182 + ...(param.example && { example: param.example }), 183 + }, 184 + }; 185 + } 186 + 187 + /** 188 + * Get the complete Swagger specification 189 + */ 190 + getSpec(): SwaggerSpec { 191 + return this.spec; 192 + } 193 + 194 + /** 195 + * Generate JSON string of the specification 196 + */ 197 + toJSON(): string { 198 + return JSON.stringify(this.spec, null, 2); 199 + } 200 + } 201 + 202 + // Export singleton instance 203 + export const swaggerGenerator = new SwaggerGenerator();
+310
src/routes/api-routes.ts
··· 1 + /** 2 + * Complete typed route definitions for all Cachet API endpoints 3 + */ 4 + 5 + import { 6 + createRoute, 7 + pathParam, 8 + queryParam, 9 + apiResponse, 10 + type RouteDefinition 11 + } from "../types/routes"; 12 + import { createAnalyticsWrapper } from "../lib/analytics-wrapper"; 13 + import * as handlers from "../handlers"; 14 + 15 + // Factory function to create all routes with injected dependencies 16 + export function createApiRoutes(cache: any, slackApp: any) { 17 + // Inject dependencies into handlers 18 + handlers.injectDependencies(cache, slackApp); 19 + 20 + const withAnalytics = createAnalyticsWrapper(cache); 21 + 22 + return { 23 + "/health": { 24 + GET: createRoute( 25 + withAnalytics("/health", "GET", handlers.handleHealthCheck), 26 + { 27 + summary: "Health check", 28 + description: "Check if the service is healthy and operational", 29 + tags: ["Health"], 30 + responses: Object.fromEntries([ 31 + apiResponse(200, "Service is healthy", { 32 + type: "object", 33 + properties: { 34 + status: { type: "string", example: "healthy" }, 35 + cache: { type: "boolean", example: true }, 36 + uptime: { type: "number", example: 123456 } 37 + } 38 + }), 39 + apiResponse(503, "Service is unhealthy") 40 + ]) 41 + } 42 + ) 43 + }, 44 + 45 + "/users/:id": { 46 + GET: createRoute( 47 + withAnalytics("/users/:id", "GET", handlers.handleGetUser), 48 + { 49 + summary: "Get user information", 50 + description: "Retrieve cached user profile information from Slack", 51 + tags: ["Users"], 52 + parameters: { 53 + path: [pathParam("id", "string", "Slack user ID", "U062UG485EE")] 54 + }, 55 + responses: Object.fromEntries([ 56 + apiResponse(200, "User information retrieved successfully", { 57 + type: "object", 58 + properties: { 59 + id: { type: "string", example: "U062UG485EE" }, 60 + userId: { type: "string", example: "U062UG485EE" }, 61 + displayName: { type: "string", example: "Kieran Klukas" }, 62 + pronouns: { type: "string", example: "he/him" }, 63 + imageUrl: { type: "string", example: "https://avatars.slack-edge.com/..." } 64 + } 65 + }), 66 + apiResponse(404, "User not found") 67 + ]) 68 + } 69 + ) 70 + }, 71 + 72 + "/users/:id/r": { 73 + GET: createRoute( 74 + withAnalytics("/users/:id/r", "GET", handlers.handleUserRedirect), 75 + { 76 + summary: "Redirect to user profile image", 77 + description: "Direct redirect to the user's cached profile image URL", 78 + tags: ["Users"], 79 + parameters: { 80 + path: [pathParam("id", "string", "Slack user ID", "U062UG485EE")] 81 + }, 82 + responses: Object.fromEntries([ 83 + apiResponse(302, "Redirect to user image"), 84 + apiResponse(307, "Temporary redirect to default avatar"), 85 + apiResponse(404, "User not found") 86 + ]) 87 + } 88 + ) 89 + }, 90 + 91 + "/users/:id/purge": { 92 + POST: createRoute( 93 + withAnalytics("/users/:id/purge", "POST", handlers.handlePurgeUser), 94 + { 95 + summary: "Purge user cache", 96 + description: "Remove a specific user from the cache (requires authentication)", 97 + tags: ["Users", "Admin"], 98 + requiresAuth: true, 99 + parameters: { 100 + path: [pathParam("id", "string", "Slack user ID to purge", "U062UG485EE")] 101 + }, 102 + responses: Object.fromEntries([ 103 + apiResponse(200, "User cache purged successfully", { 104 + type: "object", 105 + properties: { 106 + message: { type: "string", example: "User cache purged" }, 107 + userId: { type: "string", example: "U062UG485EE" }, 108 + success: { type: "boolean", example: true } 109 + } 110 + }), 111 + apiResponse(401, "Unauthorized") 112 + ]) 113 + } 114 + ) 115 + }, 116 + 117 + "/emojis": { 118 + GET: createRoute( 119 + withAnalytics("/emojis", "GET", handlers.handleListEmojis), 120 + { 121 + summary: "List all emojis", 122 + description: "Get a list of all cached custom emojis from the Slack workspace", 123 + tags: ["Emojis"], 124 + responses: Object.fromEntries([ 125 + apiResponse(200, "List of emojis retrieved successfully", { 126 + type: "array", 127 + items: { 128 + type: "object", 129 + properties: { 130 + name: { type: "string", example: "hackshark" }, 131 + imageUrl: { type: "string", example: "https://emoji.slack-edge.com/..." }, 132 + alias: { type: "string", nullable: true, example: null } 133 + } 134 + } 135 + }) 136 + ]) 137 + } 138 + ) 139 + }, 140 + 141 + "/emojis/:name": { 142 + GET: createRoute( 143 + withAnalytics("/emojis/:name", "GET", handlers.handleGetEmoji), 144 + { 145 + summary: "Get emoji information", 146 + description: "Retrieve information about a specific custom emoji", 147 + tags: ["Emojis"], 148 + parameters: { 149 + path: [pathParam("name", "string", "Emoji name (without colons)", "hackshark")] 150 + }, 151 + responses: Object.fromEntries([ 152 + apiResponse(200, "Emoji information retrieved successfully", { 153 + type: "object", 154 + properties: { 155 + name: { type: "string", example: "hackshark" }, 156 + imageUrl: { type: "string", example: "https://emoji.slack-edge.com/..." }, 157 + alias: { type: "string", nullable: true, example: null } 158 + } 159 + }), 160 + apiResponse(404, "Emoji not found") 161 + ]) 162 + } 163 + ) 164 + }, 165 + 166 + "/emojis/:name/r": { 167 + GET: createRoute( 168 + withAnalytics("/emojis/:name/r", "GET", handlers.handleEmojiRedirect), 169 + { 170 + summary: "Redirect to emoji image", 171 + description: "Direct redirect to the emoji's cached image URL", 172 + tags: ["Emojis"], 173 + parameters: { 174 + path: [pathParam("name", "string", "Emoji name (without colons)", "hackshark")] 175 + }, 176 + responses: Object.fromEntries([ 177 + apiResponse(302, "Redirect to emoji image"), 178 + apiResponse(404, "Emoji not found") 179 + ]) 180 + } 181 + ) 182 + }, 183 + 184 + "/reset": { 185 + POST: createRoute( 186 + withAnalytics("/reset", "POST", handlers.handleResetCache), 187 + { 188 + summary: "Reset entire cache", 189 + description: "Clear all cached data (requires authentication)", 190 + tags: ["Admin"], 191 + requiresAuth: true, 192 + responses: Object.fromEntries([ 193 + apiResponse(200, "Cache reset successfully", { 194 + type: "object", 195 + properties: { 196 + message: { type: "string", example: "Cache has been reset" }, 197 + users: { type: "number", example: 42 }, 198 + emojis: { type: "number", example: 1337 } 199 + } 200 + }), 201 + apiResponse(401, "Unauthorized") 202 + ]) 203 + } 204 + ) 205 + }, 206 + 207 + "/api/stats/essential": { 208 + GET: createRoute( 209 + withAnalytics("/api/stats/essential", "GET", handlers.handleGetEssentialStats), 210 + { 211 + summary: "Get essential analytics", 212 + description: "Fast-loading essential statistics for the dashboard", 213 + tags: ["Analytics"], 214 + parameters: { 215 + query: [queryParam("days", "number", "Number of days to analyze", false, 7)] 216 + }, 217 + responses: Object.fromEntries([ 218 + apiResponse(200, "Essential stats retrieved successfully", { 219 + type: "object", 220 + properties: { 221 + totalRequests: { type: "number", example: 12345 }, 222 + averageResponseTime: { type: "number", example: 23.5 }, 223 + uptime: { type: "number", example: 99.9 }, 224 + period: { type: "string", example: "7 days" } 225 + } 226 + }) 227 + ]) 228 + } 229 + ) 230 + }, 231 + 232 + "/api/stats/charts": { 233 + GET: createRoute( 234 + withAnalytics("/api/stats/charts", "GET", handlers.handleGetChartData), 235 + { 236 + summary: "Get chart data", 237 + description: "Time-series data for request and latency charts", 238 + tags: ["Analytics"], 239 + parameters: { 240 + query: [queryParam("days", "number", "Number of days to analyze", false, 7)] 241 + }, 242 + responses: Object.fromEntries([ 243 + apiResponse(200, "Chart data retrieved successfully", { 244 + type: "array", 245 + items: { 246 + type: "object", 247 + properties: { 248 + time: { type: "string", example: "2024-01-01T12:00:00Z" }, 249 + count: { type: "number", example: 42 }, 250 + averageResponseTime: { type: "number", example: 25.3 } 251 + } 252 + } 253 + }) 254 + ]) 255 + } 256 + ) 257 + }, 258 + 259 + "/api/stats/useragents": { 260 + GET: createRoute( 261 + withAnalytics("/api/stats/useragents", "GET", handlers.handleGetUserAgents), 262 + { 263 + summary: "Get user agents statistics", 264 + description: "List of user agents accessing the service with request counts", 265 + tags: ["Analytics"], 266 + parameters: { 267 + query: [queryParam("days", "number", "Number of days to analyze", false, 7)] 268 + }, 269 + responses: Object.fromEntries([ 270 + apiResponse(200, "User agents data retrieved successfully", { 271 + type: "array", 272 + items: { 273 + type: "object", 274 + properties: { 275 + userAgent: { type: "string", example: "Mozilla/5.0..." }, 276 + count: { type: "number", example: 123 } 277 + } 278 + } 279 + }) 280 + ]) 281 + } 282 + ) 283 + }, 284 + 285 + "/stats": { 286 + GET: createRoute( 287 + withAnalytics("/stats", "GET", handlers.handleGetStats), 288 + { 289 + summary: "Get complete analytics (legacy)", 290 + description: "Legacy endpoint returning all analytics data in one response", 291 + tags: ["Analytics", "Legacy"], 292 + parameters: { 293 + query: [queryParam("days", "number", "Number of days to analyze", false, 7)] 294 + }, 295 + responses: Object.fromEntries([ 296 + apiResponse(200, "Complete analytics data retrieved", { 297 + type: "object", 298 + properties: { 299 + totalRequests: { type: "number" }, 300 + averageResponseTime: { type: "number" }, 301 + chartData: { type: "array" }, 302 + userAgents: { type: "array" } 303 + } 304 + }) 305 + ]) 306 + } 307 + ) 308 + } 309 + }; 310 + }
-553
src/swagger.ts
··· 1 - import { version } from "../package.json"; 2 - 3 - // Define the Swagger specification 4 - const swaggerSpec = { 5 - openapi: "3.0.0", 6 - info: { 7 - title: "Cachet", 8 - version: version, 9 - description: 10 - "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.", 11 - contact: { 12 - name: "Kieran Klukas", 13 - email: "me@dunkirk.sh", 14 - }, 15 - license: { 16 - name: "AGPL 3.0", 17 - url: "https://github.com/taciturnaxolotl/cachet/blob/main/LICENSE.md", 18 - }, 19 - }, 20 - tags: [ 21 - { 22 - name: "The Cache!", 23 - description: "*must be read in an ominous voice*", 24 - }, 25 - { 26 - name: "Status", 27 - description: "*Rather boring status endpoints :(*", 28 - }, 29 - ], 30 - paths: { 31 - "/users/{user}": { 32 - get: { 33 - tags: ["The Cache!"], 34 - summary: "Get user information", 35 - description: 36 - "Retrieves user information from the cache or from Slack if not cached", 37 - parameters: [ 38 - { 39 - name: "user", 40 - in: "path", 41 - required: true, 42 - schema: { 43 - type: "string", 44 - }, 45 - description: "Slack user ID", 46 - }, 47 - ], 48 - responses: { 49 - "200": { 50 - description: "User information", 51 - content: { 52 - "application/json": { 53 - schema: { 54 - type: "object", 55 - properties: { 56 - id: { 57 - type: "string", 58 - example: "90750e24-c2f0-4c52-8681-e6176da6e7ab", 59 - }, 60 - expiration: { 61 - type: "string", 62 - format: "date-time", 63 - example: new Date().toISOString(), 64 - }, 65 - user: { 66 - type: "string", 67 - example: "U12345678", 68 - }, 69 - displayName: { 70 - type: "string", 71 - example: "krn", 72 - }, 73 - pronouns: { 74 - type: "string", 75 - nullable: true, 76 - example: "possibly/blank", 77 - }, 78 - image: { 79 - type: "string", 80 - example: 81 - "https://avatars.slack-edge.com/2024-11-30/8105375749571_53898493372773a01a1f_original.jpg", 82 - }, 83 - }, 84 - }, 85 - }, 86 - }, 87 - }, 88 - "404": { 89 - description: "User not found", 90 - content: { 91 - "application/json": { 92 - schema: { 93 - type: "object", 94 - properties: { 95 - message: { 96 - type: "string", 97 - example: "User not found", 98 - }, 99 - }, 100 - }, 101 - }, 102 - }, 103 - }, 104 - "500": { 105 - description: "Error fetching user from Slack", 106 - content: { 107 - "application/json": { 108 - schema: { 109 - type: "object", 110 - properties: { 111 - message: { 112 - type: "string", 113 - example: "Error fetching user from Slack", 114 - }, 115 - }, 116 - }, 117 - }, 118 - }, 119 - }, 120 - }, 121 - }, 122 - }, 123 - "/users/{user}/r": { 124 - get: { 125 - tags: ["The Cache!"], 126 - summary: "Redirect to user profile image", 127 - description: "Redirects to the user's profile image URL", 128 - parameters: [ 129 - { 130 - name: "user", 131 - in: "path", 132 - required: true, 133 - schema: { 134 - type: "string", 135 - }, 136 - description: "Slack user ID", 137 - }, 138 - ], 139 - responses: { 140 - "302": { 141 - description: "Redirect to user profile image", 142 - }, 143 - "307": { 144 - description: "Redirect to default image when user not found", 145 - }, 146 - "500": { 147 - description: "Error fetching user from Slack", 148 - content: { 149 - "application/json": { 150 - schema: { 151 - type: "object", 152 - properties: { 153 - message: { 154 - type: "string", 155 - example: "Error fetching user from Slack", 156 - }, 157 - }, 158 - }, 159 - }, 160 - }, 161 - }, 162 - }, 163 - }, 164 - }, 165 - "/users/{user}/purge": { 166 - post: { 167 - tags: ["The Cache!"], 168 - summary: "Purge user cache", 169 - description: "Purges a specific user's cache", 170 - parameters: [ 171 - { 172 - name: "user", 173 - in: "path", 174 - required: true, 175 - schema: { 176 - type: "string", 177 - }, 178 - description: "Slack user ID", 179 - }, 180 - { 181 - name: "authorization", 182 - in: "header", 183 - required: true, 184 - schema: { 185 - type: "string", 186 - example: "Bearer <token>", 187 - }, 188 - description: "Bearer token for authentication", 189 - }, 190 - ], 191 - responses: { 192 - "200": { 193 - description: "User cache purged", 194 - content: { 195 - "application/json": { 196 - schema: { 197 - type: "object", 198 - properties: { 199 - message: { 200 - type: "string", 201 - example: "User cache purged", 202 - }, 203 - userId: { 204 - type: "string", 205 - example: "U12345678", 206 - }, 207 - success: { 208 - type: "boolean", 209 - example: true, 210 - }, 211 - }, 212 - }, 213 - }, 214 - }, 215 - }, 216 - "401": { 217 - description: "Unauthorized", 218 - content: { 219 - "text/plain": { 220 - schema: { 221 - type: "string", 222 - example: "Unauthorized", 223 - }, 224 - }, 225 - }, 226 - }, 227 - }, 228 - }, 229 - }, 230 - "/emojis": { 231 - get: { 232 - tags: ["The Cache!"], 233 - summary: "Get all emojis", 234 - description: "Retrieves all emojis from the cache", 235 - responses: { 236 - "200": { 237 - description: "List of emojis", 238 - content: { 239 - "application/json": { 240 - schema: { 241 - type: "array", 242 - items: { 243 - type: "object", 244 - properties: { 245 - id: { 246 - type: "string", 247 - example: "5427fe70-686f-4684-9da5-95d9ef4c1090", 248 - }, 249 - expiration: { 250 - type: "string", 251 - format: "date-time", 252 - example: new Date().toISOString(), 253 - }, 254 - name: { 255 - type: "string", 256 - example: "blahaj-heart", 257 - }, 258 - alias: { 259 - type: "string", 260 - nullable: true, 261 - example: "blobhaj-heart", 262 - }, 263 - image: { 264 - type: "string", 265 - example: 266 - "https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png", 267 - }, 268 - }, 269 - }, 270 - }, 271 - }, 272 - }, 273 - }, 274 - }, 275 - }, 276 - }, 277 - "/emojis/{emoji}": { 278 - get: { 279 - tags: ["The Cache!"], 280 - summary: "Get emoji information", 281 - description: "Retrieves information about a specific emoji", 282 - parameters: [ 283 - { 284 - name: "emoji", 285 - in: "path", 286 - required: true, 287 - schema: { 288 - type: "string", 289 - }, 290 - description: "Emoji name", 291 - }, 292 - ], 293 - responses: { 294 - "200": { 295 - description: "Emoji information", 296 - content: { 297 - "application/json": { 298 - schema: { 299 - type: "object", 300 - properties: { 301 - id: { 302 - type: "string", 303 - example: "9ed0a560-928d-409c-89fc-10fe156299da", 304 - }, 305 - expiration: { 306 - type: "string", 307 - format: "date-time", 308 - example: new Date().toISOString(), 309 - }, 310 - name: { 311 - type: "string", 312 - example: "orphmoji-yay", 313 - }, 314 - image: { 315 - type: "string", 316 - example: 317 - "https://emoji.slack-edge.com/T0266FRGM/orphmoji-yay/23a37f4af47092d3.png", 318 - }, 319 - }, 320 - }, 321 - }, 322 - }, 323 - }, 324 - "404": { 325 - description: "Emoji not found", 326 - content: { 327 - "application/json": { 328 - schema: { 329 - type: "object", 330 - properties: { 331 - message: { 332 - type: "string", 333 - example: "Emoji not found", 334 - }, 335 - }, 336 - }, 337 - }, 338 - }, 339 - }, 340 - }, 341 - }, 342 - }, 343 - "/emojis/{emoji}/r": { 344 - get: { 345 - tags: ["The Cache!"], 346 - summary: "Redirect to emoji image", 347 - description: "Redirects to the emoji image URL", 348 - parameters: [ 349 - { 350 - name: "emoji", 351 - in: "path", 352 - required: true, 353 - schema: { 354 - type: "string", 355 - }, 356 - description: "Emoji name", 357 - }, 358 - ], 359 - responses: { 360 - "302": { 361 - description: "Redirect to emoji image", 362 - }, 363 - "404": { 364 - description: "Emoji not found", 365 - content: { 366 - "application/json": { 367 - schema: { 368 - type: "object", 369 - properties: { 370 - message: { 371 - type: "string", 372 - example: "Emoji not found", 373 - }, 374 - }, 375 - }, 376 - }, 377 - }, 378 - }, 379 - }, 380 - }, 381 - }, 382 - "/reset": { 383 - post: { 384 - tags: ["The Cache!"], 385 - summary: "Reset cache", 386 - description: "Purges all items from the cache", 387 - parameters: [ 388 - { 389 - name: "authorization", 390 - in: "header", 391 - required: true, 392 - schema: { 393 - type: "string", 394 - example: "Bearer <token>", 395 - }, 396 - description: "Bearer token for authentication", 397 - }, 398 - ], 399 - responses: { 400 - "200": { 401 - description: "Cache purged", 402 - content: { 403 - "application/json": { 404 - schema: { 405 - type: "object", 406 - properties: { 407 - message: { 408 - type: "string", 409 - example: "Cache purged", 410 - }, 411 - users: { 412 - type: "number", 413 - example: 10, 414 - }, 415 - emojis: { 416 - type: "number", 417 - example: 100, 418 - }, 419 - }, 420 - }, 421 - }, 422 - }, 423 - }, 424 - "401": { 425 - description: "Unauthorized", 426 - content: { 427 - "text/plain": { 428 - schema: { 429 - type: "string", 430 - example: "Unauthorized", 431 - }, 432 - }, 433 - }, 434 - }, 435 - }, 436 - }, 437 - }, 438 - "/health": { 439 - get: { 440 - tags: ["Status"], 441 - summary: "Health check", 442 - description: 443 - "Checks the health of the API, Slack connection, and database", 444 - responses: { 445 - "200": { 446 - description: "Health check passed", 447 - content: { 448 - "application/json": { 449 - schema: { 450 - type: "object", 451 - properties: { 452 - http: { 453 - type: "boolean", 454 - example: true, 455 - }, 456 - slack: { 457 - type: "boolean", 458 - example: true, 459 - }, 460 - database: { 461 - type: "boolean", 462 - example: true, 463 - }, 464 - }, 465 - }, 466 - }, 467 - }, 468 - }, 469 - "500": { 470 - description: "Health check failed", 471 - content: { 472 - "application/json": { 473 - schema: { 474 - type: "object", 475 - properties: { 476 - http: { 477 - type: "boolean", 478 - example: false, 479 - }, 480 - slack: { 481 - type: "boolean", 482 - example: false, 483 - }, 484 - database: { 485 - type: "boolean", 486 - example: false, 487 - }, 488 - }, 489 - }, 490 - }, 491 - }, 492 - }, 493 - }, 494 - }, 495 - }, 496 - "/stats": { 497 - get: { 498 - tags: ["Status"], 499 - summary: "Get analytics statistics", 500 - description: "Retrieves analytics statistics for the API", 501 - parameters: [ 502 - { 503 - name: "days", 504 - in: "query", 505 - required: false, 506 - schema: { 507 - type: "string", 508 - }, 509 - description: "Number of days to look back (default: 7)", 510 - }, 511 - ], 512 - responses: { 513 - "200": { 514 - description: "Analytics statistics", 515 - content: { 516 - "application/json": { 517 - schema: { 518 - type: "object", 519 - properties: { 520 - totalRequests: { 521 - type: "number", 522 - }, 523 - requestsByEndpoint: { 524 - type: "array", 525 - items: { 526 - type: "object", 527 - properties: { 528 - endpoint: { 529 - type: "string", 530 - }, 531 - count: { 532 - type: "number", 533 - }, 534 - averageResponseTime: { 535 - type: "number", 536 - }, 537 - }, 538 - }, 539 - }, 540 - // Additional properties omitted for brevity 541 - }, 542 - }, 543 - }, 544 - }, 545 - }, 546 - }, 547 - }, 548 - }, 549 - }, 550 - }; 551 - 552 - // Export the Swagger specification for use in other files 553 - export default swaggerSpec;
+93
src/types/routes.ts
··· 1 + /** 2 + * Type-safe route system that generates Swagger documentation from route definitions 3 + * This ensures the Swagger docs stay in sync with the actual API implementation 4 + */ 5 + 6 + // Base types for HTTP methods 7 + export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 8 + 9 + // Parameter types 10 + export interface RouteParam { 11 + name: string; 12 + type: 'string' | 'number' | 'boolean'; 13 + required: boolean; 14 + description: string; 15 + example?: any; 16 + } 17 + 18 + // Response types 19 + export interface ApiResponse { 20 + status: number; 21 + description: string; 22 + schema?: any; // JSON Schema or example object 23 + } 24 + 25 + // Route metadata for Swagger generation 26 + export interface RouteMetadata { 27 + summary: string; 28 + description?: string; 29 + tags?: string[]; 30 + parameters?: { 31 + path?: RouteParam[]; 32 + query?: RouteParam[]; 33 + body?: any; // JSON Schema for request body 34 + }; 35 + responses: Record<number, ApiResponse>; 36 + requiresAuth?: boolean; 37 + } 38 + 39 + // Handler function type 40 + export type RouteHandler = (request: Request) => Promise<Response> | Response; 41 + 42 + // Enhanced route definition that includes metadata 43 + export interface TypedRoute { 44 + handler: RouteHandler; 45 + metadata: RouteMetadata; 46 + } 47 + 48 + // Method-specific route definitions (matching Bun's pattern) 49 + export interface RouteDefinition { 50 + GET?: TypedRoute; 51 + POST?: TypedRoute; 52 + PUT?: TypedRoute; 53 + DELETE?: TypedRoute; 54 + PATCH?: TypedRoute; 55 + } 56 + 57 + // Type helper to create routes with metadata 58 + export function createRoute( 59 + handler: RouteHandler, 60 + metadata: RouteMetadata 61 + ): TypedRoute { 62 + return { handler, metadata }; 63 + } 64 + 65 + // Type helper for path parameters 66 + export function pathParam( 67 + name: string, 68 + type: RouteParam['type'] = 'string', 69 + description: string, 70 + example?: any 71 + ): RouteParam { 72 + return { name, type, required: true, description, example }; 73 + } 74 + 75 + // Type helper for query parameters 76 + export function queryParam( 77 + name: string, 78 + type: RouteParam['type'] = 'string', 79 + description: string, 80 + required = false, 81 + example?: any 82 + ): RouteParam { 83 + return { name, type, required, description, example }; 84 + } 85 + 86 + // Type helper for API responses 87 + export function apiResponse( 88 + status: number, 89 + description: string, 90 + schema?: any 91 + ): [number, ApiResponse] { 92 + return [status, { status, description, schema }]; 93 + }