LANCommander/LANCommander.Server.Services/ArchiveService.cs
2026-05-28 19:54:58 -05:00

378 lines
14 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)
{
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);
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;
}
}
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);
}
}
}