Adds a tool that checks archives to see if they use data descriptors instead of local entry headers for tracking entry size. It will also repack these archives if required.
491 lines
19 KiB
C#
491 lines
19 KiB
C#
using LANCommander.Server.Data;
|
|
using LANCommander.Server.Data.Models;
|
|
using LANCommander.Helpers;
|
|
using System.IO.Compression;
|
|
using System.Linq.Expressions;
|
|
using AutoMapper;
|
|
using LANCommander.SDK.Services;
|
|
using LANCommander.Server.Services.Extensions;
|
|
using YamlDotNet.Serialization;
|
|
using ZiggyCreatures.Caching.Fusion;
|
|
using LANCommander.Server.Services.Models;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PascalCaseNamingConvention = YamlDotNet.Serialization.NamingConventions.PascalCaseNamingConvention;
|
|
using LANCommander.SDK;
|
|
|
|
namespace LANCommander.Server.Services
|
|
{
|
|
public sealed class ArchiveService(
|
|
ILogger<ArchiveService> logger,
|
|
SettingsProvider<Settings.Settings> settingsProvider,
|
|
IFusionCache cache,
|
|
IMapper mapper,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IDbContextFactory<DatabaseContext> dbContextFactory,
|
|
StorageLocationService storageLocationService) : BaseDatabaseService<Archive>(logger, settingsProvider, cache, mapper, httpContextAccessor, dbContextFactory), IArchiveClient
|
|
{
|
|
public async Task<Archive> GetLatestArchiveAsync(Expression<Func<Archive, bool>> predicate)
|
|
{
|
|
return await Query(q =>
|
|
{
|
|
return q.OrderByDescending(a => a.CreatedOn);
|
|
}).FirstOrDefaultAsync(predicate);
|
|
}
|
|
|
|
public string GetArchiveFileLocation(Archive archive, StorageLocation storageLocation)
|
|
{
|
|
return Path.IsPathRooted(storageLocation.Path)
|
|
? Path.Combine(storageLocation.Path, archive.ObjectKey)
|
|
: AppPaths.GetConfigPath(storageLocation.Path, archive.ObjectKey);
|
|
}
|
|
|
|
public async Task<string> GetArchiveFileLocationAsync(Archive archive)
|
|
{
|
|
string storageLocationPath;
|
|
|
|
if (archive.StorageLocation != null)
|
|
storageLocationPath = archive.StorageLocation.Path;
|
|
else
|
|
{
|
|
var storageLocation = await storageLocationService.GetAsync(archive.StorageLocationId);
|
|
|
|
storageLocationPath = storageLocation.Path;
|
|
}
|
|
|
|
return Path.IsPathRooted(storageLocationPath) ?
|
|
Path.Combine(storageLocationPath, archive.ObjectKey) :
|
|
AppPaths.GetConfigPath(storageLocationPath, archive.ObjectKey);
|
|
}
|
|
|
|
public async Task<string> GetArchiveFileLocationAsync(string objectKey)
|
|
{
|
|
var archive = await Include(a => a.StorageLocation).FirstOrDefaultAsync(a => a.ObjectKey == objectKey);
|
|
|
|
return await GetArchiveFileLocationAsync(archive);
|
|
}
|
|
|
|
public override async Task<Archive> AddAsync(Archive entity)
|
|
{
|
|
await cache.ExpireGameCacheAsync(entity.GameId);
|
|
|
|
return await base.AddAsync(entity, async context =>
|
|
{
|
|
await context.UpdateRelationshipAsync(a => a.Game);
|
|
await context.UpdateRelationshipAsync(a => a.Redistributable);
|
|
await context.UpdateRelationshipAsync(a => a.Tool);
|
|
await context.UpdateRelationshipAsync(a => a.StorageLocation);
|
|
});
|
|
}
|
|
|
|
public override async Task<ExistingEntityResult<Archive>> AddMissingAsync(Expression<Func<Archive, bool>> predicate, Archive entity)
|
|
{
|
|
await cache.ExpireGameCacheAsync(entity.GameId);
|
|
|
|
return await base.AddMissingAsync(predicate, entity);
|
|
}
|
|
|
|
public override async Task<Archive> UpdateAsync(Archive updatedArchive)
|
|
{
|
|
await cache.ExpireGameCacheAsync(updatedArchive.GameId);
|
|
await cache.ExpireArchiveCacheAsync(updatedArchive.Id);
|
|
|
|
return await base.UpdateAsync(updatedArchive, async context =>
|
|
{
|
|
await context.UpdateRelationshipAsync(a => a.Game);
|
|
await context.UpdateRelationshipAsync(a => a.Redistributable);
|
|
await context.UpdateRelationshipAsync(a => a.Tool);
|
|
await context.UpdateRelationshipAsync(a => a.StorageLocation);
|
|
});
|
|
}
|
|
|
|
public override async Task DeleteAsync(Archive archive)
|
|
{
|
|
FileHelpers.DeleteIfExists(await GetArchiveFileLocationAsync(archive));
|
|
|
|
await cache.ExpireGameCacheAsync(archive.GameId);
|
|
await cache.ExpireArchiveCacheAsync(archive.Id);
|
|
|
|
await base.DeleteAsync(archive);
|
|
}
|
|
|
|
public async Task DeleteAsync(Archive archive, StorageLocation storageLocation = null)
|
|
{
|
|
if (storageLocation == null)
|
|
FileHelpers.DeleteIfExists(await GetArchiveFileLocationAsync(archive));
|
|
else
|
|
FileHelpers.DeleteIfExists(GetArchiveFileLocation(archive, storageLocation));
|
|
|
|
await cache.ExpireGameCacheAsync(archive.GameId);
|
|
|
|
await base.DeleteAsync(archive);
|
|
}
|
|
|
|
public async Task<SDK.Models.Manifest.Game> ReadManifestAsync(string objectKey)
|
|
{
|
|
var upload = await GetArchiveFileLocationAsync(objectKey);
|
|
|
|
string manifestContents = String.Empty;
|
|
|
|
if (!File.Exists(upload))
|
|
throw new FileNotFoundException(upload);
|
|
|
|
using (ZipArchive zip = ZipFile.OpenRead(upload))
|
|
{
|
|
var entry = zip.Entries.FirstOrDefault(e => e.FullName == "_manifest.yml");
|
|
|
|
if (entry == null)
|
|
throw new FileNotFoundException("Manifest not found");
|
|
|
|
using (StreamReader sr = new StreamReader(entry.Open()))
|
|
{
|
|
manifestContents = sr.ReadToEnd();
|
|
}
|
|
}
|
|
|
|
var deserializer = new DeserializerBuilder()
|
|
.IgnoreUnmatchedProperties()
|
|
.WithNamingConvention(new PascalCaseNamingConvention())
|
|
.Build();
|
|
|
|
var manifest = deserializer.Deserialize<SDK.Models.Manifest.Game>(manifestContents);
|
|
|
|
return manifest;
|
|
}
|
|
|
|
public async Task<byte[]> ReadFileAsync(string objectKey, string path)
|
|
{
|
|
var upload = await GetArchiveFileLocationAsync(objectKey);
|
|
|
|
if (!File.Exists(upload))
|
|
throw new FileNotFoundException(upload);
|
|
|
|
using (ZipArchive zip = ZipFile.OpenRead(upload))
|
|
{
|
|
var entry = zip.Entries.FirstOrDefault(e => e.FullName == path);
|
|
|
|
if (entry == null)
|
|
throw new FileNotFoundException(path);
|
|
|
|
using (var ms = new MemoryStream())
|
|
{
|
|
entry.Open().CopyTo(ms);
|
|
|
|
return ms.ToArray();
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<bool> FileExistsAsync(Guid archiveId)
|
|
{
|
|
var archive = await Include(a => a.StorageLocation).GetAsync(archiveId);
|
|
|
|
if (archive == null)
|
|
return false;
|
|
|
|
var path = await GetArchiveFileLocationAsync(archive);
|
|
|
|
return File.Exists(path);
|
|
}
|
|
|
|
public async Task<Guid> CopyFromLocalFileAsync(string path, Guid storageLocationId, bool move = false)
|
|
{
|
|
var storageLocation = await storageLocationService.GetAsync(storageLocationId);
|
|
|
|
if (!Directory.Exists(storageLocation.Path))
|
|
Directory.CreateDirectory(storageLocation.Path);
|
|
|
|
var archive = new Archive
|
|
{
|
|
ObjectKey = Guid.NewGuid().ToString(),
|
|
StorageLocationId = storageLocation.Id,
|
|
Version = ""
|
|
};
|
|
|
|
archive = await AddAsync(archive);
|
|
|
|
var archivePath = await GetArchiveFileLocationAsync(archive);
|
|
|
|
var archiveDirectory = Path.GetDirectoryName(archivePath);
|
|
|
|
if (!string.IsNullOrEmpty(archiveDirectory) && !Directory.Exists(archiveDirectory))
|
|
Directory.CreateDirectory(archiveDirectory);
|
|
|
|
if (move)
|
|
{
|
|
try
|
|
{
|
|
File.Move(path, archivePath, true);
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
_logger.LogInformation(ex, "Could not move local file from {SourcePath} to {DestinationPath}, falling back to copy", path, archivePath);
|
|
|
|
File.Copy(path, archivePath, true);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
File.Copy(path, archivePath, true);
|
|
}
|
|
|
|
return Guid.Parse(archive.ObjectKey);
|
|
}
|
|
|
|
public async Task<IEnumerable<ZipArchiveEntry>> GetContentsAsync(Guid archiveId)
|
|
{
|
|
var archive = await Include(a => a.StorageLocation).GetAsync(archiveId);
|
|
|
|
var upload = await GetArchiveFileLocationAsync(archive);
|
|
|
|
using (ZipArchive zip = ZipFile.OpenRead(upload))
|
|
{
|
|
return zip.Entries;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspects an archive for entries the launcher's streaming extractor cannot read reliably
|
|
/// (stored uncompressed entries written with a streaming data descriptor).
|
|
/// </summary>
|
|
public async Task<ArchiveStreamingReport> InspectStreamingCompatibilityAsync(Guid archiveId)
|
|
{
|
|
var archive = await Include(a => a.StorageLocation).GetAsync(archiveId);
|
|
var path = await GetArchiveFileLocationAsync(archive);
|
|
|
|
var report = new ArchiveStreamingReport();
|
|
|
|
if (!File.Exists(path))
|
|
{
|
|
_logger?.LogWarning("Cannot inspect archive {ArchiveId}; file not found at {Path}", archiveId, path);
|
|
return report;
|
|
}
|
|
|
|
var entries = ZipStreamingInspector.ReadCentralDirectory(path);
|
|
report.TotalEntries = entries.Count;
|
|
|
|
foreach (var entry in entries.Where(e => e.IsStreamingUnsafe))
|
|
{
|
|
report.ProblemEntries.Add(new ArchiveStreamingProblemEntry
|
|
{
|
|
Name = entry.Name,
|
|
UncompressedSize = entry.UncompressedSize,
|
|
Reason = "Stored (uncompressed) entry written with a streaming data descriptor",
|
|
});
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewrites an archive into a streaming-safe layout. Writing through System.IO.Compression
|
|
/// to a seekable file back-patches real sizes into the local file headers and omits the
|
|
/// streaming data descriptors, so the launcher's forward-only reader can extract every
|
|
/// entry without scanning. Original compression is preserved per entry (stored entries stay
|
|
/// stored) to avoid needlessly re-deflating already-compressed payloads.
|
|
/// </summary>
|
|
public async Task RepackArchiveAsync(Guid archiveId)
|
|
{
|
|
var archive = await Include(a => a.StorageLocation).GetAsync(archiveId);
|
|
var path = await GetArchiveFileLocationAsync(archive);
|
|
|
|
if (!File.Exists(path))
|
|
throw new FileNotFoundException("Archive file not found", path);
|
|
|
|
var storedEntryNames = ZipStreamingInspector.ReadCentralDirectory(path)
|
|
.Where(e => e.IsStored)
|
|
.Select(e => e.Name)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
var tempPath = path + ".repack.tmp";
|
|
|
|
if (File.Exists(tempPath))
|
|
File.Delete(tempPath);
|
|
|
|
try
|
|
{
|
|
using (var source = ZipFile.OpenRead(path))
|
|
using (var destStream = new FileStream(tempPath, FileMode.Create, FileAccess.ReadWrite))
|
|
using (var dest = new ZipArchive(destStream, ZipArchiveMode.Create))
|
|
{
|
|
foreach (var sourceEntry in source.Entries)
|
|
{
|
|
var level = storedEntryNames.Contains(sourceEntry.FullName)
|
|
? CompressionLevel.NoCompression
|
|
: CompressionLevel.Optimal;
|
|
|
|
var destEntry = dest.CreateEntry(sourceEntry.FullName, level);
|
|
destEntry.LastWriteTime = sourceEntry.LastWriteTime;
|
|
|
|
using var sourceEntryStream = sourceEntry.Open();
|
|
using var destEntryStream = destEntry.Open();
|
|
await sourceEntryStream.CopyToAsync(destEntryStream);
|
|
}
|
|
}
|
|
|
|
File.Delete(path);
|
|
File.Move(tempPath, path);
|
|
|
|
_logger?.LogInformation("Repacked archive {ArchiveId} into a streaming-safe layout", archiveId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Could not repack archive {ArchiveId}", archiveId);
|
|
|
|
if (File.Exists(tempPath))
|
|
File.Delete(tempPath);
|
|
|
|
throw;
|
|
}
|
|
|
|
await RecalculateFileSizeArchiveAsync(archive);
|
|
}
|
|
|
|
public async Task<long> GetCompressedSizeAsync(Archive archive)
|
|
{
|
|
long size = 0;
|
|
|
|
try
|
|
{
|
|
size = new FileInfo(await GetArchiveFileLocationAsync(archive)).Length;
|
|
}
|
|
catch { }
|
|
|
|
return size;
|
|
}
|
|
|
|
public async Task<long> GetUncompressedSizeAsync(Archive archive)
|
|
{
|
|
long size = 0;
|
|
|
|
using (ZipArchive zip = ZipFile.OpenRead(await GetArchiveFileLocationAsync(archive)))
|
|
{
|
|
foreach (ZipArchiveEntry entry in zip.Entries)
|
|
{
|
|
size += entry.Length;
|
|
}
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
public async Task<bool> RecalculateFileSizeArchiveAsync(Archive archive)
|
|
{
|
|
try
|
|
{
|
|
var path = await GetArchiveFileLocationAsync(archive);
|
|
|
|
if (File.Exists(path))
|
|
{
|
|
archive.CompressedSize = await GetCompressedSizeAsync(archive);
|
|
archive.UncompressedSize = await GetUncompressedSizeAsync(archive);
|
|
|
|
await UpdateAsync(archive);
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
_logger?.LogWarning("No local file found for archive {ArchiveId} on path: {path}", archive.Id, path);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Could not recalculate file size for archive {ArchiveId}", archive.Id);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public async Task<Archive> WriteToFileAsync(Archive archive, Stream stream, bool overwrite = true)
|
|
{
|
|
if (archive.StorageLocation == null)
|
|
archive = await Include(a => a.StorageLocation).GetAsync(archive.Id);
|
|
|
|
if (archive.StorageLocation == null)
|
|
throw new ArgumentException("Archive has no storage location", nameof(archive));
|
|
|
|
var path = GetArchiveFileLocation(archive, archive.StorageLocation);
|
|
|
|
if (overwrite && File.Exists(path))
|
|
File.Delete(path);
|
|
|
|
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write))
|
|
{
|
|
await stream.CopyToAsync(fs);
|
|
}
|
|
|
|
archive.CompressedSize = await GetCompressedSizeAsync(archive);
|
|
archive.UncompressedSize = await GetUncompressedSizeAsync(archive);
|
|
|
|
await UpdateAsync(archive);
|
|
|
|
return archive;
|
|
}
|
|
|
|
public async Task PatchArchiveAsync(Archive originalArchive, Archive alteredArchive, CompressionLevel compressionLevel = CompressionLevel.Optimal)
|
|
{
|
|
var alteredZipPath = await GetArchiveFileLocationAsync(alteredArchive);
|
|
var patchZipPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
|
|
|
ZipArchive originalZip = ZipFile.Open(await GetArchiveFileLocationAsync(originalArchive), ZipArchiveMode.Update);
|
|
ZipArchive alteredZip = ZipFile.OpenRead(alteredZipPath);
|
|
ZipArchive patchZip = ZipFile.Open(patchZipPath, ZipArchiveMode.Create);
|
|
|
|
int i = 0;
|
|
|
|
foreach (var entry in alteredZip.Entries)
|
|
{
|
|
var originalEntry = originalZip.GetEntry(entry.FullName);
|
|
|
|
if (originalEntry == null || originalEntry.Crc32 != entry.Crc32)
|
|
{
|
|
originalEntry?.Delete();
|
|
|
|
var updatedEntry = originalZip.CreateEntry(entry.FullName, compressionLevel);
|
|
var patchEntry = patchZip.CreateEntry(entry.FullName, compressionLevel);
|
|
|
|
// Copy the contents of the entry from the altered archive to the original archive
|
|
using (var updatedStream = updatedEntry.Open())
|
|
using (var alteredStream = entry.Open())
|
|
{
|
|
await alteredStream.CopyToAsync(updatedStream);
|
|
|
|
_logger?.LogInformation("Added {EntryFullName} to base archive {ArchiveId} and new patch archive", entry.FullName, originalArchive.Id.ToString());
|
|
}
|
|
|
|
// Copy the contents of the entry from the altered archive to the patch archive
|
|
using (var patchStream = patchEntry.Open())
|
|
using (var alteredStream = entry.Open())
|
|
{
|
|
await alteredStream.CopyToAsync(patchStream);
|
|
|
|
_logger?.LogInformation("Updated {EntryFullName} in base archive {ArchiveId} and added to new patch archive", entry.FullName, originalArchive.Id.ToString());
|
|
}
|
|
}
|
|
|
|
i++;
|
|
|
|
_logger?.LogInformation("Finished processing entry {EntryIndex}/{TotalEntries} for original archive {ArchiveId}", i.ToString(), originalZip.Entries.Count.ToString(), originalArchive.Id.ToString());
|
|
}
|
|
|
|
originalZip.Dispose();
|
|
alteredZip.Dispose();
|
|
patchZip.Dispose();
|
|
|
|
// Replace the uploaded altered ZIP with the new patch ZIP
|
|
if (File.Exists(alteredZipPath))
|
|
File.Delete(alteredZipPath);
|
|
|
|
File.Move(patchZipPath, alteredZipPath);
|
|
|
|
alteredArchive.CompressedSize = new FileInfo(await GetArchiveFileLocationAsync(alteredArchive)).Length;
|
|
originalArchive.CompressedSize = new FileInfo(await GetArchiveFileLocationAsync(originalArchive)).Length;
|
|
|
|
await UpdateAsync(alteredArchive);
|
|
await UpdateAsync(originalArchive);
|
|
|
|
_logger?.LogInformation("Finished merging original archive {ArchiveId} and rebuilt patch archive {PatchArchivePath}", originalArchive.Id.ToString(), alteredZipPath);
|
|
}
|
|
}
|
|
}
|