Bugs Fixed: - Fixed SQLite missing column errors (Cash, Gold, Level, etc.) - Fixed LINQ translation error in AssetsController (File.Exists) - Fixed type mismatch in ModdingController (null coalescing) - Applied database migrations for complete schema Database Changes: - Added User currency columns (Gold, Cash, Level, XP, Reputation) - Added Car custom content fields (IsCustom, CustomAuthor, CustomVersion) - Added GameAsset metadata fields (Md5Hash, CompressedSize) - Added ModPacks table for mod bundling Testing: - Comprehensive test report: COMPREHENSIVE_TEST_REPORT.md - 9/9 critical endpoints passing - All APK-required functionality verified - Database operations validated - Response format compatibility confirmed Status: ✅ Server is production-ready (pending assets) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
287 lines
9.6 KiB
C#
287 lines
9.6 KiB
C#
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<AssetsController> _logger;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly string _assetsBasePath;
|
|
|
|
public AssetsController(RR3DbContext context, ILogger<AssetsController> logger, IConfiguration configuration)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
_configuration = configuration;
|
|
|
|
// Path where .pak files are stored
|
|
_assetsBasePath = configuration.GetValue<string>("AssetsBasePath")
|
|
?? Path.Combine(Directory.GetCurrentDirectory(), "Assets", "downloaded");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get asset manifest - lists all available assets
|
|
/// </summary>
|
|
[HttpGet("manifest")]
|
|
public async Task<ActionResult<SynergyResponse<List<AssetManifestEntry>>>> 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<List<AssetManifestEntry>>
|
|
{
|
|
resultCode = 0,
|
|
message = "Success",
|
|
data = assets
|
|
};
|
|
|
|
return Ok(response);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Download a specific asset file
|
|
/// Matches Cloudcell CDN URL pattern: /path/to/asset.ext
|
|
/// </summary>
|
|
[HttpGet("{**assetPath}")]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get asset info (metadata)
|
|
/// </summary>
|
|
[HttpGet("info/{**assetPath}")]
|
|
public async Task<ActionResult<SynergyResponse<object>>> 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<object>
|
|
{
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if assets are available for download
|
|
/// </summary>
|
|
[HttpGet("status")]
|
|
public async Task<ActionResult<SynergyResponse<object>>> 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<object>
|
|
{
|
|
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<string> 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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asset manifest entry matching RR3 manifest format
|
|
/// </summary>
|
|
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; }
|
|
}
|