Files
PS_Launcher/src/PSLauncher.Core/Downloads/DownloadManager.cs
j.foucher 500f7d12e6 v0.29.10 — Admin UI refonte (modals + onglets), emails de release, 4-digit versions
Admin backoffice — UI redesign :
 - Pages versions.php + licenses.php : remplace les <details> inline qui
   débordaient horizontalement par UN bouton « ✎ Modifier » par row qui
   ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes,
   BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel,
   BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets.
 - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback
   visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la
   clé aux clients.
 - Overlay « hashing en cours » plein écran sur tous les boutons de hash
   (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé
   par scope (bulk vs single).
 - Date de release passée de datetime-local à date (l'heure n'a pas de
   sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an
   pour min_license_date (= license standard couvre les releases sur 1 an).

Emails de notification release :
 - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV)
 - Onglet « Contacts » sur la modal licenses pour saisir les emails par
   license (parsing tolérant : CSV, ligne par ligne, point-virgule)
 - Bouton « ✉ Notifier » par version : POST notify_release filtre les
   licenses éligibles (channel match + min_license_date + can_see_betas
   pour BÊTA) et envoie un email HTML à chaque contact (dédup global)
 - Template email table-based + bgcolor (compat Outlook/Word engine),
   navy foncé #0F172A, logo Asterion en CID embed (= affichage direct
   sans demande de permission Outlook), bouton download installer
   centré (align="center" + margin auto), release notes en <pre>
 - Mailer.php helper : parse emails, multipart/related avec attachments
   inline, fallback execCommand pour clipboard

4-digit version support (X.Y.Z.B) :
 - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent =
   0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve
   le format d'origine au round-trip.
 - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît
   « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque
 - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php,
   Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit
 - Use case : dev/test iterations cohabitant avec leur release stable

Bugs fixes :
 - migrate.php : strip ligne par ligne les commentaires SQL avant le
   check is-empty. Sans ça, le PREMIER chunk d'un fichier migration
   (= header + premier ALTER) commençait par `--` et était silencieusement
   skip → ALTER jamais appliqué. Affectait migrations 003, 004.
 - SignManifest::getOrComputeSha256 ignorait son cache interne quand force
   demandé par le caller, retournant l'ancien hash en 0 ms même après
   re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force.
   Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique.
 - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh
   triggers, avec HEAD probe pour comparer taille serveur vs manifest →
   message d'erreur explicite si ZIP tronqué. Bps display lissé sur une
   fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un
   segment finit / Polly retry. SHA mismatch popup enrichi avec les
   deux SHAs (attendu vs calculé) extraits via regex de l'exception.
 - DownloadUrl.php : signature de l'URL utilisait un template hardcodé
   /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit
   maintenant download.url du manifest et signe le filename réel.

Strings i18n (5 langues) :
 - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch,
   stale manifest, manifest refreshed auto-retry, force fresh menu

Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:24:50 +02:00

899 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using Polly;
using Polly.Retry;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Integrity;
using PSLauncher.Models;
namespace PSLauncher.Core.Downloads;
/// <summary>
/// Téléchargement HTTP avec :
/// - reprise par Range requests (If-Range sur ETag/Last-Modified) ;
/// - retry exponentiel via Polly (1-32 s, 6 tentatives, jitter) ;
/// - téléchargement parallèle multi-segments (config <see cref="LocalConfig.ParallelDownloadSegments"/>) ;
/// - vérif SHA-256 streaming en fin de DL ;
/// - persistance d'état (state.json) toutes les 5 s ou 100 Mo pour reprise après crash.
///
/// Le multi-segments contourne le throttling per-connection d'Apache (notamment OVH mutualisé)
/// : chaque segment ouvre une connexion TCP séparée et écrit à son offset dans le fichier
/// pré-alloué via <c>SetLength</c>. Speed-up typique observé : ×4 à ×10.
/// </summary>
public sealed class DownloadManager : IDownloadManager
{
private const int BufferSize = 4 << 20; // 4 MiB par segment (avant 1 MiB)
private const int StateFlushBytesInterval = 50 * 1024 * 1024; // 50 MiB
private const double StateFlushSecondsInterval = 5.0;
private const long MultiSegmentMinSize = 50 * 1024 * 1024; // < 50 MiB → single-segment
private readonly HttpClient _http;
private readonly IDownloadStateStore _stateStore;
private readonly IIntegrityService _integrity;
private readonly LocalConfig _config;
private readonly ILogger<DownloadManager> _logger;
private readonly ResiliencePipeline _retryPipeline;
// URL active partagée entre tous les segments d'un DL en cours, et lock pour
// refresher de façon coordonnée quand l'URL HMAC expire (TTL serveur = 6 h).
// Plusieurs segments peuvent recevoir 403 simultanément — un seul doit faire
// l'appel /api/download-url/.
private Uri _currentUrl = new("about:blank");
private DateTime _lastUrlRefresh = DateTime.MinValue;
private readonly SemaphoreSlim _urlRefreshLock = new(1, 1);
public DownloadManager(
HttpClient http,
IDownloadStateStore stateStore,
IIntegrityService integrity,
LocalConfig config,
ILogger<DownloadManager> logger)
{
_http = http;
_stateStore = stateStore;
_integrity = integrity;
_config = config;
_logger = logger;
_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
// 10 tentatives × backoff 1→32s avec jitter ≈ ~3 min de retry budget
// par segment. Couvre les outages PHP-FPM courts en multi-PC (où des
// segments peuvent être tués mid-stream par request_terminate_timeout)
// sans donner l'impression d'une boucle infinie en cas de panne réelle.
MaxRetryAttempts = 10,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(32),
UseJitter = true,
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
ex is HttpRequestException
or IOException
or TimeoutException
|| (ex is HttpResumableException re && re.IsTransient)),
OnRetry = args =>
{
_logger.LogWarning(
"Download retry #{Attempt} after {Delay} : {Error}",
args.AttemptNumber + 1, args.RetryDelay, args.Outcome.Exception?.Message);
return ValueTask.CompletedTask;
}
})
.Build();
}
public DownloadState? GetResumableState(string version) => _stateStore.Load(version);
public void DiscardResumableState(string version) => _stateStore.Discard(version);
public Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
=> DownloadAsync(job, progress, null, ct);
/// <summary>
/// Récupère l'URL active. Si la dernière récup date de moins de 5 s,
/// retourne celle en cache (évite que 8 segments en 403 simultanés
/// déclenchent 8 requêtes /api/download-url/). Sinon refresh via le
/// callback du job. Si pas de callback, retourne l'URL initiale du job.
/// </summary>
private async Task<Uri> GetOrRefreshUrlAsync(DownloadJob job, bool forceRefresh, CancellationToken ct)
{
await _urlRefreshLock.WaitAsync(ct).ConfigureAwait(false);
try
{
// Premier appel : initialise avec l'URL du job
if (_currentUrl.Scheme == "about")
{
_currentUrl = job.Url;
_lastUrlRefresh = DateTime.UtcNow;
}
// Si refresh forcé (un segment a vu 403), tente de fetcher une nouvelle URL.
// Debounce 5 s pour fusionner les refresh en rafale des 8 segments.
if (forceRefresh && (DateTime.UtcNow - _lastUrlRefresh).TotalSeconds >= 5
&& job.RefreshUrlAsync is not null)
{
_logger.LogInformation("Refreshing signed URL for v{Version} (URL expired or 403)", job.Version);
var fresh = await job.RefreshUrlAsync(ct).ConfigureAwait(false);
if (fresh is not null)
{
_currentUrl = fresh;
_lastUrlRefresh = DateTime.UtcNow;
_logger.LogInformation("Signed URL refreshed for v{Version}", job.Version);
}
}
return _currentUrl;
}
finally
{
_urlRefreshLock.Release();
}
}
public async Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
// Reset URL state pour ce job (DownloadManager est singleton DI)
_currentUrl = job.Url;
_lastUrlRefresh = DateTime.UtcNow;
var partialPath = _stateStore.GetPartialPath(job.Version);
var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip
// 1. Récupère l'état existant. On compare la ressource par son SHA-256 (signé via le
// manifest) plutôt que par URL : les URLs HMAC-signées changent à chaque appel
// (?exp=…&sig=…), donc une comparaison stricte d'URL invaliderait toujours le resume.
// On compare aussi le path (host+path sans query) pour détecter un changement de
// serveur/fichier, mais on ignore la querystring.
var existing = _stateStore.Load(job.Version);
if (existing is not null)
{
bool shaMismatch = !string.IsNullOrEmpty(job.ExpectedSha256)
&& !string.IsNullOrEmpty(existing.ExpectedSha256)
&& !string.Equals(existing.ExpectedSha256, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase);
bool pathMismatch = false;
if (Uri.TryCreate(existing.Url, UriKind.Absolute, out var oldUri))
{
pathMismatch = oldUri.Host != job.Url.Host || oldUri.AbsolutePath != job.Url.AbsolutePath;
}
if (shaMismatch || pathMismatch)
{
_logger.LogInformation("Cached state for {Version} mismatches new job (sha={Sha} path={Path}) — discarding",
job.Version, shaMismatch, pathMismatch);
_stateStore.Discard(job.Version);
existing = null;
}
else
{
// Même ressource : on rafraîchit juste l'URL stockée (utile pour les futures requêtes
// au sein de cette session, même si en pratique chaque segment requête via job.Url).
existing.Url = job.Url.ToString();
}
}
// 2. Choix single vs multi-segment
// Hard-cap à 32 : au-delà OVH mutualisé risque le rate-limit per-IP et le
// gain marginal devient négatif. 16 = défaut, 24-32 OK pour les serveurs
// qui le supportent. 1 = comportement single-segment legacy.
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 32);
var useMultiSegment = requestedSegments > 1
&& job.ExpectedSize >= MultiSegmentMinSize
&& (existing is null || existing.Segments.Count > 0);
// Si l'état existant est en mode legacy (single-segment) et qu'on veut multi → on conserve le legacy
// pour pouvoir reprendre proprement (sinon on jetterait son DL).
if (existing is { Segments.Count: 0 } && File.Exists(partialPath))
{
_logger.LogInformation("Existing single-segment partial detected — staying single-segment for resume");
useMultiSegment = false;
}
// 3. Vérif espace disque (uniquement à l'initial, pas au resume)
if (existing is null && job.ExpectedSize > 0)
{
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
var needed = (long)(job.ExpectedSize * 1.05);
if (drive.AvailableFreeSpace < needed)
throw new IOException(
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
}
if (useMultiSegment)
{
return await DownloadMultiSegmentAsync(
job, partialPath, finalPath, existing, requestedSegments, progress, hashProgress, ct).ConfigureAwait(false);
}
return await DownloadSingleSegmentAsync(
job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
}
// ===================================================================
// ============== MULTI-SEGMENT (parallèle, fast path) ==============
// ===================================================================
private async Task<string> DownloadMultiSegmentAsync(
DownloadJob job,
string partialPath,
string finalPath,
DownloadState? existing,
int segmentCount,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
var swPhase = Stopwatch.StartNew();
// 1. On fait CONFIANCE au manifest : pas de HEAD probe.
// Le HEAD ajoutait jusqu'à 30 s-5 min de latence quand le serveur faisait
// un md5_file() sur 14 Go à chaque requête (vu sur OVH/gate.php). Le manifest
// est signé Ed25519 donc sa taille est de toute façon fiable. Si le serveur
// ne supporte pas Range, on le détecte sur le premier segment (200 au lieu
// de 206) et on fallback sur single-segment.
long total = job.ExpectedSize;
if (total <= 0)
{
_logger.LogInformation("ExpectedSize=0 — falling back to single-segment");
return await DownloadSingleSegmentAsync(job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
}
// 2. State (resume vs fresh)
DownloadState state;
if (existing is not null && existing.Segments.Count > 0 && existing.TotalBytes == total && File.Exists(partialPath))
{
state = existing;
_logger.LogInformation("Resuming multi-segment DL for v{Version} ({Segments} segments, {Done}/{Total} bytes)",
job.Version, state.Segments.Count, state.DownloadedBytes, state.TotalBytes);
}
else
{
// Discard tout résidu (state mismatch ou partial sans state)
_stateStore.Discard(job.Version);
state = new DownloadState
{
Version = job.Version,
Url = job.Url.ToString(),
TotalBytes = total,
ExpectedSha256 = job.ExpectedSha256,
PartialPath = partialPath,
StartedAtUtc = DateTime.UtcNow,
LastChunkAtUtc = DateTime.UtcNow,
Segments = BuildSegments(total, segmentCount),
};
_stateStore.Save(state);
}
// 3. Pré-allocation du fichier final.
// Stratégie : on tente de marquer le fichier en SPARSE (FSCTL_SET_SPARSE)
// avant le SetLength, ce qui rend l'opération métadata-only (~milliseconde
// au lieu de 5-30 s sur HDD à cause du zero-fill NTFS).
// Pas besoin d'admin (contrairement à SetFileValidData). Aucune fuite de
// données : les zones non écrites sont lues comme zéros virtuels par NTFS.
// Si FSCTL_SET_SPARSE échoue (volume FAT32 ou autre), fallback sur SetLength
// classique.
var swAlloc = Stopwatch.StartNew();
await Task.Run(() =>
{
using var alloc = new FileStream(partialPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite);
if (alloc.Length != total)
{
bool sparseOk = SparseFileSupport.TrySetSparse(alloc.SafeFileHandle);
_logger.LogInformation("Pre-allocating {Path} to {Bytes} bytes (sparse={Sparse})", partialPath, total, sparseOk);
alloc.SetLength(total);
}
}, ct).ConfigureAwait(false);
swAlloc.Stop();
_logger.LogInformation("Pre-alloc done in {Ms} ms (HEAD skipped, total phase {TotalMs} ms)", swAlloc.ElapsedMilliseconds, swPhase.ElapsedMilliseconds);
// Une fois la pré-alloc terminée, on émet immédiatement un progress 0/total
// pour que le footer passe de « Préparation… » à « ⬇ v1.4.6 : 0 / 14 Go ».
// La vraie progression suit ~250 ms après quand le reporter task tick.
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, null));
// 4. Lance les workers en parallèle.
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
// Initialisé sur la somme des seg.DownloadedBytes DURABLES plutôt que sur
// state.DownloadedBytes (live aggregate persisté, qui peut être en avance
// de la réalité disque si pause survenue entre checkpoints). Sans ça, le
// footer afficherait "paused at X" mais segments resumeraient à un point
// en arrière, et l'aggregate finirait avec un drift cosmétique.
long aggregateBytes = state.Segments.Sum(s => s.DownloadedBytes);
// Action passée aux segments : juste un Add atomique (zéro contention).
void OnSegmentBytes(int _, long deltaBytes)
=> Interlocked.Add(ref aggregateBytes, deltaBytes);
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
// Tous les 5 s ou 50 Mo, persist le state.json.
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
//
// Calcul du débit : on utilise une FENÊTRE GLISSANTE de ~3 s (12 samples
// de 250ms) plutôt qu'un delta instantané sur le dernier tick. Pourquoi :
// avec un delta tick-à-tick, dès qu'un segment finit OU que Polly retry
// entre 2 tentatives OU qu'il y a un micro-stall réseau, le compteur ne
// bouge pas pendant 1-2 ticks → bps = 0 → ETA = null → UI cache les
// deux 250-500 ms → clignotement permanent à l'écran (symptôme rapporté).
// Avec la fenêtre, un trou de 1-2 ticks est dilué dans 12 samples → le
// débit affiché reste stable. La latence d'adaptation est ~3s, ce qui
// est largement OK visuellement (l'opérateur ne perçoit pas 3s de retard
// sur un chiffre qui de toute façon fluctue de ±5-10 % au cours d'un DL).
const int BpsWindowSize = 12; // 12 × 250 ms = 3 s
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var reporterTask = Task.Run(async () =>
{
var sw = Stopwatch.StartNew();
var lastFlush = TimeSpan.Zero;
long lastFlushBytes = Interlocked.Read(ref aggregateBytes);
// Fenêtre glissante (time, bytes) pour lissage du débit.
var bpsWindow = new Queue<(TimeSpan At, long Bytes)>(BpsWindowSize + 1);
try
{
while (!reporterCts.IsCancellationRequested)
{
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
var snapshot = Interlocked.Read(ref aggregateBytes);
var elapsed = sw.Elapsed;
// Push le nouveau sample, évince le plus ancien si on dépasse.
bpsWindow.Enqueue((elapsed, snapshot));
while (bpsWindow.Count > BpsWindowSize) bpsWindow.Dequeue();
// bps = (bytes_now - bytes_oldest_in_window) / time_span_window
// Fallback à 0 tant qu'on n'a pas au moins 2 samples (le 1er tick).
double bps = 0;
if (bpsWindow.Count >= 2)
{
var oldest = bpsWindow.Peek();
var span = (elapsed - oldest.At).TotalSeconds;
if (span > 0) bps = (snapshot - oldest.Bytes) / span;
}
TimeSpan? eta = null;
if (bps > 0 && total > snapshot)
eta = TimeSpan.FromSeconds((total - snapshot) / bps);
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
{
state.DownloadedBytes = snapshot;
state.LastChunkAtUtc = DateTime.UtcNow;
_stateStore.Save(state);
lastFlush = elapsed;
lastFlushBytes = snapshot;
}
}
}
catch (OperationCanceledException) { /* attendu en fin de DL */ }
}, reporterCts.Token);
var tasks = state.Segments
.Where(s => !s.Completed)
.Select(s => Task.Run(() =>
_retryPipeline.ExecuteAsync(async innerCt =>
{
await DownloadSegmentAsync(job, state, s, partialPath, OnSegmentBytes, innerCt).ConfigureAwait(false);
}, ct).AsTask(), ct))
.ToArray();
try
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch
{
reporterCts.Cancel();
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
_stateStore.Save(state);
throw;
}
// Stoppe le reporter et fais un dernier flush
reporterCts.Cancel();
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
_stateStore.Save(state);
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero));
// 5. Vérif complétion des segments
// Le check `actualSize != total` ci-dessous est INSUFFISANT car le fichier est
// pré-alloué en sparse à `total` octets — sa taille filesystem matchera toujours
// peu importe ce qui a été écrit. Si un segment a échoué silencieusement (cas
// pré-0.24.5 ou bug futur), on aurait des trous (zéros NTFS) → SHA KO en sortie
// sans diagnostic clair. On vérifie explicitement que CHAQUE segment a atteint
// sa cible AVANT de lancer le SHA-256 (qui prend 30s-3min sur un 14 Go).
var incomplete = state.Segments.FirstOrDefault(s => !s.Completed || s.DownloadedBytes < s.Length);
if (incomplete is not null)
{
_stateStore.Save(state); // garde l'état pour permettre un resume manuel
throw new InvalidDataException(
$"Segment {incomplete.Index} incomplet : {incomplete.DownloadedBytes:N0}/{incomplete.Length:N0} octets " +
$"(Completed={incomplete.Completed}). Relance le téléchargement pour reprendre.");
}
var actualSize = new FileInfo(partialPath).Length;
if (actualSize != total)
{
_stateStore.Discard(job.Version);
throw new InvalidDataException($"Taille téléchargée incorrecte : attendu {total}, obtenu {actualSize}");
}
// 6. Vérif SHA-256
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
return finalPath;
}
private static List<DownloadSegment> BuildSegments(long total, int count)
{
var segments = new List<DownloadSegment>(count);
long segSize = total / count;
for (int i = 0; i < count; i++)
{
long start = i * segSize;
long end = (i == count - 1) ? total - 1 : (start + segSize - 1);
segments.Add(new DownloadSegment
{
Index = i,
Start = start,
End = end,
DownloadedBytes = 0,
Completed = false,
});
}
return segments;
}
private async Task DownloadSegmentAsync(
DownloadJob job,
DownloadState state,
DownloadSegment seg,
string partialPath,
Action<int, long> onBytes,
CancellationToken ct)
{
if (seg.Completed) return;
var segStart = seg.Start + seg.DownloadedBytes;
if (segStart > seg.End) { seg.Completed = true; return; }
// Récupère l'URL active (peut avoir été refreshée par un autre segment)
var url = await GetOrRefreshUrlAsync(job, forceRefresh: false, ct).ConfigureAwait(false);
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Range = new RangeHeaderValue(segStart, seg.End);
if (!string.IsNullOrEmpty(state.Etag))
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.OK)
{
// Le serveur a renvoyé tout au lieu du range : la ressource a probablement changé
// (ou il n'a en fait pas accepté le range). On signale un retry à blanc.
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
}
// 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
// expired"), OU le fichier a été renommé/supprimé côté serveur (cas
// typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
// OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
// l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
// vers le fichier actuellement présent sur le serveur. Si refresh donne
// une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry
// avec la nouvelle. Sinon → c'est un vrai 404, on bubble.
//
// Note importante : on compare à `url` (la valeur utilisée dans la
// requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un
// snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments
// tombent en 404 simultanément, le PREMIER refresh met _currentUrl à
// jour, les autres voient le debounce 5s (no-op). Comparer à `url`
// (= URL réellement utilisée par CETTE requête, OLD) détecte
// correctement que _currentUrl est maintenant NEW → retry. Si on
// comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait
// un faux 404 fatal sur tous les segments sauf le premier.
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|| resp.StatusCode == HttpStatusCode.Gone
|| resp.StatusCode == HttpStatusCode.NotFound
|| resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable;
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
{
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
bool urlChanged = !Uri.Equals(url, _currentUrl);
if (urlChanged)
{
_logger.LogInformation(
"Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying",
(int)resp.StatusCode, seg.Index, url, _currentUrl);
throw new HttpResumableException(
$"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed",
isTransient: true);
}
// URL unchanged → soit le serveur insiste sur la même URL morte,
// soit le refresh a fallback sur le manifest cached (donc stale).
// 404 non-transient pour arrêter Polly et remonter une erreur claire
// qui guide l'opérateur vers « Vérifier les MAJ ».
if (resp.StatusCode == HttpStatusCode.NotFound)
{
throw new HttpResumableException(
$"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.",
isTransient: false);
}
// 416 unchanged URL = vrai mismatch de taille. On fait un HEAD probe
// pour obtenir la taille RÉELLE côté serveur et la rapporter dans le
// message d'erreur — l'opérateur saura ainsi s'il a un upload SFTP
// tronqué (file plus petit que prévu) ou un manifest qui ment sur
// sizeBytes. Cas concret : SFTP coupe au milieu d'un upload de 13 Go
// → le file fait 7 Go sur disque, mais le manifest dit 13 Go.
// Segment N qui demande bytes 9G-10G reçoit 416 (out of range).
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
{
long? actualSize = null;
try
{
using var headReq = new HttpRequestMessage(HttpMethod.Head, url);
using var headResp = await _http.SendAsync(headReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (headResp.IsSuccessStatusCode)
actualSize = headResp.Content.Headers.ContentLength;
}
catch (Exception ex) { _logger.LogDebug(ex, "HEAD probe after 416 failed"); }
var actualStr = actualSize.HasValue ? $"{actualSize.Value:N0}" : "?";
var expectedStr = $"{state.TotalBytes:N0}";
_logger.LogError(
"HTTP 416 on segment {Seg} — actual server file size: {Actual} bytes, manifest expects: {Expected} bytes",
seg.Index, actualStr, expectedStr);
throw new HttpResumableException(
$"HTTP 416 on segment {seg.Index} — incohérence taille de fichier serveur : " +
$"le ZIP sur le serveur fait {actualStr} octets, le manifest attend {expectedStr} octets. " +
$"Probable cause : upload SFTP tronqué OU manifest avec sizeBytes/sha256 obsolètes. " +
$"Re-upload le ZIP complet puis « 🔁 Hasher les versions + signer » côté admin.",
isTransient: false);
}
throw new HttpResumableException(
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
}
if (!resp.IsSuccessStatusCode)
{
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
throw new HttpResumableException($"HTTP {(int)resp.StatusCode} on segment {seg.Index}", isTransient: transient);
}
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
if (resp.Content.Headers.LastModified is { } lm) state.LastModified = lm.ToString("R");
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
dst.Seek(segStart, SeekOrigin.Begin);
// CHECKPOINT : durabilité ET anti-double-comptage.
//
// Durabilité : on n'incrémente seg.DownloadedBytes qu'APRÈS un FlushAsync
// réussi. Sans ça, sur pause/cancel, le buffer FileStream (4 MiB) ou le
// cache OS peuvent ne pas avoir atteint le disque, mais seg.DownloadedBytes
// les compte comme écrits → resume saute ces bytes → trou (zéros NTFS du
// pre-alloc sparse) → SHA-256 fail.
//
// Anti-double-comptage : on appelle ÉGALEMENT `onBytes` uniquement au
// checkpoint, avec le delta inFlight. Sans ça, un retry Polly mid-segment
// (très fréquent sur PHP-FPM OVH mutualisé qui coupe les requêtes longues)
// re-télécharge les bytes depuis le dernier checkpoint et les compte une
// 2e fois dans l'aggregate → footer affiche >100% en fin de DL.
// Avec onBytes au checkpoint : les bytes re-téléchargés n'ont jamais été
// reportés à la 1re tentative (l'erreur Polly est levée AVANT le checkpoint
// sur l'attempt failed), donc pas de double count.
//
// Trade-off : UI/footer update tous les 64 MiB par segment au lieu de
// chaque 4 MiB. Avec 16 segments en parallèle, ça reste ~10-20 updates/s
// au pic, le reporter task échantillonne à 4 Hz donc invisible côté UX.
const long FlushIntervalBytes = 64L * 1024 * 1024;
var buffer = new byte[BufferSize];
long inFlight = 0;
int n;
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
inFlight += n;
if (inFlight >= FlushIntervalBytes)
{
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
seg.DownloadedBytes += inFlight;
onBytes(seg.Index, inFlight);
inFlight = 0;
}
}
// Checkpoint final : flush + ack du reste du buffer.
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
if (inFlight > 0)
{
seg.DownloadedBytes += inFlight;
onBytes(seg.Index, inFlight);
inFlight = 0;
}
seg.Completed = (seg.DownloadedBytes >= seg.Length);
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
// fermé la connexion sans erreur HTTP (typique : PHP-FPM hit `request_terminate_timeout`
// sur un gros segment, OVH coupe la connexion silencieusement, proxy intermédiaire qui
// ferme). Sans cette détection, le segment retourne `Completed=false` mais sans exception,
// Polly ne retry pas, le fichier final a des trous (zones sparse jamais écrites = zéros
// NTFS) et le SHA-256 échoue à la fin. On lance une exception transitoire pour forcer
// un retry — celui-ci reprendra à l'offset où on s'est arrêté grâce à seg.DownloadedBytes.
if (!seg.Completed)
{
var missing = seg.Length - seg.DownloadedBytes;
throw new HttpResumableException(
$"Segment {seg.Index} : connexion fermée prématurément, {missing:N0} octets manquants (probablement timeout PHP-FPM serveur)",
isTransient: true);
}
}
// ===================================================================
// ============= SINGLE-SEGMENT (legacy / fallback path) ============
// ===================================================================
private async Task<string> DownloadSingleSegmentAsync(
DownloadJob job,
string partialPath,
string finalPath,
DownloadState? existing,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
long resumeFrom = 0;
if (existing is not null && File.Exists(partialPath))
{
resumeFrom = new FileInfo(partialPath).Length;
if (resumeFrom != existing.DownloadedBytes)
{
_logger.LogInformation(
"State/file size mismatch ({StateBytes} vs {FileBytes}) — using file size",
existing.DownloadedBytes, resumeFrom);
existing.DownloadedBytes = resumeFrom;
}
}
else if (File.Exists(partialPath))
{
File.Delete(partialPath);
resumeFrom = 0;
}
var state = existing ?? new DownloadState
{
Version = job.Version,
Url = job.Url.ToString(),
TotalBytes = job.ExpectedSize,
DownloadedBytes = 0,
ExpectedSha256 = job.ExpectedSha256,
PartialPath = partialPath,
StartedAtUtc = DateTime.UtcNow,
LastChunkAtUtc = DateTime.UtcNow,
};
await _retryPipeline.ExecuteAsync(async innerCt =>
{
await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
}, ct).ConfigureAwait(false);
var actualSize = new FileInfo(partialPath).Length;
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
{
_stateStore.Discard(job.Version);
throw new InvalidDataException(
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
}
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
return finalPath;
}
private async Task DownloadChunkAsync(
DownloadJob job,
DownloadState state,
string partialPath,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
{
var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return;
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
if (resumeFrom > 0)
{
req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
if (!string.IsNullOrEmpty(state.Etag))
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
else if (!string.IsNullOrEmpty(state.LastModified) &&
DateTimeOffset.TryParse(state.LastModified, out var lm))
req.Headers.IfRange = new RangeConditionHeaderValue(lm);
}
_logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK)
{
_logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
resp.Dispose();
File.Delete(partialPath);
_stateStore.Discard(job.Version);
throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
}
if (!resp.IsSuccessStatusCode)
{
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
throw new HttpResumableException(
$"HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}",
isTransient: transient);
}
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");
var contentLength = resp.Content.Headers.ContentLength ?? 0;
var total = state.TotalBytes;
if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl })
total = cl;
else if (resp.StatusCode == HttpStatusCode.OK && contentLength > 0)
total = contentLength;
if (total > 0) state.TotalBytes = total;
var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create;
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);
var buffer = new byte[BufferSize];
long read = resumeFrom;
var sw = Stopwatch.StartNew();
var lastReport = sw.Elapsed;
long lastReportBytes = read;
var lastFlush = sw.Elapsed;
long lastFlushBytes = read;
int n;
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
read += n;
state.DownloadedBytes = read;
state.LastChunkAtUtc = DateTime.UtcNow;
var elapsed = sw.Elapsed;
if ((elapsed - lastReport).TotalMilliseconds >= 250)
{
var deltaBytes = read - lastReportBytes;
var deltaSeconds = (elapsed - lastReport).TotalSeconds;
var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0;
TimeSpan? eta = null;
if (bps > 0 && state.TotalBytes > 0) eta = TimeSpan.FromSeconds((state.TotalBytes - read) / bps);
progress?.Report(new DownloadProgress(read, state.TotalBytes, bps, eta));
lastReport = elapsed;
lastReportBytes = read;
}
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
read - lastFlushBytes >= StateFlushBytesInterval)
{
await dst.FlushAsync(ct).ConfigureAwait(false);
_stateStore.Save(state);
lastFlush = elapsed;
lastFlushBytes = read;
}
}
await dst.FlushAsync(ct).ConfigureAwait(false);
_stateStore.Save(state);
progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero));
}
// ===================================================================
// ====================== FINALISATION COMMUNE ======================
// ===================================================================
private async Task VerifyAndFinalizeAsync(
DownloadJob job,
string partialPath,
string finalPath,
IProgress<double>? hashProgress,
CancellationToken ct)
{
// Évalue si on doit vérifier le hash :
// 1. Le manifest doit fournir un sha256 valide (pas vide, pas "REPLACE…")
// 2. L'algo demandé n'est pas "none"
// 3. La config locale n'a pas désactivé la vérification globale
bool hashAlgoSkipped = string.Equals(job.HashAlgorithm, "none", StringComparison.OrdinalIgnoreCase);
bool hasUsableSha = !string.IsNullOrEmpty(job.ExpectedSha256)
&& !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase);
if (hasUsableSha && !hashAlgoSkipped && _config.VerifyDownloadHash)
{
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
var sw = Stopwatch.StartNew();
var sha = await _integrity.ComputeSha256Async(partialPath, hashProgress, ct).ConfigureAwait(false);
sw.Stop();
_logger.LogInformation("SHA-256 verified in {Elapsed} ({MBps:0.#} MB/s)",
sw.Elapsed,
sw.Elapsed.TotalSeconds > 0 ? new FileInfo(partialPath).Length / sw.Elapsed.TotalSeconds / (1024.0 * 1024.0) : 0);
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
{
_stateStore.Discard(job.Version);
throw new InvalidDataException(
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
}
}
else if (hashAlgoSkipped)
{
_logger.LogInformation("Hash verification skipped (manifest specifies hashAlgorithm=none)");
}
else if (!_config.VerifyDownloadHash)
{
_logger.LogInformation("Hash verification skipped (VerifyDownloadHash=false in local config)");
}
else
{
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
}
if (File.Exists(finalPath)) File.Delete(finalPath);
File.Move(partialPath, finalPath);
_stateStore.Discard(job.Version);
}
}
/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
internal sealed class HttpResumableException : Exception
{
public bool IsTransient { get; }
public HttpResumableException(string message, bool isTransient) : base(message)
{
IsTransient = isTransient;
}
}
internal static class SparseFileSupport
{
private const uint FSCTL_SET_SPARSE = 0x000900C4;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeviceIoControl(
SafeFileHandle hDevice,
uint dwIoControlCode,
IntPtr lpInBuffer,
uint nInBufferSize,
IntPtr lpOutBuffer,
uint nOutBufferSize,
out uint lpBytesReturned,
IntPtr lpOverlapped);
/// <summary>
/// Marque le fichier comme sparse via FSCTL_SET_SPARSE.
/// Une fois sparse, <c>SetLength(14 GB)</c> est métadata-only (&lt; 100 ms partout).
/// Les zones non écrites sont lues comme zéros virtuels (pas de fuite de données
/// disque, contrairement à SetFileValidData qui exigerait SeManageVolumePrivilege).
/// Retourne false si le volume ne supporte pas sparse (ex. FAT32) — auquel cas
/// on retombe sur le SetLength classique avec zero-fill.
/// </summary>
public static bool TrySetSparse(SafeFileHandle handle)
{
if (!OperatingSystem.IsWindows()) return false;
try
{
return DeviceIoControl(handle, FSCTL_SET_SPARSE,
IntPtr.Zero, 0, IntPtr.Zero, 0, out _, IntPtr.Zero);
}
catch
{
return false;
}
}
}