Files
rr3-server/RR3CommunityServer/Controllers/ModdingController.cs
Daniel Elliott 7a683f636e Fix database bugs and add comprehensive test report 🔧📋
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>
2026-02-18 02:00:52 -08:00

498 lines
16 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using RR3CommunityServer.Models;
using System.Security.Cryptography;
using static RR3CommunityServer.Data.RR3DbContext;
namespace RR3CommunityServer.Controllers;
[ApiController]
[Route("modding/api")]
public class ModdingController : ControllerBase
{
private readonly RR3DbContext _context;
private readonly ILogger<ModdingController> _logger;
private readonly IConfiguration _configuration;
private readonly string _customAssetsPath;
private readonly string _modsPath;
public ModdingController(RR3DbContext context, ILogger<ModdingController> logger, IConfiguration configuration)
{
_context = context;
_logger = logger;
_configuration = configuration;
_customAssetsPath = configuration.GetValue<string>("CustomAssetsPath")
?? Path.Combine(Directory.GetCurrentDirectory(), "Assets", "custom");
_modsPath = configuration.GetValue<string>("ModsPath")
?? Path.Combine(Directory.GetCurrentDirectory(), "Assets", "mods");
// Ensure directories exist
Directory.CreateDirectory(_customAssetsPath);
Directory.CreateDirectory(_modsPath);
}
/// <summary>
/// Upload a custom car
/// </summary>
[HttpPost("cars/upload")]
[RequestSizeLimit(100_000_000)] // 100 MB max
public async Task<IActionResult> UploadCustomCar([FromForm] CustomCarUpload upload)
{
_logger.LogInformation("Custom car upload: {Name} by {Author}", upload.CarName, upload.AuthorName);
try
{
// Validate files
if (upload.Model3D == null || upload.Thumbnail == null)
{
return BadRequest(new { error = "Model and thumbnail are required" });
}
// Generate unique car ID
var carId = $"custom_{Guid.NewGuid():N}";
var carFolder = Path.Combine(_customAssetsPath, "cars", carId);
Directory.CreateDirectory(carFolder);
// Save files
var modelPath = await SaveFile(upload.Model3D, carFolder, "model.pak");
var thumbnailPath = await SaveFile(upload.Thumbnail, carFolder, "thumbnail.png");
string? texturePath = null;
if (upload.Textures != null)
{
texturePath = await SaveFile(upload.Textures, carFolder, "textures.pvr");
}
string? audioPath = null;
if (upload.EngineAudio != null)
{
audioPath = await SaveFile(upload.EngineAudio, carFolder, "engine.ogg");
}
// Calculate MD5 hashes
var modelMd5 = await CalculateMd5(modelPath);
// Create car record
var customCar = new Car
{
CarId = carId,
Name = upload.CarName,
Manufacturer = upload.Manufacturer,
ClassType = upload.ClassType,
BasePerformanceRating = upload.PerformanceRating,
Year = upload.Year ?? DateTime.Now.Year,
CashPrice = upload.CashPrice ?? 0,
GoldPrice = upload.GoldPrice ?? 0,
Description = upload.Description,
IsCustom = true,
CustomAuthor = upload.AuthorName,
CustomVersion = upload.Version ?? "1.0",
CreatedAt = DateTime.UtcNow
};
_context.Cars.Add(customCar);
// Create asset entries
var modelAsset = new GameAsset
{
AssetType = "car_model",
FileName = "model.pak",
EaCdnPath = $"/custom/cars/{carId}/model.pak",
LocalPath = modelPath,
FileSize = new FileInfo(modelPath).Length,
Md5Hash = modelMd5,
ContentType = "application/octet-stream",
Category = "cars",
CarId = carId,
IsCustomContent = true,
CustomAuthor = upload.AuthorName,
DownloadedAt = DateTime.UtcNow
};
_context.GameAssets.Add(modelAsset);
// Add thumbnail asset
var thumbAsset = new GameAsset
{
AssetType = "car_thumbnail",
FileName = "thumbnail.png",
EaCdnPath = $"/custom/cars/{carId}/thumbnail.png",
LocalPath = thumbnailPath,
FileSize = new FileInfo(thumbnailPath).Length,
Md5Hash = await CalculateMd5(thumbnailPath),
ContentType = "image/png",
Category = "ui",
CarId = carId,
IsCustomContent = true,
CustomAuthor = upload.AuthorName,
DownloadedAt = DateTime.UtcNow
};
_context.GameAssets.Add(thumbAsset);
await _context.SaveChangesAsync();
_logger.LogInformation("Custom car uploaded successfully: {CarId} - {Name}", carId, upload.CarName);
return Ok(new
{
success = true,
carId = carId,
name = upload.CarName,
message = "Custom car uploaded successfully! It will appear in the catalog.",
files = new
{
model = modelPath,
thumbnail = thumbnailPath,
textures = texturePath,
audio = audioPath
}
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading custom car");
return StatusCode(500, new { error = "Upload failed", details = ex.Message });
}
}
/// <summary>
/// Upload a custom track
/// </summary>
[HttpPost("tracks/upload")]
[RequestSizeLimit(200_000_000)] // 200 MB max for tracks
public async Task<IActionResult> UploadCustomTrack([FromForm] CustomTrackUpload upload)
{
_logger.LogInformation("Custom track upload: {Name} by {Author}", upload.TrackName, upload.AuthorName);
try
{
if (upload.TrackData == null || upload.Thumbnail == null)
{
return BadRequest(new { error = "Track data and thumbnail are required" });
}
var trackId = $"custom_{Guid.NewGuid():N}";
var trackFolder = Path.Combine(_customAssetsPath, "tracks", trackId);
Directory.CreateDirectory(trackFolder);
// Save files
var trackPath = await SaveFile(upload.TrackData, trackFolder, "track.pak");
var thumbnailPath = await SaveFile(upload.Thumbnail, trackFolder, "thumbnail.png");
string? sceneryPath = null;
if (upload.Scenery != null)
{
sceneryPath = await SaveFile(upload.Scenery, trackFolder, "scenery.pak");
}
// Create track record in database (you'll need to add Track entity)
var trackAsset = new GameAsset
{
AssetType = "track",
FileName = "track.pak",
EaCdnPath = $"/custom/tracks/{trackId}/track.pak",
LocalPath = trackPath,
FileSize = new FileInfo(trackPath).Length,
Md5Hash = await CalculateMd5(trackPath),
ContentType = "application/octet-stream",
Category = "tracks",
TrackId = trackId,
IsCustomContent = true,
CustomAuthor = upload.AuthorName,
DownloadedAt = DateTime.UtcNow
};
_context.GameAssets.Add(trackAsset);
await _context.SaveChangesAsync();
return Ok(new
{
success = true,
trackId = trackId,
name = upload.TrackName,
message = "Custom track uploaded successfully!",
files = new
{
track = trackPath,
thumbnail = thumbnailPath,
scenery = sceneryPath
}
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading custom track");
return StatusCode(500, new { error = "Upload failed", details = ex.Message });
}
}
/// <summary>
/// Get list of all custom content
/// </summary>
[HttpGet("content")]
public async Task<IActionResult> GetCustomContent(
[FromQuery] string? type = null,
[FromQuery] string? author = null)
{
var query = _context.GameAssets
.Where(a => a.IsCustomContent);
if (!string.IsNullOrEmpty(type))
{
query = query.Where(a => a.AssetType == type);
}
if (!string.IsNullOrEmpty(author))
{
query = query.Where(a => a.CustomAuthor == author);
}
var content = await query
.GroupBy(a => new { a.CarId, a.TrackId, a.CustomAuthor })
.Select(g => new
{
id = g.Key.CarId ?? g.Key.TrackId,
author = g.Key.CustomAuthor,
type = g.First().AssetType,
files = g.Select(a => new
{
name = a.FileName,
path = a.EaCdnPath,
size = a.FileSize,
uploadedAt = a.DownloadedAt
}).ToList()
})
.ToListAsync();
return Ok(new
{
success = true,
count = content.Count,
content = content.Select(c => new
{
id = c.id,
author = c.author,
type = c.type,
files = c.files.ToList()
}).ToList()
});
}
/// <summary>
/// Get custom cars list
/// </summary>
[HttpGet("cars")]
public async Task<IActionResult> GetCustomCars()
{
var customCars = await _context.Cars
.Where(c => c.IsCustom)
.Select(c => new
{
carId = c.CarId,
name = c.Name,
manufacturer = c.Manufacturer,
classType = c.ClassType,
performanceRating = c.BasePerformanceRating,
year = c.Year,
author = c.CustomAuthor,
version = c.CustomVersion,
description = c.Description,
cashPrice = c.CashPrice,
goldPrice = c.GoldPrice,
createdAt = c.CreatedAt
})
.ToListAsync();
return Ok(new
{
success = true,
count = customCars.Count,
cars = customCars
});
}
/// <summary>
/// Delete custom content (moderator only)
/// </summary>
[HttpDelete("content/{contentId}")]
public async Task<IActionResult> DeleteCustomContent(string contentId)
{
_logger.LogInformation("Deleting custom content: {ContentId}", contentId);
// Find all assets for this content
var assets = await _context.GameAssets
.Where(a => a.CarId == contentId || a.TrackId == contentId)
.ToListAsync();
if (assets.Count == 0)
{
return NotFound(new { error = "Content not found" });
}
// Delete files
foreach (var asset in assets)
{
if (!string.IsNullOrEmpty(asset.LocalPath) && System.IO.File.Exists(asset.LocalPath))
{
System.IO.File.Delete(asset.LocalPath);
}
}
// Delete from database
_context.GameAssets.RemoveRange(assets);
// Delete car record if it's a car
var car = await _context.Cars.FirstOrDefaultAsync(c => c.CarId == contentId);
if (car != null)
{
_context.Cars.Remove(car);
}
await _context.SaveChangesAsync();
return Ok(new { success = true, message = "Content deleted" });
}
/// <summary>
/// Create a mod pack (bundle of custom content)
/// </summary>
[HttpPost("modpack/create")]
public async Task<IActionResult> CreateModPack([FromBody] ModPackRequest request)
{
_logger.LogInformation("Creating mod pack: {Name} by {Author}", request.PackName, request.Author);
var modPackId = $"modpack_{Guid.NewGuid():N}";
var modPackPath = Path.Combine(_modsPath, modPackId);
Directory.CreateDirectory(modPackPath);
// Create mod pack metadata
var modPack = new ModPack
{
PackId = modPackId,
Name = request.PackName,
Author = request.Author,
Description = request.Description,
Version = request.Version ?? "1.0",
CarIds = request.CarIds != null ? string.Join(",", request.CarIds) : string.Empty,
TrackIds = request.TrackIds != null ? string.Join(",", request.TrackIds) : string.Empty,
CreatedAt = DateTime.UtcNow
};
_context.ModPacks.Add(modPack);
await _context.SaveChangesAsync();
return Ok(new
{
success = true,
packId = modPackId,
name = request.PackName,
message = "Mod pack created! Users can now subscribe to it.",
downloadUrl = $"/modding/api/modpack/{modPackId}/download"
});
}
/// <summary>
/// Get available mod packs
/// </summary>
[HttpGet("modpacks")]
public async Task<IActionResult> GetModPacks()
{
var packs = await _context.ModPacks
.ToListAsync();
var result = packs.Select(p => new
{
packId = p.PackId,
name = p.Name,
author = p.Author,
description = p.Description,
version = p.Version,
carCount = !string.IsNullOrEmpty(p.CarIds) ? p.CarIds.Split(',').Length : 0,
trackCount = !string.IsNullOrEmpty(p.TrackIds) ? p.TrackIds.Split(',').Length : 0,
downloads = p.DownloadCount,
rating = p.Rating,
createdAt = p.CreatedAt
})
.ToList();
return Ok(new
{
success = true,
count = result.Count,
modPacks = result
});
}
#region Helper Methods
private async Task<string> SaveFile(IFormFile file, string folder, string filename)
{
var filePath = Path.Combine(folder, filename);
using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
return filePath;
}
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();
}
#endregion
}
#region Request Models
public class CustomCarUpload
{
public IFormFile Model3D { get; set; } = null!;
public IFormFile Thumbnail { get; set; } = null!;
public IFormFile? Textures { get; set; }
public IFormFile? EngineAudio { get; set; }
public string CarName { get; set; } = string.Empty;
public string Manufacturer { get; set; } = string.Empty;
public string ClassType { get; set; } = "Custom";
public int PerformanceRating { get; set; }
public int? Year { get; set; }
public int? CashPrice { get; set; }
public int? GoldPrice { get; set; }
public string? Description { get; set; }
public string AuthorName { get; set; } = string.Empty;
public string? Version { get; set; }
}
public class CustomTrackUpload
{
public IFormFile TrackData { get; set; } = null!;
public IFormFile Thumbnail { get; set; } = null!;
public IFormFile? Scenery { get; set; }
public string TrackName { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string? Description { get; set; }
public string AuthorName { get; set; } = string.Empty;
public string? Version { get; set; }
public double? LengthKm { get; set; }
public int? Corners { get; set; }
}
public class ModPackRequest
{
public string PackName { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Version { get; set; }
public List<string>? CarIds { get; set; }
public List<string>? TrackIds { get; set; }
}
#endregion