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); /// Gegen eine Test-Website (-dev, BSO_LAUNCHER_API)? Dann kommen Releases zuerst von dort. public static bool TestWebsite => Base != BaseUrl.TrimEnd('/'); /// Neueste Version: vom Git-Server (Gitea-Releases), sonst von der Website - mit -dev umgekehrt. public static async Task Latest(string product) => TestWebsite ? await WebsiteLatest(product) ?? await GiteaReleases.Latest(product) : await GiteaReleases.Latest(product) ?? await WebsiteLatest(product); static async Task WebsiteLatest(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"); } }