feat(api): api-key admin uçları — PATCH güncelleme, Redis kota usage, global liste

Süper Panel'den tam anahtar yönetimi için:
- GET /internal/admin/api-keys artık userId/email'siz TÜM anahtarları
  kullanıcı e-postasıyla birlikte döner
- GET /internal/admin/api-keys/usage — günlük decode kotasının anlık
  Redis durumu (limit/used/remaining), ids ile filtrelenebilir
- PATCH /internal/admin/api-keys/:id — name, dailyDecodeLimit(null=varsayılan),
  domains (yalnız publishable), rateLimitMax(null=plugin varsayılanı),
  expiresInDays(null=süresiz)
- PublicApiQuotaService internal-admin'e provider olarak eklendi

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 06:20:05 +03:00
parent 123b701ed7
commit 8f6894eecb
2 changed files with 156 additions and 6 deletions

View File

@@ -8,16 +8,18 @@ import {
Inject,
NotFoundException,
Param,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { desc, eq, inArray } from "drizzle-orm";
import { getApiKeyApi } from "../auth/auth";
import { Public } from "../common/decorators/public.decorator";
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
import { DATABASE, type Database } from "../database/database.provider";
import { apikeys, users } from "../database/schema/core";
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
// better-auth createApiKey `expiresIn`'i SANİYE bekler (ms değil).
const DAY_SECONDS = 24 * 60 * 60;
@@ -31,7 +33,10 @@ const DAY_SECONDS = 24 * 60 * 60;
@Public()
@UseGuards(InternalTokenGuard)
export class ApiKeysAdminController {
constructor(@Inject(DATABASE) private readonly db: Database) {}
constructor(
@Inject(DATABASE) private readonly db: Database,
private readonly quota: PublicApiQuotaService,
) {}
/**
* Anahtar üret. Kullanıcı `userId` veya `email` ile seçilir (anahtar o
@@ -107,10 +112,14 @@ export class ApiKeysAdminController {
};
}
/** Kullanıcının anahtarlarını listele (hash asla dönmez). */
/**
* Anahtarları listele (hash asla dönmez). userId/email verilirse o
* kullanıcıya filtrelenir; verilmezse TÜM anahtarlar döner (Süper Panel
* genel görünümü). Her satırda sahibi kullanıcı da bulunur.
*/
@Get()
async list(@Query("userId") userId?: string, @Query("email") email?: string) {
const user = await this.resolveUser({ userId, email });
const user = userId || email ? await this.resolveUser({ userId, email }) : null;
const rows = await this.db
.select({
id: apikeys.id,
@@ -118,16 +127,140 @@ export class ApiKeysAdminController {
start: apikeys.start,
prefix: apikeys.prefix,
enabled: apikeys.enabled,
rateLimitMax: apikeys.rateLimitMax,
requestCount: apikeys.requestCount,
lastRequest: apikeys.lastRequest,
expiresAt: apikeys.expiresAt,
createdAt: apikeys.createdAt,
metadata: apikeys.metadata,
userId: apikeys.userId,
userEmail: users.email,
})
.from(apikeys)
.where(eq(apikeys.userId, user.id))
.innerJoin(users, eq(apikeys.userId, users.id))
.where(user ? eq(apikeys.userId, user.id) : undefined)
.orderBy(desc(apikeys.createdAt));
return { user: { id: user.id, email: user.email }, keys: rows };
return {
...(user ? { user: { id: user.id, email: user.email } } : {}),
keys: rows,
};
}
/**
* Günlük decode-kotası anlık durumu (Redis sayaçları). `ids` virgüllü
* liste; verilmezse tüm anahtarlar. Kota günü TR gecesi döndüğü için
* `used` yalnız bugünü yansıtır — tarihçe tutulmaz.
*/
@Get("usage")
async usage(@Query("ids") ids?: string) {
const idList = (ids ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const rows = await this.db
.select({ id: apikeys.id, metadata: apikeys.metadata, enabled: apikeys.enabled })
.from(apikeys)
.where(idList.length ? inArray(apikeys.id, idList) : undefined);
const items = await Promise.all(
rows.map(async (row) => {
const limit = this.quota.resolveLimit(parseKeyMetadata(row.metadata));
const state = await this.quota.getState(row.id, limit);
return { id: row.id, enabled: row.enabled, ...state };
}),
);
return { items };
}
/**
* Anahtar ayarlarını güncelle. Alan verilmezse dokunulmaz; `null`
* geçilen alan temizlenir (dailyDecodeLimit → env varsayılanına döner,
* expiresInDays → süresiz, rateLimitMax → plugin varsayılanı 120/dk).
*/
@Patch(":id")
async update(
@Param("id") id: string,
@Body()
body: {
name?: string;
dailyDecodeLimit?: number | null;
domains?: string[];
rateLimitMax?: number | null;
expiresInDays?: number | null;
},
) {
const [row] = await this.db.select().from(apikeys).where(eq(apikeys.id, id)).limit(1);
if (!row) throw new NotFoundException("API anahtarı bulunamadı");
const metadata = parseKeyMetadata(row.metadata);
let metadataTouched = false;
const set: Partial<typeof apikeys.$inferInsert> = { updatedAt: new Date() };
if (body.name !== undefined) {
const name = String(body.name).trim();
if (!name || name.length > 32) {
throw new BadRequestException("name 1-32 karakter olmalı");
}
set.name = name;
}
if (body.dailyDecodeLimit !== undefined) {
if (body.dailyDecodeLimit === null) {
// JSON.stringify undefined alanı düşürür → env varsayılanına döner.
metadata.dailyDecodeLimit = undefined;
} else if (typeof body.dailyDecodeLimit !== "number" || body.dailyDecodeLimit <= 0) {
throw new BadRequestException("dailyDecodeLimit pozitif bir sayı veya null olmalı");
} else {
metadata.dailyDecodeLimit = Math.floor(body.dailyDecodeLimit);
}
metadataTouched = true;
}
if (body.domains !== undefined) {
if (!metadata.publishable) {
throw new BadRequestException("domains yalnız publishable anahtarlarda kullanılır");
}
if (!Array.isArray(body.domains)) {
throw new BadRequestException('domains bir dizi olmalı (ör. ["musteri.com"])');
}
metadata.domains = body.domains.filter((d) => typeof d === "string" && d.trim());
metadataTouched = true;
}
if (body.rateLimitMax !== undefined) {
if (body.rateLimitMax === null) {
set.rateLimitMax = null;
set.rateLimitTimeWindow = null;
} else if (typeof body.rateLimitMax !== "number" || body.rateLimitMax <= 0) {
throw new BadRequestException("rateLimitMax pozitif bir sayı veya null olmalı");
} else {
set.rateLimitEnabled = true;
set.rateLimitTimeWindow = 60_000;
set.rateLimitMax = Math.floor(body.rateLimitMax);
}
}
if (body.expiresInDays !== undefined) {
if (body.expiresInDays === null) {
set.expiresAt = null;
} else if (typeof body.expiresInDays !== "number" || body.expiresInDays <= 0) {
throw new BadRequestException("expiresInDays pozitif bir sayı veya null olmalı");
} else {
set.expiresAt = new Date(Date.now() + body.expiresInDays * DAY_SECONDS * 1000);
}
}
// better-auth şema transform'u tek JSON.stringify yazar — aynı format.
if (metadataTouched) set.metadata = JSON.stringify(metadata);
const [updated] = await this.db.update(apikeys).set(set).where(eq(apikeys.id, id)).returning({
id: apikeys.id,
name: apikeys.name,
enabled: apikeys.enabled,
rateLimitMax: apikeys.rateLimitMax,
expiresAt: apikeys.expiresAt,
metadata: apikeys.metadata,
});
return updated;
}
/** Anahtarı devre dışı bırak (geri açmak için enable). */
@@ -166,3 +299,16 @@ export class ApiKeysAdminController {
return user;
}
}
/** apikeys.metadata text kolonu — better-auth tek JSON.stringify ile yazar. */
function parseKeyMetadata(raw: string | null): Record<string, unknown> {
if (!raw) return {};
try {
let value: unknown = JSON.parse(raw);
// Bazı better-auth sürümleri çift stringify yapabiliyor; toleranslı ol.
if (typeof value === "string") value = JSON.parse(value);
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
} catch {
return {};
}
}