diff --git a/src/c#/GeneralUpdate.Core/Network/VersionService.cs b/src/c#/GeneralUpdate.Core/Network/VersionService.cs
index 480c9132..0be48e82 100644
--- a/src/c#/GeneralUpdate.Core/Network/VersionService.cs
+++ b/src/c#/GeneralUpdate.Core/Network/VersionService.cs
@@ -1,116 +1,117 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
+using System.Threading;
using System.Threading.Tasks;
using GeneralUpdate.Core;
using GeneralUpdate.Core.JsonContext;
using GeneralUpdate.Core.Configuration;
+using GeneralUpdate.Core.Security;
namespace GeneralUpdate.Core.Network
{
public class VersionService
{
+ private static readonly HttpClient _sharedClient;
+ private static ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy();
+
+ private readonly IHttpAuthProvider _auth;
+ private readonly TimeSpan _timeout;
+ private readonly int _maxRetries;
+
+ static VersionService()
+ {
+ var handler = new HttpClientHandler();
+ handler.ServerCertificateCustomValidationCallback = SharedCertValidation;
+ _sharedClient = new HttpClient(handler, disposeHandler: false);
+ }
+
+ public static void SetSslValidationPolicy(ISslValidationPolicy policy)
+ => _globalSslPolicy = policy ?? throw new ArgumentNullException(nameof(policy));
+
+ private static bool SharedCertValidation(HttpRequestMessage m, X509Certificate2? c,
+ X509Chain? ch, SslPolicyErrors e)
+ => _globalSslPolicy.ValidateCertificate(c, ch, e);
+
+ public VersionService(IHttpAuthProvider? auth = null, TimeSpan? timeout = null, int maxRetries = 3)
+ {
+ _auth = auth ?? new NoOpAuthProvider();
+ _timeout = timeout ?? TimeSpan.FromSeconds(30);
+ _maxRetries = maxRetries;
+ }
private VersionService() { }
-
- ///
- /// Report the result of this update: whether it was successful.
- ///
- ///
- ///
- ///
- ///
- ///
- public static async Task Report(string httpUrl
- , int recordId
- , int status
- , int? type
- , string scheme = null
- , string token = null)
+
+ // Static API (backward-compatible)
+ public static Task Report(string url, int recordId, int status, int? type,
+ string scheme = null, string token = null)
{
- var parameters = new Dictionary
- {
- { "RecordId", recordId },
- { "Status", status },
- { "Type", type }
- };
- await PostTaskAsync>(httpUrl, parameters, ReportRespJsonContext.Default.BaseResponseDTOBoolean, scheme, token);
+ var a = HttpAuthProviderFactory.Create(scheme, token, null);
+ return new VersionService(a).ReportAsync(url, recordId, status, type);
}
- ///
- /// Verify whether the current version needs an update.
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static async Task Validate(string httpUrl
- , string version
- , int appType
- , string appKey
- , int platform
- , string productId
- , string scheme = null
- , string token = null)
+ public static Task Validate(string url, string version,
+ int appType, string appKey, int platform, string productId,
+ string scheme = null, string token = null)
{
- var parameters = new Dictionary
- {
- { "Version", version },
- { "AppType", appType },
- { "AppKey", appKey },
- { "Platform", platform },
- { "ProductId", productId }
- };
- return await PostTaskAsync(httpUrl, parameters, VersionRespJsonContext.Default.VersionRespDTO, scheme, token);
+ var a = HttpAuthProviderFactory.Create(scheme, token, appKey);
+ return new VersionService(a).ValidateAsync(url, version, appType, platform, productId);
}
- private static async Task PostTaskAsync(string httpUrl, Dictionary parameters, JsonTypeInfo? typeInfo = null, string scheme = null, string token = null)
+ private async Task ReportAsync(string url, int recordId, int status, int? type, CancellationToken t = default)
{
- try
+ var p = new Dictionary { ["RecordId"] = recordId, ["Status"] = status, ["Type"] = type };
+ await PostAsync>(url, p, ReportRespJsonContext.Default.BaseResponseDTOBoolean, t);
+ }
+
+ private async Task ValidateAsync(string url, string v, int at, int pf, string pid,
+ CancellationToken t = default)
+ {
+ var p = new Dictionary { ["Version"] = v, ["AppType"] = at, ["Platform"] = pf, ["ProductId"] = pid };
+ return await PostAsync(url, p, VersionRespJsonContext.Default.VersionRespDTO, t);
+ }
+
+ private async Task PostAsync(string url, Dictionary p,
+ JsonTypeInfo? ti, CancellationToken t)
+ {
+ for (int attempt = 0; ; attempt++)
{
- var uri = new Uri(httpUrl);
- using var httpClient = new HttpClient(new HttpClientHandler
+ try { return await SendAsync(url, p, ti, t).ConfigureAwait(false); }
+ catch (Exception ex) when (attempt < _maxRetries - 1 && IsRetryable(ex))
{
- ServerCertificateCustomValidationCallback = CheckValidationResult
- });
- httpClient.Timeout = TimeSpan.FromSeconds(15);
- httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html, application/xhtml+xml, */*");
-
- if (!string.IsNullOrEmpty(scheme) && !string.IsNullOrEmpty(token))
- {
- httpClient.DefaultRequestHeaders.Authorization =
- new System.Net.Http.Headers.AuthenticationHeaderValue(scheme, token);
+ GeneralTracer.Warn($"HTTP attempt {attempt + 1}/{_maxRetries} failed, retrying. {ex.Message}");
+ await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 1000), t).ConfigureAwait(false);
}
-
- var parametersJson =
- JsonSerializer.Serialize(parameters, HttpParameterJsonContext.Default.DictionaryStringObject);
- var stringContent = new StringContent(parametersJson, Encoding.UTF8, "application/json");
- var result = await httpClient.PostAsync(uri, stringContent);
- var reseponseJson = await result.Content.ReadAsStringAsync();
- return typeInfo == null
- ? JsonSerializer.Deserialize(reseponseJson)
- : JsonSerializer.Deserialize(reseponseJson, typeInfo);
- }
- catch (Exception e)
- {
- GeneralTracer.Error("The PostTaskAsync method in the VersionService class throws an exception.", e);
- throw e;
}
}
- private static bool CheckValidationResult(
- HttpRequestMessage message,
- X509Certificate2 certificate,
- X509Chain chain,
- SslPolicyErrors sslPolicyErrors
- ) => true;
+ private async Task SendAsync(string url, Dictionary p,
+ JsonTypeInfo? ti, CancellationToken t)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
+ req.Headers.Accept.ParseAdd("application/json");
+ var json = JsonSerializer.Serialize(p, HttpParameterJsonContext.Default.DictionaryStringObject);
+ req.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ await _auth.ApplyAuthAsync(req, t).ConfigureAwait(false);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(t);
+ cts.CancelAfter(_timeout);
+ var r = await _sharedClient.SendAsync(req, cts.Token).ConfigureAwait(false);
+ r.EnsureSuccessStatusCode();
+ var rj = await r.Content.ReadAsStringAsync().ConfigureAwait(false);
+ return ti == null ? JsonSerializer.Deserialize(rj) : JsonSerializer.Deserialize(rj, ti);
+ }
+
+ private static bool IsRetryable(Exception ex)
+ {
+ if (ex is OperationCanceledException) return false;
+ if (ex is TaskCanceledException or TimeoutException or System.IO.IOException) return true;
+ if (ex is HttpRequestException h && (h.Message ?? "").Contains("timeout", StringComparison.OrdinalIgnoreCase)) return true;
+ return false;
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/c#/GeneralUpdate.Core/Security/IHttpAuthProvider.cs b/src/c#/GeneralUpdate.Core/Security/IHttpAuthProvider.cs
new file mode 100644
index 00000000..f5df56b4
--- /dev/null
+++ b/src/c#/GeneralUpdate.Core/Security/IHttpAuthProvider.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GeneralUpdate.Core.Security;
+
+public interface IHttpAuthProvider
+{
+ Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default);
+}
+
+public sealed class NoOpAuthProvider : IHttpAuthProvider
+{
+ public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
+ => Task.CompletedTask;
+}
+
+public sealed class BearerTokenAuthProvider : IHttpAuthProvider
+{
+ private readonly string _token;
+ public BearerTokenAuthProvider(string token)
+ => _token = token ?? throw new ArgumentNullException(nameof(token));
+ public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
+ {
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class ApiKeyAuthProvider : IHttpAuthProvider
+{
+ private readonly string _h;
+ private readonly string _k;
+ public ApiKeyAuthProvider(string apiKey, string headerName = "X-Api-Key")
+ {
+ _k = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
+ _h = headerName ?? throw new ArgumentNullException(nameof(headerName));
+ }
+ public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
+ {
+ request.Headers.Add(_h, _k);
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class HmacAuthProvider : IHttpAuthProvider
+{
+ private readonly string _secret;
+ public HmacAuthProvider(string secretKey)
+ => _secret = secretKey ?? throw new ArgumentNullException(nameof(secretKey));
+ public async Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
+ {
+ var body = request.Content != null
+ ? await request.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty;
+ var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
+ var sig = HmacSha256($"{body}|{ts}", _secret);
+ request.Headers.Add("X-Update-Timestamp", ts);
+ request.Headers.Add("X-Update-Signature", sig);
+ }
+ private static string HmacSha256(string data, string key)
+ {
+ var h = new HMACSHA256(Encoding.UTF8.GetBytes(key))
+ .ComputeHash(Encoding.UTF8.GetBytes(data));
+ return BitConverter.ToString(h).Replace("-", "").ToLowerInvariant();
+ }
+}
+
+public static class HttpAuthProviderFactory
+{
+ public static IHttpAuthProvider Create(string? scheme, string? token, string? secretKey)
+ {
+ if (!string.IsNullOrEmpty(secretKey)) return new HmacAuthProvider(secretKey);
+ if (!string.IsNullOrEmpty(token))
+ return (scheme ?? "").ToLowerInvariant() switch
+ {
+ "apikey" => new ApiKeyAuthProvider(token),
+ _ => new BearerTokenAuthProvider(token)
+ };
+ return new NoOpAuthProvider();
+ }
+}
diff --git a/src/c#/GeneralUpdate.Core/Security/ISslValidationPolicy.cs b/src/c#/GeneralUpdate.Core/Security/ISslValidationPolicy.cs
new file mode 100644
index 00000000..8b5afa6b
--- /dev/null
+++ b/src/c#/GeneralUpdate.Core/Security/ISslValidationPolicy.cs
@@ -0,0 +1,21 @@
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+
+namespace GeneralUpdate.Core.Security;
+
+public interface ISslValidationPolicy
+{
+ bool ValidateCertificate(
+ X509Certificate2? certificate,
+ X509Chain? chain,
+ SslPolicyErrors sslPolicyErrors);
+}
+
+public sealed class StrictSslValidationPolicy : ISslValidationPolicy
+{
+ public bool ValidateCertificate(
+ X509Certificate2? certificate,
+ X509Chain? chain,
+ SslPolicyErrors sslPolicyErrors)
+ => sslPolicyErrors == SslPolicyErrors.None;
+}