using System.Text.Json; using MiGu.Server.Infra; using Microsoft.Extensions.Options; namespace MiGu.Server.Ota; public sealed class OtaStore { private readonly object _gate = new(); private readonly ILogger _log; private readonly JsonSerializerOptions _json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; public string Root { get; } public string PackagesDir { get; } public string JobsDir { get; } public string HistoryDir { get; } public string TargetFile { get; } public string SettingsFile { get; } private string? _lastPullId; private readonly System.Collections.Concurrent.ConcurrentDictionary _pullByIp = new(StringComparer.OrdinalIgnoreCase); private readonly System.Collections.Concurrent.ConcurrentDictionary _pullLastReceiveUtc = new(StringComparer.OrdinalIgnoreCase); private long _jobSeq; public OtaStore(IWebHostEnvironment env, IOptions options, ILogger log) { _log = log; var cfg = options.Value.DataRoot; Root = Path.IsPathRooted(cfg) ? cfg : Path.Combine(env.ContentRootPath, cfg); PackagesDir = Path.Combine(Root, "packages"); JobsDir = Path.Combine(Root, "jobs"); HistoryDir = Path.Combine(Root, "history"); TargetFile = Path.Combine(Root, "target.json"); SettingsFile = Path.Combine(Root, "settings.json"); Directory.CreateDirectory(PackagesDir); Directory.CreateDirectory(JobsDir); Directory.CreateDirectory(HistoryDir); } public OtaSettings GetSettings() { lock (_gate) { if (!File.Exists(SettingsFile)) return new OtaSettings(); try { return JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings(); } catch (Exception ex) { _log.LogWarning(ex, "OTA settings load failed"); return new OtaSettings(); } } } public OtaSettings SaveSettings(OtaSettings settings) { lock (_gate) { AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json)); return settings; } } public OtaTarget? GetTarget() { lock (_gate) { if (!File.Exists(TargetFile)) return null; try { return JsonSerializer.Deserialize(File.ReadAllText(TargetFile), _json); } catch (Exception ex) { _log.LogWarning(ex, "OTA target load failed"); return null; } } } public void SetTarget(OtaTarget target) { lock (_gate) { AtomicFile.WriteAllText(TargetFile, JsonSerializer.Serialize(target, _json)); var settings = GetSettingsUnlocked(); if (!string.IsNullOrWhiteSpace(target.Name)) { settings.NewVersionName = target.Name; AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json)); } } } private OtaSettings GetSettingsUnlocked() { if (!File.Exists(SettingsFile)) return new OtaSettings(); try { return JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings(); } catch { return new OtaSettings(); } } public string BeginPullPackage(string? sourceIp) { lock (_gate) { var baseId = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}({SafeIdPart(sourceIp ?? "upload")})"; var id = baseId; var dir = Path.Combine(PackagesDir, id); for (var i = 1; Directory.Exists(dir); i++) { id = $"{baseId}-{i:D2}"; dir = Path.Combine(PackagesDir, id); } Directory.CreateDirectory(dir); foreach (var app in new[] { "M", "D", "C" }) Directory.CreateDirectory(Path.Combine(dir, app)); Directory.CreateDirectory(Path.Combine(dir, "M", "plugins")); var ip = NormalizeIp(sourceIp); if (ip != null) _pullByIp[ip] = id; _lastPullId = id; return id; } } public string? ActivePullId { get { lock (_gate) return _lastPullId; } } public bool TryGetActivePullId(string? sourceIp, out string? id) { lock (_gate) { var ip = NormalizeIp(sourceIp); // 必须匹配会话 IP;禁止无 IP 时回退到最近一次拉包(可被伪造/误写)。 if (ip != null && _pullByIp.TryGetValue(ip, out var byIp)) { id = byIp; return true; } id = null; return false; } } public void NotePullReceive(string? sourceIp) { var ip = NormalizeIp(sourceIp); if (ip == null) return; _pullLastReceiveUtc[ip] = DateTime.UtcNow; } public bool TryGetLastReceiveAt(string? sourceIp, out DateTime utc) { var ip = NormalizeIp(sourceIp); if (ip != null && _pullLastReceiveUtc.TryGetValue(ip, out utc)) return true; utc = default; return false; } public void ClearActivePull(string? sourceIp = null) { lock (_gate) { var ip = NormalizeIp(sourceIp); if (ip != null) { _pullByIp.TryRemove(ip, out _); _pullLastReceiveUtc.TryRemove(ip, out _); } if (_pullByIp.IsEmpty) _lastPullId = null; } } /// 把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。 private static string? NormalizeIp(string? ip) { if (string.IsNullOrWhiteSpace(ip)) return null; if (System.Net.IPAddress.TryParse(ip, out var addr)) return (addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4() : addr).ToString(); return ip.Trim(); } public string ResolveReceivePath(string routeKey, string? clientIp = null) { // routeKey examples: Medullaexe, Medulladll, Detourexe, ClumsyConsoleexe, ClumsyConsoledll // Do not fall back to the latest session for a known-but-unregistered client. if (!TryGetActivePullId(clientIp, out var id) || id == null) throw new InvalidOperationException("无进行中的拉取会话"); var dir = Path.Combine(PackagesDir, id); return routeKey.ToLowerInvariant() switch { "medullaexe" => Path.Combine(dir, "M", "Medulla.exe"), "medulladll" => Path.Combine(dir, "M", "plugins", "CartActivator.dll"), "medullapdb" => Path.Combine(dir, "M", "plugins", "CartActivator.pdb"), "detourexe" => Path.Combine(dir, "D", "Detour.exe"), "clumsyconsoleexe" or "clumsyexe" => Path.Combine(dir, "C", "ClumsyConsole.exe"), "clumsyconsoledll" or "clumsydll" => Path.Combine(dir, "C", "FG2305014_C.dll"), "clumsyconsolepdb" or "clumsypdb" => Path.Combine(dir, "C", "FG2305014_C.pdb"), _ => throw new ArgumentException($"未知接收路由: {routeKey}") }; } public OtaPackageInfo ScanPackage(string id) { var dir = ResolveUnder(PackagesDir, id); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(id); var info = new OtaPackageInfo { Id = id, CreatedAt = Directory.GetCreationTimeUtc(dir), SourceIp = ExtractSourceIp(id) }; long total = 0; foreach (var key in OtaPathMap.ComponentKeys) { var rel = OtaPathMap.RelPathFor(key); if (rel == null) continue; var path = Path.Combine(dir, rel.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(path)) continue; var fi = new FileInfo(path); total += fi.Length; info.Components[key] = new OtaFileArtifact { Hash = OtaHash.OfFile(path), Path = path, FileName = Path.GetFileName(path), Time = fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"), Size = fi.Length }; } info.TotalBytes = total; var target = GetTarget(); info.IsTarget = target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal); return info; } public List ListPackages() { if (!Directory.Exists(PackagesDir)) return new(); var list = new List(); foreach (var dir in Directory.GetDirectories(PackagesDir).OrderByDescending(d => d)) { try { list.Add(ScanPackage(Path.GetFileName(dir))); } catch (Exception ex) { _log.LogDebug(ex, "skip package {Dir}", dir); } } return list; } public void DeletePackage(string id) { lock (_gate) { var target = GetTarget(); if (target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal)) throw new InvalidOperationException("不能删除当前目标版本"); var dir = ResolveUnder(PackagesDir, id); if (Directory.Exists(dir)) Directory.Delete(dir, true); } } public OtaTarget ActivatePackage(string id, string? name) { var pkg = ScanPackage(id); if (pkg.Components.Count == 0) throw new InvalidOperationException("包内无有效组件文件"); var safeId = RequireSafeId(id); var target = new OtaTarget { PackageId = safeId, Name = name ?? safeId, ActivatedAt = DateTimeOffset.UtcNow, Components = pkg.Components }; SetTarget(target); return target; } public string PackageDir(string id) => ResolveUnder(PackagesDir, id); public void SaveJob(OtaJob job) { lock (_gate) { var path = ResolveUnder(JobsDir, $"{RequireSafeId(job.Id)}.json"); AtomicFile.WriteAllText(path, JsonSerializer.Serialize(job, _json)); } } public OtaJob? GetJob(string id) { var path = ResolveUnder(JobsDir, $"{RequireSafeId(id)}.json"); if (!File.Exists(path)) return null; try { return JsonSerializer.Deserialize(File.ReadAllText(path), _json); } catch (Exception ex) { _log.LogWarning(ex, "job load failed {Id}", id); return null; } } public List ListJobs(int take = 100) { if (!Directory.Exists(JobsDir)) return new(); return Directory.GetFiles(JobsDir, "*.json") .Select(f => { try { return JsonSerializer.Deserialize(File.ReadAllText(f), _json); } catch { return null; } }) .Where(j => j != null) .Cast() .OrderByDescending(j => j.CreatedAt) .Take(take) .ToList(); } public string NextJobId() { // 进程内自增序号保证唯一(同秒也不会撞 ID → 不会两个 job 写同一文件)。 var seq = System.Threading.Interlocked.Increment(ref _jobSeq); return $"J{DateTime.UtcNow:yyyyMMddHHmmss}-{seq:D4}"; } private static string? ExtractSourceIp(string id) { var open = id.IndexOf('('); var close = id.IndexOf(')'); if (open >= 0 && close > open) return id[(open + 1)..close]; return null; } private static string SafeIdPart(string raw) { var safe = raw.Trim(); foreach (var ch in Path.GetInvalidFileNameChars()) safe = safe.Replace(ch, '_'); return string.IsNullOrWhiteSpace(safe) ? "upload" : safe; } /// 拒绝路径段(含 .. / 分隔符),只允许单层文件名。 private static string RequireSafeId(string id) { if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("无效标识"); var trimmed = id.Trim(); if (trimmed is "." or ".." || trimmed.Contains('/') || trimmed.Contains('\\') || trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) throw new ArgumentException("无效标识"); return trimmed; } private string ResolveUnder(string root, string id) { var safe = RequireSafeId(id); var fullRoot = Path.GetFullPath(root); var full = Path.GetFullPath(Path.Combine(fullRoot, safe)); var prefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && !string.Equals(full, fullRoot, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("无效标识"); return full; } }