import { BadRequestException, Body, Controller, Get, HttpCode, HttpStatus, Inject, NotFoundException, Param, Patch, Post, Query, UseGuards, } from "@nestjs/common"; 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; /** * Public API anahtarı yönetimi — kapalı beta döneminde anahtarlar self-service * değil, buradan (Süper Panel / curl + x-internal-token) elle üretilir. * Anahtarın düz metni YALNIZCA create cevabında döner; DB'de hash saklanır. */ @Controller("internal/admin/api-keys") @Public() @UseGuards(InternalTokenGuard) export class ApiKeysAdminController { constructor( @Inject(DATABASE) private readonly db: Database, private readonly quota: PublicApiQuotaService, ) {} /** * Anahtar üret. Kullanıcı `userId` veya `email` ile seçilir (anahtar o * kullanıcının aboneliği/marka yetkisiyle çalışır). `dailyDecodeLimit` * anahtar metadata'sına yazılır; verilmezse env/100 varsayılanı geçerli. * * `publishable: true` → widget anahtarı (sase_pk_): müşteri sayfasında * görünür, /widget shell'i yalnız bunları kabul eder ve `domains` * listesinden frame-ancestors CSP kilidi üretir. domains zorunludur * (boş liste = hiçbir 3. parti sitede render olmaz, sadece test). */ @Post() @HttpCode(HttpStatus.CREATED) async create( @Body() body: { userId?: string; email?: string; name?: string; dailyDecodeLimit?: number; expiresInDays?: number; rateLimitMax?: number; test?: boolean; publishable?: boolean; domains?: string[]; }, ) { const user = await this.resolveUser(body); if ( body.dailyDecodeLimit !== undefined && (typeof body.dailyDecodeLimit !== "number" || body.dailyDecodeLimit <= 0) ) { throw new BadRequestException("dailyDecodeLimit pozitif bir sayı olmalı"); } if (body.publishable && body.domains !== undefined && !Array.isArray(body.domains)) { throw new BadRequestException('domains bir dizi olmalı (ör. ["musteri.com"])'); } if (body.domains && !body.publishable) { throw new BadRequestException("domains yalnız publishable anahtarlarda kullanılır"); } const metadata: Record = {}; if (body.dailyDecodeLimit) metadata.dailyDecodeLimit = Math.floor(body.dailyDecodeLimit); if (body.publishable) { metadata.publishable = true; metadata.domains = (body.domains ?? []).filter((d) => typeof d === "string" && d.trim()); } const created = await getApiKeyApi().createApiKey({ body: { userId: user.id, name: body.name ?? `${user.email} public-api`, prefix: body.publishable ? "sase_pk_" : body.test ? "sase_test_" : "sase_live_", ...(body.expiresInDays ? { expiresIn: body.expiresInDays * DAY_SECONDS } : {}), ...(body.rateLimitMax ? { rateLimitEnabled: true, rateLimitTimeWindow: 60_000, rateLimitMax: body.rateLimitMax } : {}), ...(Object.keys(metadata).length ? { metadata } : {}), }, }); return { id: created.id, // Düz metin anahtar — sadece bu cevapta görünür, bir daha üretilemez. key: created.key, start: created.start, user: { id: user.id, email: user.email }, name: created.name, expiresAt: created.expiresAt, dailyDecodeLimit: body.dailyDecodeLimit ?? null, ...(body.publishable ? { publishable: true, domains: metadata.domains } : {}), }; } /** * 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 = userId || email ? await this.resolveUser({ userId, email }) : null; const rows = await this.db .select({ id: apikeys.id, name: apikeys.name, 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) .innerJoin(users, eq(apikeys.userId, users.id)) .where(user ? eq(apikeys.userId, user.id) : undefined) .orderBy(desc(apikeys.createdAt)); 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 = { 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). */ @Post(":id/disable") @HttpCode(HttpStatus.OK) async disable(@Param("id") id: string) { return this.setEnabled(id, false); } @Post(":id/enable") @HttpCode(HttpStatus.OK) async enable(@Param("id") id: string) { return this.setEnabled(id, true); } private async setEnabled(id: string, enabled: boolean) { const [row] = await this.db .update(apikeys) .set({ enabled, updatedAt: new Date() }) .where(eq(apikeys.id, id)) .returning({ id: apikeys.id, enabled: apikeys.enabled }); if (!row) throw new NotFoundException("API anahtarı bulunamadı"); return row; } private async resolveUser(input: { userId?: string; email?: string }) { if (!input.userId && !input.email) { throw new BadRequestException("userId veya email gerekli"); } const [user] = await this.db .select({ id: users.id, email: users.email }) .from(users) .where(input.userId ? eq(users.id, input.userId) : eq(users.email, input.email as string)) .limit(1); if (!user) throw new NotFoundException("Kullanıcı bulunamadı"); return user; } } /** apikeys.metadata text kolonu — better-auth tek JSON.stringify ile yazar. */ function parseKeyMetadata(raw: string | null): Record { 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) : {}; } catch { return {}; } }