Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import IORedis from "ioredis";
|
||||
import { prisma } from "./db";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export const DEFAULT_API_RATE_LIMIT = 60;
|
||||
export const API_RATE_WINDOW_SECONDS = 60;
|
||||
export const MAX_ADMIN_API_RATE_BONUS = 120;
|
||||
const SELFHOSTED_API_RATE_LIMIT = 100_000;
|
||||
|
||||
type MemoryBucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const memoryBuckets = new Map<string, MemoryBucket>();
|
||||
let redis: IORedis | null | undefined;
|
||||
|
||||
function getRedis() {
|
||||
if (redis !== undefined) return redis;
|
||||
try {
|
||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
redis = new IORedis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: true,
|
||||
});
|
||||
return redis;
|
||||
} catch {
|
||||
redis = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rateKey(userId: string) {
|
||||
return `s2yt:api-rate:${userId}`;
|
||||
}
|
||||
|
||||
export async function getEffectiveApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { apiRateLimitBonus: true, plan: true },
|
||||
});
|
||||
if (!user || user.plan !== "PREMIUM") return DEFAULT_API_RATE_LIMIT;
|
||||
return DEFAULT_API_RATE_LIMIT + Math.max(0, user.apiRateLimitBonus);
|
||||
}
|
||||
|
||||
function memoryStatus(userId: string, limit: number) {
|
||||
const now = Date.now();
|
||||
const bucket = memoryBuckets.get(userId);
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
return {
|
||||
limit,
|
||||
used: 0,
|
||||
remaining: limit,
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: API_RATE_WINDOW_SECONDS,
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
}
|
||||
const used = Math.min(bucket.count, limit);
|
||||
return {
|
||||
limit,
|
||||
used,
|
||||
remaining: Math.max(0, limit - used),
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getApiRateLimitStatus(userId: string) {
|
||||
const limit = await getEffectiveApiRateLimit(userId);
|
||||
const client = getRedis();
|
||||
if (!client) return memoryStatus(userId, limit);
|
||||
|
||||
try {
|
||||
if (client.status !== "ready") {
|
||||
await client.connect().catch(() => {});
|
||||
}
|
||||
const key = rateKey(userId);
|
||||
const [countRaw, ttl] = await Promise.all([client.get(key), client.ttl(key)]);
|
||||
const used = Math.min(Number(countRaw || 0), limit);
|
||||
return {
|
||||
limit,
|
||||
used,
|
||||
remaining: Math.max(0, limit - used),
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: ttl > 0 ? ttl : API_RATE_WINDOW_SECONDS,
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
} catch {
|
||||
return { ...memoryStatus(userId, limit), bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT) };
|
||||
}
|
||||
}
|
||||
|
||||
function checkMemoryRateLimit(userId: string, limit: number) {
|
||||
const now = Date.now();
|
||||
const bucket = memoryBuckets.get(userId);
|
||||
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
memoryBuckets.set(userId, {
|
||||
count: 1,
|
||||
resetAt: now + API_RATE_WINDOW_SECONDS * 1000,
|
||||
});
|
||||
return { ok: true as const, limit, remaining: limit - 1 };
|
||||
}
|
||||
|
||||
if (bucket.count >= limit) {
|
||||
return {
|
||||
ok: false as const,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
||||
limit,
|
||||
remaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
bucket.count += 1;
|
||||
return { ok: true as const, limit, remaining: Math.max(0, limit - bucket.count) };
|
||||
}
|
||||
|
||||
export async function checkApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) {
|
||||
return { ok: true as const, limit: SELFHOSTED_API_RATE_LIMIT, remaining: SELFHOSTED_API_RATE_LIMIT };
|
||||
}
|
||||
const limit = await getEffectiveApiRateLimit(userId);
|
||||
const client = getRedis();
|
||||
if (!client) return checkMemoryRateLimit(userId, limit);
|
||||
|
||||
try {
|
||||
if (client.status !== "ready") {
|
||||
await client.connect().catch(() => {});
|
||||
}
|
||||
|
||||
const key = rateKey(userId);
|
||||
const count = await client.incr(key);
|
||||
if (count === 1) {
|
||||
await client.expire(key, API_RATE_WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
if (count > limit) {
|
||||
const ttl = await client.ttl(key);
|
||||
return {
|
||||
ok: false as const,
|
||||
retryAfterSeconds: Math.max(1, ttl > 0 ? ttl : API_RATE_WINDOW_SECONDS),
|
||||
limit,
|
||||
remaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true as const, limit, remaining: Math.max(0, limit - count) };
|
||||
} catch {
|
||||
return checkMemoryRateLimit(userId, limit);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user