From 75d3dab85e59870b1481d6d93147fc361eca5de9 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 4 Jun 2025 15:47:51 -0400 Subject: [PATCH] add index route info, make it fetch per hour instead of every user, add health route, update to latest biome config aswell as logger --- biome.json | 24 +- config/{discordBadges.ts => constants.ts} | 58 ++++- config/environment.ts | 43 ---- config/index.ts | 45 ++++ logger.json | 2 +- package.json | 10 +- src/helpers/badges.ts | 192 ---------------- src/index.ts | 22 +- src/lib/badgeCache.ts | 233 +++++++++++++++++++ src/lib/badges.ts | 260 ++++++++++++++++++++++ src/{helpers => lib}/char.ts | 6 +- src/routes/[id].ts | 89 ++++---- src/routes/health.ts | 89 ++++++++ src/routes/index.ts | 57 ++++- src/server.ts | 44 ++-- src/websocket.ts | 2 +- tsconfig.json | 24 +- types/badge.d.ts | 67 ++++++ types/bun.d.ts | 14 +- types/health.d.ts | 17 ++ types/logger.d.ts | 9 - 21 files changed, 943 insertions(+), 364 deletions(-) rename config/{discordBadges.ts => constants.ts} (58%) delete mode 100644 config/environment.ts create mode 100644 config/index.ts delete mode 100644 src/helpers/badges.ts create mode 100644 src/lib/badgeCache.ts create mode 100644 src/lib/badges.ts rename src/{helpers => lib}/char.ts (50%) create mode 100644 src/routes/health.ts create mode 100644 types/health.d.ts delete mode 100644 types/logger.d.ts diff --git a/biome.json b/biome.json index 921a7a5..3a44cc4 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": true, - "ignore": [] + "ignore": ["dist"] }, "formatter": { "enabled": true, @@ -17,11 +17,29 @@ "organizeImports": { "enabled": true }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, "linter": { "enabled": true, "rules": { - "recommended": true - } + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + }, + "suspicious": { + "noConsole": "error" + }, + "style": { + "useConst": "error", + "noVar": "error" + } + }, + "ignore": ["types"] }, "javascript": { "formatter": { diff --git a/config/discordBadges.ts b/config/constants.ts similarity index 58% rename from config/discordBadges.ts rename to config/constants.ts index 0e9bc66..37a8a5a 100644 --- a/config/discordBadges.ts +++ b/config/constants.ts @@ -1,4 +1,4 @@ -export const discordBadges = { +const discordBadges = { // User badges STAFF: 1 << 0, PARTNER: 1 << 1, @@ -23,7 +23,7 @@ export const discordBadges = { USES_AUTOMOD: 1 << 24, }; -export const discordBadgeDetails = { +const discordBadgeDetails = { HYPESQUAD: { tooltip: "HypeSquad Events", icon: "/public/badges/discord/HYPESQUAD.svg", @@ -86,3 +86,57 @@ export const discordBadgeDetails = { icon: "/public/badges/discord/USES_AUTOMOD.svg", }, }; + +const badgeServices: badgeURLMap[] = [ + { + service: "Vencord", + url: "https://badges.vencord.dev/badges.json", + }, + { + service: "Equicord", // Ekwekord ! WOOP + url: "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json", + }, + { + service: "Nekocord", + url: "https://nekocord.dev/assets/badges.json", + }, + { + service: "ReviewDb", + url: "https://manti.vendicated.dev/api/reviewdb/badges", + }, + { + service: "Enmity", + url: (userId: string) => ({ + user: `https://raw.githubusercontent.com/enmity-mod/badges/main/${userId}.json`, + badge: (id: string) => + `https://raw.githubusercontent.com/enmity-mod/badges/main/data/${id}.json`, + }), + }, + { + service: "Discord", + url: (userId: string) => `https://discord.com/api/v10/users/${userId}`, + }, +]; + +function getServiceDescription(service: string): string { + const descriptions: Record = { + Vencord: "Custom badges from Vencord Discord client", + Equicord: "Custom badges from Equicord Discord client", + Nekocord: "Custom badges from Nekocord Discord client", + ReviewDb: "Badges from ReviewDB service", + Enmity: "Custom badges from Enmity mobile Discord client", + Discord: "Official Discord badges (staff, partner, hypesquad, etc.)", + }; + + return descriptions[service] || "Custom badge service"; +} + +const gitUrl = "https://git.creations.works/creations/badgeAPI"; + +export { + badgeServices, + discordBadges, + discordBadgeDetails, + getServiceDescription, + gitUrl, +}; diff --git a/config/environment.ts b/config/environment.ts deleted file mode 100644 index 6b056d6..0000000 --- a/config/environment.ts +++ /dev/null @@ -1,43 +0,0 @@ -export const environment: Environment = { - port: Number.parseInt(process.env.PORT || "8080", 10), - host: process.env.HOST || "0.0.0.0", - development: - process.env.NODE_ENV === "development" || process.argv.includes("--dev"), -}; - -export const redisTtl: number = process.env.REDIS_TTL - ? Number.parseInt(process.env.REDIS_TTL, 10) - : 60 * 60 * 1; // 1 hour - -export const badgeServices: badgeURLMap[] = [ - { - service: "Vencord", - url: "https://badges.vencord.dev/badges.json", - }, - { - service: "Equicord", // Ekwekord ! WOOP - url: "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json", - }, - { - service: "Nekocord", - url: "https://nekocord.dev/assets/badges.json", - }, - { - service: "ReviewDb", - url: "https://manti.vendicated.dev/api/reviewdb/badges", - }, - { - service: "Enmity", - url: (userId: string) => ({ - user: `https://raw.githubusercontent.com/enmity-mod/badges/main/${userId}.json`, - badge: (id: string) => - `https://raw.githubusercontent.com/enmity-mod/badges/main/data/${id}.json`, - }), - }, - { - service: "Discord", - url: (userId: string) => `https://discord.com/api/v10/users/${userId}`, - }, -]; - -export const botToken: string | undefined = process.env.DISCORD_TOKEN; diff --git a/config/index.ts b/config/index.ts new file mode 100644 index 0000000..5f7ad84 --- /dev/null +++ b/config/index.ts @@ -0,0 +1,45 @@ +import { echo } from "@atums/echo"; + +const environment: Environment = { + port: Number.parseInt(process.env.PORT || "8080", 10), + host: process.env.HOST || "0.0.0.0", + development: + process.env.NODE_ENV === "development" || process.argv.includes("--dev"), +}; + +const redisTtl: number = process.env.REDIS_TTL + ? Number.parseInt(process.env.REDIS_TTL, 10) + : 60 * 60 * 1; // 1 hour + +const badgeFetchInterval: number = process.env.BADGE_FETCH_INTERVAL + ? Number.parseInt(process.env.BADGE_FETCH_INTERVAL, 10) + : 60 * 60 * 1000; // 1 hour + +const botToken: string | undefined = process.env.DISCORD_TOKEN; + +function verifyRequiredVariables(): void { + const requiredVariables = ["HOST", "PORT", "DISCORD_TOKEN"]; + + let hasError = false; + + for (const key of requiredVariables) { + const value = process.env[key]; + if (value === undefined || value.trim() === "") { + echo.error(`Missing or empty environment variable: ${key}`); + hasError = true; + } + } + + if (hasError) { + process.exit(1); + } +} + +export * from "@config/constants"; +export { + environment, + redisTtl, + badgeFetchInterval, + botToken, + verifyRequiredVariables, +}; diff --git a/logger.json b/logger.json index 521b3bc..cc10cab 100644 --- a/logger.json +++ b/logger.json @@ -1,6 +1,6 @@ { "directory": "logs", - "level": "debug", + "level": "info", "disableFile": false, "rotate": true, diff --git a/package.json b/package.json index dc9b552..1a420b5 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,10 @@ "cleanup": "rm -rf logs node_modules bun.lock" }, "devDependencies": { - "@types/bun": "^1.2.9", - "@types/ejs": "^3.1.5", - "globals": "^16.0.0", + "@types/bun": "latest", "@biomejs/biome": "^1.9.4" }, - "peerDependencies": { - "typescript": "^5.8.3" - }, "dependencies": { - "@atums/echo": "^1.0.3", - "ejs": "^3.1.10" + "@atums/echo": "latest" } } diff --git a/src/helpers/badges.ts b/src/helpers/badges.ts deleted file mode 100644 index ba8b272..0000000 --- a/src/helpers/badges.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { discordBadgeDetails, discordBadges } from "@config/discordBadges"; -import { badgeServices, botToken, redisTtl } from "@config/environment"; -import { fetch, redis } from "bun"; - -function getRequestOrigin(request: Request): string { - const headers = request.headers; - const forwardedProto = headers.get("X-Forwarded-Proto") || "http"; - const host = headers.get("Host") || new URL(request.url).host; - return `${forwardedProto}://${host}`; -} - -export async function fetchBadges( - userId: string, - services: string[], - options?: FetchBadgesOptions, - request?: Request, -): Promise { - const { nocache = false, separated = false } = options ?? {}; - const results: Record = {}; - - await Promise.all( - services.map(async (service) => { - const entry = badgeServices.find( - (s) => s.service.toLowerCase() === service.toLowerCase(), - ); - if (!entry) return; - - const serviceKey = service.toLowerCase(); - const cacheKey = `badges:${serviceKey}:${userId}`; - - if (!nocache) { - const cached = await redis.get(cacheKey); - if (cached) { - try { - const parsed: Badge[] = JSON.parse(cached); - results[serviceKey] = parsed; - return; - } catch { - // corrupted cache, proceed with fetch :p - } - } - } - - const result: Badge[] = []; - - try { - let url: string | { user: string; badge: (id: string) => string }; - if (typeof entry.url === "function") { - url = entry.url(userId); - } else { - url = entry.url; - } - - switch (serviceKey) { - case "vencord": - case "equicord": { - const res = await fetch(url as string); - if (!res.ok) break; - - const data = await res.json(); - const userBadges = data[userId]; - if (Array.isArray(userBadges)) { - for (const b of userBadges) { - result.push({ - tooltip: b.tooltip, - badge: b.badge, - }); - } - } - break; - } - - case "nekocord": { - const res = await fetch(url as string); - if (!res.ok) break; - - const data = await res.json(); - const userBadgeIds = data.users?.[userId]?.badges; - if (Array.isArray(userBadgeIds)) { - for (const id of userBadgeIds) { - const badgeInfo = data.badges?.[id]; - if (badgeInfo) { - result.push({ - tooltip: badgeInfo.name, - badge: badgeInfo.image, - }); - } - } - } - break; - } - - case "reviewdb": { - const res = await fetch(url as string); - if (!res.ok) break; - - const data = await res.json(); - for (const b of data) { - if (b.discordID === userId) { - result.push({ - tooltip: b.name, - badge: b.icon, - }); - } - } - break; - } - - case "enmity": { - if ( - typeof url !== "object" || - typeof url.user !== "string" || - typeof url.badge !== "function" - ) - break; - - const userRes = await fetch(url.user); - if (!userRes.ok) break; - - const badgeIds: string[] = await userRes.json(); - if (!Array.isArray(badgeIds)) break; - - await Promise.all( - badgeIds.map(async (id) => { - const badgeRes = await fetch(url.badge(id)); - if (!badgeRes.ok) return; - - const badge = await badgeRes.json(); - if (!badge?.name || !badge?.url?.dark) return; - - result.push({ - tooltip: badge.name, - badge: badge.url.dark, - }); - }), - ); - break; - } - - case "discord": { - if (!botToken) break; - - const res = await fetch(url as string, { - headers: { - Authorization: `Bot ${botToken}`, - }, - }); - if (!res.ok) break; - - const data = await res.json(); - const origin = request ? getRequestOrigin(request) : ""; - - if (data.avatar.startsWith("a_")) { - result.push({ - tooltip: "Discord Nitro", - badge: `${origin}/public/badges/discord/NITRO.svg`, - }); - } - - for (const [flag, bitwise] of Object.entries(discordBadges)) { - if (data.flags & bitwise) { - const badge = - discordBadgeDetails[flag as keyof typeof discordBadgeDetails]; - result.push({ - tooltip: badge.tooltip, - badge: `${origin}${badge.icon}`, - }); - } - } - break; - } - } - - if (result.length > 0) { - results[serviceKey] = result; - if (!nocache) { - await redis.set(cacheKey, JSON.stringify(result)); - await redis.expire(cacheKey, redisTtl); - } - } - } catch (_) {} - }), - ); - - if (separated) return results; - - const combined: Badge[] = []; - for (const group of Object.values(results)) { - combined.push(...group); - } - return combined; -} diff --git a/src/index.ts b/src/index.ts index 74320a0..fbdbfe9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,25 @@ import { echo } from "@atums/echo"; - -import { serverHandler } from "@/server"; +import { verifyRequiredVariables } from "@config"; +import { badgeCacheManager } from "@lib/badgeCache"; +import { serverHandler } from "@server"; async function main(): Promise { + verifyRequiredVariables(); + + await badgeCacheManager.initialize(); + + process.on("SIGINT", async () => { + echo.debug("Received SIGINT, shutting down gracefully..."); + await badgeCacheManager.shutdown(); + process.exit(0); + }); + + process.on("SIGTERM", async () => { + echo.debug("Received SIGTERM, shutting down gracefully..."); + await badgeCacheManager.shutdown(); + process.exit(0); + }); + serverHandler.initialize(); } @@ -15,5 +32,6 @@ main().catch((error: Error) => { }); if (process.env.IN_PTERODACTYL === "true") { + // biome-ignore lint/suspicious/noConsole: Needed for Pterodactyl to actually know the server started console.log("Server Started"); } diff --git a/src/lib/badgeCache.ts b/src/lib/badgeCache.ts new file mode 100644 index 0000000..682f6c9 --- /dev/null +++ b/src/lib/badgeCache.ts @@ -0,0 +1,233 @@ +import { echo } from "@atums/echo"; +import { badgeFetchInterval, badgeServices, gitUrl, redisTtl } from "@config"; +import { redis } from "bun"; + +class BadgeCacheManager { + private updateInterval: Timer | null = null; + private readonly CACHE_PREFIX = "badge_service_data:"; + private readonly CACHE_TIMESTAMP_PREFIX = "badge_cache_timestamp:"; + + async initialize(): Promise { + echo.debug("Initializing badge cache manager..."); + + const needsUpdate = await this.checkIfUpdateNeeded(); + if (needsUpdate) { + await this.updateAllServiceData(); + } else { + echo.debug("Badge cache is still valid, skipping initial update"); + } + + this.updateInterval = setInterval( + () => this.updateAllServiceData(), + badgeFetchInterval, + ); + + echo.debug("Badge cache manager initialized with 1-hour update interval"); + } + + async shutdown(): Promise { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + echo.debug("Badge cache manager shut down"); + } + + private async checkIfUpdateNeeded(): Promise { + try { + const staticServices = ["vencord", "equicord", "nekocord", "reviewdb"]; + const now = Date.now(); + + for (const serviceName of staticServices) { + const timestampKey = `${this.CACHE_TIMESTAMP_PREFIX}${serviceName}`; + const cacheKey = `${this.CACHE_PREFIX}${serviceName}`; + + const [timestamp, data] = await Promise.all([ + redis.get(timestampKey), + redis.get(cacheKey), + ]); + + if (!data || !timestamp) { + echo.debug(`Cache missing for service: ${serviceName}`); + return true; + } + + const lastUpdate = Number.parseInt(timestamp, 10); + if (now - lastUpdate > badgeFetchInterval) { + echo.debug(`Cache expired for service: ${serviceName}`); + return true; + } + } + + echo.debug("All service caches are valid"); + return false; + } catch (error) { + echo.warn({ + message: "Failed to check cache validity, forcing update", + error: error instanceof Error ? error.message : String(error), + }); + return true; + } + } + + private async updateAllServiceData(): Promise { + echo.debug("Updating badge service data..."); + + const updatePromises = badgeServices.map(async (service: BadgeService) => { + try { + await this.updateServiceData(service); + } catch (error) { + echo.error({ + message: `Failed to update service data for ${service.service}`, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + await Promise.allSettled(updatePromises); + echo.debug("Badge service data update completed"); + } + + private async updateServiceData(service: BadgeService): Promise { + const serviceKey = service.service.toLowerCase(); + const cacheKey = `${this.CACHE_PREFIX}${serviceKey}`; + const timestampKey = `${this.CACHE_TIMESTAMP_PREFIX}${serviceKey}`; + + try { + let data: BadgeServiceData | null = null; + + switch (serviceKey) { + case "vencord": + case "equicord": { + if (typeof service.url === "string") { + const res = await fetch(service.url, { + headers: { + "User-Agent": `BadgeAPI/1.0 ${gitUrl}`, + }, + }); + + if (res.ok) { + data = (await res.json()) as VencordEquicordData; + } + } + break; + } + + case "nekocord": { + if (typeof service.url === "string") { + const res = await fetch(service.url, { + headers: { + "User-Agent": `BadgeAPI/1.0 ${gitUrl}`, + }, + }); + + if (res.ok) { + data = (await res.json()) as NekocordData; + } + } + break; + } + + case "reviewdb": { + if (typeof service.url === "string") { + const res = await fetch(service.url, { + headers: { + "User-Agent": `BadgeAPI/1.0 ${gitUrl}`, + }, + }); + + if (res.ok) { + data = (await res.json()) as ReviewDbData; + } + } + break; + } + + case "discord": + case "enmity": + return; + + default: + echo.warn(`Unknown service type: ${serviceKey}`); + return; + } + + if (data) { + const now = Date.now(); + await Promise.all([ + redis.set(cacheKey, JSON.stringify(data)), + redis.set(timestampKey, now.toString()), + redis.expire(cacheKey, redisTtl * 2), + redis.expire(timestampKey, redisTtl * 2), + ]); + + echo.debug(`Updated cache for service: ${service.service}`); + } + } catch (error) { + echo.warn({ + message: `Failed to fetch data for service: ${service.service}`, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async getServiceData(serviceKey: string): Promise { + const cacheKey = `${this.CACHE_PREFIX}${serviceKey}`; + + try { + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as BadgeServiceData; + } + } catch (error) { + echo.warn({ + message: `Failed to get cached data for service: ${serviceKey}`, + error: error instanceof Error ? error.message : String(error), + }); + } + + return null; + } + + async getVencordEquicordData( + serviceKey: string, + ): Promise { + const data = await this.getServiceData(serviceKey); + if (data && (serviceKey === "vencord" || serviceKey === "equicord")) { + return data as VencordEquicordData; + } + return null; + } + + async getNekocordData(): Promise { + const data = await this.getServiceData("nekocord"); + if (data) { + return data as NekocordData; + } + return null; + } + + async getReviewDbData(): Promise { + const data = await this.getServiceData("reviewdb"); + if (data) { + return data as ReviewDbData; + } + return null; + } + + async forceUpdateService(serviceName: string): Promise { + const service = badgeServices.find( + (s: BadgeService) => + s.service.toLowerCase() === serviceName.toLowerCase(), + ); + + if (service) { + await this.updateServiceData(service); + echo.info(`Force updated service: ${serviceName}`); + } else { + throw new Error(`Service not found: ${serviceName}`); + } + } +} + +export const badgeCacheManager = new BadgeCacheManager(); diff --git a/src/lib/badges.ts b/src/lib/badges.ts new file mode 100644 index 0000000..284cdcc --- /dev/null +++ b/src/lib/badges.ts @@ -0,0 +1,260 @@ +import { echo } from "@atums/echo"; +import { discordBadgeDetails, discordBadges } from "@config"; +import { badgeServices, botToken, redisTtl } from "@config"; +import { badgeCacheManager } from "@lib/badgeCache"; +import { redis } from "bun"; + +function getRequestOrigin(request: Request): string { + const headers = request.headers; + const forwardedProto = headers.get("X-Forwarded-Proto") || "http"; + const host = headers.get("Host") || new URL(request.url).host; + return `${forwardedProto}://${host}`; +} + +export async function fetchBadges( + userId: string | undefined, + services: string[], + options?: FetchBadgesOptions, + request?: Request, +): Promise { + const { nocache = false, separated = false } = options ?? {}; + const results: Record = {}; + + if (!userId || !Array.isArray(services) || services.length === 0) { + return separated ? results : []; + } + + const userCachePromises = services.map(async (service) => { + const serviceKey = service.toLowerCase(); + const userCacheKey = `user_badges:${serviceKey}:${userId}`; + + if (!nocache) { + try { + const cached = await redis.get(userCacheKey); + if (cached) { + const parsed: Badge[] = JSON.parse(cached); + results[serviceKey] = parsed; + return true; + } + } catch {} + } + return false; + }); + + const cacheHits = await Promise.all(userCachePromises); + + const servicesToFetch = services.filter((_, index) => !cacheHits[index]); + + await Promise.all( + servicesToFetch.map(async (service) => { + const entry = badgeServices.find( + (s) => s.service.toLowerCase() === service.toLowerCase(), + ); + if (!entry) return; + + const serviceKey = service.toLowerCase(); + const result: Badge[] = []; + + try { + switch (serviceKey) { + case "vencord": + case "equicord": { + const serviceData = + await badgeCacheManager.getVencordEquicordData(serviceKey); + if (!serviceData) { + echo.warn(`No cached data for service: ${serviceKey}`); + break; + } + + const userBadges = serviceData[userId]; + if (Array.isArray(userBadges)) { + for (const badgeItem of userBadges) { + result.push({ + tooltip: badgeItem.tooltip, + badge: badgeItem.badge, + }); + } + } + break; + } + + case "nekocord": { + const serviceData = await badgeCacheManager.getNekocordData(); + if (!serviceData) { + echo.warn(`No cached data for service: ${serviceKey}`); + break; + } + + const userBadgeIds = serviceData.users?.[userId]?.badges; + if (Array.isArray(userBadgeIds)) { + for (const id of userBadgeIds) { + const badgeInfo = serviceData.badges?.[id]; + if (badgeInfo) { + result.push({ + tooltip: badgeInfo.name, + badge: badgeInfo.image, + }); + } + } + } + break; + } + + case "reviewdb": { + const serviceData = await badgeCacheManager.getReviewDbData(); + if (!serviceData) { + echo.warn(`No cached data for service: ${serviceKey}`); + break; + } + + for (const badgeItem of serviceData) { + if (badgeItem.discordID === userId) { + result.push({ + tooltip: badgeItem.name, + badge: badgeItem.icon, + }); + } + } + break; + } + + case "enmity": { + if (typeof entry.url !== "function") { + break; + } + + const urlResult = entry.url(userId); + + if ( + typeof urlResult !== "object" || + typeof urlResult.user !== "string" || + typeof urlResult.badge !== "function" + ) { + break; + } + + const userRes = await fetch(urlResult.user); + if (!userRes.ok) break; + + const badgeIds = await userRes.json(); + if (!Array.isArray(badgeIds)) break; + + await Promise.all( + badgeIds.map(async (id: string) => { + try { + const badgeRes = await fetch(urlResult.badge(id)); + if (!badgeRes.ok) return; + + const badge: EnmityBadgeItem = await badgeRes.json(); + if (!badge?.name || !badge?.url?.dark) return; + + result.push({ + tooltip: badge.name, + badge: badge.url.dark, + }); + } catch (error) { + echo.warn({ + message: `Failed to fetch Enmity badge ${id}`, + error: + error instanceof Error ? error.message : String(error), + }); + } + }), + ); + break; + } + + case "discord": { + if (!botToken) { + echo.warn("Discord bot token not configured"); + break; + } + + if (typeof entry.url !== "function") { + echo.warn("Discord service URL should be a function"); + break; + } + + const url = entry.url(userId); + if (typeof url !== "string") { + echo.warn("Discord URL function should return a string"); + break; + } + + const res = await fetch(url, { + headers: { + Authorization: `Bot ${botToken}`, + }, + }); + + if (!res.ok) { + echo.warn( + `Discord API request failed with status: ${res.status}`, + ); + break; + } + + const data: DiscordUserData = await res.json(); + const origin = request ? getRequestOrigin(request) : ""; + + if (data.avatar?.startsWith("a_")) { + result.push({ + tooltip: "Discord Nitro", + badge: `${origin}/public/badges/discord/NITRO.svg`, + }); + } + + if (typeof data.flags === "number") { + for (const [flag, bitwise] of Object.entries(discordBadges)) { + if (data.flags & bitwise) { + const badge = + discordBadgeDetails[ + flag as keyof typeof discordBadgeDetails + ]; + if (badge) { + result.push({ + tooltip: badge.tooltip, + badge: `${origin}${badge.icon}`, + }); + } + } + } + } + break; + } + + default: + echo.warn(`Unknown service: ${serviceKey}`); + break; + } + + if ( + result.length > 0 || + serviceKey === "discord" || + serviceKey === "enmity" + ) { + results[serviceKey] = result; + if (!nocache) { + const userCacheKey = `user_badges:${serviceKey}:${userId}`; + await redis.set(userCacheKey, JSON.stringify(result)); + await redis.expire(userCacheKey, Math.min(redisTtl, 900)); + } + } + } catch (error) { + echo.warn({ + message: `Failed to fetch badges for service ${serviceKey}`, + error: error instanceof Error ? error.message : String(error), + userId, + }); + } + }), + ); + + if (separated) return results; + + const combined: Badge[] = []; + for (const group of Object.values(results)) { + combined.push(...group); + } + return combined; +} diff --git a/src/helpers/char.ts b/src/lib/char.ts similarity index 50% rename from src/helpers/char.ts rename to src/lib/char.ts index 74868aa..f6038db 100644 --- a/src/helpers/char.ts +++ b/src/lib/char.ts @@ -1,12 +1,14 @@ -export function validateID(id: string): boolean { +function validateID(id: string | undefined): boolean { if (!id) return false; return /^\d{17,20}$/.test(id.trim()); } -export function parseServices(input: string): string[] { +function parseServices(input: string): string[] { return input .split(/[\s,]+/) .map((s) => s.trim()) .filter(Boolean); } + +export { validateID, parseServices }; diff --git a/src/routes/[id].ts b/src/routes/[id].ts index 4b8421e..1f291cf 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -1,6 +1,6 @@ -import { badgeServices } from "@config/environment"; -import { fetchBadges } from "@helpers/badges"; -import { parseServices, validateID } from "@helpers/char"; +import { badgeServices } from "@config"; +import { fetchBadges } from "@lib/badges"; +import { parseServices, validateID } from "@lib/char"; function isValidServices(services: string[]): boolean { if (!Array.isArray(services)) return false; @@ -18,47 +18,52 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { const { id: userId } = request.params; - const { services, cache, seperated } = request.query; - - let validServices: string[]; + const { services, cache = "true", seperated = "false" } = request.query; if (!validateID(userId)) { return Response.json( { status: 400, - error: "Invalid Discord User ID", - }, - { - status: 400, + error: "Invalid Discord User ID. Must be 17-20 digits.", }, + { status: 400 }, ); } + let validServices: string[]; + const availableServices = badgeServices.map((b) => b.service); + if (services) { const parsed = parseServices(services); - - if (parsed.length > 0) { - if (!isValidServices(parsed)) { - return Response.json( - { - status: 400, - error: "Invalid Services", - }, - { - status: 400, - }, - ); - } - - validServices = parsed; - } else { - validServices = badgeServices.map((b) => b.service); + if (parsed.length === 0) { + return Response.json( + { + status: 400, + error: "No valid services provided", + availableServices, + }, + { status: 400 }, + ); } + + if (!isValidServices(parsed)) { + return Response.json( + { + status: 400, + error: "Invalid service(s) provided", + availableServices, + provided: parsed, + }, + { status: 400 }, + ); + } + + validServices = parsed; } else { - validServices = badgeServices.map((b) => b.service); + validServices = availableServices; } - const badges: BadgeResult = await fetchBadges( + const badges = await fetchBadges( userId, validServices, { @@ -68,27 +73,18 @@ async function handler(request: ExtendedRequest): Promise { request, ); - if (badges instanceof Error) { - return Response.json( - { - status: 500, - error: badges.message, - }, - { - status: 500, - }, - ); - } + const isEmpty = Array.isArray(badges) + ? badges.length === 0 + : Object.keys(badges).length === 0; - if (badges.length === 0) { + if (isEmpty) { return Response.json( { status: 404, - error: "No Badges Found", - }, - { - status: 404, + error: "No badges found for this user", + services: validServices, }, + { status: 404 }, ); } @@ -105,9 +101,6 @@ async function handler(request: ExtendedRequest): Promise { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Max-Age": "86400", - "Access-Control-Allow-Credentials": "true", - "Access-Control-Expose-Headers": "Content-Type", }, }, ); diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..12c0dc9 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,89 @@ +import { redis } from "bun"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(): Promise { + const health: HealthResponse = { + status: "ok", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + services: { + redis: "unknown", + }, + cache: { + lastFetched: {}, + nextUpdate: null, + }, + }; + + try { + await redis.connect(); + health.services.redis = "ok"; + } catch { + health.services.redis = "error"; + health.status = "degraded"; + } + + if (health.services.redis === "ok") { + const services = ["vencord", "equicord", "nekocord", "reviewdb"]; + const timestampPrefix = "badge_cache_timestamp:"; + + try { + const timestamps = await Promise.all( + services.map(async (service) => { + const timestamp = await redis.get(`${timestampPrefix}${service}`); + return { + service, + timestamp: timestamp ? Number.parseInt(timestamp, 10) : null, + }; + }), + ); + + const lastFetched: Record = {}; + let oldestTimestamp: number | null = null; + + for (const { service, timestamp } of timestamps) { + if (timestamp) { + const date = new Date(timestamp); + lastFetched[service] = { + timestamp: date.toISOString(), + age: `${Math.floor((Date.now() - timestamp) / 1000)}s ago`, + }; + + if (!oldestTimestamp || timestamp < oldestTimestamp) { + oldestTimestamp = timestamp; + } + } else { + lastFetched[service] = { + timestamp: null, + age: "never", + }; + } + } + + health.cache.lastFetched = lastFetched; + + if (oldestTimestamp) { + const nextUpdate = new Date(oldestTimestamp + 60 * 60 * 1000); + health.cache.nextUpdate = nextUpdate.toISOString(); + } + } catch { + health.cache.lastFetched = { error: "Failed to fetch cache timestamps" }; + } + } + + const status = health.status === "ok" ? 200 : 503; + + return Response.json(health, { + status, + headers: { + "Cache-Control": "no-cache", + }, + }); +} + +export { handler, routeDef }; diff --git a/src/routes/index.ts b/src/routes/index.ts index 76e5c37..bbfacb5 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,5 @@ +import { badgeServices, getServiceDescription, gitUrl } from "@config"; + const routeDef: RouteDef = { method: "GET", accepts: "*/*", @@ -8,15 +10,56 @@ async function handler(request: ExtendedRequest): Promise { const endPerf: number = Date.now(); const perf: number = endPerf - request.startPerf; - const { query, params } = request; - - const response: Record = { - perf, - query, - params, + const response = { + name: "Badge Aggregator API", + description: + "A fast Discord badge aggregation API built with Bun and Redis caching", + version: "1.0.0", + author: "creations.works", + repository: gitUrl, + performance: { + responseTime: `${perf}ms`, + uptime: `${process.uptime()}s`, + }, + routes: { + "GET /": "API information and available routes", + "GET /:userId": "Get badges for a Discord user", + "GET /health": "Health check endpoint", + }, + endpoints: { + badges: { + path: "/:userId", + method: "GET", + description: "Fetch badges for a Discord user", + parameters: { + path: { + userId: "Discord User ID (17-20 digits)", + }, + query: { + services: "Comma/space separated list of services (optional)", + cache: "Enable/disable caching (true/false, default: true)", + seperated: + "Return results grouped by service (true/false, default: false)", + }, + }, + example: "/:userId?services=discord,vencord&seperated=true&cache=true", + }, + }, + supportedServices: badgeServices.map((service) => ({ + name: service.service, + description: getServiceDescription(service.service), + })), + ratelimit: { + window: "60 seconds", + requests: 60, + }, }; - return Response.json(response); + return Response.json(response, { + headers: { + "Cache-Control": "public, max-age=300", + }, + }); } export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts index e851a58..d24b025 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,14 @@ import { resolve } from "node:path"; -import { echo } from "@atums/echo"; -import { environment } from "@config/environment"; +import { Echo, echo } from "@atums/echo"; +import { environment } from "@config"; import { type BunFile, FileSystemRouter, type MatchedRoute, - type Serve, + type Server, } from "bun"; -import { webSocketHandler } from "@/websocket"; +import { webSocketHandler } from "@websocket"; class ServerHandler { private router: FileSystemRouter; @@ -19,14 +19,14 @@ class ServerHandler { ) { this.router = new FileSystemRouter({ style: "nextjs", - dir: "./src/routes", + dir: resolve("src", "routes"), fileExtensions: [".ts"], origin: `http://${this.host}:${this.port}`, }); } public initialize(): void { - const server: Serve = Bun.serve({ + const server: Server = Bun.serve({ port: this.port, hostname: this.host, fetch: this.handleRequest.bind(this), @@ -37,19 +37,15 @@ class ServerHandler { }, }); - const accessUrls: string[] = [ - `http://${server.hostname}:${server.port}`, - `http://localhost:${server.port}`, - `http://127.0.0.1:${server.port}`, - ]; + const echoChild = new Echo({ disableFile: true }); - echo.info(`Server running at ${accessUrls[0]}`); - echo.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`); - - this.logRoutes(); + echoChild.info( + `Server running at http://${server.hostname}:${server.port}`, + ); + this.logRoutes(echoChild); } - private logRoutes(): void { + private logRoutes(echo: Echo): void { echo.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( @@ -82,7 +78,7 @@ class ServerHandler { if (await file.exists()) { const fileContent: ArrayBuffer = await file.arrayBuffer(); - const contentType: string = file.type || "application/octet-stream"; + const contentType: string = file.type ?? "application/octet-stream"; response = new Response(fileContent, { headers: { "Content-Type": contentType }, @@ -129,7 +125,7 @@ class ServerHandler { private async handleRequest( request: Request, - server: BunServer, + server: Server, ): Promise { const extendedRequest: ExtendedRequest = request as ExtendedRequest; extendedRequest.startPerf = performance.now(); @@ -142,23 +138,25 @@ class ServerHandler { ip = headers.get("CF-Connecting-IP")?.trim() || headers.get("X-Real-IP")?.trim() || - headers.get("X-Forwarded-For")?.split(",")[0].trim() || + headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || "unknown"; } const pathname: string = new URL(request.url).pathname; - const baseDir = resolve("public/custom"); + const baseDir = resolve("public", "custom"); const customPath = resolve(baseDir, pathname.slice(1)); if (!customPath.startsWith(baseDir)) { - return new Response("Forbidden", { status: 403 }); + response = new Response("Forbidden", { status: 403 }); + this.logRequest(extendedRequest, response, ip); + return response; } const customFile = Bun.file(customPath); if (await customFile.exists()) { const content = await customFile.arrayBuffer(); - const type = customFile.type || "application/octet-stream"; + const type: string = customFile.type ?? "application/octet-stream"; response = new Response(content, { headers: { "Content-Type": type }, }); @@ -180,7 +178,7 @@ class ServerHandler { const routeModule: RouteModule = await import(filePath); const contentType: string | null = request.headers.get("Content-Type"); const actualContentType: string | null = contentType - ? contentType.split(";")[0].trim() + ? (contentType.split(";")[0]?.trim() ?? null) : null; if ( diff --git a/src/websocket.ts b/src/websocket.ts index 1877f6b..6be34fd 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -26,7 +26,7 @@ class WebSocketHandler { } } - public handleClose(ws: ServerWebSocket, code: number, reason: string): void { + public handleClose(_ws: ServerWebSocket, code: number, reason: string): void { echo.info(`WebSocket closed with code ${code}, reason: ${reason}`); } } diff --git a/tsconfig.json b/tsconfig.json index 68a5a97..ec4e48d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,32 +2,30 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@/*": ["src/*"], + "@*": ["src/*"], + "@config": ["config/index.ts"], "@config/*": ["config/*"], "@types/*": ["types/*"], - "@helpers/*": ["src/helpers/*"] + "@lib/*": ["src/lib/*"] }, - "typeRoots": ["./src/types", "./node_modules/@types"], - // Enable latest features + "typeRoots": ["./types", "./node_modules/@types"], "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - // Bundler mode + "allowJs": false, "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "allowImportingTsExtensions": false, "verbatimModuleSyntax": true, "noEmit": true, - // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, "noPropertyAccessFromIndexSignature": false }, - "include": ["src", "types", "config"] + "include": ["src", "types"] } diff --git a/types/badge.d.ts b/types/badge.d.ts index 0731370..7d17c08 100644 --- a/types/badge.d.ts +++ b/types/badge.d.ts @@ -20,3 +20,70 @@ type badgeURLMap = { badge: (id: string) => string; }); }; + +interface VencordEquicordData { + [userId: string]: Array<{ + tooltip: string; + badge: string; + }>; +} + +interface NekocordData { + users: { + [userId: string]: { + badges: string[]; + }; + }; + badges: { + [badgeId: string]: { + name: string; + image: string; + }; + }; +} + +interface ReviewDbData + extends Array<{ + discordID: string; + name: string; + icon: string; + }> {} + +type BadgeServiceData = VencordEquicordData | NekocordData | ReviewDbData; + +interface BadgeService { + service: string; + url: + | string + | (( + userId: string, + ) => string | { user: string; badge: (id: string) => string }); +} + +interface VencordBadgeItem { + tooltip: string; + badge: string; +} + +interface NekocordBadgeInfo { + name: string; + image: string; +} + +interface ReviewDbBadgeItem { + discordID: string; + name: string; + icon: string; +} + +interface EnmityBadgeItem { + name: string; + url: { + dark: string; + }; +} + +interface DiscordUserData { + avatar: string; + flags: number; +} diff --git a/types/bun.d.ts b/types/bun.d.ts index 018bf35..9afe286 100644 --- a/types/bun.d.ts +++ b/types/bun.d.ts @@ -1,14 +1,8 @@ -import type { Server } from "bun"; - type Query = Record; type Params = Record; -declare global { - type BunServer = Server; - - interface ExtendedRequest extends Request { - startPerf: number; - query: Query; - params: Params; - } +interface ExtendedRequest extends Request { + startPerf: number; + query: Query; + params: Params; } diff --git a/types/health.d.ts b/types/health.d.ts new file mode 100644 index 0000000..786acc1 --- /dev/null +++ b/types/health.d.ts @@ -0,0 +1,17 @@ +interface CacheInfo { + timestamp: string | null; + age: string; +} + +interface HealthResponse { + status: "ok" | "degraded"; + timestamp: string; + uptime: number; + services: { + redis: "ok" | "error" | "unknown"; + }; + cache: { + lastFetched: Record | { error: string }; + nextUpdate: string | null; + }; +} diff --git a/types/logger.d.ts b/types/logger.d.ts deleted file mode 100644 index ff6a601..0000000 --- a/types/logger.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -type ILogMessagePart = { value: string; color: string }; - -type ILogMessageParts = { - level: ILogMessagePart; - filename: ILogMessagePart; - readableTimestamp: ILogMessagePart; - message: ILogMessagePart; - [key: string]: ILogMessagePart; -};