LANCommander/LANCommander.Server.Services/ArchiveService.cs

487 lines
18 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 AppPaths.ResolveStorageLocationPath(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 AppPaths.ResolveStorageLocationPath(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);
}
}
}