Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

33 changed files with 729 additions and 1449 deletions

View file

@ -1,14 +0,0 @@
node_modules
.env
.env.example
logs
dist
.vscode
.git
.gitignore
Dockerfile
.dockerignore
README.md
LICENSE
.forgejo
.editorconfig

3
.gitignore vendored
View file

@ -1,5 +1,4 @@
/node_modules /node_modules
bun.lock
.env .env
.vscode/settings.json .vscode/settings.json
logs
dragonfly-data

View file

@ -1,30 +0,0 @@
FROM oven/bun:latest AS base
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --production
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
FROM base AS release
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/src ./src
COPY --from=prerelease /usr/src/app/public ./public
COPY --from=prerelease /usr/src/app/package.json .
COPY --from=prerelease /usr/src/app/tsconfig.json .
COPY --from=prerelease /usr/src/app/config ./config
COPY --from=prerelease /usr/src/app/types ./types
COPY --from=prerelease /usr/src/app/logger.json .
RUN mkdir -p /usr/src/app/logs && chown bun:bun /usr/src/app/logs
USER bun
ENTRYPOINT [ "bun", "run", "start" ]

41
LICENSE
View file

@ -1,28 +1,21 @@
BSD 3-Clause License MIT License
Copyright (c) 2025, creations.works Copyright (c) 2025 [fullname]
Redistribution and use in source and binary forms, with or without Permission is hereby granted, free of charge, to any person obtaining a copy
modification, are permitted provided that the following conditions are met: 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:
1. Redistributions of source code must retain the above copyright notice, this The above copyright notice and this permission notice shall be included in all
list of conditions and the following disclaimer. copies or substantial portions of the Software.
2. Redistributions in binary form must reproduce the above copyright notice, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
this list of conditions and the following disclaimer in the documentation IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
and/or other materials provided with the distribution. FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
3. Neither the name of the copyright holder nor the names of its LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
contributors may be used to endorse or promote products derived from OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
this software without specific prior written permission. SOFTWARE.
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.

View file

@ -2,9 +2,6 @@
A fast Discord badge aggregation API built with [Bun](https://bun.sh) and Redis caching. A fast Discord badge aggregation API built with [Bun](https://bun.sh) and Redis caching.
# Preview
https://badges.atums.world
## Features ## Features
- Aggregates custom badge data from multiple sources (e.g. Vencord, Nekocord, Equicord, etc.) - Aggregates custom badge data from multiple sources (e.g. Vencord, Nekocord, Equicord, etc.)
@ -60,8 +57,8 @@ GET /:userId
### Query Parameters ### Query Parameters
| Name | Description | | Name | Description |
|--------------|---------------------------------------------------------------------------------------------------| |--------------|--------------------------------------------------------------------------|
| `services` | A comma or space separated list of services to fetch badges from, if this is empty it fetches all | | `services` | A comma or space separated list of services to fetch badges from |
| `cache` | Set to `true` or `false` (default: `true`). `false` bypasses Redis | | `cache` | Set to `true` or `false` (default: `true`). `false` bypasses Redis |
| `seperated` | Set to `true` to return results grouped by service, else merged array | | `seperated` | Set to `true` to return results grouped by service, else merged array |
@ -71,8 +68,6 @@ GET /:userId
- Equicord - Equicord
- Nekocord - Nekocord
- ReviewDb - ReviewDb
- Enmity
- Discord ( some )
### Example ### Example
@ -80,12 +75,20 @@ GET /:userId
GET /209830981060788225?seperated=true&cache=true&services=equicord 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 ## Start the Server
```bash ```bash
bun i
bun run start bun run start
``` ```
## License ## License
[BSD 3](LICENSE) [MIT](LICENSE)

View file

@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": true, "ignoreUnknown": true,
"ignore": ["dist"] "ignore": []
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@ -17,30 +17,12 @@
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
"css": {
"formatter": {
"indentStyle": "tab",
"lineEnding": "lf"
}
},
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "recommended": true
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "error"
},
"suspicious": {
"noConsole": "error"
},
"style": {
"useConst": "error",
"noVar": "error"
} }
}, },
"ignore": ["types"]
},
"javascript": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "double", "quoteStyle": "double",

View file

@ -1,48 +0,0 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "bun_frontend_template",
"dependencies": {
"@atums/echo": "latest",
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "latest",
},
},
},
"packages": {
"@atums/echo": ["@atums/echo@1.0.3", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-WQ2d4oWTaE+6VeLIu2FepmZipdwUrM+SiiO5moHhSsP4P+MaQCjq5qp34nwB/vOHv2jd9UcBzy27iUziTffCjg=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
"@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="],
"bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"date-fns-tz": ["date-fns-tz@3.2.0", "", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}

View file

@ -1,30 +0,0 @@
services:
badge-api:
container_name: badge-api
pull_policy: build
build:
context: .
restart: unless-stopped
ports:
- "8080:8080"
environment:
- REDIS_URL=redis://dragonfly:6379
depends_on:
dragonfly:
condition: service_started
networks:
- badge-api-network
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly
restart: unless-stopped
ulimits:
memlock: -1
volumes:
- ./dragonfly-data:/data
networks:
- badge-api-network
networks:
badge-api-network:
driver: bridge

View file

@ -1,162 +0,0 @@
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",
},
// Custom
VENCORD_CONTRIBUTOR: {
tooltip: "Vencord Contributor",
icon: "/public/badges/vencord.png",
},
EQUICORD_CONTRIBUTOR: {
tooltip: "Equicord Contributor",
icon: "/public/badges/equicord.svg",
},
DISCORD_NITRO: {
tooltip: "Discord Nitro",
icon: "/public/badges/discord/NITRO.svg",
},
};
const badgeServices: BadgeService[] = [
{
service: "Vencord",
url: "https://badges.vencord.dev/badges.json",
pluginsUrl:
"https://raw.githubusercontent.com/Vencord/builds/main/plugins.json",
},
{
service: "Equicord", // Ekwekord ! WOOP
url: "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json",
pluginsUrl:
"https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/plugins.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<string, string> = {
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,
};

87
config/discordBadges.ts Normal file
View file

@ -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",
},
};

43
config/environment.ts Normal file
View file

@ -0,0 +1,43 @@
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;

View file

@ -1,45 +0,0 @@
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"];
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,
};

View file

@ -1,39 +0,0 @@
{
"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
}

View file

@ -7,13 +7,18 @@
"dev": "bun run --hot src/index.ts --dev", "dev": "bun run --hot src/index.ts --dev",
"lint": "bunx biome check", "lint": "bunx biome check",
"lint:fix": "bunx biome check --fix", "lint:fix": "bunx biome check --fix",
"cleanup": "rm -rf logs node_modules bun.lock" "cleanup": "rm -rf logs node_modules bun.lockdb"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "^1.2.9",
"@types/ejs": "^3.1.5",
"globals": "^16.0.0",
"@biomejs/biome": "^1.9.4" "@biomejs/biome": "^1.9.4"
}, },
"peerDependencies": {
"typescript": "^5.8.3"
},
"dependencies": { "dependencies": {
"@atums/echo": "latest" "ejs": "^3.1.10"
} }
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 900 B

184
src/helpers/badges.ts Normal file
View file

@ -0,0 +1,184 @@
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<BadgeResult> {
const { nocache = false, separated = false } = options ?? {};
const results: Record<string, Badge[]> = {};
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();
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) {
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;
}

19
src/helpers/char.ts Normal file
View file

@ -0,0 +1,19 @@
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);
}

205
src/helpers/logger.ts Normal file
View file

@ -0,0 +1,205 @@
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 };

View file

@ -1,37 +1,12 @@
import { echo } from "@atums/echo"; import { logger } from "@helpers/logger";
import { verifyRequiredVariables } from "@config";
import { badgeCacheManager } from "@lib/badgeCache"; import { serverHandler } from "@/server";
import { serverHandler } from "@server";
async function main(): Promise<void> { async function main(): Promise<void> {
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(); serverHandler.initialize();
} }
main().catch((error: Error) => { main().catch((error: Error) => {
echo.error({ logger.error(["Error initializing the server:", error]);
message: "Error initializing the server",
error: error.message,
});
process.exit(1); 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");
}

View file

@ -1,296 +0,0 @@
import { echo } from "@atums/echo";
import {
badgeFetchInterval,
badgeServices,
discordBadgeDetails,
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<void> {
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<void> {
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
echo.debug("Badge cache manager shut down");
}
private async checkIfUpdateNeeded(): Promise<boolean> {
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<void> {
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<void> {
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 service.pluginsUrl === "string") {
const contributorRes = await fetch(service.pluginsUrl, {
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<string>();
for (const plugin of pluginData) {
if (plugin.authors && Array.isArray(plugin.authors)) {
for (const author of plugin.authors) {
if (author.id) {
contributors.add(author.id);
}
}
}
}
const badgeDetails =
serviceKey === "vencord"
? {
tooltip:
discordBadgeDetails.VENCORD_CONTRIBUTOR.tooltip,
badge: discordBadgeDetails.VENCORD_CONTRIBUTOR.icon,
}
: {
tooltip:
discordBadgeDetails.EQUICORD_CONTRIBUTOR.tooltip,
badge: discordBadgeDetails.EQUICORD_CONTRIBUTOR.icon,
};
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<BadgeServiceData | null> {
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<VencordEquicordData | null> {
const data = await this.getServiceData(serviceKey);
if (data && (serviceKey === "vencord" || serviceKey === "equicord")) {
return data as VencordEquicordData;
}
return null;
}
async getNekocordData(): Promise<NekocordData | null> {
const data = await this.getServiceData("nekocord");
if (data) {
return data as NekocordData;
}
return null;
}
async getReviewDbData(): Promise<ReviewDbData | null> {
const data = await this.getServiceData("reviewdb");
if (data) {
return data as ReviewDbData;
}
return null;
}
async forceUpdateService(serviceName: string): Promise<void> {
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();

View file

@ -1,270 +0,0 @@
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}`;
}
const USER_CACHE_SERVICES = ["discord", "enmity"];
export async function fetchBadges(
userId: string | undefined,
services: string[],
options?: FetchBadgesOptions,
request?: Request,
): Promise<BadgeResult> {
const { nocache = false, separated = false } = options ?? {};
const results: Record<string, Badge[]> = {};
if (!userId || !Array.isArray(services) || services.length === 0) {
return separated ? results : [];
}
const userCachePromises = services.map(async (service) => {
const serviceKey = service.toLowerCase();
if (!USER_CACHE_SERVICES.includes(serviceKey) || nocache) {
return false;
}
const userCacheKey = `user_badges:${serviceKey}:${userId}`;
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)) {
const origin = request ? getRequestOrigin(request) : "";
for (const badgeItem of userBadges) {
const badgeUrl = badgeItem.badge.startsWith("/")
? `${origin}${badgeItem.badge}`
: badgeItem.badge;
result.push({
tooltip: badgeItem.tooltip,
badge: badgeUrl,
});
}
}
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: discordBadgeDetails.DISCORD_NITRO.tooltip,
badge: `${origin}${discordBadgeDetails.DISCORD_NITRO.icon}`,
});
}
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;
}
results[serviceKey] = result;
if (
USER_CACHE_SERVICES.includes(serviceKey) &&
!nocache &&
result.length > 0
) {
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;
}

View file

@ -1,14 +0,0 @@
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 };

View file

@ -1,6 +1,6 @@
import { badgeServices } from "@config"; import { badgeServices } from "@config/environment";
import { fetchBadges } from "@lib/badges"; import { fetchBadges } from "@helpers/badges";
import { parseServices, validateID } from "@lib/char"; import { parseServices, validateID } from "@helpers/char";
function isValidServices(services: string[]): boolean { function isValidServices(services: string[]): boolean {
if (!Array.isArray(services)) return false; if (!Array.isArray(services)) return false;
@ -18,52 +18,47 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
const { id: userId } = request.params; const { id: userId } = request.params;
const { services, cache = "true", seperated = "false" } = request.query; const { services, cache, seperated } = request.query;
let validServices: string[];
if (!validateID(userId)) { if (!validateID(userId)) {
return Response.json( return Response.json(
{ {
status: 400, status: 400,
error: "Invalid Discord User ID. Must be 17-20 digits.", error: "Invalid Discord User ID",
},
{
status: 400,
}, },
{ status: 400 },
); );
} }
let validServices: string[];
const availableServices = badgeServices.map((b) => b.service);
if (services) { if (services) {
const parsed = parseServices(services); const parsed = parseServices(services);
if (parsed.length === 0) {
return Response.json(
{
status: 400,
error: "No valid services provided",
availableServices,
},
{ status: 400 },
);
}
if (parsed.length > 0) {
if (!isValidServices(parsed)) { if (!isValidServices(parsed)) {
return Response.json( return Response.json(
{ {
status: 400, status: 400,
error: "Invalid service(s) provided", error: "Invalid Services",
availableServices, },
provided: parsed, {
status: 400,
}, },
{ status: 400 },
); );
} }
validServices = parsed; validServices = parsed;
} else { } else {
validServices = availableServices; validServices = badgeServices.map((b) => b.service);
}
} else {
validServices = badgeServices.map((b) => b.service);
} }
const badges = await fetchBadges( const badges: BadgeResult = await fetchBadges(
userId, userId,
validServices, validServices,
{ {
@ -73,18 +68,27 @@ async function handler(request: ExtendedRequest): Promise<Response> {
request, request,
); );
const isEmpty = Array.isArray(badges) if (badges instanceof Error) {
? badges.length === 0 return Response.json(
: Object.keys(badges).length === 0; {
status: 500,
error: badges.message,
},
{
status: 500,
},
);
}
if (isEmpty) { if (badges.length === 0) {
return Response.json( return Response.json(
{ {
status: 404, status: 404,
error: "No badges found for this user", error: "No Badges Found",
services: validServices, },
{
status: 404,
}, },
{ status: 404 },
); );
} }
@ -101,6 +105,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "Content-Type",
}, },
}, },
); );

View file

@ -1,89 +0,0 @@
import { redis } from "bun";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
};
async function handler(): Promise<Response> {
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<string, CacheInfo> = {};
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 };

View file

@ -1,5 +1,3 @@
import { badgeServices, getServiceDescription, gitUrl } from "@config";
const routeDef: RouteDef = { const routeDef: RouteDef = {
method: "GET", method: "GET",
accepts: "*/*", accepts: "*/*",
@ -10,56 +8,15 @@ async function handler(request: ExtendedRequest): Promise<Response> {
const endPerf: number = Date.now(); const endPerf: number = Date.now();
const perf: number = endPerf - request.startPerf; const perf: number = endPerf - request.startPerf;
const response = { const { query, params } = request;
name: "Badge Aggregator API",
description: const response: Record<string, unknown> = {
"A fast Discord badge aggregation API built with Bun and Redis caching", perf,
version: "1.0.0", query,
author: "creations.works", params,
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 }; export { handler, routeDef };

View file

@ -1,14 +1,14 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { Echo, echo } from "@atums/echo"; import { environment } from "@config/environment";
import { environment } from "@config"; import { logger } from "@helpers/logger";
import { import {
type BunFile, type BunFile,
FileSystemRouter, FileSystemRouter,
type MatchedRoute, type MatchedRoute,
type Server, type Serve,
} from "bun"; } from "bun";
import { webSocketHandler } from "@websocket"; import { webSocketHandler } from "@/websocket";
class ServerHandler { class ServerHandler {
private router: FileSystemRouter; private router: FileSystemRouter;
@ -19,14 +19,14 @@ class ServerHandler {
) { ) {
this.router = new FileSystemRouter({ this.router = new FileSystemRouter({
style: "nextjs", style: "nextjs",
dir: resolve("src", "routes"), dir: "./src/routes",
fileExtensions: [".ts"], fileExtensions: [".ts"],
origin: `http://${this.host}:${this.port}`, origin: `http://${this.host}:${this.port}`,
}); });
} }
public initialize(): void { public initialize(): void {
const server: Server = Bun.serve({ const server: Serve = Bun.serve({
port: this.port, port: this.port,
hostname: this.host, hostname: this.host,
fetch: this.handleRequest.bind(this), fetch: this.handleRequest.bind(this),
@ -37,16 +37,16 @@ class ServerHandler {
}, },
}); });
const echoChild = new Echo({ disableFile: true }); logger.info(
echoChild.info(
`Server running at http://${server.hostname}:${server.port}`, `Server running at http://${server.hostname}:${server.port}`,
true,
); );
this.logRoutes(echoChild);
this.logRoutes();
} }
private logRoutes(echo: Echo): void { private logRoutes(): void {
echo.info("Available routes:"); logger.info("Available routes:");
const sortedRoutes: [string, string][] = Object.entries( const sortedRoutes: [string, string][] = Object.entries(
this.router.routes, this.router.routes,
@ -55,19 +55,14 @@ class ServerHandler {
); );
for (const [path, filePath] of sortedRoutes) { for (const [path, filePath] of sortedRoutes) {
echo.info(`Route: ${path}, File: ${filePath}`); logger.info(`Route: ${path}, File: ${filePath}`);
} }
} }
private async serveStaticFile( private async serveStaticFile(pathname: string): Promise<Response> {
request: ExtendedRequest,
pathname: string,
ip: string,
): Promise<Response> {
let filePath: string;
let response: Response;
try { try {
let filePath: string;
if (pathname === "/favicon.ico") { if (pathname === "/favicon.ico") {
filePath = resolve("public", "assets", "favicon.ico"); filePath = resolve("public", "assets", "favicon.ico");
} else { } else {
@ -78,98 +73,35 @@ class ServerHandler {
if (await file.exists()) { if (await file.exists()) {
const fileContent: ArrayBuffer = await file.arrayBuffer(); 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, { return new Response(fileContent, {
headers: { "Content-Type": contentType }, 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) { } catch (error) {
echo.error({ logger.error([`Error serving static file: ${pathname}`, error as Error]);
message: `Error serving static file: ${pathname}`, return new Response("Internal Server Error", { status: 500 });
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( private async handleRequest(
request: Request, request: Request,
server: Server, server: BunServer,
): Promise<Response> { ): Promise<Response> {
const extendedRequest: ExtendedRequest = request as ExtendedRequest; const extendedRequest: ExtendedRequest = request as ExtendedRequest;
extendedRequest.startPerf = performance.now(); 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 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") { if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
return await this.serveStaticFile(extendedRequest, pathname, ip); return await this.serveStaticFile(pathname);
} }
const match: MatchedRoute | null = this.router.match(request); const match: MatchedRoute | null = this.router.match(request);
let requestBody: unknown = {}; let requestBody: unknown = {};
let response: Response;
if (match) { if (match) {
const { filePath, params, query } = match; const { filePath, params, query } = match;
@ -178,7 +110,7 @@ class ServerHandler {
const routeModule: RouteModule = await import(filePath); const routeModule: RouteModule = await import(filePath);
const contentType: string | null = request.headers.get("Content-Type"); const contentType: string | null = request.headers.get("Content-Type");
const actualContentType: string | null = contentType const actualContentType: string | null = contentType
? (contentType.split(";")[0]?.trim() ?? null) ? contentType.split(";")[0].trim()
: null; : null;
if ( if (
@ -267,10 +199,7 @@ class ServerHandler {
} }
} }
} catch (error: unknown) { } catch (error: unknown) {
echo.error({ logger.error([`Error handling route ${request.url}:`, error as Error]);
message: `Error handling route ${request.url}`,
error: error,
});
response = Response.json( response = Response.json(
{ {
@ -292,11 +221,31 @@ class ServerHandler {
); );
} }
this.logRequest(extendedRequest, response, ip); 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",
);
return response; return response;
} }
} }
const serverHandler: ServerHandler = new ServerHandler( const serverHandler: ServerHandler = new ServerHandler(
environment.port, environment.port,
environment.host, environment.host,

View file

@ -1,33 +1,27 @@
import { echo } from "@atums/echo"; import { logger } from "@helpers/logger";
import type { ServerWebSocket } from "bun"; import type { ServerWebSocket } from "bun";
class WebSocketHandler { class WebSocketHandler {
public handleMessage(ws: ServerWebSocket, message: string): void { public handleMessage(ws: ServerWebSocket, message: string): void {
echo.info(`WebSocket received: ${message}`); logger.info(`WebSocket received: ${message}`);
try { try {
ws.send(`You said: ${message}`); ws.send(`You said: ${message}`);
} catch (error) { } catch (error) {
echo.error({ logger.error(["WebSocket send error", error as Error]);
message: "WebSocket send error",
error: (error as Error).message,
});
} }
} }
public handleOpen(ws: ServerWebSocket): void { public handleOpen(ws: ServerWebSocket): void {
echo.info("WebSocket connection opened."); logger.info("WebSocket connection opened.");
try { try {
ws.send("Welcome to the WebSocket server!"); ws.send("Welcome to the WebSocket server!");
} catch (error) { } catch (error) {
echo.error({ logger.error(["WebSocket send error", error as Error]);
message: "WebSocket send error",
error: (error as Error).message,
});
} }
} }
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}`); logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
} }
} }

View file

@ -2,30 +2,32 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@*": ["src/*"], "@/*": ["src/*"],
"@config": ["config/index.ts"],
"@config/*": ["config/*"], "@config/*": ["config/*"],
"@types/*": ["types/*"], "@types/*": ["types/*"],
"@lib/*": ["src/lib/*"] "@helpers/*": ["src/helpers/*"]
}, },
"typeRoots": ["./types", "./node_modules/@types"], "typeRoots": ["./src/types", "./node_modules/@types"],
// Enable latest features
"lib": ["ESNext", "DOM"], "lib": ["ESNext", "DOM"],
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleDetection": "force", "moduleDetection": "force",
"allowJs": false, "jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": false, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUnusedLocals": true, // Some stricter flags (disabled by default)
"noUnusedParameters": true, "noUnusedLocals": false,
"exactOptionalPropertyTypes": true, "noUnusedParameters": false,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
}, },
"include": ["src", "types"] "include": ["src", "types", "config"]
} }

82
types/badge.d.ts vendored
View file

@ -10,7 +10,7 @@ interface FetchBadgesOptions {
separated?: boolean; separated?: boolean;
} }
type BadgeService = { type badgeURLMap = {
service: string; service: string;
url: url:
| string | string
@ -19,84 +19,4 @@ type BadgeService = {
user: string; user: string;
badge: (id: string) => string; badge: (id: string) => string;
}); });
pluginsUrl?: 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;
}

8
types/bun.d.ts vendored
View file

@ -1,8 +1,14 @@
import type { Server } from "bun";
type Query = Record<string, string>; type Query = Record<string, string>;
type Params = Record<string, string>; type Params = Record<string, string>;
interface ExtendedRequest extends Request { declare global {
type BunServer = Server;
interface ExtendedRequest extends Request {
startPerf: number; startPerf: number;
query: Query; query: Query;
params: Params; params: Params;
}
} }

17
types/health.d.ts vendored
View file

@ -1,17 +0,0 @@
interface CacheInfo {
timestamp: string | null;
age: string;
}
interface HealthResponse {
status: "ok" | "degraded";
timestamp: string;
uptime: number;
services: {
redis: "ok" | "error" | "unknown";
};
cache: {
lastFetched: Record<string, CacheInfo> | { error: string };
nextUpdate: string | null;
};
}

9
types/logger.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
type ILogMessagePart = { value: string; color: string };
type ILogMessageParts = {
level: ILogMessagePart;
filename: ILogMessagePart;
readableTimestamp: ILogMessagePart;
message: ILogMessagePart;
[key: string]: ILogMessagePart;
};