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")] public class AttachmentsController : ControllerBase { private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB per file keeps local storage use predictable. private static readonly HashSet 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); // 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 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}"; } [HttpGet("{jobId:int}")] public async Task>> 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)) .ToListAsync(cancellationToken); return Ok(items); } [HttpGet("download/{id:int}")] public async Task 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 RenameAttachmentRequest(string FileName); [HttpPatch("{id:int}")] public async Task Rename([FromRoute] int id, [FromBody] RenameAttachmentRequest request, CancellationToken cancellationToken) { var att = await FindOwnedAttachmentAsync(id, cancellationToken); if (att is null) return NotFound(); var name = Path.GetFileName((request.FileName ?? "").Trim()); if (name.Length == 0) return BadRequest("FileName is required."); 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 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 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 }); } await _db.SaveChangesAsync(cancellationToken); return Ok(); } } }