From cbd92de7a57245950f1480c3a85d31c5dc7f7f10 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 19 Apr 2025 20:47:06 -0400 Subject: [PATCH 01/20] add Enmity shame! --- config/environment.ts | 17 ++++++------ src/helpers/badges.ts | 61 +++++++++++++++++++++++++++++++++++-------- types/badge.d.ts | 11 ++++++++ types/config.d.ts | 5 ---- 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/config/environment.ts b/config/environment.ts index 03a3107..7591352 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -9,11 +9,6 @@ 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", @@ -31,8 +26,12 @@ export const badgeServices: badgeURLMap[] = [ service: "ReviewDb", url: "https://manti.vendicated.dev/api/reviewdb/badges", }, - // { - // service: "ClientMods", - // url: getClientModBadgesUrl, - // } + { + 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`, + }), + }, ]; diff --git a/src/helpers/badges.ts b/src/helpers/badges.ts index 892233d..fb0208b 100644 --- a/src/helpers/badges.ts +++ b/src/helpers/badges.ts @@ -32,23 +32,23 @@ export async function fetchBadges( } } - let url: string; - if (typeof entry.url === "function") { - url = entry.url(userId); - } else { - url = entry.url; - } + const result: Badge[] = []; try { - const res = await fetch(url); - if (!res.ok) return; - const data = await res.json(); - - const result: Badge[] = []; + 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) { @@ -62,6 +62,10 @@ export async function fetchBadges( } 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) { @@ -78,6 +82,10 @@ export async function fetchBadges( } 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({ @@ -88,6 +96,37 @@ export async function fetchBadges( } 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; + } } if (result.length > 0) { diff --git a/types/badge.d.ts b/types/badge.d.ts index 04b58c9..0731370 100644 --- a/types/badge.d.ts +++ b/types/badge.d.ts @@ -9,3 +9,14 @@ interface FetchBadgesOptions { nocache?: boolean; separated?: boolean; } + +type badgeURLMap = { + service: string; + url: + | string + | ((userId: string) => string) + | ((userId: string) => { + user: string; + badge: (id: string) => string; + }); +}; 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); -}; From c73b8725c1667b51185eeaef7d3fa4a91b211c34 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 19 Apr 2025 22:46:35 -0400 Subject: [PATCH 02/20] add discord badges from https://git.creations.works/seth pr, fixed a few things --- .env.example | 3 + README.md | 3 + config/discordBadges.ts | 87 +++++++++++++++++++ config/environment.ts | 6 ++ public/badges/discord/ACTIVE_DEVELOPER.svg | 3 + public/badges/discord/BUG_HUNTER_LEVEL_1.svg | 1 + public/badges/discord/BUG_HUNTER_LEVEL_2.svg | 1 + public/badges/discord/CERTIFIED_MODERATOR.svg | 1 + public/badges/discord/HYPESQUAD.svg | 1 + .../discord/HYPESQUAD_ONLINE_HOUSE_1.svg | 1 + .../discord/HYPESQUAD_ONLINE_HOUSE_2.svg | 1 + .../discord/HYPESQUAD_ONLINE_HOUSE_3.svg | 1 + public/badges/discord/NITRO.svg | 1 + public/badges/discord/PARTNER.svg | 1 + .../discord/PREMIUM_EARLY_SUPPORTER.svg | 1 + public/badges/discord/STAFF.svg | 1 + public/badges/discord/SUPPORTS_COMMANDS.svg | 1 + public/badges/discord/USES_AUTOMOD.svg | 19 ++++ public/badges/discord/VERIFIED_DEVELOPER.svg | 1 + src/helpers/badges.ts | 36 +++++++- src/routes/[id].ts | 13 ++- 21 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 config/discordBadges.ts create mode 100644 public/badges/discord/ACTIVE_DEVELOPER.svg create mode 100644 public/badges/discord/BUG_HUNTER_LEVEL_1.svg create mode 100644 public/badges/discord/BUG_HUNTER_LEVEL_2.svg create mode 100644 public/badges/discord/CERTIFIED_MODERATOR.svg create mode 100644 public/badges/discord/HYPESQUAD.svg create mode 100644 public/badges/discord/HYPESQUAD_ONLINE_HOUSE_1.svg create mode 100644 public/badges/discord/HYPESQUAD_ONLINE_HOUSE_2.svg create mode 100644 public/badges/discord/HYPESQUAD_ONLINE_HOUSE_3.svg create mode 100644 public/badges/discord/NITRO.svg create mode 100644 public/badges/discord/PARTNER.svg create mode 100644 public/badges/discord/PREMIUM_EARLY_SUPPORTER.svg create mode 100644 public/badges/discord/STAFF.svg create mode 100644 public/badges/discord/SUPPORTS_COMMANDS.svg create mode 100644 public/badges/discord/USES_AUTOMOD.svg create mode 100644 public/badges/discord/VERIFIED_DEVELOPER.svg 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/README.md b/README.md index ab230e9..5084653 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,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 diff --git a/config/discordBadges.ts b/config/discordBadges.ts new file mode 100644 index 0000000..7e0e258 --- /dev/null +++ b/config/discordBadges.ts @@ -0,0 +1,87 @@ +export const discordBadges = { + // User badges + HYPESQUAD: 2 << 2, + HYPESQUAD_ONLINE_HOUSE_1: 2 << 6, + HYPESQUAD_ONLINE_HOUSE_2: 2 << 7, + HYPESQUAD_ONLINE_HOUSE_3: 2 << 8, + + STAFF: 2 << 0, + PARTNER: 2 << 1, + CERTIFIED_MODERATOR: 2 << 18, + + VERIFIED_DEVELOPER: 2 << 17, + ACTIVE_DEVELOPER: 2 << 22, + + PREMIUM_EARLY_SUPPORTER: 2 << 9, + + BUG_HUNTER_LEVEL_1: 2 << 3, + BUG_HUNTER_LEVEL_2: 2 << 14, + + // Bot badges + SUPPORTS_COMMANDS: 2 << 23, + USES_AUTOMOD: 2 << 24, +}; + +export 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", + }, +}; diff --git a/config/environment.ts b/config/environment.ts index 7591352..6b056d6 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -34,4 +34,10 @@ export const badgeServices: badgeURLMap[] = [ `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/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 index fb0208b..68d5ce4 100644 --- a/src/helpers/badges.ts +++ b/src/helpers/badges.ts @@ -1,10 +1,12 @@ -import { badgeServices, redisTtl } from "@config/environment"; +import { discordBadgeDetails, discordBadges } from "@config/discordBadges"; +import { badgeServices, botToken, redisTtl } from "@config/environment"; import { fetch, redis } from "bun"; export async function fetchBadges( userId: string, services: string[], options?: FetchBadgesOptions, + request?: Request, ): Promise { const { nocache = false, separated = false } = options ?? {}; const results: Record = {}; @@ -127,6 +129,38 @@ export async function fetchBadges( ); 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(); + + if (data.avatar.startsWith("a_")) { + result.push({ + tooltip: "Discord Nitro", + badge: `${request ? new URL(request.url).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: `${request ? new URL(request.url).origin : ""}${badge.icon}`, + }); + } + } + break; + } } if (result.length > 0) { diff --git a/src/routes/[id].ts b/src/routes/[id].ts index af6a621..4b8421e 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -58,10 +58,15 @@ async function handler(request: ExtendedRequest): Promise { validServices = badgeServices.map((b) => b.service); } - const badges: BadgeResult = await fetchBadges(userId, validServices, { - nocache: cache !== "true", - separated: seperated === "true", - }); + const badges: BadgeResult = await fetchBadges( + userId, + validServices, + { + nocache: cache !== "true", + separated: seperated === "true", + }, + request, + ); if (badges instanceof Error) { return Response.json( From 72a660821a3c0deb658be4dfe39a13909e3c2b28 Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 20 Apr 2025 12:48:07 -0400 Subject: [PATCH 03/20] try to fix http return on https --- src/helpers/badges.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/helpers/badges.ts b/src/helpers/badges.ts index 68d5ce4..ad5406b 100644 --- a/src/helpers/badges.ts +++ b/src/helpers/badges.ts @@ -2,6 +2,14 @@ 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[], @@ -141,11 +149,12 @@ export async function fetchBadges( 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: `${request ? new URL(request.url).origin : ""}/public/badges/discord/NITRO.svg`, + badge: `${origin}/public/badges/discord/NITRO.svg`, }); } @@ -155,7 +164,7 @@ export async function fetchBadges( discordBadgeDetails[flag as keyof typeof discordBadgeDetails]; result.push({ tooltip: badge.tooltip, - badge: `${request ? new URL(request.url).origin : ""}${badge.icon}`, + badge: `${origin}${badge.icon}`, }); } } From 881d4a08696dd24c5b011f617f1372a3055ffdd4 Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 20 Apr 2025 15:52:56 -0400 Subject: [PATCH 04/20] Fix lint --- src/helpers/badges.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/helpers/badges.ts b/src/helpers/badges.ts index ad5406b..ba8b272 100644 --- a/src/helpers/badges.ts +++ b/src/helpers/badges.ts @@ -9,7 +9,6 @@ function getRequestOrigin(request: Request): string { return `${forwardedProto}://${host}`; } - export async function fetchBadges( userId: string, services: string[], From dd4a96cea40997ae7e461945318cb094344a25dc Mon Sep 17 00:00:00 2001 From: creations Date: Tue, 22 Apr 2025 20:16:51 -0400 Subject: [PATCH 05/20] move to logger on npm --- package.json | 3 +- src/helpers/char.ts | 7 -- src/helpers/logger.ts | 205 ------------------------------------------ src/index.ts | 2 +- src/server.ts | 6 +- src/websocket.ts | 2 +- 6 files changed, 8 insertions(+), 217 deletions(-) delete mode 100644 src/helpers/logger.ts diff --git a/package.json b/package.json index db5e8a9..0e80369 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "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", @@ -19,6 +19,7 @@ "typescript": "^5.8.3" }, "dependencies": { + "@creations.works/logger": "^1.0.3", "ejs": "^3.1.10" } } diff --git a/src/helpers/char.ts b/src/helpers/char.ts index c885429..74868aa 100644 --- a/src/helpers/char.ts +++ b/src/helpers/char.ts @@ -1,10 +1,3 @@ -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; 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..c4148b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { serverHandler } from "@/server"; diff --git a/src/server.ts b/src/server.ts index 909813c..ee79859 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import { environment } from "@config/environment"; -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type BunFile, FileSystemRouter, @@ -39,7 +39,9 @@ class ServerHandler { logger.info( `Server running at http://${server.hostname}:${server.port}`, - true, + { + breakLine: true, + } ); this.logRoutes(); diff --git a/src/websocket.ts b/src/websocket.ts index 99686e8..7b65476 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import type { ServerWebSocket } from "bun"; class WebSocketHandler { From 45d9053aea356b8dcbc62d83ab727fd687408e08 Mon Sep 17 00:00:00 2001 From: creations Date: Tue, 22 Apr 2025 20:19:22 -0400 Subject: [PATCH 06/20] add stupid env var --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index c4148b4..73eaed7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,3 +10,7 @@ main().catch((error: Error) => { logger.error(["Error initializing the server:", error]); process.exit(1); }); + +if (process.env.Inpterodactyl === "true") { + console.log("Server Started"); +} From db53308044cb32d72efa16602470640c0f73e4b8 Mon Sep 17 00:00:00 2001 From: creations Date: Tue, 22 Apr 2025 20:20:39 -0400 Subject: [PATCH 07/20] renmame env --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 73eaed7..11b1e84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,6 @@ main().catch((error: Error) => { process.exit(1); }); -if (process.env.Inpterodactyl === "true") { +if (process.env.IN_PTERODACTYL === "true") { console.log("Server Started"); } From a1dae32f80bcdffc8d82f0ff6fe06431fa85131a Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 23 Apr 2025 07:30:12 -0400 Subject: [PATCH 08/20] Fix lint --- src/server.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/server.ts b/src/server.ts index ee79859..5646636 100644 --- a/src/server.ts +++ b/src/server.ts @@ -37,12 +37,9 @@ class ServerHandler { }, }); - logger.info( - `Server running at http://${server.hostname}:${server.port}`, - { - breakLine: true, - } - ); + logger.info(`Server running at http://${server.hostname}:${server.port}`, { + breakLine: true, + }); this.logRoutes(); } From 891d61b2ef66453759e53f23113ed372bc85f77b Mon Sep 17 00:00:00 2001 From: serstars Date: Wed, 23 Apr 2025 17:20:32 +0200 Subject: [PATCH 09/20] Fix Discord badges math Following https://discord.com/developers/docs/resources/user#user-object-user-flags --- config/discordBadges.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/config/discordBadges.ts b/config/discordBadges.ts index 7e0e258..ea7f8a2 100644 --- a/config/discordBadges.ts +++ b/config/discordBadges.ts @@ -1,25 +1,25 @@ export const discordBadges = { // User badges - HYPESQUAD: 2 << 2, - HYPESQUAD_ONLINE_HOUSE_1: 2 << 6, - HYPESQUAD_ONLINE_HOUSE_2: 2 << 7, - HYPESQUAD_ONLINE_HOUSE_3: 2 << 8, + HYPESQUAD: 1 << 2, + HYPESQUAD_ONLINE_HOUSE_1: 1 << 6, + HYPESQUAD_ONLINE_HOUSE_2: 1 << 7, + HYPESQUAD_ONLINE_HOUSE_3: 1 << 8, - STAFF: 2 << 0, - PARTNER: 2 << 1, - CERTIFIED_MODERATOR: 2 << 18, + STAFF: 1 << 0, + PARTNER: 1 << 1, + CERTIFIED_MODERATOR: 1 << 18, - VERIFIED_DEVELOPER: 2 << 17, - ACTIVE_DEVELOPER: 2 << 22, + VERIFIED_DEVELOPER: 1 << 17, + ACTIVE_DEVELOPER: 1 << 22, - PREMIUM_EARLY_SUPPORTER: 2 << 9, + PREMIUM_EARLY_SUPPORTER: 1 << 9, - BUG_HUNTER_LEVEL_1: 2 << 3, - BUG_HUNTER_LEVEL_2: 2 << 14, + BUG_HUNTER_LEVEL_1: 1 << 3, + BUG_HUNTER_LEVEL_2: 1 << 14, // Bot badges - SUPPORTS_COMMANDS: 2 << 23, - USES_AUTOMOD: 2 << 24, + SUPPORTS_COMMANDS: 1 << 23, + USES_AUTOMOD: 1 << 24, }; export const discordBadgeDetails = { From 9d7bd605b73e975607b796469a65f9af693acea2 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 25 Apr 2025 23:24:13 -0400 Subject: [PATCH 10/20] add more discord badges --- config/discordBadges.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/config/discordBadges.ts b/config/discordBadges.ts index ea7f8a2..0e9bc66 100644 --- a/config/discordBadges.ts +++ b/config/discordBadges.ts @@ -1,23 +1,24 @@ export 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, - - STAFF: 1 << 0, - PARTNER: 1 << 1, - CERTIFIED_MODERATOR: 1 << 18, - + 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, - PREMIUM_EARLY_SUPPORTER: 1 << 9, - - BUG_HUNTER_LEVEL_1: 1 << 3, - BUG_HUNTER_LEVEL_2: 1 << 14, - // Bot badges + VERIFIED_BOT: 1 << 16, + BOT_HTTP_INTERACTIONS: 1 << 19, SUPPORTS_COMMANDS: 1 << 23, USES_AUTOMOD: 1 << 24, }; From 50c5d5d55146e550bf405b90059e7eeb0466ef35 Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 28 Apr 2025 18:04:55 -0400 Subject: [PATCH 11/20] change to bsd -3 --- LICENSE | 41 ++++++++++++++++++++++++----------------- README.md | 2 +- 2 files changed, 25 insertions(+), 18 deletions(-) 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 5084653..28e4b3d 100644 --- a/README.md +++ b/README.md @@ -91,4 +91,4 @@ bun run start ``` ## License -[MIT](LICENSE) +[BSD 3](LICENSE) From 49ab7d6f197458822d6ae2d75ce7a9d095c24d26 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 2 May 2025 19:09:13 +0200 Subject: [PATCH 12/20] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 28e4b3d..2d4474c 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.creations.works + ## Features - Aggregates custom badge data from multiple sources (e.g. Vencord, Nekocord, Equicord, etc.) From 53a1bb7d6be2d166ca7b7bdaeaf31ec0f523abb5 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 2 May 2025 22:59:17 +0200 Subject: [PATCH 13/20] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2d4474c..4641b5d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ GET /:userId - Equicord - Nekocord - ReviewDb +- Nekocord ### Example From 4ff0577906c8119357d979420cc409c563b5ada2 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 2 May 2025 23:00:21 +0200 Subject: [PATCH 14/20] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4641b5d..e90c298 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ GET /:userId - Equicord - Nekocord - ReviewDb -- Nekocord +- Enmity +- Discord ( some ) ### Example From 0f36203c1c8527357cdb98c8e2b7c3d21f8ed395 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 2 May 2025 23:04:43 +0200 Subject: [PATCH 15/20] Update README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e90c298..4168fd9 100644 --- a/README.md +++ b/README.md @@ -59,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 From 0ba0181e2b54ad86e2b6bb3a58818f91219ad815 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 2 May 2025 23:05:40 +0200 Subject: [PATCH 16/20] Update README.md last time trust --- README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/README.md b/README.md index 4168fd9..a4783ec 100644 --- a/README.md +++ b/README.md @@ -80,18 +80,10 @@ 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 ``` From 8cfa75ec5716d89a022026cae4f243ebe7d0f5c8 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 30 May 2025 20:12:53 -0400 Subject: [PATCH 17/20] move to @atums/echo logger, --- .gitignore | 1 + logger.json | 39 ++++++++++++++ package.json | 2 +- src/index.ts | 7 ++- src/server.ts | 132 +++++++++++++++++++++++++++++++++-------------- src/websocket.ts | 18 ++++--- 6 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 logger.json 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/logger.json b/logger.json new file mode 100644 index 0000000..521b3bc --- /dev/null +++ b/logger.json @@ -0,0 +1,39 @@ +{ + "directory": "logs", + "level": "debug", + "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 0e80369..dc9b552 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "typescript": "^5.8.3" }, "dependencies": { - "@creations.works/logger": "^1.0.3", + "@atums/echo": "^1.0.3", "ejs": "^3.1.10" } } diff --git a/src/index.ts b/src/index.ts index 11b1e84..74320a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { logger } from "@creations.works/logger"; +import { echo } from "@atums/echo"; import { serverHandler } from "@/server"; @@ -7,7 +7,10 @@ async function main(): Promise { } main().catch((error: Error) => { - logger.error(["Error initializing the server:", error]); + echo.error({ + message: "Error initializing the server", + error: error.message, + }); process.exit(1); }); diff --git a/src/server.ts b/src/server.ts index 5646636..e851a58 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; +import { echo } from "@atums/echo"; import { environment } from "@config/environment"; -import { logger } from "@creations.works/logger"; import { type BunFile, FileSystemRouter, @@ -37,15 +37,20 @@ class ServerHandler { }, }); - logger.info(`Server running at http://${server.hostname}:${server.port}`, { - breakLine: true, - }); + const accessUrls: string[] = [ + `http://${server.hostname}:${server.port}`, + `http://localhost:${server.port}`, + `http://127.0.0.1:${server.port}`, + ]; + + echo.info(`Server running at ${accessUrls[0]}`); + echo.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`); this.logRoutes(); } private logRoutes(): void { - logger.info("Available routes:"); + echo.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( this.router.routes, @@ -54,14 +59,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 { @@ -74,16 +84,47 @@ class ServerHandler { const fileContent: ArrayBuffer = await file.arrayBuffer(); 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( @@ -93,14 +134,44 @@ class ServerHandler { 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)) { + return new Response("Forbidden", { status: 403 }); + } + + const customFile = Bun.file(customPath); + if (await customFile.exists()) { + const content = await customFile.arrayBuffer(); + const type = 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; @@ -198,7 +269,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( { @@ -220,31 +294,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 7b65476..1877f6b 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,27 +1,33 @@ -import { logger } from "@creations.works/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}`); + echo.info(`WebSocket closed with code ${code}, reason: ${reason}`); } } From 75d3dab85e59870b1481d6d93147fc361eca5de9 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 4 Jun 2025 15:47:51 -0400 Subject: [PATCH 18/20] 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; -}; From d300f20b4911d9386f87b1c6ca9c1135b294d019 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 6 Jun 2025 00:39:36 +0200 Subject: [PATCH 19/20] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a4783ec..d6c2938 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A fast Discord badge aggregation API built with [Bun](https://bun.sh) and Redis caching. # Preview -https://badges.creations.works +https://badges.atums.world ## Features From 269b858e8848b5f8d14cc355096ef0313e4d0911 Mon Sep 17 00:00:00 2001 From: creations Date: Thu, 5 Jun 2025 19:49:23 -0400 Subject: [PATCH 20/20] add vencord and equicord contributor to fetching --- config/constants.ts | 6 +++- src/lib/badgeCache.ts | 74 ++++++++++++++++++++++++++++++++++++++++++- types/badge.d.ts | 32 +++++++++++++------ 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/config/constants.ts b/config/constants.ts index 37a8a5a..ac3a5c6 100644 --- a/config/constants.ts +++ b/config/constants.ts @@ -87,7 +87,7 @@ const discordBadgeDetails = { }, }; -const badgeServices: badgeURLMap[] = [ +const badgeServices: BadgeService[] = [ { service: "Vencord", url: "https://badges.vencord.dev/badges.json", @@ -118,6 +118,9 @@ const badgeServices: badgeURLMap[] = [ }, ]; +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", @@ -137,6 +140,7 @@ export { badgeServices, discordBadges, discordBadgeDetails, + vencordEquicordContributorUrl, getServiceDescription, gitUrl, }; diff --git a/src/lib/badgeCache.ts b/src/lib/badgeCache.ts index 682f6c9..8711187 100644 --- a/src/lib/badgeCache.ts +++ b/src/lib/badgeCache.ts @@ -1,5 +1,11 @@ import { echo } from "@atums/echo"; -import { badgeFetchInterval, badgeServices, gitUrl, redisTtl } from "@config"; +import { + badgeFetchInterval, + badgeServices, + gitUrl, + redisTtl, + vencordEquicordContributorUrl, +} from "@config"; import { redis } from "bun"; class BadgeCacheManager { @@ -110,6 +116,72 @@ class BadgeCacheManager { 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; } diff --git a/types/badge.d.ts b/types/badge.d.ts index 7d17c08..08043b9 100644 --- a/types/badge.d.ts +++ b/types/badge.d.ts @@ -10,7 +10,7 @@ interface FetchBadgesOptions { separated?: boolean; } -type badgeURLMap = { +type BadgeService = { service: string; url: | string @@ -51,15 +51,6 @@ interface ReviewDbData 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; @@ -87,3 +78,24 @@ 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; +}