140 lines
7.1 KiB
C#
140 lines
7.1 KiB
C#
using JobTrackerApi.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace JobTrackerApi.Services;
|
|
|
|
public sealed record AccountOwnedFile(string Category, string ExportPath, string? SourcePath, byte[]? InlineBytes);
|
|
public sealed record AccountOwnedFileInventoryResult(IReadOnlyList<AccountOwnedFile> Files, IReadOnlyList<string> Warnings);
|
|
|
|
public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths paths, IAttachmentStorage attachmentStorage)
|
|
{
|
|
public async Task<AccountOwnedFileInventoryResult> BuildAsync(string ownerUserId, CancellationToken cancellationToken, bool includeAccountExports = false)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
|
|
var files = new List<AccountOwnedFile>();
|
|
var warnings = new List<string>();
|
|
var applicationIds = await db.JobApplications.IgnoreQueryFilters().AsNoTracking()
|
|
.Where(item => item.OwnerUserId == ownerUserId)
|
|
.Select(item => item.Id)
|
|
.ToListAsync(cancellationToken);
|
|
var attachments = await db.Attachments.IgnoreQueryFilters().AsNoTracking()
|
|
.Where(item => applicationIds.Contains(item.JobApplicationId))
|
|
.OrderBy(item => item.Id)
|
|
.ToListAsync(cancellationToken);
|
|
foreach (var attachment in attachments)
|
|
{
|
|
AddPath(files, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath);
|
|
}
|
|
|
|
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking()
|
|
.Where(item => item.OwnerUserId == ownerUserId)
|
|
.OrderBy(item => item.Id)
|
|
.ToListAsync(cancellationToken);
|
|
foreach (var artifact in artifacts)
|
|
{
|
|
AddPath(files, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", candidate => IsManagedPath(paths.CvArtifactsRoot, candidate));
|
|
}
|
|
|
|
var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken);
|
|
AddAvatar(files, warnings, ownerUserId, avatar);
|
|
AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv");
|
|
AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export");
|
|
if (includeAccountExports)
|
|
AddDirectory(files, warnings, paths.GetOwnerAccountExportsRoot(ownerUserId), "files/account-exports", "account-export");
|
|
return new AccountOwnedFileInventoryResult(files, warnings);
|
|
}
|
|
|
|
private void AddAvatar(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string ownerUserId, string? storedAvatar)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(storedAvatar)) return;
|
|
if (storedAvatar.StartsWith("file:", StringComparison.Ordinal))
|
|
{
|
|
var path = storedAvatar[5..];
|
|
var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId));
|
|
AddPath(files, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate));
|
|
return;
|
|
}
|
|
if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var comma = storedAvatar.IndexOf(',');
|
|
if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
try
|
|
{
|
|
files.Add(new AccountOwnedFile("avatar", "files/avatar/avatar", null, Convert.FromBase64String(storedAvatar[(comma + 1)..])));
|
|
return;
|
|
}
|
|
catch (FormatException) { }
|
|
}
|
|
}
|
|
warnings.Add("The profile avatar was stored in an unsupported format and could not be included.");
|
|
}
|
|
|
|
private static void AddPath(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string sourcePath, string exportPath, string category, Func<string, bool> isManaged)
|
|
{
|
|
if (!isManaged(sourcePath))
|
|
{
|
|
warnings.Add($"Excluded unsafe {category} path for {exportPath}.");
|
|
return;
|
|
}
|
|
if (!File.Exists(sourcePath))
|
|
{
|
|
warnings.Add($"Owned {category} file was unavailable: {exportPath}.");
|
|
return;
|
|
}
|
|
files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(sourcePath), null));
|
|
}
|
|
|
|
private static void AddDirectory(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string root, string exportRoot, string category)
|
|
{
|
|
if (!Directory.Exists(root)) return;
|
|
foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions
|
|
{
|
|
RecurseSubdirectories = true,
|
|
IgnoreInaccessible = true,
|
|
AttributesToSkip = FileAttributes.ReparsePoint,
|
|
}))
|
|
{
|
|
var relative = Path.GetRelativePath(root, path);
|
|
if (relative.StartsWith("..", StringComparison.Ordinal) || !IsManagedPath(root, path))
|
|
{
|
|
warnings.Add($"Excluded unsafe {category} path.");
|
|
continue;
|
|
}
|
|
var exportPath = $"{exportRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}";
|
|
files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(path), null));
|
|
}
|
|
}
|
|
|
|
public static bool IsManagedPath(string root, string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return false;
|
|
try
|
|
{
|
|
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
|
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
var fullPath = Path.GetFullPath(path);
|
|
if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false;
|
|
var current = Path.GetDirectoryName(fullPath);
|
|
while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison))
|
|
{
|
|
if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false;
|
|
current = Path.GetDirectoryName(current);
|
|
}
|
|
return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string SafeSegment(string? value)
|
|
{
|
|
var candidate = Path.GetFileName(value ?? string.Empty).Trim();
|
|
foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_');
|
|
if (candidate.Length > 120) candidate = candidate[..120];
|
|
return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate;
|
|
}
|
|
}
|