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:
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user