feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -21,15 +21,17 @@ namespace JobTrackerApi.Controllers
|
||||
".pdf", ".doc", ".docx", ".txt", ".rtf", ".png", ".jpg", ".jpeg", ".webp"
|
||||
};
|
||||
|
||||
private readonly AppPaths _paths;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly UserManager<ApplicationUser>? _users;
|
||||
private readonly IAttachmentStorage _storage;
|
||||
private readonly ILogger<AttachmentsController> _logger;
|
||||
|
||||
public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null)
|
||||
public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null, IAttachmentStorage? storage = null, ILogger<AttachmentsController>? logger = null)
|
||||
{
|
||||
_paths = paths;
|
||||
_db = db;
|
||||
_users = users;
|
||||
_storage = storage ?? new AttachmentStorage(paths);
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AttachmentsController>.Instance;
|
||||
}
|
||||
|
||||
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi);
|
||||
@@ -104,8 +106,14 @@ namespace JobTrackerApi.Controllers
|
||||
var att = await FindOwnedAttachmentAsync(id, cancellationToken);
|
||||
if (att is null) return NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(att.FilePath) || !System.IO.File.Exists(att.FilePath))
|
||||
if (string.IsNullOrWhiteSpace(att.FilePath) || !_storage.IsManagedPath(att.FilePath))
|
||||
return Conflict("The attachment storage path is invalid.");
|
||||
if (!System.IO.File.Exists(att.FilePath))
|
||||
{
|
||||
if (System.IO.File.Exists(_storage.StagePath(att.FilePath)))
|
||||
return Conflict("The attachment is still being finalized. Try again after the service restarts.");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var contentType = string.IsNullOrWhiteSpace(att.FileType) ? "application/octet-stream" : att.FileType;
|
||||
var fileName = Path.GetFileName(att.FileName);
|
||||
@@ -132,40 +140,29 @@ namespace JobTrackerApi.Controllers
|
||||
}
|
||||
|
||||
var rawName = (request.FileName ?? string.Empty).Trim();
|
||||
if (rawName.Length == 0)
|
||||
if (rawName.Length > 0)
|
||||
{
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (purposeChanged)
|
||||
{
|
||||
// Recompute needs the Purpose change committed first -- a fresh query
|
||||
// wouldn't see the pending change yet.
|
||||
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
return NoContent();
|
||||
var name = Path.GetFileName(rawName);
|
||||
var ext = Path.GetExtension(name);
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return BadRequest("That file type is not allowed.");
|
||||
|
||||
// The generated storage name is intentionally stable. A user-visible rename is metadata,
|
||||
// so no filesystem/DB split can leave the row pointing at a moved file.
|
||||
att.FileName = name;
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(rawName);
|
||||
var ext = Path.GetExtension(name);
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return BadRequest("That file type is not allowed.");
|
||||
|
||||
var folder = Path.GetDirectoryName(att.FilePath) ?? _paths.AttachmentsRoot;
|
||||
var newPath = Path.Combine(folder, BuildStoredFileName(name));
|
||||
|
||||
if (System.IO.File.Exists(att.FilePath) && !string.Equals(att.FilePath, newPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
System.IO.File.Move(att.FilePath, newPath, overwrite: false);
|
||||
}
|
||||
|
||||
att.FileName = name;
|
||||
att.FilePath = newPath;
|
||||
await using var transaction = _db.Database.IsRelational()
|
||||
? await _db.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (purposeChanged)
|
||||
{
|
||||
// This query must see the new persisted purpose; the transaction keeps both saves atomic.
|
||||
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -178,19 +175,77 @@ namespace JobTrackerApi.Controllers
|
||||
|
||||
var path = att.FilePath;
|
||||
var jobId = att.JobApplicationId;
|
||||
_db.Attachments.Remove(att);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(path) && !_storage.IsManagedPath(path))
|
||||
return Conflict("The attachment storage path is invalid.");
|
||||
|
||||
var deletePath = string.IsNullOrWhiteSpace(path) ? null : _storage.DeletePath(path);
|
||||
var quarantined = false;
|
||||
if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
|
||||
{
|
||||
_storage.Quarantine(path, deletePath!);
|
||||
quarantined = true;
|
||||
}
|
||||
else if (deletePath is not null && System.IO.File.Exists(deletePath))
|
||||
{
|
||||
return Accepted(new { recoveryPending = true });
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
_logger.LogWarning("Attachment {AttachmentId} metadata referenced missing bytes; deleting the stale row.", id);
|
||||
}
|
||||
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction = null;
|
||||
var rolledBack = false;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
|
||||
System.IO.File.Delete(path);
|
||||
if (_db.Database.IsRelational())
|
||||
transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
_db.Attachments.Remove(att);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort
|
||||
if (transaction is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.RollbackAsync(CancellationToken.None);
|
||||
rolledBack = true;
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
_logger.LogWarning(rollbackError, "Attachment {AttachmentId} transaction outcome is uncertain; quarantined bytes await startup reconciliation.", id);
|
||||
}
|
||||
}
|
||||
if (rolledBack && quarantined && deletePath is not null && System.IO.File.Exists(deletePath) && !System.IO.File.Exists(path))
|
||||
{
|
||||
try { _storage.Restore(deletePath, path); }
|
||||
catch (Exception restoreError)
|
||||
{
|
||||
_logger.LogWarning(restoreError, "Attachment {AttachmentId} bytes could not be restored after database rollback; startup reconciliation will retry.", id);
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null) await transaction.DisposeAsync();
|
||||
}
|
||||
|
||||
if (deletePath is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_storage.Purge(deletePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Attachment {AttachmentId} metadata was deleted; quarantined bytes await startup reconciliation.", id);
|
||||
return Accepted(new { recoveryPending = true });
|
||||
}
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -218,9 +273,7 @@ namespace JobTrackerApi.Controllers
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Storage limit reached ({limit / 1_000_000} MB). Remove files or upgrade your plan.");
|
||||
}
|
||||
|
||||
var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString());
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var validFiles = new List<(IFormFile File, string DisplayName, string ContentType, string Purpose, string FinalPath, string StagePath)>();
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (file.Length == 0) continue;
|
||||
@@ -232,30 +285,109 @@ namespace JobTrackerApi.Controllers
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return BadRequest($"{displayName} is not an allowed file type.");
|
||||
|
||||
// Store uploads under unique generated filenames so re-uploads never overwrite
|
||||
// earlier files with the same visible name.
|
||||
var storedName = BuildStoredFileName(displayName);
|
||||
var path = Path.Combine(folder, storedName);
|
||||
await using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
|
||||
_db.Attachments.Add(new Attachment
|
||||
{
|
||||
JobApplicationId = jobId,
|
||||
FileName = displayName,
|
||||
FilePath = path,
|
||||
UploadDate = DateTime.Now,
|
||||
FileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||
FileSize = file.Length,
|
||||
Purpose = GuessPurpose(displayName),
|
||||
UseForAi = true,
|
||||
});
|
||||
var finalPath = _storage.CreateFinalPath(jobId, storedName);
|
||||
validFiles.Add((
|
||||
file,
|
||||
displayName,
|
||||
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||
GuessPurpose(displayName),
|
||||
finalPath,
|
||||
_storage.StagePath(finalPath)));
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok();
|
||||
if (validFiles.Count == 0) return BadRequest("At least one non-empty file is required.");
|
||||
|
||||
var stagedPaths = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var item in validFiles)
|
||||
{
|
||||
await _storage.StageAsync(item.File, item.StagePath, cancellationToken);
|
||||
stagedPaths.Add(item.StagePath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var stagedPath in stagedPaths)
|
||||
{
|
||||
try { _storage.Purge(stagedPath); } catch { }
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction = null;
|
||||
var rolledBack = false;
|
||||
try
|
||||
{
|
||||
if (_db.Database.IsRelational())
|
||||
transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
foreach (var item in validFiles)
|
||||
{
|
||||
_db.Attachments.Add(new Attachment
|
||||
{
|
||||
JobApplicationId = jobId,
|
||||
FileName = item.DisplayName,
|
||||
FilePath = item.FinalPath,
|
||||
UploadDate = DateTime.Now,
|
||||
FileType = item.ContentType,
|
||||
FileSize = item.File.Length,
|
||||
Purpose = item.Purpose,
|
||||
UseForAi = true,
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.RollbackAsync(CancellationToken.None);
|
||||
rolledBack = true;
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
_logger.LogWarning(rollbackError, "Attachment upload transaction outcome is uncertain; staged bytes await startup reconciliation.");
|
||||
}
|
||||
}
|
||||
if (rolledBack)
|
||||
{
|
||||
foreach (var item in validFiles)
|
||||
{
|
||||
try { _storage.Purge(item.StagePath); }
|
||||
catch (Exception purgeError)
|
||||
{
|
||||
_logger.LogWarning(purgeError, "Attachment upload rolled back but staged bytes could not be removed; startup reconciliation will retry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null) await transaction.DisposeAsync();
|
||||
}
|
||||
|
||||
var pending = false;
|
||||
foreach (var item in validFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
_storage.Promote(item.StagePath, item.FinalPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
pending = true;
|
||||
_logger.LogWarning(ex, "Attachment upload committed for job {JobId}; staged bytes await startup reconciliation.", jobId);
|
||||
}
|
||||
}
|
||||
|
||||
return pending ? Accepted(new { recoveryPending = true }) : Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user