Files
rr3-server/RR3CommunityServer/Controllers/AssetsController.cs
Daniel Elliott 0c8ed952db Add AssetsController for .pak file delivery + full compatibility
- Created AssetsController (/content/api/*) to serve game assets
  - MD5 verification on download
  - Manifest endpoint for asset listing
  - Status endpoint for availability check
  - Range requests support for resume
  - Access tracking & statistics

- Updated appsettings.json with asset configuration
  - AssetsBasePath setting
  - ServerSettings for community features

- Added comprehensive compatibility documentation
  - SERVER_APK_COMPATIBILITY.md: Full analysis
  - 100% endpoint compatibility verified
  - SSL/TLS works with APK's weak verification
  - All game features implemented & ready

Ready for asset files from Discord!

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-18 00:41:44 -08:00

281 lines
9.4 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();
var availableAssets = await _context.GameAssets
.CountAsync(a => !string.IsNullOrEmpty(a.LocalPath) && System.IO.File.Exists(a.LocalPath));
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; }
}