using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MiGu.Server.Ota; namespace MiGu.Server.Controllers; /// /// WatchDog 回传包接收端。 /// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}, /// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。 /// 会话校验使用 TCP 对端 IP(见 Program 中 TcpRemoteIp),忽略可伪造的 X-Forwarded-For。 /// [ApiController] [AllowAnonymous] public class OtaReceiveController : ControllerBase { public const string TcpRemoteIpItemKey = "TcpRemoteIp"; private readonly OtaStore _store; private readonly ILogger _log; public OtaReceiveController(OtaStore store, ILogger log) { _store = store; _log = log; } [HttpGet("/hello")] [HttpGet("/api/ota/receive/hello")] public IActionResult Hello() => Ok("ok"); /// WatchDog 官方路径:/upload-mdcs/{Medullaexe|...} [HttpPost("/upload-mdcs/{routeKey}")] [HttpPost("/api/ota/receive/upload-mdcs/{routeKey}")] [HttpPost("/api/ota/receive/upload-mdcs{routeKey}")] [RequestSizeLimit(512_000_000)] public Task UploadMdcs(string routeKey, CancellationToken ct) => SaveAsync(routeKey, ct); [HttpPost("/upload-history/{routeKey}")] [HttpPost("/api/ota/receive/upload-history/{routeKey}")] [HttpPost("/api/ota/receive/upload-history{routeKey}")] [RequestSizeLimit(512_000_000)] public async Task UploadHistory(string routeKey, CancellationToken ct) { var ip = ResolveTcpRemoteIp(); if (!_store.TryGetActivePullId(ip, out _)) { _log.LogWarning("OTA history rejected without active pull session from {Ip}", ip ?? "unknown"); return BadRequest("no active pull session"); } var day = DateTime.Now.ToString("yyyy-MM-dd"); var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip ?? "unknown", "unknown")); Directory.CreateDirectory(dir); var file = await ReadFirstFileAsync(ct); if (file == null || file.Length == 0) return BadRequest("empty"); // 净化文件名:routeKey / FileName 都可能含路径分隔符,必须 GetFileName 防穿越。 var rawName = string.IsNullOrWhiteSpace(file.FileName) ? routeKey : file.FileName; var safeName = SafeFileName(rawName, "unnamed"); var path = Path.Combine(dir, safeName); await using var fs = System.IO.File.Create(path); await file.CopyToAsync(fs, ct); _store.NotePullReceive(ip); _log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length); return Ok(new { ok = true }); } private async Task SaveAsync(string routeKey, CancellationToken ct) { try { var clientIp = ResolveTcpRemoteIp(); if (!_store.TryGetActivePullId(clientIp, out _)) { _log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown"); return BadRequest("no active pull session"); } var file = await ReadFirstFileAsync(ct); if (file == null || file.Length == 0) return BadRequest("empty file"); var dest = _store.ResolveReceivePath(routeKey, clientIp); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); await using (var fs = System.IO.File.Create(dest)) await file.CopyToAsync(fs, ct); _store.NotePullReceive(clientIp); _log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length); return Ok(new { ok = true }); } catch (Exception ex) { _log.LogWarning(ex, "OTA receive failed {Route}", routeKey); return BadRequest(ex.Message); } } /// 优先取 ForwardedHeaders 之前写入的 TCP 对端 IP,避免 X-Forwarded-For 投毒。 private string? ResolveTcpRemoteIp() { if (HttpContext.Items.TryGetValue(TcpRemoteIpItemKey, out var boxed) && boxed is string s && !string.IsNullOrWhiteSpace(s)) return s; return HttpContext.Connection.RemoteIpAddress?.ToString(); } private async Task ReadFirstFileAsync(CancellationToken ct) { if (!Request.HasFormContentType) return null; var form = await Request.ReadFormAsync(ct); return form.Files.FirstOrDefault(); } private static string SafeFileName(string raw, string fallback) { var safe = Path.GetFileName(raw); if (string.IsNullOrWhiteSpace(safe)) safe = fallback; foreach (var ch in Path.GetInvalidFileNameChars()) safe = safe.Replace(ch, '_'); return string.IsNullOrWhiteSpace(safe) ? fallback : safe; } }