From fa8deb15b6edd496cef3d5e40d12506fb40854c9 Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 12 Mar 2026 12:07:22 +0100 Subject: [PATCH] feat: API-Keys-Verwaltung in DB statt appsettings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Tabelle api_keys (Provider, Name, ApiKey, IsActive) - Admin-CRUD: GET/POST/PUT/DELETE /api/admin/api-keys - Aktivieren/Deaktivieren pro Provider (nur einer aktiv) - Live-Token-Update im AnthropicService ohne Neustart - Migration: AnthropicToken aus appsettings.Production.json → DB - AnthropicToken aus ServerSettings-Definitionen + appsettings entfernt - Frontend: API-Keys-Seite mit Erstellen/Bearbeiten/Löschen/Toggle - Dashboard: API-Keys-Link für Admins --- Endpoints/ApiKeysEndpoint.cs | 174 ++++++++++++++++++++++++ Endpoints/ServerSettingsEndpoint.cs | 1 - Models/ApiKeyRecord.cs | 13 ++ Program.cs | 9 +- Services/AnthropicService.cs | 10 +- Services/DatabaseService.cs | 141 +++++++++++++++++++ appsettings.json | 2 +- wwwroot/index.html | 35 +++++ wwwroot/js/api.js | 61 +++++++++ wwwroot/js/pages/api-keys-page.js | 201 ++++++++++++++++++++++++++++ wwwroot/js/pages/dashboard.js | 6 + 11 files changed, 649 insertions(+), 4 deletions(-) create mode 100644 Endpoints/ApiKeysEndpoint.cs create mode 100644 Models/ApiKeyRecord.cs create mode 100644 wwwroot/js/pages/api-keys-page.js diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs new file mode 100644 index 0000000..c2dde47 --- /dev/null +++ b/Endpoints/ApiKeysEndpoint.cs @@ -0,0 +1,174 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Gott.Extensions; +using Gott.Models; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Gott.Endpoints; + +public static class ApiKeysEndpoint +{ + public static void MapApiKeysEndpoints(this WebApplication app) + { + app.MapGet("/api/admin/api-keys", HandleGetAll); + app.MapPost("/api/admin/api-keys", HandleCreate); + app.MapPut("/api/admin/api-keys/{keyId}", HandleUpdate); + app.MapPut("/api/admin/api-keys/{keyId}/activate", HandleActivate); + app.MapPut("/api/admin/api-keys/{keyId}/deactivate", HandleDeactivate); + app.MapDelete("/api/admin/api-keys/{keyId}", HandleDelete); + } + + private static async Task HandleGetAll(HttpContext ctx, DatabaseService db) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + + var keys = await db.GetApiKeys(); + var result = keys.Select(k => new + { + k.KeyId, + k.Provider, + k.Name, + k.ApiKey, + k.IsActive, + k.CreatedAt, + k.UpdatedAt + }); + await ctx.Response.WriteAsJsonAsync(result); + } + + private static async Task HandleCreate(HttpContext ctx, DatabaseService db) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + + var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); + var provider = body.GetProperty("provider").GetString()?.Trim().ToLower() ?? ""; + var name = body.GetProperty("name").GetString()?.Trim() ?? ""; + var apiKey = body.GetProperty("apiKey").GetString()?.Trim() ?? ""; + + if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(apiKey)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Provider, Name und API-Key sind erforderlich" }); + return; + } + + var now = DateTime.UtcNow.ToString("o"); + var record = new ApiKeyRecord + { + KeyId = Guid.NewGuid().ToString("N"), + Provider = provider, + Name = name, + ApiKey = apiKey, + IsActive = false, + CreatedAt = now, + UpdatedAt = now + }; + + await db.CreateApiKey(record); + await ctx.Response.WriteAsJsonAsync(new + { + record.KeyId, + record.Provider, + record.Name, + record.ApiKey, + record.IsActive, + record.CreatedAt + }); + } + + private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + + var existing = await db.GetApiKey(keyId); + if (existing == null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" }); + return; + } + + var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); + var name = body.TryGetProperty("name", out var n) ? n.GetString()?.Trim() : null; + var apiKey = body.TryGetProperty("apiKey", out var k) ? k.GetString()?.Trim() : null; + + if (string.IsNullOrWhiteSpace(name)) + name = existing.Name; + + await db.UpdateApiKey(keyId, name, apiKey); + + // Wenn aktiver Key geändert wird → Token im Service aktualisieren + if (existing.IsActive && existing.Provider == "anthropic" && !string.IsNullOrWhiteSpace(apiKey)) + anthropic.UpdateToken(apiKey); + + await ctx.Response.WriteAsJsonAsync(new { ok = true }); + } + + private static async Task HandleActivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + + var existing = await db.GetApiKey(keyId); + if (existing == null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" }); + return; + } + + await db.SetActiveApiKey(keyId, existing.Provider); + + // Token im laufenden Service aktualisieren + if (existing.Provider == "anthropic") + anthropic.UpdateToken(existing.ApiKey); + + await ctx.Response.WriteAsJsonAsync(new { ok = true }); + } + + private static async Task HandleDeactivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + + var existing = await db.GetApiKey(keyId); + if (existing == null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" }); + return; + } + + await db.DeactivateApiKey(keyId); + + // Token im laufenden Service leeren + if (existing.Provider == "anthropic") + anthropic.UpdateToken(""); + + await ctx.Response.WriteAsJsonAsync(new { ok = true }); + } + + private static async Task HandleDelete(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + + var existing = await db.GetApiKey(keyId); + if (existing == null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" }); + return; + } + + // Wenn aktiver Key gelöscht wird → Token leeren + if (existing.IsActive && existing.Provider == "anthropic") + anthropic.UpdateToken(""); + + await db.DeleteApiKey(keyId); + await ctx.Response.WriteAsJsonAsync(new { ok = true }); + } + + +} diff --git a/Endpoints/ServerSettingsEndpoint.cs b/Endpoints/ServerSettingsEndpoint.cs index 2a0802f..6fb2125 100644 --- a/Endpoints/ServerSettingsEndpoint.cs +++ b/Endpoints/ServerSettingsEndpoint.cs @@ -23,7 +23,6 @@ public static class ServerSettingsEndpoint private static readonly List Definitions = new List { new SettingDefinition("Port", "int", 19000, "Server-Port (erfordert Neustart)", "number"), - new SettingDefinition("AnthropicToken", "string", "", "Anthropic API Key", "password"), new SettingDefinition("MaxIterations", "int", 100, "Max Tool-Loop Iterationen pro Chat", "number"), new SettingDefinition("MaxHistory", "int", 120, "Max History-Messages pro Request", "number"), new SettingDefinition("ReminderInterval", "int", 30, "System-Prompt Reminder alle N Messages (0 = deaktiviert)", "number"), diff --git a/Models/ApiKeyRecord.cs b/Models/ApiKeyRecord.cs new file mode 100644 index 0000000..dad680c --- /dev/null +++ b/Models/ApiKeyRecord.cs @@ -0,0 +1,13 @@ +namespace Gott.Models; + +public class ApiKeyRecord +{ + public long Id { get; set; } + public string KeyId { get; set; } + public string Provider { get; set; } // anthropic, gemini, openai, ... + public string Name { get; set; } // Anzeigename z.B. "Tausi Pro Key" + public string ApiKey { get; set; } // Der eigentliche Key + public bool IsActive { get; set; } // Nur einer pro Provider aktiv + public string CreatedAt { get; set; } + public string UpdatedAt { get; set; } +} diff --git a/Program.cs b/Program.cs index a237986..7857196 100644 --- a/Program.cs +++ b/Program.cs @@ -53,7 +53,13 @@ builder.Services.AddSingleton(embeddingBgService); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService) -var anthropicToken = builder.Configuration["AnthropicToken"] ?? ""; +// Token wird aus DB geladen (api_keys Tabelle), Fallback auf Config +var anthropicToken = ""; +var activeKey = dbService.GetActiveApiKey("anthropic").GetAwaiter().GetResult(); +if (activeKey != null) + anthropicToken = activeKey.ApiKey; +else if (!string.IsNullOrWhiteSpace(builder.Configuration["AnthropicToken"])) + anthropicToken = builder.Configuration["AnthropicToken"]; var payloadLogging = builder.Configuration.GetValue("PayloadLogging", false); var payloadLoggingGzip = builder.Configuration.GetValue("PayloadLoggingGzip", false); builder.Services.AddSingleton(sp => @@ -116,6 +122,7 @@ app.MapKreaEndpoints(); app.MapSharedEndpoints(); app.MapAdminEndpoints(); app.MapServerSettingsEndpoints(); +app.MapApiKeysEndpoints(); app.MapOpenClawEndpoints(); app.MapRemoteEndpoints(); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 748f33c..7ff739d 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -16,7 +16,7 @@ namespace Gott.Services; public class AnthropicService { private readonly IHttpClientFactory _httpFactory; - private readonly string _token; + private string _token; private readonly ToolService _toolService; private readonly DatabaseService _db; private readonly MemoryService _memoryService; @@ -54,6 +54,14 @@ public class AnthropicService _telegramService = telegramService; } + /// + /// Aktualisiert den API-Token zur Laufzeit (z.B. wenn ein anderer Key aktiviert wird). + /// + public void UpdateToken(string token) + { + _token = token ?? ""; + } + public async Task StreamChat( HttpResponse response, string modelId, diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 9660c02..14f6c7c 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -289,6 +289,23 @@ public class DatabaseService CREATE INDEX IF NOT EXISTS idx_shared_messages_message ON shared_messages(message_id); "); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_id TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL, + name TEXT NOT NULL, + api_key TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_api_keys_provider ON api_keys(provider); + "); + + // Migration: AnthropicToken aus appsettings.Production.json → api_keys Tabelle + MigrateAnthropicTokenFromConfig(conn); + TryAddColumn(conn, "agents", "avatar_small", "TEXT"); TryAddColumn(conn, "agents", "is_public", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "sessions", "image_small", "TEXT"); @@ -1399,4 +1416,128 @@ public class DatabaseService WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now", new { now }); } + + // ── API Keys ── + + private void MigrateAnthropicTokenFromConfig(SqliteConnection conn) + { + // Nur migrieren wenn noch keine Anthropic-Keys existieren + var count = conn.ExecuteScalar("SELECT COUNT(*) FROM api_keys WHERE provider = 'anthropic'"); + if (count > 0) return; + + // Token aus appsettings.Production.json lesen + var configPath = Path.Combine(Path.GetDirectoryName(_connectionString.Replace("Data Source=", "")) ?? "", "..", "appsettings.Production.json"); + // Normaler Pfad: neben der DB-Datei im übergeordneten Ordner + var basePath = Path.GetDirectoryName(_connectionString.Replace("Data Source=", "")); + if (basePath != null) + { + var prodPath = Path.Combine(basePath, "..", "appsettings.Production.json"); + if (File.Exists(prodPath)) + { + try + { + var json = File.ReadAllText(prodPath); + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("AnthropicToken", out var tokenEl)) + { + var token = tokenEl.GetString(); + if (!string.IsNullOrWhiteSpace(token)) + { + var now = DateTime.UtcNow.ToString("o"); + conn.Execute(@" + INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at) + VALUES (@keyId, 'anthropic', 'Migriert aus Config', @token, 1, @now, @now)", + new { keyId = Guid.NewGuid().ToString("N"), token, now }); + Console.WriteLine("[Migration] AnthropicToken aus appsettings.Production.json → api_keys migriert"); + } + } + } + catch { /* Config nicht lesbar – ignorieren */ } + } + } + } + + public async Task> GetApiKeys() + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM api_keys ORDER BY provider, name"); + } + + public async Task> GetApiKeysByProvider(string provider) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM api_keys WHERE provider = @provider ORDER BY name", + new { provider }); + } + + public async Task GetActiveApiKey(string provider) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1", + new { provider }); + } + + public async Task GetApiKey(string keyId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM api_keys WHERE key_id = @keyId", + new { keyId }); + } + + public async Task CreateApiKey(ApiKeyRecord record) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at) + VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @CreatedAt, @UpdatedAt)", + record); + } + + public async Task UpdateApiKey(string keyId, string name, string apiKey = null) + { + using var conn = CreateConnection(); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + await conn.ExecuteAsync( + "UPDATE api_keys SET name = @name, api_key = @apiKey, updated_at = @now WHERE key_id = @keyId", + new { keyId, name, apiKey, now = DateTime.UtcNow.ToString("o") }); + } + else + { + await conn.ExecuteAsync( + "UPDATE api_keys SET name = @name, updated_at = @now WHERE key_id = @keyId", + new { keyId, name, now = DateTime.UtcNow.ToString("o") }); + } + } + + public async Task SetActiveApiKey(string keyId, string provider) + { + using var conn = CreateConnection(); + // Erst alle deaktivieren + await conn.ExecuteAsync( + "UPDATE api_keys SET is_active = 0, updated_at = @now WHERE provider = @provider", + new { provider, now = DateTime.UtcNow.ToString("o") }); + // Dann den gewählten aktivieren + await conn.ExecuteAsync( + "UPDATE api_keys SET is_active = 1, updated_at = @now WHERE key_id = @keyId", + new { keyId, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task DeactivateApiKey(string keyId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE api_keys SET is_active = 0, updated_at = @now WHERE key_id = @keyId", + new { keyId, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task DeleteApiKey(string keyId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync("DELETE FROM api_keys WHERE key_id = @keyId", new { keyId }); + } } diff --git a/appsettings.json b/appsettings.json index 9a11bb8..ba37ba5 100755 --- a/appsettings.json +++ b/appsettings.json @@ -7,10 +7,10 @@ }, "AllowedHosts": "*", "Port": 19000, - "AnthropicToken": "", "MaxIterations": 100, "MaxHistory": 120, "ReminderInterval": 30, + "ExecuteTimeout": 180, "PayloadLogging": false, "PayloadLoggingGzip": false, "KreaToken": "", diff --git a/wwwroot/index.html b/wwwroot/index.html index b5ef6a3..14d8cc7 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -57,6 +57,7 @@ @@ -539,6 +540,39 @@ + +