Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ HTTP_FETCH_RETRIES=3 # Number of retry attempts (default: 3)
# Optional: Discord Badges (leave empty to disable)
DISCORD_TOKEN=

# Optional: GitHub Token (for better rate limits?)
# GITHUB_TOKEN=

BLOCKLIST_ENABLED=true # Enable IP/user blocklist (default: true)

# Optional: Admin API Key (required for cache management endpoints)
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM oven/bun:latest AS base
LABEL org.opencontainers.image.description="Discord badge aggregation API built with Bun and Redis"
WORKDIR /usr/src/app

FROM base AS install
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ GET /:userId?services=vencord,equicord&separated=true
- `capitalize` - Capitalize service names (with separated)

### Supported Services
Vencord, Equicord, Nekocord, ReviewDB, Aero, Aliucord, Ra1ncord, Velocity, BadgeVault, Enmity, Discord, Replugged
Vencord, Equicord, Nekocord, ReviewDB, Aero, Aliucord, Ra1ncord, Velocity, BadgeVault, Enmity, Discord, Replugged, Paicord

## Admin API

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
27 changes: 12 additions & 15 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
services:
badge-api:
container_name: badge-api
image: cr8ns/badgeapi:latest
image: ghcr.io/equicord/equibadges:latest
restart: unless-stopped
ports:
- "8080:8080"
Expand Down
3 changes: 0 additions & 3 deletions config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ const badgeFetchInterval: number = process.env.BADGE_FETCH_INTERVAL

const botToken: string | undefined = process.env.DISCORD_TOKEN;

const githubToken: string | undefined = process.env.GITHUB_TOKEN;

const cachePaths = {
badgevault: path.resolve(process.cwd(), "cache/badgevault"),
enmity: path.resolve(process.cwd(), "cache/enmity"),
Expand Down Expand Up @@ -84,7 +82,6 @@ export {
redisTtl,
badgeFetchInterval,
botToken,
githubToken,
cachePaths,
blocklistConfig,
cacheConfig,
Expand Down
31 changes: 21 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,36 @@ import { verifyRequiredVariables } from "@config";
import { badgeCacheManager } from "@lib/badgeCache";
import { serverHandler } from "@server";

let isShuttingDown = false;

async function gracefulShutdown(signal: string): Promise<void> {
if (isShuttingDown) {
echo.debug(`Already shutting down, ignoring ${signal}`);
return;
}
isShuttingDown = true;

echo.info(`Received ${signal}, shutting down gracefully...`);

await serverHandler.waitForRequestsToComplete(30000);

await badgeCacheManager.shutdown();

echo.info("Shutdown complete");
process.exit(0);
}

async function main(): Promise<void> {
verifyRequiredVariables();

await badgeCacheManager.initialize();

process.on("SIGINT", () => {
echo.debug("Received SIGINT, shutting down gracefully...");
void (async () => {
await badgeCacheManager.shutdown();
process.exit(0);
})();
void gracefulShutdown("SIGINT");
});

process.on("SIGTERM", () => {
echo.debug("Received SIGTERM, shutting down gracefully...");
void (async () => {
await badgeCacheManager.shutdown();
process.exit(0);
})();
void gracefulShutdown("SIGTERM");
});

serverHandler.initialize();
Expand Down
105 changes: 70 additions & 35 deletions src/lib/badgeCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
cacheConfig,
cachePaths,
discordBadgeDetails,
githubToken,
gitUrl,
redisTtl,
} from "@config";
Expand All @@ -17,6 +16,28 @@ const BADGE_API_HEADERS = {
"User-Agent": `BadgeAPI ${gitUrl}`,
};

const PER_USER_SERVICES = ["discord", "replugged"];

function getStaticServices(): string[] {
return badgeServices
.map((s) => s.service.toLowerCase())
.filter((s) => !PER_USER_SERVICES.includes(s));
}

async function readFileWithTimeout(
filePath: string,
timeoutMs = 5000,
): Promise<string> {
const filePromise = Bun.file(filePath).text();
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error(`File read timeout: ${filePath}`)),
timeoutMs,
);
});
return Promise.race([filePromise, timeoutPromise]);
}

async function fetchWithTimeout(
url: string,
options: RequestInit = {},
Expand Down Expand Up @@ -77,6 +98,7 @@ class BadgeCacheManager {
private readonly CACHE_TIMESTAMP_PREFIX =
`badge_cache_timestamp:${cacheConfig.version}:`;
private readonly GIT_LOCK_PREFIX = "git_lock:";
private readonly lockTokens = new Map<string, string>();
private metrics = {
hits: 0,
misses: 0,
Expand Down Expand Up @@ -143,12 +165,23 @@ class BadgeCacheManager {
clearInterval(this.updateInterval);
this.updateInterval = null;
}

try {
redis.close();
echo.debug("Redis connection closed");
} catch (error) {
echo.warn({
message: "Failed to close Redis connection",
error: error instanceof Error ? error.message : String(error),
});
}

echo.debug("Badge cache manager shut down");
}

private async acquireGitLock(service: string): Promise<boolean> {
const lockKey = `${this.GIT_LOCK_PREFIX}${service}`;
const lockValue = Date.now().toString();
const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const lockTTL = 300;

try {
Expand All @@ -159,7 +192,11 @@ class BadgeCacheManager {
lockTTL.toString(),
"NX",
]);
return result === "OK";
if (result === "OK") {
this.lockTokens.set(service, lockValue);
return true;
}
return false;
} catch (error) {
echo.error({
message: `Failed to acquire git lock for ${service}`,
Expand All @@ -171,13 +208,37 @@ class BadgeCacheManager {

private async releaseGitLock(service: string): Promise<void> {
const lockKey = `${this.GIT_LOCK_PREFIX}${service}`;
const expectedToken = this.lockTokens.get(service);

if (!expectedToken) {
echo.warn(`No lock token found for ${service}, skipping release`);
return;
}

try {
await redis.del(lockKey);
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
const result = await redis.send("EVAL", [
luaScript,
"1",
lockKey,
expectedToken,
]);
if (result === 0) {
echo.warn(`Lock token mismatch for ${service}, lock may have expired`);
}
this.lockTokens.delete(service);
} catch (error) {
echo.error({
message: `Failed to release git lock for ${service}`,
error: error instanceof Error ? error.message : String(error),
});
this.lockTokens.delete(service);
}
}

Expand All @@ -191,19 +252,7 @@ class BadgeCacheManager {

private async checkIfUpdateNeeded(): Promise<boolean> {
try {
const staticServices = [
"vencord",
"equicord",
"nekocord",
"reviewdb",
"aero",
"aliucord",
"ra1ncord",
"velocity",
"badgevault",
"enmity",
"paicord",
];
const staticServices = getStaticServices();
const now = Date.now();

for (const serviceName of staticServices) {
Expand Down Expand Up @@ -445,7 +494,6 @@ class BadgeCacheManager {
cacheDir,
"https://github.com/WolfPlugs/BadgeVault.git",
"BadgeVault",
githubToken,
);

echo.debug("BadgeVault: Reading user badge files...");
Expand All @@ -462,7 +510,7 @@ class BadgeCacheManager {
for (const file of userFiles) {
const userId = file.replace(".json", "");
const filePath = path.join(userDir, file);
const fileContent = await Bun.file(filePath).text();
const fileContent = await readFileWithTimeout(filePath);
const userData: BadgeVaultData = JSON.parse(fileContent);
badgeVaultData[userId] = userData;
}
Expand Down Expand Up @@ -497,7 +545,6 @@ class BadgeCacheManager {
cacheDir,
"https://github.com/enmity-mod/badges.git",
"Enmity",
githubToken,
);

echo.debug("Enmity: Reading user badge files...");
Expand All @@ -521,7 +568,7 @@ class BadgeCacheManager {
const badgeDefinitions: Record<string, EnmityBadgeItem> = {};
for (const file of badgeFiles) {
const filePath = path.join(dataDir, file);
const fileContent = await Bun.file(filePath).text();
const fileContent = await readFileWithTimeout(filePath);
const badge: EnmityBadgeItem = JSON.parse(fileContent);
badgeDefinitions[badge.id] = badge;
}
Expand All @@ -534,7 +581,7 @@ class BadgeCacheManager {
for (const file of userFiles) {
const userId = file.replace(".json", "");
const filePath = path.join(cacheDir, file);
const fileContent = await Bun.file(filePath).text();
const fileContent = await readFileWithTimeout(filePath);
const badgeIds: string[] = JSON.parse(fileContent);

const badges: EnmityBadgeItem[] = [];
Expand Down Expand Up @@ -680,19 +727,7 @@ class BadgeCacheManager {
return 2;
}

const services = [
"vencord",
"equicord",
"nekocord",
"reviewdb",
"aero",
"aliucord",
"ra1ncord",
"velocity",
"badgevault",
"enmity",
"paicord",
];
const services = getStaticServices();

let deleteCount = 0;
for (const service of services) {
Expand Down
Loading