using System.Net.Http.Json; using System.Security.Cryptography; using System.Text.Json; using BrassAndSigil.Server.Models; namespace BrassAndSigil.Server.Services; /// /// Server-side mod sync. Downloads only mods that the server needs: /// queries Modrinth's project metadata for each mod's `server_side` field /// and skips anything marked "unsupported" (Iris, Sodium, JEI, etc). /// CurseForge mods can't be auto-classified without an API key, so they /// are downloaded as-is and the server admin can manually delete unwanted ones. /// public sealed class ManifestSync { private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) }; private const string PackVersionFile = "pack-version.json"; private const string ServerManifestCache = "server-pack.cache.json"; public sealed record SyncResult(int Downloaded, int Removed, int Skipped, string PackVersion); public async Task FetchManifestAsync(string url, CancellationToken ct = default) { var json = await _http.GetStringAsync(url, ct); var manifest = JsonSerializer.Deserialize(json, JsonOpts.CaseInsensitive) ?? throw new InvalidOperationException("Manifest is empty."); manifest.Files ??= new(); return manifest; } public async Task SyncAsync( string manifestUrl, string serverDir, IProgress? progress = null, CancellationToken ct = default) { progress?.Report("Fetching manifest..."); var manifest = await FetchManifestAsync(manifestUrl, ct); progress?.Report($"Pack: {manifest.Name} v{manifest.Version}"); Directory.CreateDirectory(serverDir); // Resolve which mods are server-side. var skipSlugs = await ResolveServerSideSkipListAsync(manifest, ct); // Build the filtered list of files to keep on the server. var keepFiles = manifest.Files .Where(f => !ShouldSkipFile(f.Path, skipSlugs)) .ToList(); var skippedCount = manifest.Files.Count - keepFiles.Count; // Prune managed files that aren't in the keep set. var wantedPaths = new HashSet( keepFiles.Select(f => f.Path.Replace('\\', '/')), StringComparer.OrdinalIgnoreCase); var toRemove = ListManagedFiles(serverDir).Where(p => !wantedPaths.Contains(p)).ToList(); foreach (var rel in toRemove) { var full = Path.Combine(serverDir, rel); try { File.Delete(full); progress?.Report($" Removed: {rel}"); } catch (Exception ex) { progress?.Report($" Could not remove {rel}: {ex.Message}"); } } // Download missing or hash-mismatched files. var toDownload = new List(); foreach (var file in keepFiles) { ct.ThrowIfCancellationRequested(); var dest = Path.Combine(serverDir, file.Path); if (!File.Exists(dest)) { toDownload.Add(file); continue; } if (!string.IsNullOrEmpty(file.Sha1)) { var actual = await ComputeSha1Async(dest, ct); if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase)) toDownload.Add(file); } } progress?.Report(toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} files..."); for (int i = 0; i < toDownload.Count; i++) { var file = toDownload[i]; progress?.Report($" [{i + 1}/{toDownload.Count}] {file.Path}"); await DownloadFileAsync(file.Url, Path.Combine(serverDir, file.Path), file.Sha1, ct); } // Write pack-version.json marker. var record = new { name = manifest.Name, version = manifest.Version, syncedAt = DateTime.UtcNow.ToString("o"), includedFiles = keepFiles.Count, skippedFiles = skippedCount }; await File.WriteAllTextAsync( Path.Combine(serverDir, PackVersionFile), JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }), ct); return new SyncResult(toDownload.Count, toRemove.Count, skippedCount, manifest.Version ?? "?"); } private static bool ShouldSkipFile(string filePath, HashSet skipSlugs) { if (skipSlugs.Count == 0) return false; var name = Path.GetFileNameWithoutExtension(filePath).ToLowerInvariant(); // Match if any skip slug appears at the start of the filename (slug-version.jar) return skipSlugs.Any(slug => name.StartsWith(slug + "-", StringComparison.OrdinalIgnoreCase) || name.Equals(slug, StringComparison.OrdinalIgnoreCase)); } /// Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set. private async Task> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct) { var skip = new HashSet(StringComparer.OrdinalIgnoreCase); var modrinthIds = new HashSet(); foreach (var file in manifest.Files) { if (!file.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase)) continue; // Modrinth URL pattern: https://cdn.modrinth.com/data/{projectId}/versions/{versionId}/... var url = file.Url; const string prefix = "cdn.modrinth.com/data/"; var idx = url.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); if (idx < 0) continue; var rest = url.Substring(idx + prefix.Length); var slash = rest.IndexOf('/'); if (slash < 0) continue; modrinthIds.Add(rest.Substring(0, slash)); } foreach (var pid in modrinthIds) { ct.ThrowIfCancellationRequested(); try { var info = await _http.GetFromJsonAsync( $"https://api.modrinth.com/v2/project/{pid}", ct); var slug = info.TryGetProperty("slug", out var s) ? s.GetString() : null; var serverSide = info.TryGetProperty("server_side", out var ss) ? ss.GetString() : null; if (!string.IsNullOrEmpty(slug) && string.Equals(serverSide, "unsupported", StringComparison.OrdinalIgnoreCase)) { skip.Add(slug); } } catch { // Best-effort: if we can't classify, keep the mod (safer to ship extra than missing) } } return skip; } private static List ListManagedFiles(string serverDir) { var roots = new[] { "mods", "config", "resourcepacks", "kubejs", "defaultconfigs" }; var result = new List(); foreach (var root in roots) { var rootDir = Path.Combine(serverDir, root); if (!Directory.Exists(rootDir)) continue; foreach (var f in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories)) result.Add(Path.GetRelativePath(serverDir, f).Replace('\\', '/')); } return result; } private static async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct) { Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); var tmp = destPath + ".part"; using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct)) { resp.EnsureSuccessStatusCode(); await using var src = await resp.Content.ReadAsStreamAsync(ct); await using var dst = File.Create(tmp); await src.CopyToAsync(dst, ct); } if (!string.IsNullOrEmpty(expectedSha1)) { var actual = await ComputeSha1Async(tmp, ct); if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase)) { File.Delete(tmp); throw new InvalidOperationException($"Hash mismatch for {Path.GetFileName(destPath)}"); } } if (File.Exists(destPath)) File.Delete(destPath); File.Move(tmp, destPath); } private static async Task ComputeSha1Async(string path, CancellationToken ct) { using var sha = SHA1.Create(); await using var stream = File.OpenRead(path); var bytes = await sha.ComputeHashAsync(stream, ct); return Convert.ToHexString(bytes).ToLowerInvariant(); } }