122 lines
3.5 KiB
TypeScript
122 lines
3.5 KiB
TypeScript
/**
|
|
* Discord guild permission helpers for dashboard access control.
|
|
* Includes short-lived cache + 429 retry to avoid Discord rate limits.
|
|
*/
|
|
|
|
export interface DiscordGuild {
|
|
id: string;
|
|
name?: string;
|
|
icon?: string | null;
|
|
permissions: string;
|
|
owner?: boolean;
|
|
}
|
|
|
|
const MANAGE_GUILD = BigInt(0x20);
|
|
const ADMINISTRATOR = BigInt(0x8);
|
|
|
|
const CACHE_TTL_MS = 90_000;
|
|
const MAX_RETRIES = 4;
|
|
|
|
type CacheEntry = { guilds: DiscordGuild[]; expiresAt: number };
|
|
|
|
const guildCache = new Map<string, CacheEntry>();
|
|
const inflight = new Map<string, Promise<DiscordGuild[]>>();
|
|
|
|
export function canManageGuild(guild: DiscordGuild): boolean {
|
|
if (guild.owner) return true;
|
|
try {
|
|
const perms = BigInt(guild.permissions);
|
|
return (
|
|
(perms & ADMINISTRATOR) === ADMINISTRATOR ||
|
|
(perms & MANAGE_GUILD) === MANAGE_GUILD
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function cacheKey(accessToken: string): string {
|
|
// Avoid storing raw token as object key longer than needed — short fingerprint is enough for process cache
|
|
return `tok:${accessToken.slice(0, 12)}:${accessToken.length}:${accessToken.slice(-8)}`;
|
|
}
|
|
|
|
export function invalidateUserGuildsCache(accessToken?: string) {
|
|
if (!accessToken) {
|
|
guildCache.clear();
|
|
return;
|
|
}
|
|
guildCache.delete(cacheKey(accessToken));
|
|
}
|
|
|
|
async function fetchUserGuildsUncached(accessToken: string): Promise<DiscordGuild[]> {
|
|
let attempt = 0;
|
|
while (attempt < MAX_RETRIES) {
|
|
attempt += 1;
|
|
const res = await fetch("https://discord.com/api/users/@me/guilds", {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
cache: "no-store",
|
|
});
|
|
|
|
if (res.ok) {
|
|
return (await res.json()) as DiscordGuild[];
|
|
}
|
|
|
|
if (res.status === 429) {
|
|
let retryAfter = 1;
|
|
try {
|
|
const body = await res.json();
|
|
retryAfter = Number(body.retry_after ?? 1);
|
|
} catch {
|
|
const header = res.headers.get("retry-after");
|
|
retryAfter = header ? Number(header) : 1;
|
|
}
|
|
const waitMs = Math.min(Math.max(retryAfter * 1000, 500), 15_000);
|
|
console.warn(`Discord guilds rate-limited — retry in ${waitMs}ms (attempt ${attempt}/${MAX_RETRIES})`);
|
|
await new Promise((r) => setTimeout(r, waitMs));
|
|
continue;
|
|
}
|
|
|
|
if (res.status === 401 || res.status === 403) {
|
|
throw new Error("Discord session expired — please sign in again.");
|
|
}
|
|
|
|
const body = await res.text().catch(() => "");
|
|
console.error("Discord guilds fetch failed:", res.status, body);
|
|
throw new Error(`Failed to fetch your Discord servers. (${res.status})`);
|
|
}
|
|
|
|
throw new Error("Failed to fetch your Discord servers. (rate limited)");
|
|
}
|
|
|
|
export async function fetchUserGuilds(accessToken: string): Promise<DiscordGuild[]> {
|
|
const key = cacheKey(accessToken);
|
|
const cached = guildCache.get(key);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
return cached.guilds;
|
|
}
|
|
|
|
const existing = inflight.get(key);
|
|
if (existing) return existing;
|
|
|
|
const promise = fetchUserGuildsUncached(accessToken)
|
|
.then((guilds) => {
|
|
guildCache.set(key, { guilds, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
return guilds;
|
|
})
|
|
.finally(() => {
|
|
inflight.delete(key);
|
|
});
|
|
|
|
inflight.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
export async function userCanManageGuild(
|
|
accessToken: string,
|
|
guildId: string
|
|
): Promise<boolean> {
|
|
const guilds = await fetchUserGuilds(accessToken);
|
|
const guild = guilds.find((g) => String(g.id) === String(guildId));
|
|
return guild ? canManageGuild(guild) : false;
|
|
}
|