Files
sase.tr/apps/api/src/internal-admin/api-keys.controller.ts
Semih Yesilyurt 8f6894eecb 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>
2026-07-04 06:20:05 +03:00

315 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> = {};
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<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). */
@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<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 {};
}
}