diff --git a/.env.example b/.env.example index 2704e2d..ebadc3a 100644 --- a/.env.example +++ b/.env.example @@ -17,9 +17,6 @@ HTTP_FETCH_RETRIES=3 # Number of retry attempts (default: 3) # Optional: Discord Badges (leave empty to disable) DISCORD_TOKEN= -# Optional: GitHub Token (for better rate limits?) -# GITHUB_TOKEN= - BLOCKLIST_ENABLED=true # Enable IP/user blocklist (default: true) # Optional: Admin API Key (required for cache management endpoints) diff --git a/Dockerfile b/Dockerfile index 4fcbdad..94dbd6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ FROM oven/bun:latest AS base +LABEL org.opencontainers.image.description="Discord badge aggregation API built with Bun and Redis" WORKDIR /usr/src/app FROM base AS install diff --git a/README.md b/README.md index fa3eabf..8ee75bb 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ GET /:userId?services=vencord,equicord&separated=true - `capitalize` - Capitalize service names (with separated) ### Supported Services -Vencord, Equicord, Nekocord, ReviewDB, Aero, Aliucord, Ra1ncord, Velocity, BadgeVault, Enmity, Discord, Replugged +Vencord, Equicord, Nekocord, ReviewDB, Aero, Aliucord, Ra1ncord, Velocity, BadgeVault, Enmity, Discord, Replugged, Paicord ## Admin API diff --git a/biome.json b/biome.json index 0ef98c7..bbf34dc 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index 9e7ac68..0e55ecc 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "bun_frontend_template", @@ -15,33 +16,29 @@ "packages": { "@atums/echo": ["@atums/echo@2.0.2", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-AanjXdq74CnepbOaAnQh64KseGw1SwS+CtQJedZtd3dw0zbyu4gSMsVHUbDXJasDZDGNaB2hGfD1z4PrestsZg=="], - "@biomejs/biome": ["@biomejs/biome@2.2.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.5", "@biomejs/cli-darwin-x64": "2.2.5", "@biomejs/cli-linux-arm64": "2.2.5", "@biomejs/cli-linux-arm64-musl": "2.2.5", "@biomejs/cli-linux-x64": "2.2.5", "@biomejs/cli-linux-x64-musl": "2.2.5", "@biomejs/cli-win32-arm64": "2.2.5", "@biomejs/cli-win32-x64": "2.2.5" }, "bin": { "biome": "bin/biome" } }, "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw=="], + "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-FLIEl73fv0R7dI10EnEiZLw+IMz3mWLnF95ASDI0kbx6DDLJjWxE5JxxBfmG+udz1hIDd3fr5wsuP7nwuTRdAg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5DjiiDfHqGgR2MS9D+AZ8kOfrzTGqLKywn8hoXpXXlJXIECGQ32t+gt/uiS2XyGBM2XQhR6ztUvbjZWeccFMoQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Ov2wgAFwqDvQiESnu7b9ufD1faRa+40uwrohgBopeY84El2TnBDoMNXx6iuQdreoFGjwW8vH6k68G21EpNERw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-fq9meKm1AEXeAWan3uCg6XSP5ObA6F/Ovm89TwaMiy1DNIwdgxPkNwxlXJX8iM6oRbFysYeGnT0OG8diCWb9ew=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-AVqLCDb/6K7aPNIcxHaTQj01sl1m989CJIQFQEaiQkGr2EQwyOpaATJ473h+nXDUuAcREhccfRpe/tu+0wu0eQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-xaOIad4wBambwJa6mdp1FigYSIF9i7PCqRbvBqtIi9y29QtPVQ13sDGtUnsRoe6SjL10auMzQ6YAe+B3RpZXVg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], - "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="], - "@types/react": ["@types/react@19.1.16", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog=="], - - "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], diff --git a/compose.yml b/compose.yml index 970c877..001f108 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,7 @@ services: badge-api: container_name: badge-api - image: cr8ns/badgeapi:latest + image: ghcr.io/equicord/equibadges:latest restart: unless-stopped ports: - "8080:8080" diff --git a/config/index.ts b/config/index.ts index 9de87f9..c83e8b3 100644 --- a/config/index.ts +++ b/config/index.ts @@ -25,8 +25,6 @@ const badgeFetchInterval: number = process.env.BADGE_FETCH_INTERVAL const botToken: string | undefined = process.env.DISCORD_TOKEN; -const githubToken: string | undefined = process.env.GITHUB_TOKEN; - const cachePaths = { badgevault: path.resolve(process.cwd(), "cache/badgevault"), enmity: path.resolve(process.cwd(), "cache/enmity"), @@ -84,7 +82,6 @@ export { redisTtl, badgeFetchInterval, botToken, - githubToken, cachePaths, blocklistConfig, cacheConfig, diff --git a/src/index.ts b/src/index.ts index 68869e4..8a27431 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,25 +3,36 @@ import { verifyRequiredVariables } from "@config"; import { badgeCacheManager } from "@lib/badgeCache"; import { serverHandler } from "@server"; +let isShuttingDown = false; + +async function gracefulShutdown(signal: string): Promise { + if (isShuttingDown) { + echo.debug(`Already shutting down, ignoring ${signal}`); + return; + } + isShuttingDown = true; + + echo.info(`Received ${signal}, shutting down gracefully...`); + + await serverHandler.waitForRequestsToComplete(30000); + + await badgeCacheManager.shutdown(); + + echo.info("Shutdown complete"); + process.exit(0); +} + async function main(): Promise { verifyRequiredVariables(); await badgeCacheManager.initialize(); process.on("SIGINT", () => { - echo.debug("Received SIGINT, shutting down gracefully..."); - void (async () => { - await badgeCacheManager.shutdown(); - process.exit(0); - })(); + void gracefulShutdown("SIGINT"); }); process.on("SIGTERM", () => { - echo.debug("Received SIGTERM, shutting down gracefully..."); - void (async () => { - await badgeCacheManager.shutdown(); - process.exit(0); - })(); + void gracefulShutdown("SIGTERM"); }); serverHandler.initialize(); diff --git a/src/lib/badgeCache.ts b/src/lib/badgeCache.ts index ff9cb5c..c35754a 100644 --- a/src/lib/badgeCache.ts +++ b/src/lib/badgeCache.ts @@ -6,7 +6,6 @@ import { cacheConfig, cachePaths, discordBadgeDetails, - githubToken, gitUrl, redisTtl, } from "@config"; @@ -17,6 +16,28 @@ const BADGE_API_HEADERS = { "User-Agent": `BadgeAPI ${gitUrl}`, }; +const PER_USER_SERVICES = ["discord", "replugged"]; + +function getStaticServices(): string[] { + return badgeServices + .map((s) => s.service.toLowerCase()) + .filter((s) => !PER_USER_SERVICES.includes(s)); +} + +async function readFileWithTimeout( + filePath: string, + timeoutMs = 5000, +): Promise { + const filePromise = Bun.file(filePath).text(); + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`File read timeout: ${filePath}`)), + timeoutMs, + ); + }); + return Promise.race([filePromise, timeoutPromise]); +} + async function fetchWithTimeout( url: string, options: RequestInit = {}, @@ -77,6 +98,7 @@ class BadgeCacheManager { private readonly CACHE_TIMESTAMP_PREFIX = `badge_cache_timestamp:${cacheConfig.version}:`; private readonly GIT_LOCK_PREFIX = "git_lock:"; + private readonly lockTokens = new Map(); private metrics = { hits: 0, misses: 0, @@ -143,12 +165,23 @@ class BadgeCacheManager { clearInterval(this.updateInterval); this.updateInterval = null; } + + try { + redis.close(); + echo.debug("Redis connection closed"); + } catch (error) { + echo.warn({ + message: "Failed to close Redis connection", + error: error instanceof Error ? error.message : String(error), + }); + } + echo.debug("Badge cache manager shut down"); } private async acquireGitLock(service: string): Promise { const lockKey = `${this.GIT_LOCK_PREFIX}${service}`; - const lockValue = Date.now().toString(); + const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const lockTTL = 300; try { @@ -159,7 +192,11 @@ class BadgeCacheManager { lockTTL.toString(), "NX", ]); - return result === "OK"; + if (result === "OK") { + this.lockTokens.set(service, lockValue); + return true; + } + return false; } catch (error) { echo.error({ message: `Failed to acquire git lock for ${service}`, @@ -171,13 +208,37 @@ class BadgeCacheManager { private async releaseGitLock(service: string): Promise { const lockKey = `${this.GIT_LOCK_PREFIX}${service}`; + const expectedToken = this.lockTokens.get(service); + + if (!expectedToken) { + echo.warn(`No lock token found for ${service}, skipping release`); + return; + } + try { - await redis.del(lockKey); + const luaScript = ` + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) + else + return 0 + end + `; + const result = await redis.send("EVAL", [ + luaScript, + "1", + lockKey, + expectedToken, + ]); + if (result === 0) { + echo.warn(`Lock token mismatch for ${service}, lock may have expired`); + } + this.lockTokens.delete(service); } catch (error) { echo.error({ message: `Failed to release git lock for ${service}`, error: error instanceof Error ? error.message : String(error), }); + this.lockTokens.delete(service); } } @@ -191,19 +252,7 @@ class BadgeCacheManager { private async checkIfUpdateNeeded(): Promise { try { - const staticServices = [ - "vencord", - "equicord", - "nekocord", - "reviewdb", - "aero", - "aliucord", - "ra1ncord", - "velocity", - "badgevault", - "enmity", - "paicord", - ]; + const staticServices = getStaticServices(); const now = Date.now(); for (const serviceName of staticServices) { @@ -445,7 +494,6 @@ class BadgeCacheManager { cacheDir, "https://github.com/WolfPlugs/BadgeVault.git", "BadgeVault", - githubToken, ); echo.debug("BadgeVault: Reading user badge files..."); @@ -462,7 +510,7 @@ class BadgeCacheManager { for (const file of userFiles) { const userId = file.replace(".json", ""); const filePath = path.join(userDir, file); - const fileContent = await Bun.file(filePath).text(); + const fileContent = await readFileWithTimeout(filePath); const userData: BadgeVaultData = JSON.parse(fileContent); badgeVaultData[userId] = userData; } @@ -497,7 +545,6 @@ class BadgeCacheManager { cacheDir, "https://github.com/enmity-mod/badges.git", "Enmity", - githubToken, ); echo.debug("Enmity: Reading user badge files..."); @@ -521,7 +568,7 @@ class BadgeCacheManager { const badgeDefinitions: Record = {}; for (const file of badgeFiles) { const filePath = path.join(dataDir, file); - const fileContent = await Bun.file(filePath).text(); + const fileContent = await readFileWithTimeout(filePath); const badge: EnmityBadgeItem = JSON.parse(fileContent); badgeDefinitions[badge.id] = badge; } @@ -534,7 +581,7 @@ class BadgeCacheManager { for (const file of userFiles) { const userId = file.replace(".json", ""); const filePath = path.join(cacheDir, file); - const fileContent = await Bun.file(filePath).text(); + const fileContent = await readFileWithTimeout(filePath); const badgeIds: string[] = JSON.parse(fileContent); const badges: EnmityBadgeItem[] = []; @@ -680,19 +727,7 @@ class BadgeCacheManager { return 2; } - const services = [ - "vencord", - "equicord", - "nekocord", - "reviewdb", - "aero", - "aliucord", - "ra1ncord", - "velocity", - "badgevault", - "enmity", - "paicord", - ]; + const services = getStaticServices(); let deleteCount = 0; for (const service of services) { diff --git a/src/lib/gitSync.ts b/src/lib/gitSync.ts index b809e23..e58b64c 100644 --- a/src/lib/gitSync.ts +++ b/src/lib/gitSync.ts @@ -5,7 +5,6 @@ export async function syncGitRepository( cacheDir: string, repoUrl: string, serviceName: string, - githubToken?: string, ): Promise { const repoExists = await Bun.file( path.join(cacheDir, ".git/config"), @@ -15,21 +14,27 @@ export async function syncGitRepository( `${serviceName}: Repository ${repoExists ? "exists, updating" : "not found, cloning"}`, ); - const authenticatedUrl = githubToken - ? repoUrl.replace("https://", `https://${githubToken}@`) - : repoUrl; - if (!repoExists) { echo.debug(`${serviceName}: Cloning repository from GitHub...`); - const cloneProc = Bun.spawn(["git", "clone", authenticatedUrl, cacheDir]); - await cloneProc.exited; + const cloneProc = Bun.spawn(["git", "clone", repoUrl, cacheDir]); + const exitCode = await cloneProc.exited; + if (exitCode !== 0) { + throw new Error( + `${serviceName}: Git clone failed with exit code ${exitCode}`, + ); + } echo.debug(`${serviceName}: Repository cloned successfully`); } else { echo.debug(`${serviceName}: Pulling latest changes...`); const pullProc = Bun.spawn(["git", "pull"], { cwd: cacheDir, }); - await pullProc.exited; + const exitCode = await pullProc.exited; + if (exitCode !== 0) { + throw new Error( + `${serviceName}: Git pull failed with exit code ${exitCode}`, + ); + } echo.debug(`${serviceName}: Repository updated successfully`); } } diff --git a/src/routes/admin/cache.ts b/src/routes/admin/cache.ts index e6ff2ea..a017a35 100644 --- a/src/routes/admin/cache.ts +++ b/src/routes/admin/cache.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { adminConfig, badgeServices } from "@config"; import { badgeCacheManager } from "@lib/badgeCache"; import { createErrorResponse } from "@lib/errorResponse"; @@ -8,9 +9,22 @@ const routeDef: RouteDef = { returns: "application/json", }; +function safeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + return false; + } + return timingSafeEqual(bufA, bufB); +} + async function handler(request: ExtendedRequest): Promise { const apiKey = request.headers.get("X-Admin-API-Key"); - if (!adminConfig.apiKey || apiKey !== adminConfig.apiKey) { + if ( + !adminConfig.apiKey || + !apiKey || + !safeCompare(apiKey, adminConfig.apiKey) + ) { return createErrorResponse( 401, "Unauthorized", diff --git a/src/routes/health.ts b/src/routes/health.ts index 12c0dc9..fecb644 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,3 +1,4 @@ +import { badgeServices, cacheConfig } from "@config"; import { redis } from "bun"; const routeDef: RouteDef = { @@ -6,6 +7,14 @@ const routeDef: RouteDef = { returns: "application/json", }; +const PER_USER_SERVICES = ["discord", "replugged"]; + +function getStaticServices(): string[] { + return badgeServices + .map((s) => s.service.toLowerCase()) + .filter((s) => !PER_USER_SERVICES.includes(s)); +} + async function handler(): Promise { const health: HealthResponse = { status: "ok", @@ -29,8 +38,8 @@ async function handler(): Promise { } if (health.services.redis === "ok") { - const services = ["vencord", "equicord", "nekocord", "reviewdb"]; - const timestampPrefix = "badge_cache_timestamp:"; + const services = getStaticServices(); + const timestampPrefix = `badge_cache_timestamp:${cacheConfig.version}:`; try { const timestamps = await Promise.all( diff --git a/src/routes/index.ts b/src/routes/index.ts index a251b42..bf06450 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -70,8 +70,7 @@ async function handler(): Promise { users: { path: "/users", method: "GET", - description: - "Get all users with badges across all supported services", + description: "Get all users with badges across all supported services", response: { totalUsers: "Total number of users with badges", users: "Object mapping user IDs to their badges", diff --git a/src/routes/users.ts b/src/routes/users.ts index d6b677b..431dacc 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,3 +1,4 @@ +import { badgeServices } from "@config"; import { badgeCacheManager } from "@lib/badgeCache"; import { getRequestOrigin } from "@lib/badges"; import { @@ -14,23 +15,19 @@ const routeDef: RouteDef = { returns: "application/json", }; +const PER_USER_SERVICES = ["discord", "replugged"]; + +function getStaticServices(): string[] { + return badgeServices + .map((s) => s.service.toLowerCase()) + .filter((s) => !PER_USER_SERVICES.includes(s)); +} + async function handler(request: ExtendedRequest): Promise { const url = request ? getRequestOrigin(request) : ""; try { - const userServices = [ - "vencord", - "equicord", - "nekocord", - "reviewdb", - "aero", - "aliucord", - "ra1ncord", - "velocity", - "badgevault", - "enmity", - "paicord", - ]; + const userServices = getStaticServices(); const serviceDataMap = await badgeCacheManager.getMultipleServiceData(userServices); diff --git a/src/server.ts b/src/server.ts index 951d2a7..aa81fc5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,4 @@ +import { realpath } from "node:fs/promises"; import { resolve } from "node:path"; import { Echo, echo } from "@atums/echo"; import { blocklistConfig, environment } from "@config"; @@ -7,11 +8,14 @@ import { type BunFile, FileSystemRouter, type MatchedRoute, + randomUUIDv7, type Server, } from "bun"; class ServerHandler { private router: FileSystemRouter; + private activeRequests = 0; + private shutdownPromiseResolve: (() => void) | null = null; constructor( private port: number, @@ -29,6 +33,61 @@ class ServerHandler { } } + public getActiveRequestCount(): number { + return this.activeRequests; + } + + public async waitForRequestsToComplete(timeoutMs = 30000): Promise { + if (this.activeRequests === 0) { + return; + } + + echo.info( + `Waiting for ${this.activeRequests} active requests to complete...`, + ); + + let timeoutId: Timer | null = null; + + try { + await Promise.race([ + new Promise((resolve) => { + if (this.activeRequests === 0) { + resolve(); + return; + } + this.shutdownPromiseResolve = resolve; + }), + new Promise((resolve) => { + timeoutId = setTimeout(() => { + if (this.activeRequests > 0) { + echo.warn( + `Timeout reached with ${this.activeRequests} requests still active`, + ); + } + resolve(); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + this.shutdownPromiseResolve = null; + } + } + + private trackRequestStart(): void { + this.activeRequests++; + } + + private trackRequestEnd(): void { + this.activeRequests--; + if (this.activeRequests === 0 && this.shutdownPromiseResolve) { + this.shutdownPromiseResolve(); + this.shutdownPromiseResolve = null; + } + } + public initialize(): void { const server: Server = Bun.serve({ port: this.port, @@ -84,14 +143,18 @@ class ServerHandler { }); } else { echo.warn(`File not found: ${filePath}`); - response = new Response("Not Found", { status: 404 }); + response = createErrorResponse( + 404, + "Not Found", + `File not found: ${pathname}`, + ); } } catch (error) { echo.error({ message: `Error serving static file: ${pathname}`, error: error as Error, }); - response = new Response("Internal Server Error", { status: 500 }); + response = createErrorResponse(500, "Internal Server Error"); } this.logRequest(request, response, ip); @@ -116,6 +179,7 @@ class ServerHandler { } echo.custom(`${request.method}`, `${response.status}`, [ + `[${request.requestId}]`, request.url, `${(performance.now() - request.startPerf).toFixed(2)}ms`, ip || "unknown", @@ -125,9 +189,23 @@ class ServerHandler { private async handleRequest( request: Request, server: Server, + ): Promise { + this.trackRequestStart(); + + try { + return await this.processRequest(request, server); + } finally { + this.trackRequestEnd(); + } + } + + private async processRequest( + request: Request, + server: Server, ): Promise { const extendedRequest: ExtendedRequest = request as ExtendedRequest; extendedRequest.startPerf = performance.now(); + extendedRequest.requestId = randomUUIDv7().slice(0, 8); const headers = request.headers; let ip = server.requestIP(request)?.address; @@ -202,13 +280,27 @@ class ServerHandler { const customPath = resolve(baseDir, pathname.slice(1)); if (!customPath.startsWith(baseDir)) { - response = new Response("Forbidden", { status: 403 }); + response = createErrorResponse(403, "Forbidden", "Access denied"); this.logRequest(extendedRequest, response, ip); return response; } const customFile = Bun.file(customPath); if (await customFile.exists()) { + try { + const realCustomPath = await realpath(customPath); + const realBaseDir = await realpath(baseDir); + if (!realCustomPath.startsWith(realBaseDir)) { + response = createErrorResponse(403, "Forbidden", "Access denied"); + this.logRequest(extendedRequest, response, ip); + return response; + } + } catch { + response = createErrorResponse(403, "Forbidden", "Access denied"); + this.logRequest(extendedRequest, response, ip); + return response; + } + const content = await customFile.arrayBuffer(); const type: string = customFile.type ?? "application/octet-stream"; response = new Response(content, { diff --git a/types/bun.d.ts b/types/bun.d.ts index 9afe286..2954d2e 100644 --- a/types/bun.d.ts +++ b/types/bun.d.ts @@ -5,4 +5,5 @@ interface ExtendedRequest extends Request { startPerf: number; query: Query; params: Params; + requestId: string; }