a cache for slack profile pictures and emojis

feat: fix random things

dunkirk.sh 949d9a2b 14f0b1cd

verified
.188625fb7dd457ed-00000000.bun-build

This is a binary file and will not be displayed.

.188625fbf9fffb7a-00000000.bun-build

This is a binary file and will not be displayed.

+7 -1
.env.example
··· 1 1 # Slack API Configuration 2 2 # Get these from https://api.slack.com/apps 3 - SLACK_TOKEN=xoxb-123456789012-123456789012-abcdefghijklmnopqrstuvwx 3 + # Either SLACK_BOT_TOKEN or SLACK_TOKEN will work (SLACK_BOT_TOKEN takes precedence) 4 + SLACK_BOT_TOKEN=xoxb-your-bot-token-here 4 5 SLACK_SIGNING_SECRET=1234567890abcdef1234567890abcdef 6 + 7 + # Optional: Slack Rate Limiting 8 + # Adjust these if you're hitting rate limits or need more throughput 9 + # SLACK_MAX_CONCURRENT=3 # Max concurrent requests (default: 3) 10 + # SLACK_MIN_TIME_MS=200 # Min time between requests in ms (default: 200, ~5 req/s) 5 11 6 12 # Environment 7 13 NODE_ENV=production
+37 -32
src/cache.ts
··· 11 11 */ 12 12 interface SlackUserProvider { 13 13 getUserInfo(userId: string): Promise<SlackUser>; 14 + testAuth(): Promise<boolean>; 14 15 } 15 16 16 17 /** ··· 36 37 37 38 interface UserAgentMetrics { 38 39 userAgent: string; 39 - count: number; 40 + hits: number; 40 41 } 41 42 42 43 interface LatencyPercentiles { ··· 649 650 heapUsed: number; 650 651 heapTotal: number; 651 652 percentage: number; 653 + details?: { 654 + heapUsedMiB: number; 655 + heapTotalMiB: number; 656 + heapPercent: number; 657 + rssMiB: number; 658 + externalMiB: number; 659 + arrayBuffersMiB: number; 660 + }; 652 661 }; 653 662 }; 654 663 uptime: number; 655 664 }> { 656 - const checks = { 665 + const checks: { 666 + database: { status: boolean; latency?: number }; 667 + slackApi: { status: boolean; error?: string }; 668 + queueDepth: number; 669 + memoryUsage: { 670 + heapUsed: number; 671 + heapTotal: number; 672 + percentage: number; 673 + details?: { 674 + heapUsedMiB: number; 675 + heapTotalMiB: number; 676 + heapPercent: number; 677 + rssMiB: number; 678 + externalMiB: number; 679 + arrayBuffersMiB: number; 680 + }; 681 + }; 682 + } = { 657 683 database: { status: false, latency: 0 }, 658 684 slackApi: { status: false }, 659 685 queueDepth: this.userUpdateQueue.size, ··· 676 702 // Check Slack API if wrapper is available 677 703 if (this.slackWrapper) { 678 704 try { 679 - await this.slackWrapper.getUserInfo("U062UG485EE"); // Use a known test user 705 + await this.slackWrapper.testAuth(); 680 706 checks.slackApi = { status: true }; 681 707 } catch (error) { 682 708 checks.slackApi = { ··· 933 959 } 934 960 935 961 /** 936 - * Lists all emoji in the cache 937 - * @returns Array of Emoji objects that haven't expired 938 - */ 939 - async listEmojis(): Promise<Emoji[]> { 940 - const results = this.db 941 - .query("SELECT * FROM emojis WHERE expiration > ?") 942 - .all(Date.now()) as Emoji[]; 943 - 944 - return results.map((result) => ({ 945 - type: "emoji", 946 - id: result.id, 947 - name: result.name, 948 - alias: result.alias || null, 949 - imageUrl: result.imageUrl, 950 - expiration: new Date(result.expiration), 951 - })); 952 - } 953 - 954 - /** 955 962 * Retrieves a user from the cache 956 963 * @param userId Unique identifier of the user 957 964 * @returns User object if found and not expired, null otherwise 958 965 */ 959 966 async getUser(userId: string): Promise<User | null> { 967 + const normalizedId = userId.toUpperCase(); 960 968 const result = this.db 961 969 .query("SELECT * FROM users WHERE userId = ?") 962 - .get(userId.toUpperCase()) as User; 970 + .get(normalizedId) as User; 963 971 964 972 if (!result) { 965 973 return null; ··· 970 978 971 979 // If user is expired, remove and return null 972 980 if (expiration < now) { 973 - this.db.run("DELETE FROM users WHERE userId = ?", [userId]); 981 + this.db.run("DELETE FROM users WHERE userId = ?", [normalizedId]); 974 982 return null; 975 983 } 976 984 ··· 983 991 const newExpiration = now + 7 * 24 * 60 * 60 * 1000; 984 992 this.db.run("UPDATE users SET expiration = ? WHERE userId = ?", [ 985 993 newExpiration, 986 - userId.toUpperCase(), 994 + normalizedId, 987 995 ]); 988 996 989 997 // Queue for background update to get fresh data 990 - this.queueUserUpdate(userId); 998 + this.queueUserUpdate(normalizedId); 991 999 992 1000 console.log( 993 - `Touch-refresh: Extended TTL for user ${userId} and queued for update`, 1001 + `Touch-refresh: Extended TTL for user ${normalizedId} and queued for update`, 994 1002 ); 995 1003 } 996 1004 ··· 1799 1807 1800 1808 /** 1801 1809 * Gets user agents data from cumulative stats table 1802 - * @param _days Unused - user_agent_stats is cumulative 1803 - * @returns User agents data 1810 + * @returns User agents data (cumulative, not time-filtered) 1804 1811 */ 1805 - async getUserAgents( 1806 - _days: number = 7, 1807 - ): Promise<Array<{ userAgent: string; hits: number }>> { 1812 + async getUserAgents(): Promise<Array<{ userAgent: string; hits: number }>> { 1808 1813 const cacheKey = "useragents_all"; 1809 1814 const cached = this.typedAnalyticsCache.getUserAgentData(cacheKey); 1810 1815
+38 -13
src/handlers/index.ts
··· 12 12 let cache!: SlackCache; 13 13 let slackApp!: SlackWrapper; 14 14 15 + /** 16 + * Parse a string to a positive integer, returning a fallback if invalid 17 + */ 18 + function parsePositiveInt(value: string | null, fallback: number): number { 19 + if (!value) return fallback; 20 + const n = Number.parseInt(value, 10); 21 + return Number.isFinite(n) && n > 0 ? n : fallback; 22 + } 23 + 15 24 export function injectDependencies( 16 25 cacheInstance: SlackCache, 17 26 slackInstance: SlackWrapper, ··· 177 186 request, 178 187 recordAnalytics, 179 188 ) => { 189 + const configuredToken = process.env.BEARER_TOKEN; 190 + if (!configuredToken) { 191 + console.error("BEARER_TOKEN is not configured"); 192 + await recordAnalytics(500); 193 + return new Response("Server misconfigured", { status: 500 }); 194 + } 195 + 180 196 const authHeader = request.headers.get("authorization") || ""; 181 - if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) { 197 + if (authHeader !== `Bearer ${configuredToken}`) { 182 198 await recordAnalytics(401); 183 199 return new Response("Unauthorized", { status: 401 }); 184 200 } ··· 246 262 request, 247 263 recordAnalytics, 248 264 ) => { 265 + const configuredToken = process.env.BEARER_TOKEN; 266 + if (!configuredToken) { 267 + console.error("BEARER_TOKEN is not configured"); 268 + await recordAnalytics(500); 269 + return new Response("Server misconfigured", { status: 500 }); 270 + } 271 + 249 272 const authHeader = request.headers.get("authorization") || ""; 250 - if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) { 273 + if (authHeader !== `Bearer ${configuredToken}`) { 251 274 await recordAnalytics(401); 252 275 return new Response("Unauthorized", { status: 401 }); 253 276 } ··· 262 285 ) => { 263 286 const url = new URL(request.url); 264 287 const params = new URLSearchParams(url.search); 265 - const daysParam = params.get("days"); 266 - const days = daysParam ? parseInt(daysParam, 10) : 7; 288 + const days = parsePositiveInt(params.get("days"), 7); 267 289 268 290 const stats = await cache.getEssentialStats(days); 269 291 await recordAnalytics(200); ··· 276 298 ) => { 277 299 const url = new URL(request.url); 278 300 const params = new URLSearchParams(url.search); 279 - const daysParam = params.get("days"); 280 - const days = daysParam ? parseInt(daysParam, 10) : 7; 301 + const days = parsePositiveInt(params.get("days"), 7); 281 302 282 303 const chartData = await cache.getChartData(days); 283 304 await recordAnalytics(200); ··· 311 332 312 333 const startParam = params.get("start"); 313 334 const endParam = params.get("end"); 314 - const daysParam = params.get("days"); 315 335 316 336 let options: { days?: number; startTime?: number; endTime?: number } = {}; 317 337 318 338 if (startParam && endParam) { 319 - options.startTime = parseInt(startParam, 10); 320 - options.endTime = parseInt(endParam, 10); 339 + const start = parsePositiveInt(startParam, 0); 340 + const end = parsePositiveInt(endParam, 0); 341 + if (start > 0 && end > 0) { 342 + options.startTime = start; 343 + options.endTime = end; 344 + } else { 345 + options.days = 7; 346 + } 321 347 } else { 322 - options.days = daysParam ? parseInt(daysParam, 10) : 7; 348 + options.days = parsePositiveInt(params.get("days"), 7); 323 349 } 324 350 325 351 const traffic = cache.getTraffic(options); ··· 333 359 ) => { 334 360 const url = new URL(request.url); 335 361 const params = new URLSearchParams(url.search); 336 - const daysParam = params.get("days"); 337 - const days = daysParam ? parseInt(daysParam, 10) : 7; 362 + const days = parsePositiveInt(params.get("days"), 7); 338 363 339 364 const [essentialStats, chartData, userAgents] = await Promise.all([ 340 365 cache.getEssentialStats(days), 341 366 cache.getChartData(days), 342 - cache.getUserAgents(days), 367 + cache.getUserAgents(), 343 368 ]); 344 369 345 370 await recordAnalytics(200);
+3 -2
src/lib/analytics-wrapper.ts
··· 32 32 "unknown"; 33 33 const referer = request.headers.get("referer") || undefined; 34 34 35 - // Use the actual request URL for dynamic paths, fallback to provided path 36 - const analyticsPath = path.includes(":") ? request.url : path; 35 + // Use the pathname for dynamic paths to ensure proper endpoint grouping 36 + const requestUrl = new URL(request.url); 37 + const analyticsPath = path.includes(":") ? requestUrl.pathname : path; 37 38 38 39 await cache.recordRequest( 39 40 analyticsPath,
+35 -9
src/routes/api-routes.ts
··· 341 341 type: "object", 342 342 properties: { 343 343 totalRequests: { type: "number", example: 12345 }, 344 - averageResponseTime: { type: "number", example: 23.5 }, 344 + averageResponseTime: { 345 + type: "number", 346 + nullable: true, 347 + example: 23.5, 348 + }, 345 349 uptime: { type: "number", example: 99.9 }, 346 - period: { type: "string", example: "7 days" }, 347 350 }, 348 351 }), 349 352 ]), ··· 371 374 }, 372 375 responses: Object.fromEntries([ 373 376 apiResponse(200, "Chart data retrieved successfully", { 374 - type: "array", 375 - items: { 376 - type: "object", 377 - properties: { 378 - time: { type: "string", example: "2024-01-01T12:00:00Z" }, 379 - count: { type: "number", example: 42 }, 380 - averageResponseTime: { type: "number", example: 25.3 }, 377 + type: "object", 378 + properties: { 379 + requestsByDay: { 380 + type: "array", 381 + items: { 382 + type: "object", 383 + properties: { 384 + date: { type: "string", example: "2024-01-01 12:00:00" }, 385 + count: { type: "number", example: 42 }, 386 + averageResponseTime: { type: "number", example: 25.3 }, 387 + }, 388 + }, 389 + }, 390 + latencyOverTime: { 391 + type: "array", 392 + items: { 393 + type: "object", 394 + properties: { 395 + time: { type: "string", example: "2024-01-01 12:00:00" }, 396 + averageResponseTime: { type: "number", example: 25.3 }, 397 + p95: { type: "number", nullable: true, example: null }, 398 + count: { type: "number", example: 42 }, 399 + }, 400 + }, 381 401 }, 382 402 }, 383 403 }), ··· 457 477 description: "Unix timestamp of bucket start", 458 478 }, 459 479 hits: { type: "number", example: 42 }, 480 + avgLatency: { 481 + type: "number", 482 + nullable: true, 483 + example: 25.3, 484 + description: "Average response time in ms", 485 + }, 460 486 }, 461 487 }, 462 488 }),
+49 -13
src/slackWrapper.ts
··· 22 22 class SlackWrapper { 23 23 private signingSecret: string; 24 24 private botToken: string; 25 - private limiter = new Bottleneck({ 26 - maxConcurrent: 10, 27 - minTime: 10, // 100 requests per second 28 - }); 25 + private limiter: Bottleneck; 29 26 30 27 /** 31 28 * Creates a new SlackWrapper instance ··· 35 32 constructor(config?: SlackConfig) { 36 33 this.signingSecret = 37 34 config?.signingSecret || process.env.SLACK_SIGNING_SECRET || ""; 38 - this.botToken = config?.botToken || process.env.SLACK_BOT_TOKEN || ""; 35 + this.botToken = 36 + config?.botToken || 37 + process.env.SLACK_BOT_TOKEN || 38 + process.env.SLACK_TOKEN || 39 + ""; 40 + 41 + // Configure rate limiting - defaults are conservative to respect Slack API limits 42 + const maxConcurrent = Number(process.env.SLACK_MAX_CONCURRENT ?? 3); 43 + const minTime = Number(process.env.SLACK_MIN_TIME_MS ?? 200); // ~5 requests per second 44 + this.limiter = new Bottleneck({ 45 + maxConcurrent: Number.isFinite(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 3, 46 + minTime: Number.isFinite(minTime) && minTime > 0 ? minTime : 200, 47 + }); 39 48 40 49 const missingFields = []; 41 50 if (!this.signingSecret) missingFields.push("signing secret"); ··· 43 52 44 53 if (missingFields.length > 0) { 45 54 throw new Error( 46 - `Missing required Slack credentials: ${missingFields.join(" and ")} either pass them to the class or set them as environment variables`, 55 + `Missing required Slack credentials: ${missingFields.join(" and ")} either pass them to the class or set them as environment variables (SLACK_BOT_TOKEN or SLACK_TOKEN)`, 47 56 ); 48 57 } 49 58 } ··· 124 133 } 125 134 126 135 /** 127 - * Verifies a Slack request signature 128 - * @param signature The signature from the request header 129 - * @param timestamp The timestamp from the request header 136 + * Verifies a Slack request signature with timestamp freshness check 137 + * @param signature The signature from the request header (x-slack-signature) 138 + * @param timestamp The timestamp from the request header (x-slack-request-timestamp) 130 139 * @param body The raw request body 140 + * @param maxAgeSeconds Maximum age of timestamp in seconds (default: 5 minutes) 131 141 * @returns boolean indicating if the signature is valid 132 142 */ 133 - verifySignature(signature: string, timestamp: string, body: string): boolean { 143 + verifySignature( 144 + signature: string, 145 + timestamp: string, 146 + body: string, 147 + maxAgeSeconds: number = 300, 148 + ): boolean { 149 + if (!signature || !timestamp) { 150 + return false; 151 + } 152 + 153 + // Reject old timestamps to prevent replay attacks 154 + const ts = Number(timestamp); 155 + if (!Number.isFinite(ts)) { 156 + return false; 157 + } 158 + 159 + const now = Math.floor(Date.now() / 1000); 160 + if (Math.abs(now - ts) > maxAgeSeconds) { 161 + return false; 162 + } 163 + 134 164 const baseString = `v0:${timestamp}:${body}`; 135 165 const hmac = createHmac("sha256", this.signingSecret); 136 - const computedSignature = `v0=${hmac.update(baseString).digest("hex")}`; 166 + const expected = `v0=${hmac.update(baseString).digest("hex")}`; 167 + 168 + // Ensure equal length before timingSafeEqual to avoid exception 169 + if (expected.length !== signature.length) { 170 + return false; 171 + } 172 + 137 173 return timingSafeEqual( 138 - new Uint8Array(Buffer.from(signature)), 139 - new Uint8Array(Buffer.from(computedSignature)), 174 + Buffer.from(signature, "utf8"), 175 + Buffer.from(expected, "utf8"), 140 176 ); 141 177 } 142 178 }