Compare commits

...
Sign in to create a new pull request.

21 commits
main ... main

Author SHA1 Message Date
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
c73b8725c1
add discord badges from https://git.creations.works/seth pr, fixed a few things 2025-04-19 22:46:35 -04:00
cbd92de7a5
add Enmity shame! 2025-04-19 20:47:06 -04:00
43 changed files with 1364 additions and 572 deletions

View file

@ -4,3 +4,6 @@ PORT=8080
REDIS_URL=redis://username:password@localhost:6379 REDIS_URL=redis://username:password@localhost:6379
REDIS_TTL=3600 # seconds REDIS_TTL=3600 # seconds
# if you wish to get discord badges
DISCORD_TOKEN=discord_bot_token

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
bun.lock bun.lock
.env .env
.vscode/settings.json .vscode/settings.json
logs

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 Redistribution and use in source and binary forms, with or without
of this software and associated documentation files (the "Software"), to deal modification, are permitted provided that the following conditions are met:
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:
The above copyright notice and this permission notice shall be included in all 1. Redistributions of source code must retain the above copyright notice, this
copies or substantial portions of the Software. list of conditions and the following disclaimer.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 2. Redistributions in binary form must reproduce the above copyright notice,
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, this list of conditions and the following disclaimer in the documentation
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE and/or other materials provided with the distribution.
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 3. Neither the name of the copyright holder nor the names of its
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE contributors may be used to endorse or promote products derived from
SOFTWARE. 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. 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.)
@ -37,6 +40,9 @@ REDIS_URL=redis://username:password@localhost:6379
# Value is in seconds # Value is in seconds
REDIS_TTL=3600 REDIS_TTL=3600
#only use this if you want to show discord badges
DISCORD_TOKEN=discord_bot_token
``` ```
## Endpoint ## Endpoint
@ -53,11 +59,11 @@ GET /:userId
### Query Parameters ### Query Parameters
| Name | Description | | Name | Description |
|--------------|--------------------------------------------------------------------------| |--------------|---------------------------------------------------------------------------------------------------|
| `services` | A comma or space separated list of services to fetch badges from | | `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 | | `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 |
### Supported Services ### Supported Services
@ -65,6 +71,8 @@ GET /:userId
- Equicord - Equicord
- Nekocord - Nekocord
- ReviewDb - ReviewDb
- Enmity
- Discord ( some )
### Example ### Example
@ -72,20 +80,12 @@ 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
[MIT](LICENSE) [BSD 3](LICENSE)

View file

@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": true, "ignoreUnknown": true,
"ignore": [] "ignore": ["dist"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@ -17,11 +17,29 @@
"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": {

146
config/constants.ts Normal file
View file

@ -0,0 +1,146 @@
const discordBadges = {
// User badges
STAFF: 1 << 0,
PARTNER: 1 << 1,
HYPESQUAD: 1 << 2,
BUG_HUNTER_LEVEL_1: 1 << 3,
HYPESQUAD_ONLINE_HOUSE_1: 1 << 6,
HYPESQUAD_ONLINE_HOUSE_2: 1 << 7,
HYPESQUAD_ONLINE_HOUSE_3: 1 << 8,
PREMIUM_EARLY_SUPPORTER: 1 << 9,
TEAM_USER: 1 << 10,
SYSTEM: 1 << 12,
BUG_HUNTER_LEVEL_2: 1 << 14,
VERIFIED_DEVELOPER: 1 << 17,
CERTIFIED_MODERATOR: 1 << 18,
SPAMMER: 1 << 20,
ACTIVE_DEVELOPER: 1 << 22,
// Bot badges
VERIFIED_BOT: 1 << 16,
BOT_HTTP_INTERACTIONS: 1 << 19,
SUPPORTS_COMMANDS: 1 << 23,
USES_AUTOMOD: 1 << 24,
};
const discordBadgeDetails = {
HYPESQUAD: {
tooltip: "HypeSquad Events",
icon: "/public/badges/discord/HYPESQUAD.svg",
},
HYPESQUAD_ONLINE_HOUSE_1: {
tooltip: "HypeSquad Bravery",
icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_1.svg",
},
HYPESQUAD_ONLINE_HOUSE_2: {
tooltip: "HypeSquad Brilliance",
icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_2.svg",
},
HYPESQUAD_ONLINE_HOUSE_3: {
tooltip: "HypeSquad Balance",
icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_3.svg",
},
STAFF: {
tooltip: "Discord Staff",
icon: "/public/badges/discord/STAFF.svg",
},
PARTNER: {
tooltip: "Discord Partner",
icon: "/public/badges/discord/PARTNER.svg",
},
CERTIFIED_MODERATOR: {
tooltip: "Certified Moderator",
icon: "/public/badges/discord/CERTIFIED_MODERATOR.svg",
},
VERIFIED_DEVELOPER: {
tooltip: "Verified Bot Developer",
icon: "/public/badges/discord/VERIFIED_DEVELOPER.svg",
},
ACTIVE_DEVELOPER: {
tooltip: "Active Developer",
icon: "/public/badges/discord/ACTIVE_DEVELOPER.svg",
},
PREMIUM_EARLY_SUPPORTER: {
tooltip: "Premium Early Supporter",
icon: "/public/badges/discord/PREMIUM_EARLY_SUPPORTER.svg",
},
BUG_HUNTER_LEVEL_1: {
tooltip: "Bug Hunter (Level 1)",
icon: "/public/badges/discord/BUG_HUNTER_LEVEL_1.svg",
},
BUG_HUNTER_LEVEL_2: {
tooltip: "Bug Hunter (Level 2)",
icon: "/public/badges/discord/BUG_HUNTER_LEVEL_2.svg",
},
SUPPORTS_COMMANDS: {
tooltip: "Supports Commands",
icon: "/public/badges/discord/SUPPORTS_COMMANDS.svg",
},
USES_AUTOMOD: {
tooltip: "Uses AutoMod",
icon: "/public/badges/discord/USES_AUTOMOD.svg",
},
};
const badgeServices: BadgeService[] = [
{
service: "Vencord",
url: "https://badges.vencord.dev/badges.json",
},
{
service: "Equicord", // Ekwekord ! WOOP
url: "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json",
},
{
service: "Nekocord",
url: "https://nekocord.dev/assets/badges.json",
},
{
service: "ReviewDb",
url: "https://manti.vendicated.dev/api/reviewdb/badges",
},
{
service: "Enmity",
url: (userId: string) => ({
user: `https://raw.githubusercontent.com/enmity-mod/badges/main/${userId}.json`,
badge: (id: string) =>
`https://raw.githubusercontent.com/enmity-mod/badges/main/data/${id}.json`,
}),
},
{
service: "Discord",
url: (userId: string) => `https://discord.com/api/v10/users/${userId}`,
},
];
const vencordEquicordContributorUrl =
"https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/plugins.json";
function getServiceDescription(service: string): string {
const descriptions: Record<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,
vencordEquicordContributorUrl,
getServiceDescription,
gitUrl,
};

View file

@ -1,38 +0,0 @@
export const environment: Environment = {
port: Number.parseInt(process.env.PORT || "8080", 10),
host: process.env.HOST || "0.0.0.0",
development:
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
};
export const redisTtl: number = process.env.REDIS_TTL
? Number.parseInt(process.env.REDIS_TTL, 10)
: 60 * 60 * 1; // 1 hour
// not sure the point ?
// function getClientModBadgesUrl(userId: string): string {
// return `https://cdn.jsdelivr.net/gh/Equicord/ClientModBadges-API@main/users/${userId}.json`;
// }
export const badgeServices: badgeURLMap[] = [
{
service: "Vencord",
url: "https://badges.vencord.dev/badges.json",
},
{
service: "Equicord", // Ekwekord ! WOOP
url: "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json",
},
{
service: "Nekocord",
url: "https://nekocord.dev/assets/badges.json",
},
{
service: "ReviewDb",
url: "https://manti.vendicated.dev/api/reviewdb/badges",
},
// {
// service: "ClientMods",
// url: getClientModBadgesUrl,
// }
];

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", "DISCORD_TOKEN"];
let hasError = false;
for (const key of requiredVariables) {
const value = process.env[key];
if (value === undefined || value.trim() === "") {
echo.error(`Missing or empty environment variable: ${key}`);
hasError = true;
}
}
if (hasError) {
process.exit(1);
}
}
export * from "@config/constants";
export {
environment,
redisTtl,
badgeFetchInterval,
botToken,
verifyRequiredVariables,
};

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", "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.lockdb" "cleanup": "rm -rf logs node_modules bun.lock"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.2.9", "@types/bun": "latest",
"@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": {
"ejs": "^3.1.10" "@atums/echo": "latest"
} }
} }

View file

@ -0,0 +1,3 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="140" height="140" viewBox="0 0 24 24" fill="none">
<path d="M6.47213 4L4 6.47213V17.5279L6.47217 20H17.5278L20 17.5279V6.47213L17.5279 4H6.47213ZM10.8582 16.4255H8.64551C8.64551 14.5952 7.1567 13.1064 5.32642 13.1064V10.8936C7.1567 10.8936 8.64551 9.40483 8.64551 7.57454H10.8582C10.8582 9.39042 9.96684 10.9908 8.61129 12C9.96684 13.0093 10.8582 14.6096 10.8582 16.4255ZM18.6667 13.1064C16.8364 13.1064 15.3476 14.5952 15.3476 16.4255H13.1348C13.1348 14.6096 14.0263 13.0093 15.3818 12C14.0263 10.9908 13.1348 9.39042 13.1348 7.57454H15.3476C15.3476 9.40483 16.8364 10.8936 18.6667 10.8936V13.1064V13.1064Z" fill="#2EA967"/>
</svg>

After

Width:  |  Height:  |  Size: 722 B

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path d="m16.5822 2.63812s7.6721 5.23623 4.7567 12.58868c-2.9154 7.3525-8.7142 5.313-6.5469 3.1648 2.1674-2.1482-2.5573-3.6059-5.58143-6.3935l7.36523-9.35998" fill="#3ba55c"/><path d="m16.1155 9.83717c-1.6175 2.05873-3.9 3.08803-5.6646 2.71723l-6.15684 7.8447c-.10362.1324-.23231.243-.37871.3256-.1464.0825-.30764.1354-.47451.1556-.16686.0202-.33606.0073-.49793-.038-.16187-.0452-.31322-.122-.44541-.2258-.13374-.1032-.2457-.2319-.32942-.3786s-.13754-.3086-.15834-.4762c-.02081-.1677-.00819-.3378.03712-.5005s.12242-.3149.22687-.4476l6.12492-7.832c-.81197-1.62394-.36443-4.11099 1.27869-6.18886 2.03946-2.58295 5.11476-3.54836 6.89856-2.15459 1.7837 1.39377 1.5664 4.61607-.4604 7.19902z" fill="#b4e1cd"/></svg>

After

Width:  |  Height:  |  Size: 839 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="140" viewBox="0 0 24 24" width="140"><mask id="a" height="19" maskUnits="userSpaceOnUse" width="16" x="2" y="2"><path d="m16.1438 9.84735c-1.6048 2.04975-3.9088 3.08265-5.7044 2.70125l-6.14926 7.8813c-.44491.572-1.22351.6356-1.79554.1907-.57203-.445-.63558-1.2235-.25423-1.7956l6.1493-7.8177c-.82626-1.60486-.38135-4.09954 1.28707-6.21286 2.04976-2.57413 5.11646-3.52751 6.91196-2.19278 1.7956 1.33473 1.5413 4.6239-.4449 7.24569z" fill="#ffd56c"/></mask><path d="m16.5888 2.60168s7.6906 5.25949 4.7351 12.63232c-2.9555 7.3728-8.7235 5.323-6.5307 3.1461s-2.5582-3.591-5.57726-6.4194z" fill="#ffeac0"/><path d="m16.1438 9.84735c-1.6048 2.04975-3.9088 3.08265-5.7044 2.70125l-6.14926 7.8813c-.44491.572-1.22351.6356-1.79554.1907-.57203-.445-.63558-1.2235-.25423-1.7956l6.1493-7.8177c-.82626-1.60486-.38135-4.09954 1.28707-6.21286 2.04976-2.57413 5.11646-3.52751 6.91196-2.19278 1.7956 1.33473 1.5413 4.6239-.4449 7.24569z" fill="#ffd56c"/><g fill="#fff" mask="url(#a)"><path d="m13.0389-1.26782.7405.09754-3.1567 23.96118-.74043-.0976z"/><path d="m14.2822-1.51801 1.6226.21377-3.1566 23.96114-1.6226-.2137z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path d="m17.2719 3h-9.54383c-.14912 1.9386-1.78947 3.42982-3.72807 3.42982v.89474c0 4.39914 2.08772 8.50004 5.74123 11.40794l2.75877 2.1622 2.7588-2.1622c3.6535-2.8334 5.7412-7.0088 5.7412-11.40794v-.89474c-1.9386 0-3.5044-1.49122-3.7281-3.42982zm-6.4868 12.8991c-2.23685-1.7895-3.57896-4.3245-3.57896-7.08331v-.52193c1.19298 0 2.23684-.89474 2.3114-2.08772h2.98246v11.10966z" fill="#FC964B"/></svg>

After

Width:  |  Height:  |  Size: 528 B

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><g fill="#fbb848"><path d="m21.5912 6.84349-7.8694 5.16551c-.1351.088-.2444.2103-.317.3543l-1.1997 2.4056c-.0174.0399-.0461.0739-.0825.0977-.0364.0239-.079.0366-.1226.0366s-.0862-.0127-.1226-.0366c-.0364-.0238-.0651-.0578-.0825-.0977l-1.1997-2.4056c-.0726-.144-.1819-.2663-.317-.3543l-7.86944-5.16551c-.03957-.04698-.09618-.07632-.15738-.08157-.0612-.00524-.12198.01404-.16896.0536-.04698.03957-.07633.09618-.08157.15738-.00525.0612.01403.12198.0536.16896l3.28825 6.39624c.01598.0335.02385.0703.02297.1074s-.01049.0734-.02804.1061c-.01756.0327-.04257.0608-.07301.082-.03043.0212-.06544.035-.10219.0402h-1.97668c-.04881-.0005-.0965.0146-.13617.043-.03967.0285-.06926.0688-.08449.1152s-.0153.0964-.00022.1428c.01509.0464.04455.0869.08413.1154l8.8142 6.3155c.0403.0275.088.0422.1368.0422s.0965-.0147.1368-.0422l8.8142-6.3155c.0396-.0285.069-.069.0841-.1154s.015-.0964-.0002-.1428-.0448-.0867-.0845-.1152c-.0396-.0284-.0873-.0435-.1362-.043h-1.9766c-.0389-.0015-.0769-.0126-.1105-.0323-.0335-.0197-.0617-.0474-.082-.0806s-.0321-.071-.0343-.1098c-.0022-.0389.0052-.0777.0216-.113l3.3132-6.39624c.0395-.04698.0588-.10776.0536-.16896-.0053-.0612-.0346-.11781-.0816-.15738-.047-.03956-.1078-.05884-.169-.0536-.0612.00525-.1178.03459-.1574.08157z"/><path d="m12.1741 2.10696.8081 1.64723c.0143.02721.0346.05084.0594.06913.0247.01829.0533.03078.0835.03654l1.8213.26107c.0356.00524.0691.02036.0966.04366s.0479.05383.0589.08814.0122.07102.0034.10595c-.0089.03494-.0273.06671-.0532.0917l-1.3178 1.28049c-.0213.02203-.0373.04854-.047.07758s-.0127.05988-.009.09025l.3108 1.80885c.0069.03487.0036.07096-.0094.10404-.013.03307-.0351.06174-.0639.08264-.0287.0209-.0628.03315-.0983.03532-.0354.00217-.0708-.00584-.1019-.02309l-1.6285-.85159c-.0265-.01527-.0565-.02331-.0871-.02331-.0305 0-.0605.00804-.087.02331l-1.6286.85159c-.031.01725-.0664.02526-.1019.02309-.0354-.00217-.0695-.01442-.0983-.03532-.0287-.0209-.0509-.04957-.0639-.08264-.0129-.03308-.0162-.06917-.0094-.10404l.3108-1.80885c.0038-.03037.0008-.06121-.0089-.09025s-.0258-.05555-.047-.07758l-1.31781-1.28049c-.02595-.02499-.04438-.05676-.05318-.0917-.00881-.03493-.00764-.07164.00336-.10595s.03141-.06484.05889-.08814c.02749-.0233.06095-.03842.0966-.04366l1.82124-.25485c.0303-.00576.0588-.01825.0836-.03654.0247-.01829.045-.04192.0594-.06913l.8081-1.64723c.015-.03321.0392-.06147.0696-.08149.0305-.02003.066-.03101.1025-.03166.0364-.00065.0723.00905.1035.02798.0311.01893.0563.0463.0725.07895z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path clip-rule="evenodd" d="m5.01502 4h13.97008c.1187 0 .215.09992.215.22305v9.97865c0 .0697-.0312.1343-.0837.1767l-6.985 5.5752c-.0389.0313-.0847.0464-.1314.0464-.0466 0-.0924-.0151-.1313-.0464l-6.985-5.5752c-.05252-.0424-.08365-.107-.08365-.1767v-9.97865c0-.12313.0963-.22305.21497-.22305zm7.82148 7.0972 4.1275-2.71296c.1039-.06863.2299.04542.1725.15644l-1.7114 3.36192c-.0403.0807.0182.1756.1079.1756h1.0246c.118 0 .1664.1504.0706.219l-4.6267 3.3175c-.0414.0303-.0978.0303-.1402 0l-4.6267-3.3175c-.0948-.0686-.04639-.219.07059-.219h1.02356c.09076 0 .14925-.0949.10791-.1756l-1.71132-3.36293c-.05648-.11001.06958-.22305.17345-.15543l4.12851 2.71296c.0716.0474.1291.112.1674.1887l.6293 1.2636c.0444.0888.1714.0888.2158 0l.6293-1.2636c.0383-.0767.0958-.1423.1674-.1887z" fill="#9c84ef" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 943 B

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path clip-rule="evenodd" d="m12 20c4.4183 0 8-3.5817 8-8 0-4.41828-3.5817-8-8-8-4.41828 0-8 3.58172-8 8 0 4.4183 3.58172 8 8 8zm.7921-8.275 3.6146-2.3738c.0909-.05916.2013.03974.151.136l-1.4986 2.9416c-.0354.0707.0158.1537.0944.1537h.8973c.1033 0 .1457.1315.0618.1916l-4.0517 2.9027c-.0362.0265-.0856.0265-.1227 0l-4.05168-2.9027c-.08301-.0601-.04062-.1916.06182-.1916h.89634c.07948 0 .1307-.083.09449-.1537l-1.49862-2.9416c-.04945-.09626.06094-.19516.1519-.136l3.61545 2.3738c.0627.0415.113.098.1465.1651l.5511 1.1057c.0389.0777.1501.0777.189 0l.551-1.1057c.0336-.0671.0839-.1245.1466-.1651z" fill="#f47b67" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 765 B

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path clip-rule="evenodd" d="m11.8622 4.05696c.076-.07595.1996-.07595.2756 0l7.8048 7.80474c.0371.0362.0574.0865.0574.1377 0 .0513-.0212.1016-.0574.1378l-7.8048 7.8047c-.038.038-.0883.0574-.1378.0574s-.0998-.0194-.1378-.0574l-7.8048-7.8047c-.03709-.0362-.0574-.0857-.0574-.1378s.02031-.1015.0574-.1377zm.9299 8.29474 3.6146-2.37377c.0909-.05917.2013.03977.151.13597l-1.4986 2.9416c-.0354.0707.0158.1537.0944.1537h.8973c.1033 0 .1457.1316.0618.1916l-4.0517 2.9028c-.0362.0265-.0856.0265-.1227 0l-4.05168-2.9028c-.08301-.06-.04062-.1916.06182-.1916h.89634c.07948 0 .1307-.083.09449-.1537l-1.49862-2.9416c-.04945-.0962.06094-.19514.1519-.13597l3.61545 2.37377c.0627.0415.113.098.1465.1651l.5511 1.1057c.0389.0777.1501.0777.189 0l.551-1.1057c.0336-.0671.0839-.1245.1466-.1651z" fill="#45ddc0" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 944 B

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><circle cx="15" cy="12" fill="#fff" r="6"/><path clip-rule="evenodd" d="m2.20812 10.124c.42636 0 .7816-.34817.7816-.76611 0-.41793-.35524-.76615-.7816-.76615h-.42635c-.42636 0-.78177.34822-.78177.76615 0 .41794.35541.76611.78177.76611zm16.13038 9.2643c4.0504-1.811 5.7558-6.4083 3.9083-10.23937-1.2791-2.71657-3.9793-4.31859-6.8217-4.45801h-8.02965c-.71065 0-1.20812.55735-1.20812 1.18425 0 .69645.56859 1.18409 1.20812 1.18409h2.06067c.42635 0 .78158.34822.78158.76616 0 .41793-.35523.76632-.78158.76632h-5.04517c-.42635 0-.78176.34822-.78176.76615 0 .41794.35541.76611.78176.76611h3.62404c.42635 0 .78159.3484.78159.7664 0 .4179-.35524.7661-.78159.7661h-2.27402c-.42636 0-.7816.3482-.7816.7662 0 .4179.35524.7663.7816.7663h1.56336c.07112.8359.2843 1.6717.63954 2.4379 1.77654 3.8311 6.46643 5.5028 10.37463 3.7614zm-7.2725-5.1884c-1.0318-2.2025-.0466-4.80794 2.2003-5.81933 2.2469-1.0114 4.9049-.04564 5.9366 2.15683 1.0318 2.2025.0468 4.8079-2.2003 5.8193-2.2469 1.0114-4.9048.0457-5.9366-2.1568z" fill="#4f5d7f" fill-rule="evenodd"/><path d="m16.8142 9.86662 1.4212 2.36838c.0711.1392.0711.2089 0 .3482l-1.4212 2.3683c-.0711.1393-.2131.1393-.2842.1393h-2.7714c-.142 0-.2131-.0697-.2841-.1393l-1.4213-2.3683c-.0709-.1393-.0709-.209 0-.3482l1.4213-2.36838c.071-.13926.2132-.13926.2841-.13926h2.7714c.1422-.06971.2131 0 .2842.13926z" fill="#c5cedd"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><g fill="#5865f2"><path d="m16.6033 9.15179-2.4908 1.66051c-.249.2491-.6642.1661-.7472 0-.2491-.2491-.6642-.4151-.9133-.4982-.6642-.166-1.2454 0-1.7435.2491l-.83027.5812-4.64945 2.9889c-.99631.6642-2.2417.4152-2.9059-.6642-.66421-1.0793-.24908-2.2417.74723-2.8228l5.31365-3.65318c1.49447-.83026 3.23804-1.24539 4.89854-.83026 1.4114.24907 2.6568.99631 3.4871 2.15867.249.16605.249.66421-.1661.83026z"/><path d="m22 11.6425c0 .7473-.4152 1.4115-.9963 1.7436l-5.4797 3.5701c-.9964.6642-2.2417.9963-3.4041.9963-.4982 0-.9963 0-1.4114-.166-1.41148-.2491-2.49081-1.1624-3.48712-2.1587-.16606-.1661-.16606-.6642.16605-.7473l2.49077-1.6605c.2491-.249.6642-.166.7472 0 .2491.2491.4982.4152.9133.4982.6642.166 1.2454 0 1.7436-.2491l1.2453-.7472 3.7362-2.4908.4982-.41513c.9963-.6642 2.2417-.41512 2.9059.66423.166.4151.3321.7472.3321 1.1623z"/></g></svg>

After

Width:  |  Height:  |  Size: 973 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><g fill="#5865f2"><path d="m5.92127 6.03526s.22115-.33086.31286-.47743c.09172-.14657-.23743-.49286-.36514-.60257-.12772-.10971-.32914-.05486-.32914-.05486-1.60715.71229-2.41115 2.17372-2.52086 2.466-.10972.29229.27943.61115.56657.76715.132.072.342-.08743.47143-.20572l.04371-.04457.06772-.06857.00085-.00086 4.37229 4.35517.59743-.5975 1.09801-1.098-4.32173-4.43224z"/><path d="m16.2505 10.6294.2306-.2194 2.0717 2.052c.0146.0129.03.018.0437.018.0395 0 .072-.036.072-.036s2.2937-2.2757 2.3015-2.2834c.0677-.0669 0-.1037 0-.1037l-1.7692-1.78119-.0026.00258-.2425-.23743.1354-.13029.2897.03343-.0548-.384.0728-.07371-.1088-.55372c-.378-.53571-1.4135-1.39371-1.4135-1.39371l-.5417-.09772-.0548.07286-.408-.06086.0394.348.0257.02572-.1209.12171-.6685-.654s-3.8795-2.10686-4.086-2.20457c-.1166-.054-.2023-.09-.2846-.09-.0634 0-.1251.02143-.1963.072-.1646.11571-.0677.34886-.0677.34886l2.412 4.45714.4826.47829-.1509.15085-.0557.05572-.3857-.05315.0591.38229-.1114.11143-.0197-.01972c-.018-.018-.0429-.02742-.0669-.02742s-.048.00942-.0668.02742c-.0369.03686-.0369.09686 0 .13372l.0197.01971-.0532.054-.0137-.01457c-.0188-.018-.0428-.02743-.0668-.02743-.0249 0-.0489.00943-.0669.02743-.0368.03686-.0368.09686 0 .13372l.0146.01457-1.0149 1.02004-.0231-.0232c-.0189-.018-.0429-.0274-.0669-.0274s-.048.0094-.0668.0274c-.0369.0369-.0369.0969 0 .1337l.024.0232-.054.054-.018-.0172c-.018-.0188-.0429-.0283-.066-.0283-.0249 0-.0489.0095-.0677.0283-.036.0369-.036.096 0 .1329l.018.018-.132.1337-.018.1697.0694.0712-.0017.0008-.084.0857-5.47632 5.4755-.07114-.0592-.22714.0326-.12858.1303-.00857-.0086c-.01885-.0189-.04285-.0283-.06685-.0283s-.04886.0094-.06686.0283c-.03686.0369-.03686.096 0 .1329l.01028.0102-.05314.0549-.00514-.0051c-.018-.0189-.04286-.0283-.06686-.0283s-.048.0094-.06686.0283c-.036.0368-.036.096 0 .1328l.006.0069-1.002 1.0191-.02057-.0206c-.01885-.0188-.042-.0274-.06685-.0274-.024 0-.048.0086-.06686.0274-.03686.0369-.03686.0969 0 .1338l.02228.0214-.05314.054-.01628-.0163c-.01886-.018-.04286-.0274-.06772-.0274-.02314 0-.048.0094-.066.0274-.03686.0369-.03686.0969 0 .1337l.01714.018-.07457.0763-.38828-.0694.02914.4337-.12257.1251.10628.5846s.16286.5091.498.8469c.32486.3274.82029.4842.84172.5005l.55971.0977.138-.1354.38572.0626-.06343-.3814.11743-.1149.054.054c.018.018.042.0274.066.0274s.04885-.0094.06685-.0274c.03686-.0377.03686-.0969 0-.1337l-.05314-.0532.05486-.0531.04628.0463c.018.0188.04286.0283.06686.0283s.048-.0095.06686-.0283c.03686-.0369.03686-.096 0-.1329l-.04543-.0463 1.01743-1.0037.04457.0446c.018.0189.04286.0274.06686.0274s.048-.0085.06685-.0274c.036-.0369.036-.0969 0-.1337l-.04371-.0429.054-.054.03771.0377c.018.018.042.0275.066.0275.02486 0 .04886-.0095.06686-.0275.03686-.0368.03686-.0968 0-.1337l-.03686-.0368.114-.1115.04115-.2442-.06086-.0609.00086-.0009.11057-.1097 5.43946-5.4411-.0026-.0052.1063.1098.1706-.0189.1534-.1543.0248.0249c.0189.018.0429.0274.0669.0274s.0489-.0094.0669-.0274c.0368-.0369.0368-.0969 0-.1337l-.0249-.0249.054-.0531.0189.0188c.018.018.042.0274.0668.0274.024 0 .048-.0094.066-.0274.0369-.0368.0369-.0968 0-.1337l-.0188-.0197 1.0165-1.0183.0266.0266c.018.018.042.0274.066.0274.0249 0 .0489-.0094.0669-.0274.0368-.0369.0368-.0969 0-.1337l-.0266-.0266.054-.054.0206.0214c.0188.018.0428.0274.0668.0274s.048-.0094.0669-.0274c.0368-.0377.0368-.0968 0-.1337l-.0206-.0214.1131-.1132.378.0592z"/><path d="m17.0057 16.7793-2.4111-1.8274-.4294-.4423-1.6637 1.6637.4183.3995 1.5711 2.3562 2.1188 2.3203 2.4421-2.2783z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="140" height="140" ><g fill="#2ea967"><path d="m8.1176653 16.0847263 4.8330812-8.1694527h2.9315882l-4.8330812 8.1694527z"/><path d="m20.4189453 9.4038086v-2.4311524c0-1.9775391-1.0825195-3.1118164-2.9697266-3.1118164h-1.5581055v1.7802734l.9594727-.0014648c.8540039 0 1.34375.5683594 1.34375 1.5585938v2.3969727c0 .8300781.1806641 1.8422852 1.5893555 2.3100586l.2856445.0947265-.2856445.0947266c-1.4086914.4677734-1.5893555 1.4799804-1.5893555 2.3100586v2.3964844c0 .9907227-.4897461 1.559082-1.34375 1.559082l-.9594727-.0014648v1.7802734h1.5581055c1.887207 0 2.9697266-1.1342773 2.9697266-3.1118164v-2.4316406c0-1.2583008.3432617-1.6264648 1.5810547-1.6445312v-1.9023438c-1.237793-.0180665-1.5810547-.3862305-1.5810547-1.6450196z"/><path d="m5.8061523 7.1982422c0-.9760742.5024414-1.5585938 1.3432617-1.5585938l.9594727.0014648v-1.7802734h-1.5576172c-1.887207 0-2.9697266 1.1342773-2.9697266 3.1118164v2.4311523c0 1.2587891-.3432617 1.6269531-1.581543 1.6450195v1.9023438c1.2382812.0180664 1.581543.3862305 1.581543 1.6445312v2.4316406c0 1.9775391 1.0825195 3.1118164 2.9697266 3.1118164h1.5576172v-1.7802734l-.9594727.0014648c-.8408203 0-1.3432617-.5830078-1.3432617-1.559082v-2.3964844c0-.8300781-.1806641-1.8422852-1.5898438-2.3100586l-.2856444-.0947264.2856445-.0947266c1.4091797-.4677734 1.5898437-1.4799804 1.5898437-2.3100586z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,19 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="140" height="140" viewBox="0 0 220 220" fill="none">
<path d="M180.722 58.0798C175.506 46.1379 169.055 36.6667 146.132 36.6667H36.8694C29.5944 36.6667 18.7723 41.3596 17.8068 58.0798C15.3012 101.469 16.2781 126.993 20.2049 146.583C24.2831 166.929 44.1677 178.981 64.9184 178.981H187.564C196.322 178.981 205.018 174.782 208.004 166.549C216.319 143.631 206.034 117.876 180.722 58.0798Z" fill="url(#paint0_linear_22_1025)"/>
<path d="M90.6762 144.86H191.447C198.128 144.86 200.541 141.52 197.386 130.756C189.471 104.369 179.863 78.5194 168.62 53.369C160.826 36.6667 155.259 36.6667 139.67 36.6667H35.6316C58.7794 36.6667 59.5827 36.6667 70.2622 130.756C73.2315 144.86 77.4999 144.86 90.6762 144.86Z" fill="url(#paint1_linear_22_1025)"/>
<path d="M157.91 86.3648C162.376 84.8532 163.891 77.4104 161.295 69.7408C158.699 62.0713 152.975 57.0792 148.509 58.5908C144.043 60.1024 142.527 67.5452 145.123 75.2148C147.719 82.8843 153.444 87.8764 157.91 86.3648Z" fill="#57F287"/>
<path d="M91.6027 86.6163C96.1379 84.9924 97.5738 77.4181 94.8098 69.6985C92.0458 61.9789 86.1286 57.0373 81.5933 58.6612C77.058 60.285 75.6221 67.8594 78.3862 75.579C81.1502 83.2985 87.0674 88.2401 91.6027 86.6163Z" fill="#57F287"/>
<path d="M152.846 102.364C153.402 117.024 146.165 127.603 133.916 127.603C121.668 127.603 107.935 117.024 99.7692 102.364H152.846Z" fill="#57F287"/>
<defs>
<linearGradient id="paint0_linear_22_1025" x1="73.3332" y1="220" x2="145.088" y2="55.3344" gradientUnits="userSpaceOnUse">
<stop offset="0.180208" stop-color="#3442D9"/>
<stop offset="0.747916" stop-color="#737FFF"/>
</linearGradient>
<linearGradient id="paint1_linear_22_1025" x1="103.296" y1="47.3376" x2="125.974" y2="142.151" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="0.11" stop-color="#050409"/>
<stop offset="0.7" stop-color="#1B1934"/>
<stop offset="1" stop-color="#242145"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path d="m21.58 11.4-4.28-7.39-.35-.6h-9.91l-.35.6-4.27 7.39-.35.6.35.6 4.27 7.39.35.6h9.92l.35-.6 4.28-7.39.35-.6zm-13.07-1.03-1.63 1.63 1.63 1.63v2.73l-4.36-4.36 4.37-4.37v2.74zm3.12 6.93-2.04-.63 3.1-9.98 2.04.64zm3.86-.93v-2.73l1.63-1.64-1.63-1.63v-2.74l4.36 4.37z" fill="#3e70dd"/></svg>

After

Width:  |  Height:  |  Size: 420 B

View file

@ -1,111 +0,0 @@
import { badgeServices, redisTtl } from "@config/environment";
import { fetch, redis } from "bun";
export async function fetchBadges(
userId: string,
services: string[],
options?: FetchBadgesOptions,
): Promise<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
}
}
}
let url: string;
if (typeof entry.url === "function") {
url = entry.url(userId);
} else {
url = entry.url;
}
try {
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const result: Badge[] = [];
switch (serviceKey) {
case "vencord":
case "equicord": {
const userBadges = data[userId];
if (Array.isArray(userBadges)) {
for (const b of userBadges) {
result.push({
tooltip: b.tooltip,
badge: b.badge,
});
}
}
break;
}
case "nekocord": {
const userBadgeIds = data.users?.[userId]?.badges;
if (Array.isArray(userBadgeIds)) {
for (const id of userBadgeIds) {
const badgeInfo = data.badges?.[id];
if (badgeInfo) {
result.push({
tooltip: badgeInfo.name,
badge: badgeInfo.image,
});
}
}
}
break;
}
case "reviewdb": {
for (const b of data) {
if (b.discordID === userId) {
result.push({
tooltip: b.name,
badge: b.icon,
});
}
}
break;
}
}
if (result.length > 0) {
results[serviceKey] = result;
if (!nocache) {
await redis.set(cacheKey, JSON.stringify(result));
await redis.expire(cacheKey, redisTtl);
}
}
} catch (_) {}
}),
);
if (separated) return results;
const combined: Badge[] = [];
for (const group of Object.values(results)) {
combined.push(...group);
}
return combined;
}

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 { echo } from "@atums/echo";
import { verifyRequiredVariables } from "@config";
import { serverHandler } from "@/server"; import { badgeCacheManager } from "@lib/badgeCache";
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) => {
logger.error(["Error initializing the server:", error]); echo.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");
}

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

@ -0,0 +1,305 @@
import { echo } from "@atums/echo";
import {
badgeFetchInterval,
badgeServices,
gitUrl,
redisTtl,
vencordEquicordContributorUrl,
} from "@config";
import { redis } from "bun";
class BadgeCacheManager {
private updateInterval: Timer | null = null;
private readonly CACHE_PREFIX = "badge_service_data:";
private readonly CACHE_TIMESTAMP_PREFIX = "badge_cache_timestamp:";
async initialize(): Promise<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 vencordEquicordContributorUrl === "string") {
const contributorRes = await fetch(vencordEquicordContributorUrl, {
headers: {
"User-Agent": `BadgeAPI/1.0 ${gitUrl}`,
},
});
if (contributorRes.ok) {
const pluginData = await contributorRes.json();
if (Array.isArray(pluginData)) {
if (!data) {
data = {} as VencordEquicordData;
}
const contributors = new Set<string>();
for (const plugin of pluginData) {
if (plugin.authors && Array.isArray(plugin.authors)) {
const isEquicordPlugin =
plugin.filePath &&
typeof plugin.filePath === "string" &&
plugin.filePath.includes("equicordplugins/");
const shouldInclude =
(serviceKey === "equicord" && isEquicordPlugin) ||
(serviceKey === "vencord" && !isEquicordPlugin);
if (shouldInclude) {
for (const author of plugin.authors) {
if (author.id) {
contributors.add(author.id);
}
}
}
}
}
const badgeDetails =
serviceKey === "vencord"
? {
tooltip: "Vencord Contributor",
badge: "https://vencord.dev/assets/favicon.png",
}
: {
tooltip: "Equicord Contributor",
badge: "https://i.imgur.com/57ATLZu.png",
};
for (const authorId of contributors) {
if (!data[authorId]) {
data[authorId] = [];
}
const hasContributorBadge = data[authorId].some(
(badge) => badge.tooltip === badgeDetails.tooltip,
);
if (!hasContributorBadge) {
data[authorId].push(badgeDetails);
}
}
}
}
}
break;
}
case "nekocord": {
if (typeof service.url === "string") {
const res = await fetch(service.url, {
headers: {
"User-Agent": `BadgeAPI/1.0 ${gitUrl}`,
},
});
if (res.ok) {
data = (await res.json()) as NekocordData;
}
}
break;
}
case "reviewdb": {
if (typeof service.url === "string") {
const res = await fetch(service.url, {
headers: {
"User-Agent": `BadgeAPI/1.0 ${gitUrl}`,
},
});
if (res.ok) {
data = (await res.json()) as ReviewDbData;
}
}
break;
}
case "discord":
case "enmity":
return;
default:
echo.warn(`Unknown service type: ${serviceKey}`);
return;
}
if (data) {
const now = Date.now();
await Promise.all([
redis.set(cacheKey, JSON.stringify(data)),
redis.set(timestampKey, now.toString()),
redis.expire(cacheKey, redisTtl * 2),
redis.expire(timestampKey, redisTtl * 2),
]);
echo.debug(`Updated cache for service: ${service.service}`);
}
} catch (error) {
echo.warn({
message: `Failed to fetch data for service: ${service.service}`,
error: error instanceof Error ? error.message : String(error),
});
}
}
async getServiceData(serviceKey: string): Promise<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();

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

@ -0,0 +1,260 @@
import { echo } from "@atums/echo";
import { discordBadgeDetails, discordBadges } from "@config";
import { badgeServices, botToken, redisTtl } from "@config";
import { badgeCacheManager } from "@lib/badgeCache";
import { redis } from "bun";
function getRequestOrigin(request: Request): string {
const headers = request.headers;
const forwardedProto = headers.get("X-Forwarded-Proto") || "http";
const host = headers.get("Host") || new URL(request.url).host;
return `${forwardedProto}://${host}`;
}
export async function fetchBadges(
userId: string | undefined,
services: string[],
options?: FetchBadgesOptions,
request?: Request,
): Promise<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();
const userCacheKey = `user_badges:${serviceKey}:${userId}`;
if (!nocache) {
try {
const cached = await redis.get(userCacheKey);
if (cached) {
const parsed: Badge[] = JSON.parse(cached);
results[serviceKey] = parsed;
return true;
}
} catch {}
}
return false;
});
const cacheHits = await Promise.all(userCachePromises);
const servicesToFetch = services.filter((_, index) => !cacheHits[index]);
await Promise.all(
servicesToFetch.map(async (service) => {
const entry = badgeServices.find(
(s) => s.service.toLowerCase() === service.toLowerCase(),
);
if (!entry) return;
const serviceKey = service.toLowerCase();
const result: Badge[] = [];
try {
switch (serviceKey) {
case "vencord":
case "equicord": {
const serviceData =
await badgeCacheManager.getVencordEquicordData(serviceKey);
if (!serviceData) {
echo.warn(`No cached data for service: ${serviceKey}`);
break;
}
const userBadges = serviceData[userId];
if (Array.isArray(userBadges)) {
for (const badgeItem of userBadges) {
result.push({
tooltip: badgeItem.tooltip,
badge: badgeItem.badge,
});
}
}
break;
}
case "nekocord": {
const serviceData = await badgeCacheManager.getNekocordData();
if (!serviceData) {
echo.warn(`No cached data for service: ${serviceKey}`);
break;
}
const userBadgeIds = serviceData.users?.[userId]?.badges;
if (Array.isArray(userBadgeIds)) {
for (const id of userBadgeIds) {
const badgeInfo = serviceData.badges?.[id];
if (badgeInfo) {
result.push({
tooltip: badgeInfo.name,
badge: badgeInfo.image,
});
}
}
}
break;
}
case "reviewdb": {
const serviceData = await badgeCacheManager.getReviewDbData();
if (!serviceData) {
echo.warn(`No cached data for service: ${serviceKey}`);
break;
}
for (const badgeItem of serviceData) {
if (badgeItem.discordID === userId) {
result.push({
tooltip: badgeItem.name,
badge: badgeItem.icon,
});
}
}
break;
}
case "enmity": {
if (typeof entry.url !== "function") {
break;
}
const urlResult = entry.url(userId);
if (
typeof urlResult !== "object" ||
typeof urlResult.user !== "string" ||
typeof urlResult.badge !== "function"
) {
break;
}
const userRes = await fetch(urlResult.user);
if (!userRes.ok) break;
const badgeIds = await userRes.json();
if (!Array.isArray(badgeIds)) break;
await Promise.all(
badgeIds.map(async (id: string) => {
try {
const badgeRes = await fetch(urlResult.badge(id));
if (!badgeRes.ok) return;
const badge: EnmityBadgeItem = await badgeRes.json();
if (!badge?.name || !badge?.url?.dark) return;
result.push({
tooltip: badge.name,
badge: badge.url.dark,
});
} catch (error) {
echo.warn({
message: `Failed to fetch Enmity badge ${id}`,
error:
error instanceof Error ? error.message : String(error),
});
}
}),
);
break;
}
case "discord": {
if (!botToken) {
echo.warn("Discord bot token not configured");
break;
}
if (typeof entry.url !== "function") {
echo.warn("Discord service URL should be a function");
break;
}
const url = entry.url(userId);
if (typeof url !== "string") {
echo.warn("Discord URL function should return a string");
break;
}
const res = await fetch(url, {
headers: {
Authorization: `Bot ${botToken}`,
},
});
if (!res.ok) {
echo.warn(
`Discord API request failed with status: ${res.status}`,
);
break;
}
const data: DiscordUserData = await res.json();
const origin = request ? getRequestOrigin(request) : "";
if (data.avatar?.startsWith("a_")) {
result.push({
tooltip: "Discord Nitro",
badge: `${origin}/public/badges/discord/NITRO.svg`,
});
}
if (typeof data.flags === "number") {
for (const [flag, bitwise] of Object.entries(discordBadges)) {
if (data.flags & bitwise) {
const badge =
discordBadgeDetails[
flag as keyof typeof discordBadgeDetails
];
if (badge) {
result.push({
tooltip: badge.tooltip,
badge: `${origin}${badge.icon}`,
});
}
}
}
}
break;
}
default:
echo.warn(`Unknown service: ${serviceKey}`);
break;
}
if (
result.length > 0 ||
serviceKey === "discord" ||
serviceKey === "enmity"
) {
results[serviceKey] = result;
if (!nocache) {
const userCacheKey = `user_badges:${serviceKey}:${userId}`;
await redis.set(userCacheKey, JSON.stringify(result));
await redis.expire(userCacheKey, Math.min(redisTtl, 900));
}
}
} catch (error) {
echo.warn({
message: `Failed to fetch badges for service ${serviceKey}`,
error: error instanceof Error ? error.message : String(error),
userId,
});
}
}),
);
if (separated) return results;
const combined: Badge[] = [];
for (const group of Object.values(results)) {
combined.push(...group);
}
return combined;
}

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 { badgeServices } from "@config";
import { fetchBadges } from "@helpers/badges"; import { fetchBadges } from "@lib/badges";
import { parseServices, validateID } from "@helpers/char"; import { parseServices, validateID } from "@lib/char";
function isValidServices(services: string[]): boolean { function isValidServices(services: string[]): boolean {
if (!Array.isArray(services)) return false; if (!Array.isArray(services)) return false;
@ -18,72 +18,73 @@ 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, seperated } = request.query; const { services, cache = "true", seperated = "false" } = 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", error: "Invalid Discord User ID. Must be 17-20 digits.",
},
{
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) {
if (parsed.length > 0) { return Response.json(
if (!isValidServices(parsed)) { {
return Response.json( status: 400,
{ error: "No valid services provided",
status: 400, availableServices,
error: "Invalid Services", },
}, { status: 400 },
{ );
status: 400,
},
);
}
validServices = parsed;
} else {
validServices = badgeServices.map((b) => b.service);
} }
if (!isValidServices(parsed)) {
return Response.json(
{
status: 400,
error: "Invalid service(s) provided",
availableServices,
provided: parsed,
},
{ status: 400 },
);
}
validServices = parsed;
} else { } else {
validServices = badgeServices.map((b) => b.service); validServices = availableServices;
} }
const badges: BadgeResult = await fetchBadges(userId, validServices, { const badges = await fetchBadges(
nocache: cache !== "true", userId,
separated: seperated === "true", validServices,
}); {
nocache: cache !== "true",
separated: seperated === "true",
},
request,
);
if (badges instanceof Error) { const isEmpty = Array.isArray(badges)
return Response.json( ? badges.length === 0
{ : Object.keys(badges).length === 0;
status: 500,
error: badges.message,
},
{
status: 500,
},
);
}
if (badges.length === 0) { if (isEmpty) {
return Response.json( return Response.json(
{ {
status: 404, status: 404,
error: "No Badges Found", error: "No badges found for this user",
}, services: validServices,
{
status: 404,
}, },
{ status: 404 },
); );
} }
@ -100,9 +101,6 @@ 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",
}, },
}, },
); );

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 = { const routeDef: RouteDef = {
method: "GET", method: "GET",
accepts: "*/*", accepts: "*/*",
@ -8,15 +10,56 @@ 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 { query, params } = request; const response = {
name: "Badge Aggregator API",
const response: Record<string, unknown> = { description:
perf, "A fast Discord badge aggregation API built with Bun and Redis caching",
query, version: "1.0.0",
params, 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 }; export { handler, routeDef };

View file

@ -1,14 +1,14 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { environment } from "@config/environment"; import { Echo, echo } from "@atums/echo";
import { logger } from "@helpers/logger"; import { environment } from "@config";
import { import {
type BunFile, type BunFile,
FileSystemRouter, FileSystemRouter,
type MatchedRoute, type MatchedRoute,
type Serve, type Server,
} 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: "./src/routes", dir: resolve("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: Serve = Bun.serve({ const server: Server = 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 {
}, },
}); });
logger.info( const echoChild = new Echo({ disableFile: true });
`Server running at http://${server.hostname}:${server.port}`,
true,
);
this.logRoutes(); echoChild.info(
`Server running at http://${server.hostname}:${server.port}`,
);
this.logRoutes(echoChild);
} }
private logRoutes(): void { private logRoutes(echo: Echo): void {
logger.info("Available routes:"); echo.info("Available routes:");
const sortedRoutes: [string, string][] = Object.entries( const sortedRoutes: [string, string][] = Object.entries(
this.router.routes, this.router.routes,
@ -55,14 +55,19 @@ class ServerHandler {
); );
for (const [path, filePath] of sortedRoutes) { 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> { private async serveStaticFile(
try { request: ExtendedRequest,
let filePath: string; pathname: string,
ip: string,
): Promise<Response> {
let filePath: string;
let response: Response;
try {
if (pathname === "/favicon.ico") { if (pathname === "/favicon.ico") {
filePath = resolve("public", "assets", "favicon.ico"); filePath = resolve("public", "assets", "favicon.ico");
} else { } else {
@ -73,35 +78,98 @@ 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";
return new Response(fileContent, { response = 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) {
logger.error([`Error serving static file: ${pathname}`, error as Error]); echo.error({
return new Response("Internal Server Error", { status: 500 }); 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( private async handleRequest(
request: Request, request: Request,
server: BunServer, server: Server,
): 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(pathname); return await this.serveStaticFile(extendedRequest, pathname, ip);
} }
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;
@ -110,7 +178,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() ? (contentType.split(";")[0]?.trim() ?? null)
: null; : null;
if ( if (
@ -199,7 +267,10 @@ class ServerHandler {
} }
} }
} catch (error: unknown) { } 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( response = Response.json(
{ {
@ -221,31 +292,11 @@ class ServerHandler {
); );
} }
const headers = request.headers; this.logRequest(extendedRequest, response, ip);
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,27 +1,33 @@
import { logger } from "@helpers/logger"; import { echo } from "@atums/echo";
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 {
logger.info(`WebSocket received: ${message}`); echo.info(`WebSocket received: ${message}`);
try { try {
ws.send(`You said: ${message}`); ws.send(`You said: ${message}`);
} catch (error) { } 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 { public handleOpen(ws: ServerWebSocket): void {
logger.info("WebSocket connection opened."); echo.info("WebSocket connection opened.");
try { try {
ws.send("Welcome to the WebSocket server!"); ws.send("Welcome to the WebSocket server!");
} catch (error) { } 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 { public handleClose(_ws: ServerWebSocket, code: number, reason: string): void {
logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); echo.info(`WebSocket closed with code ${code}, reason: ${reason}`);
} }
} }

View file

@ -2,32 +2,30 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": ["src/*"], "@*": ["src/*"],
"@config": ["config/index.ts"],
"@config/*": ["config/*"], "@config/*": ["config/*"],
"@types/*": ["types/*"], "@types/*": ["types/*"],
"@helpers/*": ["src/helpers/*"] "@lib/*": ["src/lib/*"]
}, },
"typeRoots": ["./src/types", "./node_modules/@types"], "typeRoots": ["./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",
"jsx": "react-jsx", "allowJs": false,
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": false,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default) "noUnusedLocals": true,
"noUnusedLocals": false, "noUnusedParameters": true,
"noUnusedParameters": false, "exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
}, },
"include": ["src", "types", "config"] "include": ["src", "types"]
} }

90
types/badge.d.ts vendored
View file

@ -9,3 +9,93 @@ interface FetchBadgesOptions {
nocache?: boolean; nocache?: boolean;
separated?: boolean; separated?: boolean;
} }
type BadgeService = {
service: string;
url:
| string
| ((userId: string) => string)
| ((userId: string) => {
user: string;
badge: (id: string) => string;
});
};
interface VencordEquicordData {
[userId: string]: Array<{
tooltip: string;
badge: string;
}>;
}
interface NekocordData {
users: {
[userId: string]: {
badges: string[];
};
};
badges: {
[badgeId: string]: {
name: string;
image: string;
};
};
}
interface ReviewDbData
extends Array<{
discordID: string;
name: string;
icon: string;
}> {}
type BadgeServiceData = VencordEquicordData | NekocordData | ReviewDbData;
interface VencordBadgeItem {
tooltip: string;
badge: string;
}
interface NekocordBadgeInfo {
name: string;
image: string;
}
interface ReviewDbBadgeItem {
discordID: string;
name: string;
icon: string;
}
interface EnmityBadgeItem {
name: string;
url: {
dark: string;
};
}
interface DiscordUserData {
avatar: string;
flags: number;
}
interface PluginData {
hasPatches: boolean;
hasCommands: boolean;
enabledByDefault: boolean;
required: boolean;
tags: string[];
name: string;
description: string;
authors: Array<{
name: string;
id: string;
}>;
filePath: string;
commands?: Array<{
name: string;
description: string;
}>;
dependencies?: string[];
target?: string;
}

14
types/bun.d.ts vendored
View file

@ -1,14 +1,8 @@
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>;
declare global { interface ExtendedRequest extends Request {
type BunServer = Server; startPerf: number;
query: Query;
interface ExtendedRequest extends Request { params: Params;
startPerf: number;
query: Query;
params: Params;
}
} }

5
types/config.d.ts vendored
View file

@ -3,8 +3,3 @@ type Environment = {
host: string; host: string;
development: boolean; development: boolean;
}; };
type badgeURLMap = {
service: string;
url: string | ((userId: string) => string);
};

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