Files
rr3-server/RR3CommunityServer/Pages/Assets.cshtml.cs
Daniel Elliott f289cdfce9 Add ZIP bulk upload to asset manager
Features:
- Upload ZIP files with folder structure
- Automatic extraction and MD5/SHA256 calculation
- Preserve folder paths as EA CDN paths
- Auto-categorize based on file extensions
- Update existing assets automatically
- Bootstrap tabs for single/bulk upload UI
- Progress feedback (X new, Y updated)

Example: cars/porsche.dat → /cars/porsche.dat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-20 09:42:52 -08:00

470 lines
17 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;
using System.IO.Compression;
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> OnPostUploadZipAsync(
IFormFile zipFile,
string baseCategory,
bool isRequired)
{
try
{
if (zipFile == null || zipFile.Length == 0)
{
Message = "No ZIP file selected.";
IsError = true;
await OnGetAsync();
return Page();
}
if (!zipFile.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
Message = "Please upload a ZIP file.";
IsError = true;
await OnGetAsync();
return Page();
}
// Ensure assets directory exists
if (!Directory.Exists(_assetsBasePath))
{
Directory.CreateDirectory(_assetsBasePath);
}
// Save ZIP to temp location
var tempZipPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".zip");
using (var stream = new FileStream(tempZipPath, FileMode.Create))
{
await zipFile.CopyToAsync(stream);
}
int extractedCount = 0;
int skippedCount = 0;
var errors = new List<string>();
// Extract ZIP and process each file
using (var archive = ZipFile.OpenRead(tempZipPath))
{
foreach (var entry in archive.Entries)
{
try
{
// Skip directories
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/"))
continue;
// Determine category from path in ZIP
var pathParts = entry.FullName.Split('/', '\\');
var category = baseCategory;
// If ZIP has folders, use first folder as subcategory
if (pathParts.Length > 1)
{
category = Path.Combine(baseCategory, pathParts[0]);
}
// Create category subdirectory
var categoryPath = Path.Combine(_assetsBasePath, category);
if (!Directory.Exists(categoryPath))
{
Directory.CreateDirectory(categoryPath);
}
// Extract file
var fileName = entry.Name;
var localPath = Path.Combine(categoryPath, fileName);
// Extract to disk
entry.ExtractToFile(localPath, overwrite: true);
// Calculate hashes
var md5Hash = await CalculateMd5Async(localPath);
var sha256Hash = await CalculateSha256Async(localPath);
var fileInfo = new FileInfo(localPath);
// Build EA CDN path from ZIP structure
var eaCdnPath = "/" + entry.FullName.Replace("\\", "/");
// Check if asset already exists
var existingAsset = await _context.GameAssets
.FirstOrDefaultAsync(a => a.EaCdnPath == eaCdnPath);
if (existingAsset != null)
{
// Update existing
existingAsset.FileName = fileName;
existingAsset.LocalPath = localPath;
existingAsset.FileSize = fileInfo.Length;
existingAsset.Md5Hash = md5Hash;
existingAsset.FileSha256 = sha256Hash;
existingAsset.Category = category;
existingAsset.IsRequired = isRequired;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.UploadedAt = DateTime.UtcNow;
skippedCount++;
}
else
{
// Create new asset
var asset = new GameAsset
{
FileName = fileName,
EaCdnPath = eaCdnPath,
LocalPath = localPath,
FileSize = fileInfo.Length,
Md5Hash = md5Hash,
FileSha256 = sha256Hash,
Category = category,
AssetType = DetermineAssetType(fileName),
IsRequired = isRequired,
Description = $"Extracted from {zipFile.FileName}",
ContentType = GetContentType(fileName),
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
_context.GameAssets.Add(asset);
extractedCount++;
}
}
catch (Exception ex)
{
errors.Add($"{entry.FullName}: {ex.Message}");
_logger.LogError(ex, "Error extracting file from ZIP: {FileName}", entry.FullName);
}
}
}
// Save changes
await _context.SaveChangesAsync();
// Clean up temp ZIP
if (System.IO.File.Exists(tempZipPath))
{
System.IO.File.Delete(tempZipPath);
}
// Build message
if (errors.Any())
{
Message = $"ZIP processed: {extractedCount} new, {skippedCount} updated. Errors: {errors.Count}";
IsError = true;
}
else
{
Message = $"ZIP extracted successfully! {extractedCount} new files, {skippedCount} updated.";
}
_logger.LogInformation("ZIP uploaded: {FileName} -> {ExtractedCount} files", zipFile.FileName, extractedCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing ZIP file");
Message = $"Error processing ZIP: {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"
};
}
private string DetermineAssetType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".png" or ".jpg" or ".jpeg" or ".pvr" or ".atlas" => "Texture",
".mp3" or ".ogg" or ".wav" => "Audio",
".json" or ".xml" => "Config",
".pak" or ".dat" or ".nct" => "Data",
_ => "Data"
};
}
}
public class AssetStats
{
public int TotalAssets { get; set; }
public int AvailableAssets { get; set; }
public long TotalSizeMB { get; set; }
public int TotalDownloads { get; set; }
}