diff --git a/.env.example b/.env.example index 21aae90..25f8efe 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ PORT=8080 REDIS_URL=redis://username:password@localhost:6379 REDIS_TTL=3600 # seconds + +# if you wish to get discord badges +DISCORD_TOKEN=discord_bot_token diff --git a/.gitignore b/.gitignore index 97ce421..c7c8dc9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bun.lock .env .vscode/settings.json +logs diff --git a/LICENSE b/LICENSE index fb5f6af..d93a942 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,28 @@ -MIT License +BSD 3-Clause License -Copyright (c) 2025 [fullname] +Copyright (c) 2025, creations.works -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index ab230e9..d6c2938 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ A fast Discord badge aggregation API built with [Bun](https://bun.sh) and Redis caching. +# Preview +https://badges.atums.world + ## Features - Aggregates custom badge data from multiple sources (e.g. Vencord, Nekocord, Equicord, etc.) @@ -37,6 +40,9 @@ REDIS_URL=redis://username:password@localhost:6379 # Value is in seconds REDIS_TTL=3600 + +#only use this if you want to show discord badges +DISCORD_TOKEN=discord_bot_token ``` ## Endpoint @@ -53,11 +59,11 @@ GET /:userId ### Query Parameters -| Name | Description | -|--------------|--------------------------------------------------------------------------| -| `services` | A comma or space separated list of services to fetch badges from | -| `cache` | Set to `true` or `false` (default: `true`). `false` bypasses Redis | -| `seperated` | Set to `true` to return results grouped by service, else merged array | +| Name | Description | +|--------------|---------------------------------------------------------------------------------------------------| +| `services` | A comma or space separated list of services to fetch badges from, if this is empty it fetches all | +| `cache` | Set to `true` or `false` (default: `true`). `false` bypasses Redis | +| `seperated` | Set to `true` to return results grouped by service, else merged array | ### Supported Services @@ -65,6 +71,8 @@ GET /:userId - Equicord - Nekocord - ReviewDb +- Enmity +- Discord ( some ) ### Example @@ -72,20 +80,12 @@ GET /:userId GET /209830981060788225?seperated=true&cache=true&services=equicord ``` -## Development - -Run formatting and linting with BiomeJS: - -```bash -bun run lint -bun run lint:fix -``` - ## Start the Server ```bash +bun i bun run start ``` ## License -[MIT](LICENSE) +[BSD 3](LICENSE) 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/constants.ts b/config/constants.ts new file mode 100644 index 0000000..ac3a5c6 --- /dev/null +++ b/config/constants.ts @@ -0,0 +1,146 @@ +const discordBadges = { + // User badges + STAFF: 1 << 0, + PARTNER: 1 << 1, + HYPESQUAD: 1 << 2, + BUG_HUNTER_LEVEL_1: 1 << 3, + HYPESQUAD_ONLINE_HOUSE_1: 1 << 6, + HYPESQUAD_ONLINE_HOUSE_2: 1 << 7, + HYPESQUAD_ONLINE_HOUSE_3: 1 << 8, + PREMIUM_EARLY_SUPPORTER: 1 << 9, + TEAM_USER: 1 << 10, + SYSTEM: 1 << 12, + BUG_HUNTER_LEVEL_2: 1 << 14, + VERIFIED_DEVELOPER: 1 << 17, + CERTIFIED_MODERATOR: 1 << 18, + SPAMMER: 1 << 20, + ACTIVE_DEVELOPER: 1 << 22, + + // Bot badges + VERIFIED_BOT: 1 << 16, + BOT_HTTP_INTERACTIONS: 1 << 19, + SUPPORTS_COMMANDS: 1 << 23, + USES_AUTOMOD: 1 << 24, +}; + +const discordBadgeDetails = { + HYPESQUAD: { + tooltip: "HypeSquad Events", + icon: "/public/badges/discord/HYPESQUAD.svg", + }, + HYPESQUAD_ONLINE_HOUSE_1: { + tooltip: "HypeSquad Bravery", + icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_1.svg", + }, + HYPESQUAD_ONLINE_HOUSE_2: { + tooltip: "HypeSquad Brilliance", + icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_2.svg", + }, + HYPESQUAD_ONLINE_HOUSE_3: { + tooltip: "HypeSquad Balance", + icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_3.svg", + }, + + STAFF: { + tooltip: "Discord Staff", + icon: "/public/badges/discord/STAFF.svg", + }, + PARTNER: { + tooltip: "Discord Partner", + icon: "/public/badges/discord/PARTNER.svg", + }, + CERTIFIED_MODERATOR: { + tooltip: "Certified Moderator", + icon: "/public/badges/discord/CERTIFIED_MODERATOR.svg", + }, + + VERIFIED_DEVELOPER: { + tooltip: "Verified Bot Developer", + icon: "/public/badges/discord/VERIFIED_DEVELOPER.svg", + }, + ACTIVE_DEVELOPER: { + tooltip: "Active Developer", + icon: "/public/badges/discord/ACTIVE_DEVELOPER.svg", + }, + + PREMIUM_EARLY_SUPPORTER: { + tooltip: "Premium Early Supporter", + icon: "/public/badges/discord/PREMIUM_EARLY_SUPPORTER.svg", + }, + + BUG_HUNTER_LEVEL_1: { + tooltip: "Bug Hunter (Level 1)", + icon: "/public/badges/discord/BUG_HUNTER_LEVEL_1.svg", + }, + BUG_HUNTER_LEVEL_2: { + tooltip: "Bug Hunter (Level 2)", + icon: "/public/badges/discord/BUG_HUNTER_LEVEL_2.svg", + }, + + SUPPORTS_COMMANDS: { + tooltip: "Supports Commands", + icon: "/public/badges/discord/SUPPORTS_COMMANDS.svg", + }, + USES_AUTOMOD: { + tooltip: "Uses AutoMod", + icon: "/public/badges/discord/USES_AUTOMOD.svg", + }, +}; + +const badgeServices: BadgeService[] = [ + { + 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}`, + }, +]; + +const vencordEquicordContributorUrl = + "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/plugins.json"; + +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, + vencordEquicordContributorUrl, + getServiceDescription, + gitUrl, +}; diff --git a/config/environment.ts b/config/environment.ts deleted file mode 100644 index 03a3107..0000000 --- a/config/environment.ts +++ /dev/null @@ -1,38 +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 - -// not sure the point ? -// function getClientModBadgesUrl(userId: string): string { -// return `https://cdn.jsdelivr.net/gh/Equicord/ClientModBadges-API@main/users/${userId}.json`; -// } - -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: "ClientMods", - // url: getClientModBadgesUrl, - // } -]; 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 new file mode 100644 index 0000000..cc10cab --- /dev/null +++ b/logger.json @@ -0,0 +1,39 @@ +{ + "directory": "logs", + "level": "info", + "disableFile": false, + + "rotate": true, + "maxFiles": 3, + + "console": true, + "consoleColor": true, + + "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS", + "timezone": "local", + + "silent": false, + + "pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}", + "levelColor": { + "debug": "blue", + "info": "green", + "warn": "yellow", + "error": "red", + "fatal": "red" + }, + + "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", + "customColors": { + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "cyan", + "HEAD": "magenta", + "OPTIONS": "white", + "TRACE": "gray" + }, + + "prettyPrint": true +} diff --git a/package.json b/package.json index db5e8a9..1a420b5 100644 --- a/package.json +++ b/package.json @@ -7,18 +7,13 @@ "dev": "bun run --hot src/index.ts --dev", "lint": "bunx biome check", "lint:fix": "bunx biome check --fix", - "cleanup": "rm -rf logs node_modules bun.lockdb" + "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": { - "ejs": "^3.1.10" + "@atums/echo": "latest" } } diff --git a/public/badges/discord/ACTIVE_DEVELOPER.svg b/public/badges/discord/ACTIVE_DEVELOPER.svg new file mode 100644 index 0000000..80aa677 --- /dev/null +++ b/public/badges/discord/ACTIVE_DEVELOPER.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/badges/discord/BUG_HUNTER_LEVEL_1.svg b/public/badges/discord/BUG_HUNTER_LEVEL_1.svg new file mode 100644 index 0000000..ca75a4e --- /dev/null +++ b/public/badges/discord/BUG_HUNTER_LEVEL_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/BUG_HUNTER_LEVEL_2.svg b/public/badges/discord/BUG_HUNTER_LEVEL_2.svg new file mode 100644 index 0000000..1c80182 --- /dev/null +++ b/public/badges/discord/BUG_HUNTER_LEVEL_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/CERTIFIED_MODERATOR.svg b/public/badges/discord/CERTIFIED_MODERATOR.svg new file mode 100644 index 0000000..6f634b2 --- /dev/null +++ b/public/badges/discord/CERTIFIED_MODERATOR.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/HYPESQUAD.svg b/public/badges/discord/HYPESQUAD.svg new file mode 100644 index 0000000..85bec04 --- /dev/null +++ b/public/badges/discord/HYPESQUAD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_1.svg b/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_1.svg new file mode 100644 index 0000000..91fd024 --- /dev/null +++ b/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_2.svg b/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_2.svg new file mode 100644 index 0000000..d0713bb --- /dev/null +++ b/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_3.svg b/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_3.svg new file mode 100644 index 0000000..01e4805 --- /dev/null +++ b/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/NITRO.svg b/public/badges/discord/NITRO.svg new file mode 100644 index 0000000..98b54ab --- /dev/null +++ b/public/badges/discord/NITRO.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/PARTNER.svg b/public/badges/discord/PARTNER.svg new file mode 100644 index 0000000..35facaf --- /dev/null +++ b/public/badges/discord/PARTNER.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/PREMIUM_EARLY_SUPPORTER.svg b/public/badges/discord/PREMIUM_EARLY_SUPPORTER.svg new file mode 100644 index 0000000..8cd0cda --- /dev/null +++ b/public/badges/discord/PREMIUM_EARLY_SUPPORTER.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/STAFF.svg b/public/badges/discord/STAFF.svg new file mode 100644 index 0000000..d65b724 --- /dev/null +++ b/public/badges/discord/STAFF.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/SUPPORTS_COMMANDS.svg b/public/badges/discord/SUPPORTS_COMMANDS.svg new file mode 100644 index 0000000..55e0c7b --- /dev/null +++ b/public/badges/discord/SUPPORTS_COMMANDS.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/badges/discord/USES_AUTOMOD.svg b/public/badges/discord/USES_AUTOMOD.svg new file mode 100644 index 0000000..e220934 --- /dev/null +++ b/public/badges/discord/USES_AUTOMOD.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/badges/discord/VERIFIED_DEVELOPER.svg b/public/badges/discord/VERIFIED_DEVELOPER.svg new file mode 100644 index 0000000..4ec174f --- /dev/null +++ b/public/badges/discord/VERIFIED_DEVELOPER.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/helpers/badges.ts b/src/helpers/badges.ts deleted file mode 100644 index 892233d..0000000 --- a/src/helpers/badges.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { badgeServices, redisTtl } from "@config/environment"; -import { fetch, redis } from "bun"; - -export async function fetchBadges( - userId: string, - services: string[], - options?: FetchBadgesOptions, -): 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 - } - } - } - - let url: string; - if (typeof entry.url === "function") { - url = entry.url(userId); - } else { - url = entry.url; - } - - try { - const res = await fetch(url); - if (!res.ok) return; - const data = await res.json(); - - const result: Badge[] = []; - - switch (serviceKey) { - case "vencord": - case "equicord": { - 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 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": { - for (const b of data) { - if (b.discordID === userId) { - result.push({ - tooltip: b.name, - badge: b.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/helpers/char.ts b/src/helpers/char.ts deleted file mode 100644 index c885429..0000000 --- a/src/helpers/char.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function timestampToReadable(timestamp?: number): string { - const date: Date = - timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date(); - if (Number.isNaN(date.getTime())) return "Invalid Date"; - return date.toISOString().replace("T", " ").replace("Z", ""); -} - -export function validateID(id: string): boolean { - if (!id) return false; - - return /^\d{17,20}$/.test(id.trim()); -} - -export function parseServices(input: string): string[] { - return input - .split(/[\s,]+/) - .map((s) => s.trim()) - .filter(Boolean); -} diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts deleted file mode 100644 index 4cbb12b..0000000 --- a/src/helpers/logger.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Stats } from "node:fs"; -import { - type WriteStream, - createWriteStream, - existsSync, - mkdirSync, - statSync, -} from "node:fs"; -import { EOL } from "node:os"; -import { basename, join } from "node:path"; -import { environment } from "@config/environment"; -import { timestampToReadable } from "@helpers/char"; - -class Logger { - private static instance: Logger; - private static log: string = join(__dirname, "../../logs"); - - public static getInstance(): Logger { - if (!Logger.instance) { - Logger.instance = new Logger(); - } - - return Logger.instance; - } - - private writeToLog(logMessage: string): void { - if (environment.development) return; - - const date: Date = new Date(); - const logDir: string = Logger.log; - const logFile: string = join( - logDir, - `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`, - ); - - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); - } - - let addSeparator = false; - - if (existsSync(logFile)) { - const fileStats: Stats = statSync(logFile); - if (fileStats.size > 0) { - const lastModified: Date = new Date(fileStats.mtime); - if ( - lastModified.getFullYear() === date.getFullYear() && - lastModified.getMonth() === date.getMonth() && - lastModified.getDate() === date.getDate() && - lastModified.getHours() !== date.getHours() - ) { - addSeparator = true; - } - } - } - - const stream: WriteStream = createWriteStream(logFile, { flags: "a" }); - - if (addSeparator) { - stream.write(`${EOL}${date.toISOString()}${EOL}`); - } - - stream.write(`${logMessage}${EOL}`); - stream.close(); - } - - private extractFileName(stack: string): string { - const stackLines: string[] = stack.split("\n"); - let callerFile = ""; - - for (let i = 2; i < stackLines.length; i++) { - const line: string = stackLines[i].trim(); - if (line && !line.includes("Logger.") && line.includes("(")) { - callerFile = line.split("(")[1]?.split(")")[0] || ""; - break; - } - } - - return basename(callerFile); - } - - private getCallerInfo(stack: unknown): { - filename: string; - timestamp: string; - } { - const filename: string = - typeof stack === "string" ? this.extractFileName(stack) : "unknown"; - - const readableTimestamp: string = timestampToReadable(); - - return { filename, timestamp: readableTimestamp }; - } - - public info(message: string | string[], breakLine = false): void { - const stack: string = new Error().stack || ""; - const { filename, timestamp } = this.getCallerInfo(stack); - - const joinedMessage: string = Array.isArray(message) - ? message.join(" ") - : message; - - const logMessageParts: ILogMessageParts = { - readableTimestamp: { value: timestamp, color: "90" }, - level: { value: "[INFO]", color: "32" }, - filename: { value: `(${filename})`, color: "36" }, - message: { value: joinedMessage, color: "0" }, - }; - - this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`); - this.writeConsoleMessageColored(logMessageParts, breakLine); - } - - public warn(message: string | string[], breakLine = false): void { - const stack: string = new Error().stack || ""; - const { filename, timestamp } = this.getCallerInfo(stack); - - const joinedMessage: string = Array.isArray(message) - ? message.join(" ") - : message; - - const logMessageParts: ILogMessageParts = { - readableTimestamp: { value: timestamp, color: "90" }, - level: { value: "[WARN]", color: "33" }, - filename: { value: `(${filename})`, color: "36" }, - message: { value: joinedMessage, color: "0" }, - }; - - this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`); - this.writeConsoleMessageColored(logMessageParts, breakLine); - } - - public error( - message: string | Error | (string | Error)[], - breakLine = false, - ): void { - const stack: string = new Error().stack || ""; - const { filename, timestamp } = this.getCallerInfo(stack); - - const messages: (string | Error)[] = Array.isArray(message) - ? message - : [message]; - const joinedMessage: string = messages - .map((msg: string | Error): string => - typeof msg === "string" ? msg : msg.message, - ) - .join(" "); - - const logMessageParts: ILogMessageParts = { - readableTimestamp: { value: timestamp, color: "90" }, - level: { value: "[ERROR]", color: "31" }, - filename: { value: `(${filename})`, color: "36" }, - message: { value: joinedMessage, color: "0" }, - }; - - this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`); - this.writeConsoleMessageColored(logMessageParts, breakLine); - } - - public custom( - bracketMessage: string, - bracketMessage2: string, - message: string | string[], - color: string, - breakLine = false, - ): void { - const stack: string = new Error().stack || ""; - const { timestamp } = this.getCallerInfo(stack); - - const joinedMessage: string = Array.isArray(message) - ? message.join(" ") - : message; - - const logMessageParts: ILogMessageParts = { - readableTimestamp: { value: timestamp, color: "90" }, - level: { value: bracketMessage, color }, - filename: { value: `${bracketMessage2}`, color: "36" }, - message: { value: joinedMessage, color: "0" }, - }; - - this.writeToLog( - `${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`, - ); - this.writeConsoleMessageColored(logMessageParts, breakLine); - } - - public space(): void { - console.log(); - } - - private writeConsoleMessageColored( - logMessageParts: ILogMessageParts, - breakLine = false, - ): void { - const logMessage: string = Object.keys(logMessageParts) - .map((key: string) => { - const part: ILogMessagePart = logMessageParts[key]; - return `\x1b[${part.color}m${part.value}\x1b[0m`; - }) - .join(" "); - console.log(logMessage + (breakLine ? EOL : "")); - } -} - -const logger: Logger = Logger.getInstance(); -export { logger }; diff --git a/src/index.ts b/src/index.ts index 60606d4..fbdbfe9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,37 @@ -import { logger } from "@helpers/logger"; - -import { serverHandler } from "@/server"; +import { echo } from "@atums/echo"; +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(); } main().catch((error: Error) => { - logger.error(["Error initializing the server:", error]); + echo.error({ + message: "Error initializing the server", + error: error.message, + }); process.exit(1); }); + +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..8711187 --- /dev/null +++ b/src/lib/badgeCache.ts @@ -0,0 +1,305 @@ +import { echo } from "@atums/echo"; +import { + badgeFetchInterval, + badgeServices, + gitUrl, + redisTtl, + vencordEquicordContributorUrl, +} 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; + } + } + + if (typeof vencordEquicordContributorUrl === "string") { + const contributorRes = await fetch(vencordEquicordContributorUrl, { + headers: { + "User-Agent": `BadgeAPI/1.0 ${gitUrl}`, + }, + }); + + if (contributorRes.ok) { + const pluginData = await contributorRes.json(); + + if (Array.isArray(pluginData)) { + if (!data) { + data = {} as VencordEquicordData; + } + + const contributors = new Set(); + + for (const plugin of pluginData) { + if (plugin.authors && Array.isArray(plugin.authors)) { + const isEquicordPlugin = + plugin.filePath && + typeof plugin.filePath === "string" && + plugin.filePath.includes("equicordplugins/"); + + const shouldInclude = + (serviceKey === "equicord" && isEquicordPlugin) || + (serviceKey === "vencord" && !isEquicordPlugin); + + if (shouldInclude) { + for (const author of plugin.authors) { + if (author.id) { + contributors.add(author.id); + } + } + } + } + } + + const badgeDetails = + serviceKey === "vencord" + ? { + tooltip: "Vencord Contributor", + badge: "https://vencord.dev/assets/favicon.png", + } + : { + tooltip: "Equicord Contributor", + badge: "https://i.imgur.com/57ATLZu.png", + }; + + for (const authorId of contributors) { + if (!data[authorId]) { + data[authorId] = []; + } + + const hasContributorBadge = data[authorId].some( + (badge) => badge.tooltip === badgeDetails.tooltip, + ); + + if (!hasContributorBadge) { + data[authorId].push(badgeDetails); + } + } + } + } + } + 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/lib/char.ts b/src/lib/char.ts new file mode 100644 index 0000000..f6038db --- /dev/null +++ b/src/lib/char.ts @@ -0,0 +1,14 @@ +function validateID(id: string | undefined): boolean { + if (!id) return false; + + return /^\d{17,20}$/.test(id.trim()); +} + +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 af6a621..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,72 +18,73 @@ 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(userId, validServices, { - nocache: cache !== "true", - separated: seperated === "true", - }); + const badges = await fetchBadges( + userId, + validServices, + { + nocache: cache !== "true", + separated: seperated === "true", + }, + 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 }, ); } @@ -100,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 909813c..d24b025 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,14 @@ import { resolve } from "node:path"; -import { environment } from "@config/environment"; -import { logger } from "@helpers/logger"; +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,16 +37,16 @@ class ServerHandler { }, }); - logger.info( - `Server running at http://${server.hostname}:${server.port}`, - true, - ); + const echoChild = new Echo({ disableFile: true }); - this.logRoutes(); + echoChild.info( + `Server running at http://${server.hostname}:${server.port}`, + ); + this.logRoutes(echoChild); } - private logRoutes(): void { - logger.info("Available routes:"); + private logRoutes(echo: Echo): void { + echo.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( this.router.routes, @@ -55,14 +55,19 @@ class ServerHandler { ); for (const [path, filePath] of sortedRoutes) { - logger.info(`Route: ${path}, File: ${filePath}`); + echo.info(`Route: ${path}, File: ${filePath}`); } } - private async serveStaticFile(pathname: string): Promise { - try { - let filePath: string; + private async serveStaticFile( + request: ExtendedRequest, + pathname: string, + ip: string, + ): Promise { + let filePath: string; + let response: Response; + try { if (pathname === "/favicon.ico") { filePath = resolve("public", "assets", "favicon.ico"); } else { @@ -73,35 +78,98 @@ 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"; - return new Response(fileContent, { + response = new Response(fileContent, { headers: { "Content-Type": contentType }, }); + } else { + echo.warn(`File not found: ${filePath}`); + response = new Response("Not Found", { status: 404 }); } - logger.warn(`File not found: ${filePath}`); - return new Response("Not Found", { status: 404 }); } catch (error) { - logger.error([`Error serving static file: ${pathname}`, error as Error]); - return new Response("Internal Server Error", { status: 500 }); + echo.error({ + message: `Error serving static file: ${pathname}`, + error: error as Error, + }); + response = new Response("Internal Server Error", { status: 500 }); } + + this.logRequest(request, response, ip); + return response; + } + + private logRequest( + request: ExtendedRequest, + response: Response, + ip: string | undefined, + ): void { + const pathname = new URL(request.url).pathname; + + const ignoredStartsWith: string[] = ["/public"]; + const ignoredPaths: string[] = ["/favicon.ico"]; + + if ( + ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) || + ignoredPaths.includes(pathname) + ) { + return; + } + + echo.custom(`${request.method}`, `${response.status}`, [ + request.url, + `${(performance.now() - request.startPerf).toFixed(2)}ms`, + ip || "unknown", + ]); } private async handleRequest( request: Request, - server: BunServer, + server: Server, ): Promise { const extendedRequest: ExtendedRequest = request as ExtendedRequest; extendedRequest.startPerf = performance.now(); + const headers = request.headers; + let ip = server.requestIP(request)?.address; + let response: Response; + + if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { + ip = + headers.get("CF-Connecting-IP")?.trim() || + headers.get("X-Real-IP")?.trim() || + headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || + "unknown"; + } + const pathname: string = new URL(request.url).pathname; + + const baseDir = resolve("public", "custom"); + const customPath = resolve(baseDir, pathname.slice(1)); + + if (!customPath.startsWith(baseDir)) { + 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: string = customFile.type ?? "application/octet-stream"; + response = new Response(content, { + headers: { "Content-Type": type }, + }); + this.logRequest(extendedRequest, response, ip); + return response; + } + if (pathname.startsWith("/public") || pathname === "/favicon.ico") { - return await this.serveStaticFile(pathname); + return await this.serveStaticFile(extendedRequest, pathname, ip); } const match: MatchedRoute | null = this.router.match(request); let requestBody: unknown = {}; - let response: Response; if (match) { const { filePath, params, query } = match; @@ -110,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 ( @@ -199,7 +267,10 @@ class ServerHandler { } } } catch (error: unknown) { - logger.error([`Error handling route ${request.url}:`, error as Error]); + echo.error({ + message: `Error handling route ${request.url}`, + error: error, + }); response = Response.json( { @@ -221,31 +292,11 @@ class ServerHandler { ); } - const headers = request.headers; - let ip = server.requestIP(request)?.address; - - if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { - ip = - headers.get("CF-Connecting-IP")?.trim() || - headers.get("X-Real-IP")?.trim() || - headers.get("X-Forwarded-For")?.split(",")[0].trim() || - "unknown"; - } - - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); - + this.logRequest(extendedRequest, response, ip); return response; } } + const serverHandler: ServerHandler = new ServerHandler( environment.port, environment.host, diff --git a/src/websocket.ts b/src/websocket.ts index 99686e8..6be34fd 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,27 +1,33 @@ -import { logger } from "@helpers/logger"; +import { echo } from "@atums/echo"; import type { ServerWebSocket } from "bun"; class WebSocketHandler { public handleMessage(ws: ServerWebSocket, message: string): void { - logger.info(`WebSocket received: ${message}`); + echo.info(`WebSocket received: ${message}`); try { ws.send(`You said: ${message}`); } catch (error) { - logger.error(["WebSocket send error", error as Error]); + echo.error({ + message: "WebSocket send error", + error: (error as Error).message, + }); } } public handleOpen(ws: ServerWebSocket): void { - logger.info("WebSocket connection opened."); + echo.info("WebSocket connection opened."); try { ws.send("Welcome to the WebSocket server!"); } catch (error) { - logger.error(["WebSocket send error", error as Error]); + echo.error({ + message: "WebSocket send error", + error: (error as Error).message, + }); } } - public handleClose(ws: ServerWebSocket, code: number, reason: string): void { - logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + 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 04b58c9..08043b9 100644 --- a/types/badge.d.ts +++ b/types/badge.d.ts @@ -9,3 +9,93 @@ interface FetchBadgesOptions { nocache?: boolean; separated?: boolean; } + +type BadgeService = { + service: string; + url: + | string + | ((userId: string) => string) + | ((userId: string) => { + user: string; + 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 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; +} + +interface PluginData { + hasPatches: boolean; + hasCommands: boolean; + enabledByDefault: boolean; + required: boolean; + tags: string[]; + name: string; + description: string; + authors: Array<{ + name: string; + id: string; + }>; + filePath: string; + commands?: Array<{ + name: string; + description: string; + }>; + dependencies?: string[]; + target?: string; +} 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/config.d.ts b/types/config.d.ts index 2d583ee..57584ed 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -3,8 +3,3 @@ type Environment = { host: string; development: boolean; }; - -type badgeURLMap = { - service: string; - url: string | ((userId: string) => string); -}; 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; -};