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>
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
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 {};
|
||
}
|
||
}
|