diff --git a/src/cache.ts b/src/cache.ts index 8faa8c3..73b9ba1 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -30,6 +30,16 @@ export type { TrafficOverview, } from "./types/analytics"; + +const SECONDS_PER_10MIN = 600; +const SECONDS_PER_DAY = 86400; +const MS_PER_HOUR = 3600000; +const USER_DEFAULT_TTL_HOURS = 7 * 24; +const USER_CLEANUP_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const TOUCH_REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000; +const QUEUE_BATCH_SIZE = 3; +const QUEUE_INTERVAL_MS = 30 * 1000; + /** * Cache class for storing user and emoji data with automatic expiration. * Composes AnalyticsQueryService and HealthMonitor for separation of concerns. @@ -267,8 +277,8 @@ class Cache { Date.now(), ]); - const oneDayAgoSec = Math.floor(Date.now() / 1000) - 86400; - const cleanupBucket = oneDayAgoSec - (oneDayAgoSec % 600); + const oneDayAgoSec = Math.floor(Date.now() / 1000) - SECONDS_PER_DAY; + const cleanupBucket = oneDayAgoSec - (oneDayAgoSec % SECONDS_PER_10MIN); this.db.run("DELETE FROM traffic_10min WHERE bucket < ?", [cleanupBucket]); return result2.changes; @@ -277,7 +287,7 @@ class Cache { private async lazyUserCleanup(): Promise { const currentHour = new Date().getUTCHours(); if (currentHour >= 8 && currentHour < 10 && Math.random() < 0.1) { - const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const sevenDaysAgo = Date.now() - USER_CLEANUP_AGE_MS; const result = this.db.run("DELETE FROM users WHERE expiration < ?", [ sevenDaysAgo, ]); @@ -356,7 +366,7 @@ class Cache { private startQueueProcessor() { this.queueIntervalId = setInterval(async () => { await this.processUserUpdateQueue(); - }, 30 * 1000); + }, QUEUE_INTERVAL_MS); } private flushTouchRefresh(newExpiration: number, normalizedId: string) { @@ -384,7 +394,7 @@ class Cache { this.isProcessingQueue = true; try { - const usersToUpdate = Array.from(this.userUpdateQueue).slice(0, 3); + const usersToUpdate = Array.from(this.userUpdateQueue).slice(0, QUEUE_BATCH_SIZE); for (const userId of usersToUpdate) { try { @@ -421,9 +431,9 @@ class Cache { expirationHours?: number, ) { const id = crypto.randomUUID(); - const userDefaultTTL = 7 * 24; + const userDefaultTTL = USER_DEFAULT_TTL_HOURS; const expiration = - Date.now() + (expirationHours || userDefaultTTL) * 3600000; + Date.now() + (expirationHours || userDefaultTTL) * MS_PER_HOUR; try { this.db.run( @@ -457,7 +467,7 @@ class Cache { ) { const id = crypto.randomUUID(); const expiration = - Date.now() + (expirationHours || this.defaultExpiration) * 3600000; + Date.now() + (expirationHours || this.defaultExpiration) * MS_PER_HOUR; try { this.db.run( @@ -488,7 +498,7 @@ class Cache { ): Promise { try { const expiration = - Date.now() + (expirationHours || this.defaultExpiration) * 3600000; + Date.now() + (expirationHours || this.defaultExpiration) * MS_PER_HOUR; this.db.transaction(() => { for (const emoji of emojis) { @@ -534,11 +544,11 @@ class Cache { return null; } - const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000; - const userAge = expiration - 7 * 24 * 60 * 60 * 1000; + const twentyFourHoursAgo = now - TOUCH_REFRESH_THRESHOLD_MS; + const userAge = expiration - USER_CLEANUP_AGE_MS; if (userAge < twentyFourHoursAgo) { - const newExpiration = now + 7 * 24 * 60 * 60 * 1000; + const newExpiration = now + USER_CLEANUP_AGE_MS; this.flushTouchRefresh(newExpiration, normalizedId); this.queueUserUpdate(normalizedId); console.log( @@ -594,14 +604,12 @@ class Cache { recordRequest( endpoint: string, - _method: string, statusCode: number, userAgent?: string, - _ipAddress?: string, responseTime?: number, referer?: string, ): void { - this.analytics.recordRequest(endpoint, _method, statusCode, userAgent, _ipAddress, responseTime, referer); + this.analytics.recordRequest(endpoint, statusCode, userAgent, responseTime, referer); } async getAnalytics(days: number = 7): Promise { diff --git a/src/handlers/index.ts b/src/handlers/index.ts index cb5c66f..73f816e 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -15,6 +15,26 @@ function parsePositiveInt(value: string | null, fallback: number): number { return Number.isFinite(n) && n > 0 ? n : fallback; } + + /** + * Validates Bearer token authorization. Returns an error Response if unauthorized, + * or null if authorized. + */ + function requireAuth(request: Request, recordAnalytics: (code: number) => void): Response | null { + const token = config.bearerToken; + if (!token) { + console.error("BEARER_TOKEN is not configured"); + recordAnalytics(500); + return new Response("Server misconfigured", { status: 500 }); + } + const authHeader = request.headers.get("authorization") || ""; + if (authHeader !== `Bearer ${token}`) { + recordAnalytics(401); + return new Response("Unauthorized", { status: 401 }); + } + return null; + } + /** * Creates all handlers with dependencies bound via closure. * Eliminates global mutable state and injectDependencies pattern. @@ -114,18 +134,8 @@ export function createHandlers(cache: SlackCache) { request, recordAnalytics, ) => { - const configuredToken = config.bearerToken; - if (!configuredToken) { - console.error("BEARER_TOKEN is not configured"); - recordAnalytics(500); - return new Response("Server misconfigured", { status: 500 }); - } - - const authHeader = request.headers.get("authorization") || ""; - if (authHeader !== `Bearer ${configuredToken}`) { - recordAnalytics(401); - return new Response("Unauthorized", { status: 401 }); - } + const authError = requireAuth(request, recordAnalytics); + if (authError) return authError; const url = new URL(request.url); const userId = url.pathname.split("/")[2] || ""; @@ -190,18 +200,9 @@ export function createHandlers(cache: SlackCache) { request, recordAnalytics, ) => { - const configuredToken = config.bearerToken; - if (!configuredToken) { - console.error("BEARER_TOKEN is not configured"); - recordAnalytics(500); - return new Response("Server misconfigured", { status: 500 }); - } + const authError = requireAuth(request, recordAnalytics); + if (authError) return authError; - const authHeader = request.headers.get("authorization") || ""; - if (authHeader !== `Bearer ${configuredToken}`) { - recordAnalytics(401); - return new Response("Unauthorized", { status: 401 }); - } const result = await cache.purgeAll(); recordAnalytics(200); return Response.json(result); diff --git a/src/index.ts b/src/index.ts index b308ece..88f19fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,10 +3,13 @@ import { config } from "./config"; import { getEmojiUrl } from "../utils/emojiHelper"; import { SlackCache } from "./cache"; import dashboard from "./dashboard.html"; -import { buildRoutes, getSwaggerSpec } from "./lib/route-builder"; +import { swaggerGenerator } from "./lib/swagger-generator"; +import { addCorsHeaders, corsPreflightResponse } from "./lib/cors"; +import type { RouteDefinition } from "./types/routes"; import { createApiRoutes } from "./routes/api-routes"; import { SlackWrapper } from "./slackWrapper"; import swagger from "./swagger.html"; +import faviconFile from "../favicon.ico"; // Initialize SlackWrapper and Cache const slackApp = new SlackWrapper({ @@ -64,33 +67,23 @@ cache.setSlackWrapper(slackApp); // Create the typed API routes with injected dependencies const apiRoutes = createApiRoutes(cache, slackApp); -// Build Bun-compatible routes and generate Swagger -const typedRoutes = buildRoutes(apiRoutes); -const generatedSwagger = getSwaggerSpec(); - -/** - * Add CORS headers to response - */ -function addCorsHeaders(response: Response): Response { - const headers = new Headers(response.headers); - headers.set("Access-Control-Allow-Origin", "*"); - headers.set( - "Access-Control-Allow-Methods", - "GET, POST, PUT, DELETE, OPTIONS", - ); - headers.set( - "Access-Control-Allow-Headers", - "Content-Type, Authorization, X-Requested-With", - ); - headers.set("Access-Control-Max-Age", "86400"); - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); +// Generate Swagger and convert typed routes to Bun format +swaggerGenerator.addRoutes(apiRoutes as Record); +const generatedSwagger = swaggerGenerator.getSpec(); + +const typedRoutes: Record Promise | Response>> = {}; +for (const [path, routeConfig] of Object.entries(apiRoutes as Record)) { + const bunRoute: Record Promise | Response> = {}; + for (const [method, typedRoute] of Object.entries(routeConfig)) { + if (typedRoute && "handler" in typedRoute) { + bunRoute[method] = typedRoute.handler; + } + } + typedRoutes[path] = bunRoute; } + + // Legacy routes (non-API) const legacyRoutes = { "/dashboard": dashboard, @@ -100,7 +93,7 @@ const legacyRoutes = { return addCorsHeaders(response); }, "/favicon.ico": async (_: Request) => { - const response = new Response(Bun.file("./favicon.ico")); + const response = new Response(faviconFile); return addCorsHeaders(response); }, @@ -108,16 +101,7 @@ const legacyRoutes = { "/": async (request: Request) => { // Handle OPTIONS preflight if (request.method === "OPTIONS") { - return new Response(null, { - status: 204, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, X-Requested-With", - "Access-Control-Max-Age": "86400", - }, - }); + return corsPreflightResponse(); } const userAgent = request.headers.get("user-agent") || ""; diff --git a/src/lib/analytics-queries.ts b/src/lib/analytics-queries.ts index adc9c63..e323ca5 100644 --- a/src/lib/analytics-queries.ts +++ b/src/lib/analytics-queries.ts @@ -7,6 +7,11 @@ import type { UserAgentData, } from "../types/analytics"; + +const SECONDS_PER_10MIN = 600; +const SECONDS_PER_HOUR = 3600; +const SECONDS_PER_DAY = 86400; + /** * Selects the appropriate bucket table based on time range */ @@ -15,11 +20,11 @@ export function selectBucketTable(days: number): { bucketSize: number; } { if (days <= 1) { - return { table: "traffic_10min", bucketSize: 600 }; + return { table: "traffic_10min", bucketSize: SECONDS_PER_10MIN }; } else if (days <= 30) { - return { table: "traffic_hourly", bucketSize: 3600 }; + return { table: "traffic_hourly", bucketSize: SECONDS_PER_HOUR }; } else { - return { table: "traffic_daily", bucketSize: 86400 }; + return { table: "traffic_daily", bucketSize: SECONDS_PER_DAY }; } } @@ -139,18 +144,16 @@ export class AnalyticsQueryService { */ recordRequest( endpoint: string, - _method: string, statusCode: number, userAgent?: string, - _ipAddress?: string, responseTime?: number, referer?: string, ): void { try { const now = Math.floor(Date.now() / 1000); - const bucket10min = now - (now % 600); - const bucketHour = now - (now % 3600); - const bucketDay = now - (now % 86400); + const bucket10min = now - (now % SECONDS_PER_10MIN); + const bucketHour = now - (now % SECONDS_PER_HOUR); + const bucketDay = now - (now % SECONDS_PER_DAY); const respTime = responseTime || 0; const nowMs = Date.now(); diff --git a/src/lib/analytics-wrapper.ts b/src/lib/analytics-wrapper.ts index 6ffa8e6..b2e5623 100644 --- a/src/lib/analytics-wrapper.ts +++ b/src/lib/analytics-wrapper.ts @@ -3,6 +3,7 @@ */ import type { SlackCache } from "../cache"; +import { addCorsHeaders, corsPreflightResponse } from "./cors"; // Cache will be injected by the route system @@ -12,28 +13,7 @@ export type RouteHandlerWithAnalytics = ( recordAnalytics: AnalyticsRecorder, ) => Promise | Response; -/** - * Add CORS headers to a response - */ -function addCorsHeaders(response: Response): Response { - const headers = new Headers(response.headers); - headers.set("Access-Control-Allow-Origin", "*"); - headers.set( - "Access-Control-Allow-Methods", - "GET, POST, PUT, DELETE, OPTIONS", - ); - headers.set( - "Access-Control-Allow-Headers", - "Content-Type, Authorization, X-Requested-With", - ); - headers.set("Access-Control-Max-Age", "86400"); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} /** * Creates analytics wrapper with injected cache @@ -45,18 +25,8 @@ export function createAnalyticsWrapper(cache: SlackCache) { handler: RouteHandlerWithAnalytics, ) { return async (request: Request): Promise => { - // Handle OPTIONS preflight requests if (request.method === "OPTIONS") { - return new Response(null, { - status: 204, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, X-Requested-With", - "Access-Control-Max-Age": "86400", - }, - }); + return corsPreflightResponse(); } const startTime = performance.now(); @@ -80,10 +50,8 @@ export function createAnalyticsWrapper(cache: SlackCache) { cache.recordRequest( analyticsPath, - method, statusCode, userAgent, - ipAddress, Number((performance.now() - startTime).toFixed(3)), referer, ); @@ -95,14 +63,3 @@ export function createAnalyticsWrapper(cache: SlackCache) { }; } -/** - * Type-safe analytics wrapper that automatically infers path and method - */ -export function createAnalyticsHandler( - cache: SlackCache, - path: string, - method: string, -) { - return (handler: RouteHandlerWithAnalytics) => - createAnalyticsWrapper(cache)(path, method, handler); -} diff --git a/src/lib/cors.ts b/src/lib/cors.ts new file mode 100644 index 0000000..9bbc20f --- /dev/null +++ b/src/lib/cors.ts @@ -0,0 +1,26 @@ +/** + * Shared CORS utilities + */ + +export const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With", + "Access-Control-Max-Age": "86400", +}; + +export function addCorsHeaders(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export function corsPreflightResponse(): Response { + return new Response(null, { status: 204, headers: CORS_HEADERS }); +} diff --git a/src/lib/route-builder.ts b/src/lib/route-builder.ts deleted file mode 100644 index e9761b4..0000000 --- a/src/lib/route-builder.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Utility to build Bun-compatible routes from typed route definitions - * and generate Swagger documentation - */ - -import type { RouteDefinition } from "../types/routes"; -import { swaggerGenerator } from "./swagger-generator"; - -type BunRoute = Record< - string, - (request: Request) => Promise | Response ->; - -/** - * Convert typed routes to Bun server format and generate Swagger - */ -export function buildRoutes(typedRoutes: Record) { - // Generate Swagger from typed routes - swaggerGenerator.addRoutes(typedRoutes); - - // Convert to Bun server format - const bunRoutes: Record = {}; - - Object.entries(typedRoutes).forEach(([path, routeConfig]) => { - const bunRoute: Record< - string, - (request: Request) => Promise | Response - > = {}; - - // Convert each HTTP method - Object.entries(routeConfig).forEach(([method, typedRoute]) => { - if (typedRoute && "handler" in typedRoute) { - bunRoute[method] = typedRoute.handler; - } - }); - - bunRoutes[path] = bunRoute; - }); - - return bunRoutes; -} - -/** - * Get the generated Swagger specification - */ -export function getSwaggerSpec() { - return swaggerGenerator.getSpec(); -} - -/** - * Merge typed routes with existing legacy routes - * This allows gradual migration - */ -export function mergeRoutes( - typedRoutes: Record, - legacyRoutes: Record, -) { - const builtRoutes = buildRoutes(typedRoutes); - - return { - ...legacyRoutes, - ...builtRoutes, - }; -} diff --git a/src/slackWrapper.ts b/src/slackWrapper.ts index 88ad1da..60b10b7 100644 --- a/src/slackWrapper.ts +++ b/src/slackWrapper.ts @@ -96,7 +96,7 @@ class SlackWrapper { * @returns Promise resolving to the user's information * @throws Error if the API request fails */ - async getUserInfo(userId: string): Promise { + async getUserInfo(userId: string): Promise { const response = await this.limiter.schedule(() => fetch(`https://slack.com/api/users.info?user=${userId}`, { method: "POST", @@ -110,11 +110,11 @@ class SlackWrapper { ); const data = (await response.json()) as SlackUserInfoResponse; - if ((!data.ok && data.error !== "user_not_found") || !data.user) { + if (!data.ok && data.error !== "user_not_found") { throw new Error(data.error); } - return data.user; + return data.user ?? null; } /** diff --git a/src/types/cache-entities.ts b/src/types/cache-entities.ts index 75225d1..e370823 100644 --- a/src/types/cache-entities.ts +++ b/src/types/cache-entities.ts @@ -4,7 +4,7 @@ import type { SlackUser } from "../slack"; * Interface for Slack user provider - minimal interface Cache needs */ export interface SlackUserProvider { - getUserInfo(userId: string): Promise; + getUserInfo(userId: string): Promise; testAuth(): Promise; }