using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RR3CommunityServer.Data; using RR3CommunityServer.Models; using System.Security.Cryptography; using System.Text; namespace RR3CommunityServer.Controllers; [ApiController] [Route("content/api")] public class AssetsController : ControllerBase { private readonly RR3DbContext _context; private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly string _assetsBasePath; public AssetsController(RR3DbContext context, ILogger logger, IConfiguration configuration) { _context = context; _logger = logger; _configuration = configuration; // Path where .pak files are stored _assetsBasePath = configuration.GetValue("AssetsBasePath") ?? Path.Combine(Directory.GetCurrentDirectory(), "Assets", "downloaded"); } /// /// Get asset manifest - lists all available assets /// [HttpGet("manifest")] public async Task>>> GetManifest( [FromQuery] string? category = null) { _logger.LogInformation("GetManifest request - category: {Category}", category ?? "all"); var assets = await _context.GameAssets .Where(a => category == null || a.Category == category) .Select(a => new AssetManifestEntry { Path = a.EaCdnPath, Md5 = a.Md5Hash, CompressedSize = a.CompressedSize ?? a.FileSize, UncompressedSize = a.FileSize, Category = a.Category }) .ToListAsync(); var response = new SynergyResponse> { resultCode = 0, message = "Success", data = assets }; return Ok(response); } /// /// Download a specific asset file /// Matches Cloudcell CDN URL pattern: /path/to/asset.ext /// [HttpGet("{**assetPath}")] public async Task DownloadAsset(string assetPath) { _logger.LogInformation("Asset download request: {Path}", assetPath); // Ensure path starts with / if (!assetPath.StartsWith("/")) { assetPath = "/" + assetPath; } // Find asset in database var asset = await _context.GameAssets .FirstOrDefaultAsync(a => a.EaCdnPath == assetPath || a.FileName == Path.GetFileName(assetPath)); if (asset == null) { _logger.LogWarning("Asset not found: {Path}", assetPath); return NotFound(new { error = "Asset not found", path = assetPath }); } // Get file path string? filePath = asset.LocalPath; if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath)) { // Try to find file in assets directory filePath = FindAssetFile(assetPath); if (string.IsNullOrEmpty(filePath)) { _logger.LogWarning("Asset file not found on disk: {Path}", assetPath); return NotFound(new { error = "Asset file not available", path = assetPath }); } } // Verify MD5 if available if (!string.IsNullOrEmpty(asset.Md5Hash)) { var fileMd5 = await CalculateMd5(filePath); if (!fileMd5.Equals(asset.Md5Hash, StringComparison.OrdinalIgnoreCase)) { _logger.LogError("MD5 mismatch for {Path}: expected {Expected}, got {Actual}", assetPath, asset.Md5Hash, fileMd5); return StatusCode(500, new { error = "Asset integrity check failed" }); } } // Update access tracking asset.AccessCount++; asset.LastAccessedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); // Determine content type var contentType = GetContentType(assetPath); // Stream file var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, true); _logger.LogInformation("Serving asset: {Path} ({Size} bytes)", assetPath, asset.FileSize); return File(stream, contentType, enableRangeProcessing: true); } /// /// Get asset info (metadata) /// [HttpGet("info/{**assetPath}")] public async Task>> GetAssetInfo(string assetPath) { if (!assetPath.StartsWith("/")) { assetPath = "/" + assetPath; } var asset = await _context.GameAssets .FirstOrDefaultAsync(a => a.EaCdnPath == assetPath); if (asset == null) { return NotFound(new { error = "Asset not found" }); } var response = new SynergyResponse { resultCode = 0, message = "Success", data = new { path = asset.EaCdnPath, fileName = asset.FileName, size = asset.FileSize, compressedSize = asset.CompressedSize, md5 = asset.Md5Hash, sha256 = asset.FileSha256, contentType = asset.ContentType, category = asset.Category, assetType = asset.AssetType, available = !string.IsNullOrEmpty(asset.LocalPath) && System.IO.File.Exists(asset.LocalPath), downloadedAt = asset.DownloadedAt, accessCount = asset.AccessCount, lastAccessed = asset.LastAccessedAt } }; return Ok(response); } /// /// Check if assets are available for download /// [HttpGet("status")] public async Task>> GetStatus() { var totalAssets = await _context.GameAssets.CountAsync(); // Get all assets with LocalPath, then check file existence in memory var assetsWithPath = await _context.GameAssets .Where(a => !string.IsNullOrEmpty(a.LocalPath)) .Select(a => a.LocalPath) .ToListAsync(); var availableAssets = assetsWithPath.Count(path => System.IO.File.Exists(path)); var categoryCounts = await _context.GameAssets .GroupBy(a => a.Category) .Select(g => new { Category = g.Key, Count = g.Count() }) .ToListAsync(); var response = new SynergyResponse { resultCode = 0, message = "Success", data = new { totalAssets = totalAssets, availableAssets = availableAssets, percentageAvailable = totalAssets > 0 ? (availableAssets * 100.0 / totalAssets) : 0, categories = categoryCounts.Select(c => new { name = c.Category ?? "uncategorized", count = c.Count }), basePath = _assetsBasePath, status = availableAssets > 0 ? "ready" : "waiting_for_assets" } }; return Ok(response); } #region Helper Methods private string? FindAssetFile(string assetPath) { // Try multiple possible locations var possiblePaths = new[] { Path.Combine(_assetsBasePath, assetPath.TrimStart('/')), Path.Combine(_assetsBasePath, Path.GetFileName(assetPath)), // Try in categorized folders Path.Combine(_assetsBasePath, "cars", Path.GetFileName(assetPath)), Path.Combine(_assetsBasePath, "tracks", Path.GetFileName(assetPath)), Path.Combine(_assetsBasePath, "audio", Path.GetFileName(assetPath)), Path.Combine(_assetsBasePath, "textures", Path.GetFileName(assetPath)), Path.Combine(_assetsBasePath, "ui", Path.GetFileName(assetPath)), }; foreach (var path in possiblePaths) { if (System.IO.File.Exists(path)) { return path; } } return null; } private async Task CalculateMd5(string filePath) { using var md5 = MD5.Create(); using var stream = System.IO.File.OpenRead(filePath); var hash = await md5.ComputeHashAsync(stream); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } private string GetContentType(string path) { var extension = Path.GetExtension(path).ToLowerInvariant(); return extension switch { ".pak" => "application/octet-stream", ".dat" => "application/octet-stream", ".nct" => "application/octet-stream", ".json" => "application/json", ".xml" => "application/xml", ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".pvr" => "image/pvr", ".atlas" => "application/octet-stream", ".z" => "application/x-compress", ".mp3" => "audio/mpeg", ".ogg" => "audio/ogg", ".wav" => "audio/wav", _ => "application/octet-stream" }; } #endregion } /// /// Asset manifest entry matching RR3 manifest format /// public class AssetManifestEntry { public string Path { get; set; } = string.Empty; public string Md5 { get; set; } = string.Empty; public long CompressedSize { get; set; } public long UncompressedSize { get; set; } public string? Category { get; set; } }