Compare commits

..

22 commits
dev ... main

Author SHA1 Message Date
868c5b6dbe
dont force env or compose 2025-06-09 18:31:00 -04:00
3aca639660
add compose logic 2025-06-09 18:12:48 -04:00
2f9b38ace8
fix required bot token, fix equicord and vencord contrib badges, move equicord to svg 2025-06-07 21:08:10 -04:00
269b858e88
add vencord and equicord contributor to fetching 2025-06-05 19:49:23 -04:00
d300f20b49 Update README.md 2025-06-06 00:39:36 +02:00
75d3dab85e
add index route info, make it fetch per hour instead of every user, add health route, update to latest biome config aswell as logger 2025-06-04 15:47:51 -04:00
8cfa75ec57
move to @atums/echo logger, 2025-05-30 20:12:53 -04:00
0ba0181e2b Update README.md
last time trust
2025-05-02 23:05:40 +02:00
0f36203c1c Update README.md 2025-05-02 23:04:43 +02:00
4ff0577906 Update README.md 2025-05-02 23:00:21 +02:00
53a1bb7d6b Update README.md 2025-05-02 22:59:17 +02:00
49ab7d6f19 Update README.md 2025-05-02 19:09:13 +02:00
50c5d5d551
change to bsd -3 2025-04-28 18:04:55 -04:00
9d7bd605b7
add more discord badges 2025-04-25 23:24:13 -04:00
e4af3be2ad
Merge branch 'serstars-main'
creations/badgeAPI#1
2025-04-23 11:41:29 -04:00
891d61b2ef Fix Discord badges math
Following https://discord.com/developers/docs/resources/user#user-object-user-flags
2025-04-23 17:20:32 +02:00
a1dae32f80
Fix lint 2025-04-23 07:30:12 -04:00
db53308044
renmame env 2025-04-22 20:20:39 -04:00
45d9053aea
add stupid env var 2025-04-22 20:19:22 -04:00
dd4a96cea4
move to logger on npm 2025-04-22 20:16:51 -04:00
881d4a0869
Fix lint 2025-04-20 15:52:56 -04:00
72a660821a
try to fix http return on https 2025-04-20 12:48:07 -04:00
33 changed files with 1451 additions and 731 deletions

14
.dockerignore Normal file
View file

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

3
.gitignore vendored
View file

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

30
Dockerfile Normal file
View file

@ -0,0 +1,30 @@
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,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.

View file

@ -2,6 +2,9 @@
A fast Discord badge aggregation API built with [Bun](https://bun.sh) and Redis caching.
# Preview
https://badges.atums.world
## Features
- Aggregates custom badge data from multiple sources (e.g. Vencord, Nekocord, Equicord, etc.)
@ -56,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
@ -68,6 +71,8 @@ GET /:userId
- Equicord
- Nekocord
- ReviewDb
- Enmity
- Discord ( some )
### Example
@ -75,20 +80,12 @@ GET /:userId
GET /209830981060788225?seperated=true&cache=true&services=equicord
```
## Development
Run formatting and linting with BiomeJS:
```bash
bun run lint
bun run lint:fix
```
## Start the Server
```bash
bun i
bun run start
```
## License
[MIT](LICENSE)
[BSD 3](LICENSE)

View file

@ -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": {

48
bun.lock Normal file
View file

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

30
compose.yml Normal file
View file

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

162
config/constants.ts Normal file
View file

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

View file

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

View file

@ -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;

45
config/index.ts Normal file
View file

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

39
logger.json Normal file
View file

@ -0,0 +1,39 @@
{
"directory": "logs",
"level": "info",
"disableFile": false,
"rotate": true,
"maxFiles": 3,
"console": true,
"consoleColor": true,
"dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
"timezone": "local",
"silent": false,
"pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}",
"levelColor": {
"debug": "blue",
"info": "green",
"warn": "yellow",
"error": "red",
"fatal": "red"
},
"customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
"customColors": {
"GET": "green",
"POST": "blue",
"PUT": "yellow",
"DELETE": "red",
"PATCH": "cyan",
"HEAD": "magenta",
"OPTIONS": "white",
"TRACE": "gray"
},
"prettyPrint": true
}

View file

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/badges/vencord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 B

View file

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

View file

@ -1,19 +0,0 @@
export function timestampToReadable(timestamp?: number): string {
const date: Date =
timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date();
if (Number.isNaN(date.getTime())) return "Invalid Date";
return date.toISOString().replace("T", " ").replace("Z", "");
}
export function validateID(id: string): boolean {
if (!id) return false;
return /^\d{17,20}$/.test(id.trim());
}
export function parseServices(input: string): string[] {
return input
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean);
}

View file

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

View file

@ -1,12 +1,37 @@
import { logger } from "@helpers/logger";
import { serverHandler } from "@/server";
import { echo } from "@atums/echo";
import { verifyRequiredVariables } from "@config";
import { badgeCacheManager } from "@lib/badgeCache";
import { serverHandler } from "@server";
async function main(): Promise<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();
}
main().catch((error: Error) => {
logger.error(["Error initializing the server:", error]);
echo.error({
message: "Error initializing the server",
error: error.message,
});
process.exit(1);
});
if (process.env.IN_PTERODACTYL === "true") {
// biome-ignore lint/suspicious/noConsole: Needed for Pterodactyl to actually know the server started
console.log("Server Started");
}

296
src/lib/badgeCache.ts Normal file
View file

@ -0,0 +1,296 @@
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();

270
src/lib/badges.ts Normal file
View file

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

14
src/lib/char.ts Normal file
View file

@ -0,0 +1,14 @@
function validateID(id: string | undefined): boolean {
if (!id) return false;
return /^\d{17,20}$/.test(id.trim());
}
function parseServices(input: string): string[] {
return input
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
export { validateID, parseServices };

View file

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

89
src/routes/health.ts Normal file
View file

@ -0,0 +1,89 @@
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,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<Response> {
const endPerf: number = Date.now();
const perf: number = endPerf - request.startPerf;
const { query, params } = request;
const response: Record<string, unknown> = {
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 };

View file

@ -1,14 +1,14 @@
import { resolve } from "node:path";
import { environment } from "@config/environment";
import { logger } from "@helpers/logger";
import { Echo, echo } from "@atums/echo";
import { environment } from "@config";
import {
type BunFile,
FileSystemRouter,
type MatchedRoute,
type Serve,
type Server,
} from "bun";
import { webSocketHandler } from "@/websocket";
import { webSocketHandler } from "@websocket";
class ServerHandler {
private router: FileSystemRouter;
@ -19,14 +19,14 @@ class ServerHandler {
) {
this.router = new FileSystemRouter({
style: "nextjs",
dir: "./src/routes",
dir: resolve("src", "routes"),
fileExtensions: [".ts"],
origin: `http://${this.host}:${this.port}`,
});
}
public initialize(): void {
const server: Serve = Bun.serve({
const server: Server = Bun.serve({
port: this.port,
hostname: this.host,
fetch: this.handleRequest.bind(this),
@ -37,16 +37,16 @@ class ServerHandler {
},
});
logger.info(
`Server running at http://${server.hostname}:${server.port}`,
true,
);
const echoChild = new Echo({ disableFile: true });
this.logRoutes();
echoChild.info(
`Server running at http://${server.hostname}:${server.port}`,
);
this.logRoutes(echoChild);
}
private logRoutes(): void {
logger.info("Available routes:");
private logRoutes(echo: Echo): void {
echo.info("Available routes:");
const sortedRoutes: [string, string][] = Object.entries(
this.router.routes,
@ -55,14 +55,19 @@ class ServerHandler {
);
for (const [path, filePath] of sortedRoutes) {
logger.info(`Route: ${path}, File: ${filePath}`);
echo.info(`Route: ${path}, File: ${filePath}`);
}
}
private async serveStaticFile(pathname: string): Promise<Response> {
try {
let filePath: string;
private async serveStaticFile(
request: ExtendedRequest,
pathname: string,
ip: string,
): Promise<Response> {
let filePath: string;
let response: Response;
try {
if (pathname === "/favicon.ico") {
filePath = resolve("public", "assets", "favicon.ico");
} else {
@ -73,35 +78,98 @@ class ServerHandler {
if (await file.exists()) {
const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string = file.type || "application/octet-stream";
const contentType: string = file.type ?? "application/octet-stream";
return new Response(fileContent, {
response = new Response(fileContent, {
headers: { "Content-Type": contentType },
});
} else {
echo.warn(`File not found: ${filePath}`);
response = new Response("Not Found", { status: 404 });
}
logger.warn(`File not found: ${filePath}`);
return new Response("Not Found", { status: 404 });
} catch (error) {
logger.error([`Error serving static file: ${pathname}`, error as Error]);
return new Response("Internal Server Error", { status: 500 });
echo.error({
message: `Error serving static file: ${pathname}`,
error: error as Error,
});
response = new Response("Internal Server Error", { status: 500 });
}
this.logRequest(request, response, ip);
return response;
}
private logRequest(
request: ExtendedRequest,
response: Response,
ip: string | undefined,
): void {
const pathname = new URL(request.url).pathname;
const ignoredStartsWith: string[] = ["/public"];
const ignoredPaths: string[] = ["/favicon.ico"];
if (
ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) ||
ignoredPaths.includes(pathname)
) {
return;
}
echo.custom(`${request.method}`, `${response.status}`, [
request.url,
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
ip || "unknown",
]);
}
private async handleRequest(
request: Request,
server: BunServer,
server: Server,
): Promise<Response> {
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
extendedRequest.startPerf = performance.now();
const headers = request.headers;
let ip = server.requestIP(request)?.address;
let response: Response;
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
ip =
headers.get("CF-Connecting-IP")?.trim() ||
headers.get("X-Real-IP")?.trim() ||
headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||
"unknown";
}
const pathname: string = new URL(request.url).pathname;
const baseDir = resolve("public", "custom");
const customPath = resolve(baseDir, pathname.slice(1));
if (!customPath.startsWith(baseDir)) {
response = new Response("Forbidden", { status: 403 });
this.logRequest(extendedRequest, response, ip);
return response;
}
const customFile = Bun.file(customPath);
if (await customFile.exists()) {
const content = await customFile.arrayBuffer();
const type: string = customFile.type ?? "application/octet-stream";
response = new Response(content, {
headers: { "Content-Type": type },
});
this.logRequest(extendedRequest, response, ip);
return response;
}
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
return await this.serveStaticFile(pathname);
return await this.serveStaticFile(extendedRequest, pathname, ip);
}
const match: MatchedRoute | null = this.router.match(request);
let requestBody: unknown = {};
let response: Response;
if (match) {
const { filePath, params, query } = match;
@ -110,7 +178,7 @@ class ServerHandler {
const routeModule: RouteModule = await import(filePath);
const contentType: string | null = request.headers.get("Content-Type");
const actualContentType: string | null = contentType
? contentType.split(";")[0].trim()
? (contentType.split(";")[0]?.trim() ?? null)
: null;
if (
@ -199,7 +267,10 @@ class ServerHandler {
}
}
} catch (error: unknown) {
logger.error([`Error handling route ${request.url}:`, error as Error]);
echo.error({
message: `Error handling route ${request.url}`,
error: error,
});
response = Response.json(
{
@ -221,31 +292,11 @@ class ServerHandler {
);
}
const headers = request.headers;
let ip = server.requestIP(request)?.address;
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
ip =
headers.get("CF-Connecting-IP")?.trim() ||
headers.get("X-Real-IP")?.trim() ||
headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
"unknown";
}
logger.custom(
`[${request.method}]`,
`(${response.status})`,
[
request.url,
`${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`,
ip || "unknown",
],
"90",
);
this.logRequest(extendedRequest, response, ip);
return response;
}
}
const serverHandler: ServerHandler = new ServerHandler(
environment.port,
environment.host,

View file

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

View file

@ -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"]
}

82
types/badge.d.ts vendored
View file

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

14
types/bun.d.ts vendored
View file

@ -1,14 +1,8 @@
import type { Server } from "bun";
type Query = Record<string, string>;
type Params = Record<string, string>;
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;
}

17
types/health.d.ts vendored Normal file
View file

@ -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<string, CacheInfo> | { error: string };
nextUpdate: string | null;
};
}

9
types/logger.d.ts vendored
View file

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