162 lines
8.1 KiB
C#
162 lines
8.1 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
|
|
namespace BSO.Launcher;
|
|
|
|
/// <summary>BSO Master: Konto und Releases (wie Api.cs beim MSCO Launcher).</summary>
|
|
public static class Api
|
|
{
|
|
public const string BaseUrl = "https://bso.mbm.wtf";
|
|
/// <summary>Zum Testen umstellbar: BSO_LAUNCHER_API=http://localhost:5080</summary>
|
|
public static string Base => (Environment.GetEnvironmentVariable("BSO_LAUNCHER_API") ?? 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);
|
|
|
|
/// <summary>JSON-Antwort oder Fehlertext des Servers.</summary>
|
|
public sealed class Result
|
|
{
|
|
public bool Ok;
|
|
public int Status;
|
|
public JsonElement Json;
|
|
public string? Error;
|
|
}
|
|
|
|
static async Task<Result> 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<Result> Login(string username, string password) =>
|
|
Send(HttpMethod.Post, "/api/auth/login", new { username, password }, null);
|
|
|
|
public static Task<Result> Register(string username, string password, string? email) =>
|
|
Send(HttpMethod.Post, "/api/auth/register", new { username, password, email }, null);
|
|
|
|
public static Task<Result> Me(string token) => Send(HttpMethod.Get, "/api/auth/me", null, token);
|
|
|
|
public static Task<Result> Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token);
|
|
|
|
public static async Task<Release?> 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<Release>(); } catch { return null; }
|
|
}
|
|
|
|
public static Task<Result> Releases() => Send(HttpMethod.Get, "/api/releases", null, null);
|
|
|
|
/// <summary>
|
|
/// Mod/Launcher laden: Signatur des Releases und SHA-256 pruefen (nur echte, vom BSO-Release-Schluessel signierte
|
|
/// Dateien werden installiert). Fortschritt 0..1.
|
|
/// </summary>
|
|
public static async Task<byte[]> Download(Release release, Action<double> 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);
|
|
}
|
|
|
|
/// <summary>Teil-Downloads (fortsetzbar) - liegen beim Launcher, benannt nach der Pruefsumme der Datei.</summary>
|
|
static string PartFolder => Path.Combine(Settings.Folder, "downloads");
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public static async Task<byte[]> DownloadVerified(string url, string sha256, long size, Action<double> 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<double> 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");
|
|
}
|
|
}
|