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; using System.Text.Json; namespace RR3CommunityServer.Pages; [Authorize] public class AssetsModel : PageModel { private readonly RR3DbContext _context; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly string _assetsBasePath; public AssetsModel(RR3DbContext context, IConfiguration configuration, ILogger logger) { _context = context; _configuration = configuration; _logger = logger; _assetsBasePath = configuration.GetValue("AssetsBasePath") ?? Path.Combine(Directory.GetCurrentDirectory(), "Assets", "downloaded"); } public List 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 OnPostUploadAsync( IFormFile assetFile, string eaCdnPath, string category, string assetType, bool isRequired, string gameVersion, 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.Version = gameVersion; 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), Version = gameVersion, 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 OnPostUploadZipAsync( IFormFile zipFile, string baseCategory, string? gameVersion, 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(); // Try to parse manifest file if exists AssetManifest? manifest = null; using (var archive = ZipFile.OpenRead(tempZipPath)) { var manifestEntry = archive.Entries.FirstOrDefault(e => e.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) || e.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase)); if (manifestEntry != null) { try { using var stream = manifestEntry.Open(); using var reader = new StreamReader(stream); var manifestContent = await reader.ReadToEndAsync(); if (manifestEntry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { manifest = JsonSerializer.Deserialize(manifestContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } if (manifest != null) { _logger.LogInformation("Loaded manifest: Version={Version}, GameVersion={GameVersion}", manifest.Version, manifest.GameVersion); // Override parameters from manifest if provided if (!string.IsNullOrEmpty(manifest.GameVersion)) gameVersion = manifest.GameVersion; if (!string.IsNullOrEmpty(manifest.Category) && baseCategory == "auto") baseCategory = manifest.Category; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse manifest file, using defaults"); } } } // Extract ZIP and process each file using (var archive = ZipFile.OpenRead(tempZipPath)) { foreach (var entry in archive.Entries) { try { // Skip directories and manifest files if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/") || entry.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) || entry.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase)) continue; // Check if this file has manifest metadata ManifestAsset? manifestAsset = manifest?.Assets?.FirstOrDefault(a => a.File.Replace("\\", "/").Equals(entry.FullName.Replace("\\", "/"), StringComparison.OrdinalIgnoreCase)); // Determine category from manifest or path in ZIP var pathParts = entry.FullName.Split('/', '\\'); var category = manifestAsset?.Category ?? baseCategory; // Auto-detect if category is "auto" if (category == "auto") { category = SmartDetectCategory(entry.FullName) ?? "base"; } // If ZIP has folders and no manifest, use first folder as subcategory if (manifestAsset == null && pathParts.Length > 1 && category != "auto") { category = Path.Combine(category, 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("\\", "/"); // Determine asset type from manifest or filename var assetType = manifestAsset?.Type ?? DetermineAssetType(fileName); var required = manifestAsset?.Required ?? isRequired; var description = manifestAsset?.Description; // 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.AssetType = assetType; existingAsset.IsRequired = required; existingAsset.Description = description; existingAsset.ContentType = GetContentType(fileName); existingAsset.Version = gameVersion; 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 = assetType, IsRequired = required, Description = description ?? $"Extracted from {zipFile.FileName}", ContentType = GetContentType(fileName), Version = gameVersion, 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 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 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 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 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? SmartDetectCategory(string fullPath) { var lowerPath = fullPath.ToLowerInvariant(); // Check for known keywords in path if (lowerPath.Contains("car") || lowerPath.Contains("vehicle") || lowerPath.Contains("automobile")) return "cars"; if (lowerPath.Contains("track") || lowerPath.Contains("circuit") || lowerPath.Contains("course")) return "tracks"; if (lowerPath.Contains("audio") || lowerPath.Contains("sound") || lowerPath.Contains("music")) return "audio"; if (lowerPath.Contains("texture") || lowerPath.Contains("material") || lowerPath.Contains("skin")) return "textures"; if (lowerPath.Contains("ui") || lowerPath.Contains("hud") || lowerPath.Contains("menu")) return "ui"; if (lowerPath.Contains("event") || lowerPath.Contains("race") || lowerPath.Contains("challenge")) return "events"; if (lowerPath.Contains("dlc") || lowerPath.Contains("expansion") || lowerPath.Contains("addon")) return "dlc"; if (lowerPath.Contains("update") || lowerPath.Contains("patch")) return "updates"; // Fall back to first folder name var pathParts = fullPath.Split('/', '\\'); if (pathParts.Length > 1) return pathParts[0].ToLowerInvariant(); return null; // Will default to "base" } 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; } } // Manifest models for automatic ZIP metadata detection public class AssetManifest { public string? Version { get; set; } public string? GameVersion { get; set; } public string? Description { get; set; } public string? Author { get; set; } public string? Category { get; set; } public List? Assets { get; set; } } public class ManifestAsset { public string File { get; set; } = string.Empty; public string? Category { get; set; } public string? Type { get; set; } public bool Required { get; set; } = true; public string? Description { get; set; } }