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 Files, IReadOnlyList Warnings); public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths paths, IAttachmentStorage attachmentStorage) { public async Task BuildAsync(string ownerUserId, CancellationToken cancellationToken, bool includeAccountExports = false) { ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId); var files = new List(); var warnings = new List(); 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 files, ICollection 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 files, ICollection warnings, string sourcePath, string exportPath, string category, Func 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 files, ICollection 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; } }