- Created Assets.cshtml and Assets.cshtml.cs for admin panel
- Upload assets with MD5/SHA256 hash calculation
- Generate asset manifests in RR3 format (tab-separated)
- Integrated with Nimble SDK asset download system
- Updated GameAsset model with IsRequired, UploadedAt, Description
- Added navigation link in _Layout.cshtml
- Supports categories: base, cars, tracks, audio, textures, UI, DLC
- Asset download endpoint at /content/api/{assetPath}
- Manifest endpoint at /content/api/manifest
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
290 lines
10 KiB
C#
290 lines
10 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using RR3CommunityServer.Data;
|
|
using RR3CommunityServer.Models;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace RR3CommunityServer.Pages;
|
|
|
|
[Authorize]
|
|
public class AssetsModel : PageModel
|
|
{
|
|
private readonly RR3DbContext _context;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<AssetsModel> _logger;
|
|
private readonly string _assetsBasePath;
|
|
|
|
public AssetsModel(RR3DbContext context, IConfiguration configuration, ILogger<AssetsModel> logger)
|
|
{
|
|
_context = context;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
_assetsBasePath = configuration.GetValue<string>("AssetsBasePath")
|
|
?? Path.Combine(Directory.GetCurrentDirectory(), "Assets", "downloaded");
|
|
}
|
|
|
|
public List<GameAsset> Assets { get; set; } = new();
|
|
public AssetStats Stats { get; set; } = new();
|
|
public string? Message { get; set; }
|
|
public bool IsError { get; set; }
|
|
|
|
public async Task OnGetAsync()
|
|
{
|
|
Assets = await _context.GameAssets
|
|
.OrderByDescending(a => a.UploadedAt)
|
|
.ToListAsync();
|
|
|
|
await CalculateStatsAsync();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostUploadAsync(
|
|
IFormFile assetFile,
|
|
string eaCdnPath,
|
|
string category,
|
|
string assetType,
|
|
bool isRequired,
|
|
string? description)
|
|
{
|
|
try
|
|
{
|
|
if (assetFile == null || assetFile.Length == 0)
|
|
{
|
|
Message = "No file selected.";
|
|
IsError = true;
|
|
await OnGetAsync();
|
|
return Page();
|
|
}
|
|
|
|
// Ensure assets directory exists
|
|
if (!Directory.Exists(_assetsBasePath))
|
|
{
|
|
Directory.CreateDirectory(_assetsBasePath);
|
|
}
|
|
|
|
// Create category subdirectory
|
|
var categoryPath = Path.Combine(_assetsBasePath, category);
|
|
if (!Directory.Exists(categoryPath))
|
|
{
|
|
Directory.CreateDirectory(categoryPath);
|
|
}
|
|
|
|
// Save file to disk
|
|
var fileName = Path.GetFileName(assetFile.FileName);
|
|
var localPath = Path.Combine(categoryPath, fileName);
|
|
|
|
using (var stream = new FileStream(localPath, FileMode.Create))
|
|
{
|
|
await assetFile.CopyToAsync(stream);
|
|
}
|
|
|
|
// Calculate MD5 and SHA256
|
|
var md5Hash = await CalculateMd5Async(localPath);
|
|
var sha256Hash = await CalculateSha256Async(localPath);
|
|
var fileInfo = new FileInfo(localPath);
|
|
|
|
// Normalize EA CDN path
|
|
if (!eaCdnPath.StartsWith("/"))
|
|
{
|
|
eaCdnPath = "/" + eaCdnPath;
|
|
}
|
|
|
|
// Check if asset already exists
|
|
var existingAsset = await _context.GameAssets
|
|
.FirstOrDefaultAsync(a => a.EaCdnPath == eaCdnPath);
|
|
|
|
if (existingAsset != null)
|
|
{
|
|
// Update existing asset
|
|
existingAsset.FileName = fileName;
|
|
existingAsset.LocalPath = localPath;
|
|
existingAsset.FileSize = fileInfo.Length;
|
|
existingAsset.Md5Hash = md5Hash;
|
|
existingAsset.FileSha256 = sha256Hash;
|
|
existingAsset.Category = category;
|
|
existingAsset.AssetType = assetType;
|
|
existingAsset.IsRequired = isRequired;
|
|
existingAsset.Description = description;
|
|
existingAsset.ContentType = GetContentType(fileName);
|
|
existingAsset.UploadedAt = DateTime.UtcNow;
|
|
|
|
Message = $"Asset '{fileName}' updated successfully!";
|
|
}
|
|
else
|
|
{
|
|
// Create new asset
|
|
var asset = new GameAsset
|
|
{
|
|
FileName = fileName,
|
|
EaCdnPath = eaCdnPath,
|
|
LocalPath = localPath,
|
|
FileSize = fileInfo.Length,
|
|
Md5Hash = md5Hash,
|
|
FileSha256 = sha256Hash,
|
|
Category = category,
|
|
AssetType = assetType,
|
|
IsRequired = isRequired,
|
|
Description = description,
|
|
ContentType = GetContentType(fileName),
|
|
UploadedAt = DateTime.UtcNow,
|
|
DownloadedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.GameAssets.Add(asset);
|
|
Message = $"Asset '{fileName}' uploaded successfully!";
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInformation("Asset uploaded: {FileName} -> {CdnPath}", fileName, eaCdnPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error uploading asset");
|
|
Message = $"Error uploading asset: {ex.Message}";
|
|
IsError = true;
|
|
}
|
|
|
|
await OnGetAsync();
|
|
return Page();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
|
{
|
|
try
|
|
{
|
|
var asset = await _context.GameAssets.FindAsync(id);
|
|
if (asset == null)
|
|
{
|
|
Message = "Asset not found.";
|
|
IsError = true;
|
|
await OnGetAsync();
|
|
return Page();
|
|
}
|
|
|
|
// Delete file from disk
|
|
if (!string.IsNullOrEmpty(asset.LocalPath) && System.IO.File.Exists(asset.LocalPath))
|
|
{
|
|
System.IO.File.Delete(asset.LocalPath);
|
|
}
|
|
|
|
_context.GameAssets.Remove(asset);
|
|
await _context.SaveChangesAsync();
|
|
|
|
Message = $"Asset '{asset.FileName}' deleted successfully!";
|
|
_logger.LogInformation("Asset deleted: {FileName}", asset.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting asset");
|
|
Message = $"Error deleting asset: {ex.Message}";
|
|
IsError = true;
|
|
}
|
|
|
|
await OnGetAsync();
|
|
return Page();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostGenerateManifestAsync()
|
|
{
|
|
try
|
|
{
|
|
var assets = await _context.GameAssets.ToListAsync();
|
|
|
|
// Generate manifest in RR3 format (tab-separated)
|
|
var manifestContent = new System.Text.StringBuilder();
|
|
foreach (var asset in assets)
|
|
{
|
|
// Format: /path/to/file.ext md5hash compressedSize uncompressedSize
|
|
manifestContent.AppendLine($"{asset.EaCdnPath}\t{asset.Md5Hash}\t{asset.CompressedSize ?? asset.FileSize}\t{asset.FileSize}");
|
|
}
|
|
|
|
// Save to Assets directory
|
|
var manifestPath = Path.Combine(_assetsBasePath, "asset_list_community.txt");
|
|
await System.IO.File.WriteAllTextAsync(manifestPath, manifestContent.ToString());
|
|
|
|
// Also generate JSON manifest for API
|
|
var jsonManifest = assets.Select(a => new
|
|
{
|
|
path = a.EaCdnPath,
|
|
md5 = a.Md5Hash,
|
|
compressedSize = a.CompressedSize ?? a.FileSize,
|
|
uncompressedSize = a.FileSize,
|
|
category = a.Category,
|
|
required = a.IsRequired
|
|
});
|
|
|
|
var jsonPath = Path.Combine(_assetsBasePath, "asset_manifest_community.json");
|
|
await System.IO.File.WriteAllTextAsync(jsonPath,
|
|
System.Text.Json.JsonSerializer.Serialize(jsonManifest, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
|
|
|
|
Message = $"Manifest generated successfully! ({assets.Count} assets)";
|
|
_logger.LogInformation("Asset manifest generated with {Count} assets", assets.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error generating manifest");
|
|
Message = $"Error generating manifest: {ex.Message}";
|
|
IsError = true;
|
|
}
|
|
|
|
await OnGetAsync();
|
|
return Page();
|
|
}
|
|
|
|
private async Task CalculateStatsAsync()
|
|
{
|
|
Stats.TotalAssets = Assets.Count;
|
|
Stats.AvailableAssets = Assets.Count(a => !string.IsNullOrEmpty(a.LocalPath) && System.IO.File.Exists(a.LocalPath));
|
|
Stats.TotalSizeMB = (long)(Assets.Sum(a => a.FileSize) / 1024.0 / 1024.0);
|
|
Stats.TotalDownloads = Assets.Sum(a => a.AccessCount);
|
|
}
|
|
|
|
private async Task<string> CalculateMd5Async(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 async Task<string> CalculateSha256Async(string filePath)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
using var stream = System.IO.File.OpenRead(filePath);
|
|
var hash = await sha256.ComputeHashAsync(stream);
|
|
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
}
|
|
|
|
private string GetContentType(string fileName)
|
|
{
|
|
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
|
return extension switch
|
|
{
|
|
".pak" => "application/octet-stream",
|
|
".dat" => "application/octet-stream",
|
|
".nct" => "application/octet-stream",
|
|
".z" => "application/x-compress",
|
|
".json" => "application/json",
|
|
".xml" => "application/xml",
|
|
".png" => "image/png",
|
|
".jpg" or ".jpeg" => "image/jpeg",
|
|
".pvr" => "image/pvr",
|
|
".atlas" => "application/octet-stream",
|
|
".mp3" => "audio/mpeg",
|
|
".ogg" => "audio/ogg",
|
|
".wav" => "audio/wav",
|
|
_ => "application/octet-stream"
|
|
};
|
|
}
|
|
}
|
|
|
|
public class AssetStats
|
|
{
|
|
public int TotalAssets { get; set; }
|
|
public int AvailableAssets { get; set; }
|
|
public long TotalSizeMB { get; set; }
|
|
public int TotalDownloads { get; set; }
|
|
}
|