- Unabhängige Kestrel-Instanz auf Port 5001 (vollständig getrennt vom Analyse-Webserver) - Admin API Endpunkte ohne Swagger-Exposition - Token-Gatekeeper mit sofortigem Login-Overlay bei unautorisiertem Zugriff - Live-Terminal via SSE-Log-Streaming und Log-Download-Übersicht - Steuerung von Workern, Analyse-Webserver, Wartungsaktionen und Einstellungen - Nahtlose Integration in Avalonia Desktop-Shell und Linux Headless-Modus - Unit-Tests für Admin-Filter, Optionen und Log-Puffer
578 lines
26 KiB
C#
578 lines
26 KiB
C#
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using System.Threading.Channels;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Predictalytics.Hosting;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
|
|
namespace Predictalytics.Hosting.Admin;
|
|
|
|
/// <summary>
|
|
/// Registriert alle API-Endpunkte für die Fernadministration der Software.
|
|
/// Diese Endpunkte werden NUR auf dem separaten Admin-Port gemappt und sind
|
|
/// im öffentlichen Analyse-Swagger nicht sichtbar.
|
|
/// </summary>
|
|
public static class AdminApiEndpoints
|
|
{
|
|
private static readonly DateTime StartTime = DateTime.UtcNow;
|
|
|
|
public static void MapAdminApi(this IEndpointRouteBuilder routes, PredictalyticsHost host)
|
|
{
|
|
var adminGroup = routes.MapGroup("/api/admin");
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Authentifizierung (Token-Validierung)
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
adminGroup.MapPost("/auth/verify", (HttpContext context, TokenVerifyRequest? body) =>
|
|
{
|
|
var expected = host.Options.AdminToken?.Trim() ?? "";
|
|
if (string.IsNullOrEmpty(expected))
|
|
{
|
|
return Results.Json(new { valid = false, message = "Auf dem Server ist kein Admin-Token konfiguriert." }, statusCode: 401);
|
|
}
|
|
|
|
string? candidate = null;
|
|
if (context.Request.Headers.TryGetValue(AdminAuthFilter.HeaderName, out var headerVal))
|
|
{
|
|
candidate = headerVal.ToString();
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(body?.Token))
|
|
{
|
|
candidate = body.Token;
|
|
}
|
|
else if (context.Request.Query.TryGetValue("token", out var qv))
|
|
{
|
|
candidate = qv.ToString();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(candidate) || !CryptographicTokenEquals(candidate, expected))
|
|
{
|
|
return Results.Json(new { valid = false, message = "Ungültiger Admin-Token." }, statusCode: 401);
|
|
}
|
|
|
|
return Results.Ok(new
|
|
{
|
|
valid = true,
|
|
version = DcConfig.AppVersion,
|
|
host = host.Options.AdminServerHost,
|
|
port = host.Options.AdminServerPort
|
|
});
|
|
});
|
|
|
|
// Alle nachfolgenden Routen verlangen zwingend den gültigen Admin-Token
|
|
var secureGroup = adminGroup.AddEndpointFilter(new AdminAuthFilter(() => host.Options.AdminToken));
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Status & Übersicht
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
secureGroup.MapGet("/status", async (CancellationToken ct) =>
|
|
{
|
|
var dbSize = await host.GetDatabaseSizeMbAsync(ct);
|
|
var memoryMb = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 1);
|
|
var uptime = DateTime.UtcNow - StartTime;
|
|
|
|
return Results.Ok(new
|
|
{
|
|
workersRunning = host.WorkersRunning,
|
|
webServerRunning = host.WebServerRunning,
|
|
webServerUrl = host.Options.WebserverUrl,
|
|
webServerPort = host.Options.WebserverPort,
|
|
webServerHost = host.Options.WebserverHost,
|
|
adminServerRunning = host.AdminServerRunning,
|
|
adminServerUrl = host.Options.AdminServerUrl,
|
|
adminServerPort = host.Options.AdminServerPort,
|
|
adminServerHost = host.Options.AdminServerHost,
|
|
isBusy = host.IsBusy,
|
|
busyOperation = host.BusyOperation,
|
|
dbSizeMb = dbSize,
|
|
appVersion = DcConfig.AppVersion,
|
|
gitCommit = DcConfig.GitCommitShort,
|
|
memoryMb = memoryMb,
|
|
uptimeSeconds = Math.Round(uptime.TotalSeconds, 0),
|
|
uptimeText = FormatUptime(uptime)
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Steuerung (Worker & Analyse-Webserver)
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
secureGroup.MapPost("/workers/start", () =>
|
|
{
|
|
if (host.WorkersRunning)
|
|
return Results.Ok(new { ok = true, message = "Worker laufen bereits.", workersRunning = true });
|
|
|
|
host.StartWorkers();
|
|
Log.Information("Admin WebGUI: Hintergrund-Worker gestartet.");
|
|
return Results.Ok(new { ok = true, message = "Hintergrund-Worker gestartet.", workersRunning = true });
|
|
});
|
|
|
|
secureGroup.MapPost("/workers/stop", () =>
|
|
{
|
|
if (!host.WorkersRunning)
|
|
return Results.Ok(new { ok = true, message = "Worker sind bereits gestoppt.", workersRunning = false });
|
|
|
|
host.StopWorkers();
|
|
Log.Information("Admin WebGUI: Hintergrund-Worker gestoppt.");
|
|
return Results.Ok(new { ok = true, message = "Hintergrund-Worker gestoppt.", workersRunning = false });
|
|
});
|
|
|
|
secureGroup.MapPost("/webserver/start", async () =>
|
|
{
|
|
if (host.WebServerRunning)
|
|
return Results.Ok(new { ok = true, message = "Analyse-Webserver läuft bereits.", webServerRunning = true });
|
|
|
|
try
|
|
{
|
|
await host.StartWebServerAsync();
|
|
Log.Information("Admin WebGUI: Analyse-Webserver gestartet auf {Url}", host.Options.WebserverUrl);
|
|
return Results.Ok(new { ok = true, message = "Analyse-Webserver gestartet.", webServerRunning = true, url = host.Options.WebserverUrl });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Admin WebGUI: Starten des Analyse-Webservers fehlgeschlagen");
|
|
return Results.BadRequest(new { ok = false, error = ex.Message });
|
|
}
|
|
});
|
|
|
|
secureGroup.MapPost("/webserver/stop", async () =>
|
|
{
|
|
if (!host.WebServerRunning)
|
|
return Results.Ok(new { ok = true, message = "Analyse-Webserver ist bereits gestoppt.", webServerRunning = false });
|
|
|
|
await host.StopWebServerAsync();
|
|
Log.Information("Admin WebGUI: Analyse-Webserver gestoppt.");
|
|
return Results.Ok(new { ok = true, message = "Analyse-Webserver gestoppt.", webServerRunning = false });
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Wartungsaktionen
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
secureGroup.MapPost("/maintenance/sync-markets", async (CancellationToken ct) =>
|
|
{
|
|
if (host.WorkersRunning)
|
|
return Results.Conflict(new { error = "Markt-Sync kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen." });
|
|
|
|
try
|
|
{
|
|
await host.RunGuardedMaintenanceAsync("Manueller Markt-Sync", async token =>
|
|
{
|
|
await host.RunSingleMarketSyncAsync(token);
|
|
return true;
|
|
}, ct);
|
|
|
|
return Results.Ok(new { ok = true, message = "Manueller Markt-Sync erfolgreich abgeschlossen." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Results.BadRequest(new { ok = false, error = ex.Message });
|
|
}
|
|
});
|
|
|
|
secureGroup.MapPost("/maintenance/update-database", async (CancellationToken ct) =>
|
|
{
|
|
if (host.WorkersRunning)
|
|
return Results.Conflict(new { error = "Datenbank-Update kann nicht laufen, während die Worker aktiv sind." });
|
|
|
|
try
|
|
{
|
|
await host.RunGuardedMaintenanceAsync("Datenbank-Update", async _ =>
|
|
{
|
|
await host.UpdateDatabaseAsync();
|
|
return true;
|
|
}, ct);
|
|
|
|
return Results.Ok(new { ok = true, message = "Datenbank erfolgreich aktualisiert." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Results.BadRequest(new { ok = false, error = ex.Message });
|
|
}
|
|
});
|
|
|
|
secureGroup.MapPost("/maintenance/recalculate-traders", async (ConfirmActionRequest? body, CancellationToken ct) =>
|
|
{
|
|
if (body?.Confirm != true)
|
|
{
|
|
return Results.BadRequest(new { error = "Bestätigung erforderlich. Bitte { 'confirm': true } mitsenden." });
|
|
}
|
|
|
|
if (host.WorkersRunning)
|
|
return Results.Conflict(new { error = "Neuberechnung kann nicht laufen, während die Worker aktiv sind." });
|
|
|
|
try
|
|
{
|
|
var summary = await host.RunGuardedMaintenanceAsync("Alle Trader neu berechnen", token =>
|
|
host.RunRecalculateAllTradersAsync(token), ct);
|
|
|
|
return Results.Ok(new { ok = true, message = "Trader-Neuberechnung abgeschlossen.", summary });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Results.BadRequest(new { ok = false, error = ex.Message });
|
|
}
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Logs & Terminal
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
secureGroup.MapGet("/logs", (int? count, string? minLevel) =>
|
|
{
|
|
var limit = Math.Clamp(count ?? 200, 10, 1000);
|
|
LogEventLevel? parsedLevel = null;
|
|
if (!string.IsNullOrWhiteSpace(minLevel) && Enum.TryParse<LogEventLevel>(minLevel, true, out var l))
|
|
{
|
|
parsedLevel = l;
|
|
}
|
|
|
|
var entries = InMemoryLogBuffer.Instance.GetRecent(limit, parsedLevel);
|
|
return Results.Ok(entries);
|
|
});
|
|
|
|
// SSE (Server-Sent Events) Stream für Live-Terminal
|
|
secureGroup.MapGet("/logs/stream", async (HttpContext ctx, CancellationToken ct) =>
|
|
{
|
|
ctx.Response.Headers.ContentType = "text/event-stream";
|
|
ctx.Response.Headers.CacheControl = "no-cache";
|
|
ctx.Response.Headers.Connection = "keep-alive";
|
|
|
|
var channel = Channel.CreateUnbounded<AdminLogEntry>(new UnboundedChannelOptions
|
|
{
|
|
SingleWriter = false,
|
|
SingleReader = true
|
|
});
|
|
|
|
void OnLog(AdminLogEntry entry) => channel.Writer.TryWrite(entry);
|
|
|
|
InMemoryLogBuffer.Instance.LogEmitted += OnLog;
|
|
|
|
try
|
|
{
|
|
// Initiale Initialisierungsmeldung
|
|
await ctx.Response.WriteAsync("event: connected\ndata: {}\n\n", ct);
|
|
await ctx.Response.Body.FlushAsync(ct);
|
|
|
|
// Zuerst die letzten 50 Zeilen senden
|
|
var recent = InMemoryLogBuffer.Instance.GetRecent(50);
|
|
foreach (var entry in recent)
|
|
{
|
|
var json = JsonSerializer.Serialize(entry);
|
|
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
|
|
}
|
|
await ctx.Response.Body.FlushAsync(ct);
|
|
|
|
// Live weiter streamen
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
var entry = await channel.Reader.ReadAsync(ct);
|
|
var json = JsonSerializer.Serialize(entry);
|
|
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
|
|
await ctx.Response.Body.FlushAsync(ct);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
finally
|
|
{
|
|
InMemoryLogBuffer.Instance.LogEmitted -= OnLog;
|
|
channel.Writer.TryComplete();
|
|
}
|
|
});
|
|
|
|
// Log-Dateien auflisten
|
|
secureGroup.MapGet("/logs/files", () =>
|
|
{
|
|
var logDir = LoggingSetup.DefaultLogDirectory;
|
|
if (!Directory.Exists(logDir))
|
|
return Results.Ok(Array.Empty<object>());
|
|
|
|
var files = Directory.EnumerateFiles(logDir, "*.log", SearchOption.AllDirectories)
|
|
.Select(path =>
|
|
{
|
|
var fileInfo = new FileInfo(path);
|
|
var relativePath = Path.GetRelativePath(logDir, path).Replace('\\', '/');
|
|
var category = Path.GetDirectoryName(relativePath)?.Replace('\\', '/') ?? "root";
|
|
return new
|
|
{
|
|
name = Path.GetFileName(path),
|
|
relativePath = relativePath,
|
|
category = string.IsNullOrEmpty(category) ? "root" : category,
|
|
sizeBytes = fileInfo.Length,
|
|
sizeKb = Math.Round(fileInfo.Length / 1024.0, 1),
|
|
modifiedUtc = fileInfo.LastWriteTimeUtc
|
|
};
|
|
})
|
|
.OrderByDescending(f => f.modifiedUtc)
|
|
.ToList();
|
|
|
|
return Results.Ok(files);
|
|
});
|
|
|
|
// Log-Datei herunterladen
|
|
secureGroup.MapGet("/logs/files/download", (string file) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(file))
|
|
return Results.BadRequest("Dateiname erforderlich.");
|
|
|
|
var logDir = Path.GetFullPath(LoggingSetup.DefaultLogDirectory);
|
|
var safePath = Path.GetFullPath(Path.Combine(logDir, file.TrimStart('/', '\\')));
|
|
|
|
if (!safePath.StartsWith(logDir, StringComparison.OrdinalIgnoreCase) || !File.Exists(safePath))
|
|
{
|
|
return Results.NotFound("Datei nicht gefunden oder ungültiger Pfad.");
|
|
}
|
|
|
|
return Results.File(safePath, "text/plain", Path.GetFileName(safePath));
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Einstellungen (Settings)
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
secureGroup.MapGet("/settings", () =>
|
|
{
|
|
var opts = host.Options;
|
|
return Results.Ok(new
|
|
{
|
|
webserver = new
|
|
{
|
|
port = opts.WebserverPort,
|
|
host = opts.WebserverHost,
|
|
apiControlToken = string.IsNullOrEmpty(opts.ApiControlToken) ? "" : "••••••••",
|
|
hasApiControlToken = !string.IsNullOrEmpty(opts.ApiControlToken),
|
|
dbConnectionDebug = opts.DbConnectionDebug
|
|
},
|
|
adminServer = new
|
|
{
|
|
enabled = opts.AdminServerEnabled,
|
|
port = opts.AdminServerPort,
|
|
host = opts.AdminServerHost,
|
|
token = opts.AdminToken,
|
|
url = opts.AdminServerUrl
|
|
},
|
|
database = new
|
|
{
|
|
server = opts.DbServer,
|
|
database = opts.DbName,
|
|
user = opts.DbUser,
|
|
hasPassword = !string.IsNullOrEmpty(opts.DbPassword),
|
|
password = string.IsNullOrEmpty(opts.DbPassword) ? "" : "••••••••",
|
|
sslMode = opts.DbSslMode.ToString()
|
|
},
|
|
deploymentCenter = new
|
|
{
|
|
serverUrl = opts.DcServerUrl,
|
|
token = string.IsNullOrEmpty(opts.DcToken) ? "" : "••••••••",
|
|
hasToken = !string.IsNullOrEmpty(opts.DcToken),
|
|
heartbeatEnabled = opts.DcHeartbeatEnabled,
|
|
source = opts.DcSource,
|
|
instance = opts.DcInstance,
|
|
intervalSeconds = opts.DcHeartbeatIntervalSeconds,
|
|
errorReportingEnabled = opts.DcErrorReportingEnabled,
|
|
updateCheckEnabled = opts.DcUpdateCheckEnabled,
|
|
updateChannel = opts.DcUpdateChannel
|
|
},
|
|
egress = new
|
|
{
|
|
channelsText = opts.EgressChannelsText
|
|
}
|
|
});
|
|
});
|
|
|
|
secureGroup.MapPost("/settings", (UpdateSettingsRequest body) =>
|
|
{
|
|
var opts = host.Options;
|
|
|
|
if (body.Webserver != null)
|
|
{
|
|
if (body.Webserver.Port.HasValue && body.Webserver.Port > 0)
|
|
opts.WebserverPort = body.Webserver.Port.Value;
|
|
if (!string.IsNullOrWhiteSpace(body.Webserver.Host))
|
|
opts.WebserverHost = body.Webserver.Host.Trim();
|
|
if (body.Webserver.ApiControlToken != null && body.Webserver.ApiControlToken != "••••••••")
|
|
opts.ApiControlToken = body.Webserver.ApiControlToken.Trim();
|
|
if (body.Webserver.DbConnectionDebug.HasValue)
|
|
opts.DbConnectionDebug = body.Webserver.DbConnectionDebug.Value;
|
|
}
|
|
|
|
if (body.AdminServer != null)
|
|
{
|
|
if (body.AdminServer.Enabled.HasValue)
|
|
opts.AdminServerEnabled = body.AdminServer.Enabled.Value;
|
|
if (body.AdminServer.Port.HasValue && body.AdminServer.Port > 0)
|
|
opts.AdminServerPort = body.AdminServer.Port.Value;
|
|
if (!string.IsNullOrWhiteSpace(body.AdminServer.Host))
|
|
opts.AdminServerHost = body.AdminServer.Host.Trim();
|
|
if (!string.IsNullOrWhiteSpace(body.AdminServer.Token))
|
|
opts.AdminToken = body.AdminServer.Token.Trim();
|
|
}
|
|
|
|
if (body.Database != null)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(body.Database.Server))
|
|
opts.DbServer = body.Database.Server.Trim();
|
|
if (!string.IsNullOrWhiteSpace(body.Database.Database))
|
|
opts.DbName = body.Database.Database.Trim();
|
|
if (!string.IsNullOrWhiteSpace(body.Database.User))
|
|
opts.DbUser = body.Database.User.Trim();
|
|
if (body.Database.Password != null && body.Database.Password != "••••••••")
|
|
opts.DbPassword = body.Database.Password.Trim();
|
|
if (!string.IsNullOrWhiteSpace(body.Database.SslMode) &&
|
|
Enum.TryParse<MySqlConnector.MySqlSslMode>(body.Database.SslMode, true, out var ssl))
|
|
{
|
|
opts.DbSslMode = ssl;
|
|
}
|
|
}
|
|
|
|
if (body.DeploymentCenter != null)
|
|
{
|
|
if (body.DeploymentCenter.Token != null && body.DeploymentCenter.Token != "••••••••")
|
|
opts.DcToken = body.DeploymentCenter.Token.Trim();
|
|
if (body.DeploymentCenter.HeartbeatEnabled.HasValue)
|
|
opts.DcHeartbeatEnabled = body.DeploymentCenter.HeartbeatEnabled.Value;
|
|
if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.Source))
|
|
opts.DcSource = body.DeploymentCenter.Source.Trim();
|
|
if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.Instance))
|
|
opts.DcInstance = body.DeploymentCenter.Instance.Trim();
|
|
if (body.DeploymentCenter.IntervalSeconds.HasValue && body.DeploymentCenter.IntervalSeconds >= 15)
|
|
opts.DcHeartbeatIntervalSeconds = body.DeploymentCenter.IntervalSeconds.Value;
|
|
if (body.DeploymentCenter.ErrorReportingEnabled.HasValue)
|
|
opts.DcErrorReportingEnabled = body.DeploymentCenter.ErrorReportingEnabled.Value;
|
|
if (body.DeploymentCenter.UpdateCheckEnabled.HasValue)
|
|
opts.DcUpdateCheckEnabled = body.DeploymentCenter.UpdateCheckEnabled.Value;
|
|
if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.UpdateChannel))
|
|
opts.DcUpdateChannel = body.DeploymentCenter.UpdateChannel.Trim();
|
|
}
|
|
|
|
if (body.Egress?.ChannelsText != null)
|
|
{
|
|
opts.EgressChannelsText = body.Egress.ChannelsText;
|
|
}
|
|
|
|
opts.Save();
|
|
DcErrorReporter.Configure(opts.DcToken, opts.DcErrorReportingEnabled);
|
|
Log.Information("Admin WebGUI: Einstellungen gespeichert.");
|
|
|
|
return Results.Ok(new { ok = true, message = "Einstellungen erfolgreich gespeichert." });
|
|
});
|
|
|
|
secureGroup.MapPost("/settings/generate-token", () =>
|
|
{
|
|
var newToken = PredictalyticsOptions.GenerateSecureToken();
|
|
return Results.Ok(new { token = newToken });
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Deployment Center & Lizenz
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
secureGroup.MapGet("/license", async () =>
|
|
{
|
|
var hwInfo = LicenseGuard.GetHardwareInfo();
|
|
var check = await LicenseGuard.TryUseCachedAsync();
|
|
|
|
return Results.Ok(new
|
|
{
|
|
productSlug = DcConfig.ProductSlug,
|
|
version = DcConfig.AppVersion,
|
|
gitCommit = DcConfig.GitCommitShort,
|
|
serverUrl = DcConfig.BaseUrl,
|
|
hardwareId = hwInfo.HardwareId,
|
|
hwidSource = hwInfo.HwidSource,
|
|
status = check.LastResult?.Status ?? "unbekannt",
|
|
isUsable = check.IsUsable,
|
|
isCached = check.LastResult?.IsCached ?? false,
|
|
message = check.LastResult?.Message ?? "",
|
|
cacheExpiresAt = check.LastResult?.CacheExpiresAt
|
|
});
|
|
});
|
|
|
|
secureGroup.MapPost("/updates/check", async () =>
|
|
{
|
|
var check = await LicenseGuard.TryUseCachedAsync();
|
|
var licenseKey = check.Session?.LicenseKey;
|
|
var result = await DcUpdateService.CheckAsync(host.Options.DcUpdateChannel, licenseKey);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
currentVersion = DcConfig.AppVersion,
|
|
updateAvailable = result.UpdateAvailable,
|
|
latestVersion = result.LatestRelease?.Version ?? DcConfig.AppVersion,
|
|
isCritical = result.IsCritical,
|
|
changelog = result.LatestRelease?.Changelog,
|
|
unauthorized = result.Unauthorized,
|
|
message = result.Message
|
|
});
|
|
});
|
|
}
|
|
|
|
private static bool CryptographicTokenEquals(string candidate, string expected)
|
|
{
|
|
var candidateBytes = System.Text.Encoding.UTF8.GetBytes(candidate.Trim());
|
|
var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected.Trim());
|
|
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes);
|
|
}
|
|
|
|
private static string FormatUptime(TimeSpan span)
|
|
{
|
|
if (span.TotalDays >= 1)
|
|
return $"{(int)span.TotalDays}d {span.Hours}h {span.Minutes}m";
|
|
if (span.TotalHours >= 1)
|
|
return $"{span.Hours}h {span.Minutes}m {span.Seconds}s";
|
|
return $"{span.Minutes}m {span.Seconds}s";
|
|
}
|
|
}
|
|
|
|
// Request Models
|
|
public sealed record TokenVerifyRequest(string? Token);
|
|
public sealed record ConfirmActionRequest(bool? Confirm);
|
|
public sealed class UpdateSettingsRequest
|
|
{
|
|
public WebserverSettingsUpdate? Webserver { get; set; }
|
|
public AdminServerSettingsUpdate? AdminServer { get; set; }
|
|
public DatabaseSettingsUpdate? Database { get; set; }
|
|
public DcSettingsUpdate? DeploymentCenter { get; set; }
|
|
public EgressSettingsUpdate? Egress { get; set; }
|
|
}
|
|
|
|
public sealed class WebserverSettingsUpdate
|
|
{
|
|
public int? Port { get; set; }
|
|
public string? Host { get; set; }
|
|
public string? ApiControlToken { get; set; }
|
|
public bool? DbConnectionDebug { get; set; }
|
|
}
|
|
|
|
public sealed class AdminServerSettingsUpdate
|
|
{
|
|
public bool? Enabled { get; set; }
|
|
public int? Port { get; set; }
|
|
public string? Host { get; set; }
|
|
public string? Token { get; set; }
|
|
}
|
|
|
|
public sealed class DatabaseSettingsUpdate
|
|
{
|
|
public string? Server { get; set; }
|
|
public string? Database { get; set; }
|
|
public string? User { get; set; }
|
|
public string? Password { get; set; }
|
|
public string? SslMode { get; set; }
|
|
}
|
|
|
|
public sealed class DcSettingsUpdate
|
|
{
|
|
public string? Token { get; set; }
|
|
public bool? HeartbeatEnabled { get; set; }
|
|
public string? Source { get; set; }
|
|
public string? Instance { get; set; }
|
|
public int? IntervalSeconds { get; set; }
|
|
public bool? ErrorReportingEnabled { get; set; }
|
|
public bool? UpdateCheckEnabled { get; set; }
|
|
public string? UpdateChannel { get; set; }
|
|
}
|
|
|
|
public sealed class EgressSettingsUpdate
|
|
{
|
|
public string? ChannelsText { get; set; }
|
|
}
|