208 lines
8.5 KiB
C#
208 lines
8.5 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace JobTrackerApi.Controllers
|
|
{
|
|
[ApiController]
|
|
[Route("api/attachments")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public class AttachmentsController : ControllerBase
|
|
{
|
|
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB per file keeps local storage use predictable.
|
|
|
|
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
".pdf", ".doc", ".docx", ".txt", ".rtf", ".png", ".jpg", ".jpeg", ".webp"
|
|
};
|
|
|
|
private readonly AppPaths _paths;
|
|
private readonly JobTrackerContext _db;
|
|
|
|
public AttachmentsController(AppPaths paths, JobTrackerContext db)
|
|
{
|
|
_paths = paths;
|
|
_db = db;
|
|
}
|
|
|
|
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi);
|
|
|
|
// Child entities are accessed by raw integer ids in a few endpoints below.
|
|
// Always resolve them through the parent JobApplication query so the global job-level
|
|
// ownership filter is still enforced for multi-user environments.
|
|
private Task<Attachment?> FindOwnedAttachmentAsync(int attachmentId, CancellationToken cancellationToken)
|
|
{
|
|
return _db.Attachments
|
|
.Include(a => a.JobApplication)
|
|
.FirstOrDefaultAsync(a => a.Id == attachmentId, cancellationToken);
|
|
}
|
|
|
|
private static string BuildStoredFileName(string originalName)
|
|
{
|
|
var ext = Path.GetExtension(originalName);
|
|
var suffix = Convert.ToHexString(RandomNumberGenerator.GetBytes(6)).ToLowerInvariant();
|
|
return $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{suffix}{ext}";
|
|
}
|
|
|
|
private static string GuessPurpose(string fileName)
|
|
{
|
|
var n = (fileName ?? string.Empty).ToLowerInvariant();
|
|
if (n.Contains("cover")) return "cover-letter";
|
|
if (n.Contains("resume") || n.Contains("résumé") || n.Contains(" cv") || n.EndsWith("cv.pdf")) return "resume";
|
|
if (n.Contains("portfolio")) return "portfolio";
|
|
if (n.Contains("case") || n.Contains("sample")) return "case-study";
|
|
if (n.Contains("cert")) return "certificate";
|
|
return "other";
|
|
}
|
|
|
|
[HttpGet("{jobId:int}")]
|
|
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
|
{
|
|
var jobOk = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
|
|
if (!jobOk) return NotFound();
|
|
|
|
var items = await _db.Attachments
|
|
.AsNoTracking()
|
|
.Where(a => a.JobApplicationId == jobId)
|
|
.OrderByDescending(a => a.UploadDate)
|
|
.Select(a => new AttachmentDto(a.Id, a.FileName, a.UploadDate, a.FileType, a.FileSize, a.Purpose, a.UseForAi))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return Ok(items);
|
|
}
|
|
|
|
[HttpGet("download/{id:int}")]
|
|
public async Task<IActionResult> Download([FromRoute] int id, CancellationToken cancellationToken)
|
|
{
|
|
var att = await FindOwnedAttachmentAsync(id, cancellationToken);
|
|
if (att is null) return NotFound();
|
|
|
|
if (string.IsNullOrWhiteSpace(att.FilePath) || !System.IO.File.Exists(att.FilePath))
|
|
return NotFound();
|
|
|
|
var contentType = string.IsNullOrWhiteSpace(att.FileType) ? "application/octet-stream" : att.FileType;
|
|
var fileName = Path.GetFileName(att.FileName);
|
|
return PhysicalFile(att.FilePath, contentType, fileName);
|
|
}
|
|
|
|
public sealed record UpdateAttachmentRequest(string? FileName, string? Purpose, bool? UseForAi);
|
|
|
|
[HttpPatch("{id:int}")]
|
|
public async Task<IActionResult> Rename([FromRoute] int id, [FromBody] UpdateAttachmentRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var att = await FindOwnedAttachmentAsync(id, cancellationToken);
|
|
if (att is null) return NotFound();
|
|
|
|
if (request.UseForAi is not null)
|
|
{
|
|
att.UseForAi = request.UseForAi.Value;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.Purpose))
|
|
{
|
|
att.Purpose = request.Purpose.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
var rawName = (request.FileName ?? string.Empty).Trim();
|
|
if (rawName.Length == 0)
|
|
{
|
|
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.");
|
|
|
|
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 _db.SaveChangesAsync(cancellationToken);
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpDelete("{id:int}")]
|
|
public async Task<IActionResult> Delete([FromRoute] int id, CancellationToken cancellationToken)
|
|
{
|
|
var att = await FindOwnedAttachmentAsync(id, cancellationToken);
|
|
if (att is null) return NotFound();
|
|
|
|
var path = att.FilePath;
|
|
_db.Attachments.Remove(att);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
|
|
System.IO.File.Delete(path);
|
|
}
|
|
catch
|
|
{
|
|
// best effort
|
|
}
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<IActionResult> Upload([FromForm] IFormFileCollection files, [FromForm] int jobId, CancellationToken cancellationToken)
|
|
{
|
|
if (jobId <= 0) return BadRequest("Valid jobId is required.");
|
|
if (files is null || files.Count == 0) return BadRequest("At least one file is required.");
|
|
|
|
var jobExists = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
|
|
if (!jobExists) return BadRequest("jobId does not exist.");
|
|
|
|
var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString());
|
|
Directory.CreateDirectory(folder);
|
|
|
|
foreach (var file in files)
|
|
{
|
|
if (file.Length == 0) continue;
|
|
if (file.Length > MaxFileSizeBytes)
|
|
return BadRequest($"{file.FileName} exceeds the 10 MB upload limit.");
|
|
|
|
var displayName = Path.GetFileName(file.FileName);
|
|
var ext = Path.GetExtension(displayName);
|
|
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,
|
|
});
|
|
}
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return Ok();
|
|
}
|
|
}
|
|
}
|