using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace BSO.Launcher;
/// BSO Master: Konto und Releases (wie Api.cs beim MSCO Launcher).
public static class Api
{
public const string BaseUrl = "https://bso.mbm.wtf";
/// Lokale Test-Website (BSO-Website mit "dotnet run --urls http://localhost:5080").
public const string DevUrl = "http://localhost:5080";
public const string DevArg = "-dev";
/// Mit -dev (oder --dev) gestartet: gegen die lokale Test-Website statt bso.mbm.wtf.
public static readonly bool Dev = Environment.GetCommandLineArgs().Skip(1).Any(a => a is "-dev" or "--dev");
/// Adresse der Website. BSO_LAUNCHER_API geht vor (beliebiger Test-Server), dann -dev, sonst bso.mbm.wtf.
public static string Base => (Environment.GetEnvironmentVariable("BSO_LAUNCHER_API") ?? (Dev ? DevUrl : BaseUrl)).TrimEnd('/');
public static readonly HttpClient Http = CreateHttp();
static HttpClient CreateHttp()
{
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("BSO-Launcher/" + SelfUpdate.CurrentVersion);
return http;
}
public sealed record Release(string product, string version, string sha256, long size, string changelog, string date, string file, string url,
string? signature = null);
/// JSON-Antwort oder Fehlertext des Servers.
public sealed class Result
{
public bool Ok;
public int Status;
public JsonElement Json;
public string? Error;
}
static async Task Send(HttpMethod method, string path, object? body, string? token)
{
var req = new HttpRequestMessage(method, Base + path);
if (body != null) req.Content = JsonContent.Create(body);
if (!string.IsNullOrEmpty(token)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
try
{
using var res = await Http.SendAsync(req);
string text = await res.Content.ReadAsStringAsync();
var result = new Result { Ok = res.IsSuccessStatusCode, Status = (int)res.StatusCode };
try { result.Json = JsonDocument.Parse(text).RootElement.Clone(); } catch { }
if (!result.Ok)
result.Error = result.Json.ValueKind == JsonValueKind.Object && result.Json.TryGetProperty("error", out var e)
? e.GetString() : $"HTTP {(int)res.StatusCode}";
return result;
}
catch (Exception e)
{
return new Result { Ok = false, Status = 0, Error = "BSO-Server nicht erreichbar (" + e.Message + ")" };
}
}
public static Task Login(string username, string password) =>
Send(HttpMethod.Post, "/api/auth/login", new { username, password }, null);
public static Task Register(string username, string password, string? email) =>
Send(HttpMethod.Post, "/api/auth/register", new { username, password, email }, null);
public static Task Me(string token) => Send(HttpMethod.Get, "/api/auth/me", null, token);
public static Task Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token);
// --- Freunde und Lobby (alles mit dem Konto-Token)
public static Task Get(string path, string token) => Send(HttpMethod.Get, path, null, token);
public static Task Post(string path, string token, object? body = null) => Send(HttpMethod.Post, path, body ?? new { }, token);
public static async Task Latest(string product)
{
var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null);
if (!r.Ok) return null;
try { return r.Json.Deserialize(); } catch { return null; }
}
public static Task Releases() => Send(HttpMethod.Get, "/api/releases", null, null);
///
/// Mod/Launcher laden: Signatur des Releases und SHA-256 pruefen (nur echte, vom BSO-Release-Schluessel signierte
/// Dateien werden installiert). Fortschritt 0..1.
///
public static async Task Download(Release release, Action progress)
{
if (!ReleaseSignature.Verify(release.product, release.version, release.sha256, release.signature))
throw new Exception($"{release.product} {release.version} ist nicht von BSO signiert - nicht installiert");
return await DownloadVerified(release.url, release.sha256, release.size, progress);
}
/// Teil-Downloads (fortsetzbar) - liegen beim Launcher, benannt nach der Pruefsumme der Datei.
static string PartFolder => Path.Combine(Settings.Folder, "downloads");
///
/// Datei laden und gegen die Pruefsumme pruefen. Fortsetzbar: geladen wird in eine Teildatei (<sha256>.part). Bricht
/// das Netz ab, wird gewartet und ab der letzten Stelle weitergeladen (HTTP Range).
///
public static async Task DownloadVerified(string url, string sha256, long size, Action progress)
{
if (!System.Text.RegularExpressions.Regex.IsMatch(sha256 ?? "", "^[0-9a-fA-F]{64}$")) throw new Exception("Ungueltige Pruefsumme");
Directory.CreateDirectory(PartFolder);
string part = Path.Combine(PartFolder, sha256!.ToLowerInvariant() + ".part");
int failures = 0;
while (true)
{
long have = File.Exists(part) ? new FileInfo(part).Length : 0;
if (size > 0 && have > size) { File.Delete(part); have = 0; }
try
{
if (size <= 0 || have < size) await DownloadPart(url, part, have, size, progress);
byte[] data = await File.ReadAllBytesAsync(part);
string sha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(data)).ToLowerInvariant();
if (!string.Equals(sha, sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(part); // kaputt - naechster Versuch von vorn
throw new InvalidDataException("Der Download ist beschaedigt (Pruefsumme passt nicht) - bitte nochmal versuchen");
}
try { File.Delete(part); } catch { }
return data;
}
catch (Exception e) when (e is HttpRequestException or IOException or TaskCanceledException or OperationCanceledException
&& e is not InvalidDataException)
{
// Netz weg / Verbindung abgebrochen: kurz warten, dann ab der letzten Stelle weiter
long now = File.Exists(part) ? new FileInfo(part).Length : 0;
failures = now > have ? 1 : failures + 1;
if (failures > 8) throw new Exception("Download abgebrochen (keine Verbindung) - nochmal versuchen, er macht dort weiter");
await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 2 * failures)));
}
}
}
static async Task DownloadPart(string url, string part, long have, long size, Action progress)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (have > 0) req.Headers.Range = new RangeHeaderValue(have, null);
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if (res.StatusCode == System.Net.HttpStatusCode.RequestedRangeNotSatisfiable)
{
File.Delete(part); // Teildatei passt nicht zur Datei auf dem Server - von vorn
throw new IOException("Range not satisfiable");
}
res.EnsureSuccessStatusCode();
bool resumed = have > 0 && res.StatusCode == System.Net.HttpStatusCode.PartialContent;
if (!resumed) have = 0; // Server liefert die ganze Datei
long total = size > 0 ? size : have + (res.Content.Headers.ContentLength ?? 0);
await using var input = await res.Content.ReadAsStreamAsync();
await using var output = new FileStream(part, resumed ? FileMode.Append : FileMode.Create, FileAccess.Write, FileShare.None);
var chunk = new byte[81920];
long done = have;
if (total > 0) progress(Math.Min(1.0, done / (double)total));
while (true)
{
// Haengt die Verbindung (keine Daten mehr), nach 30 s abbrechen und neu ansetzen
using var stall = new CancellationTokenSource(TimeSpan.FromSeconds(30));
int read = await input.ReadAsync(chunk, stall.Token);
if (read <= 0) break;
await output.WriteAsync(chunk.AsMemory(0, read));
done += read;
if (total > 0) progress(Math.Min(1.0, done / (double)total));
}
await output.FlushAsync();
if (size > 0 && done < size) throw new IOException("Verbindung zu frueh beendet");
}
}