using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; using JobTrackerApi.Services; using System.Security.Claims; namespace JobTrackerApi.Controllers { [ApiController] [Route("api/export")] [Authorize(AuthenticationSchemes = "local")] public class ExportController : ControllerBase { private readonly JobTrackerContext _db; private readonly AccountDataExportService? _accountExport; private readonly TimeProvider _timeProvider; public ExportController(JobTrackerContext db, AccountDataExportService? accountExport = null, TimeProvider? timeProvider = null) { _db = db; _accountExport = accountExport; _timeProvider = timeProvider ?? TimeProvider.System; } [HttpPost("account")] [EnableRateLimiting("account-data")] public async Task ExportAccount(CancellationToken cancellationToken) { var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User); if (ownerUserId is null) return Unauthorized(); if (_accountExport is null) throw new InvalidOperationException("Account export is not configured."); var sessionId = User.FindFirstValue("sid"); var recentCutoff = _timeProvider.GetUtcNow().AddMinutes(-15); var currentSession = string.IsNullOrWhiteSpace(sessionId) ? null : await _db.UserSessions.IgnoreQueryFilters().AsNoTracking() .FirstOrDefaultAsync(item => item.Id == sessionId && item.UserId == ownerUserId, cancellationToken); var recentlyAuthenticated = currentSession is { RevokedAtUtc: null } && currentSession.CreatedAtUtc >= recentCutoff; if (!recentlyAuthenticated) { return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetails { Title = "Recent sign-in required", Detail = "Sign in again before downloading a complete account export.", }); } var artifact = await _accountExport.CreateAsync(ownerUserId, cancellationToken); var stream = new FileStream(artifact.StoragePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.DeleteOnClose); return File(stream, "application/zip", artifact.DownloadFileName); } [HttpGet("jobs")] public async Task ExportJobs( [FromQuery] string format = "json", [FromQuery] bool includeDeleted = false, CancellationToken cancellationToken = default ) { var query = _db.JobApplications .AsNoTracking() .Include(j => j.Company) .AsQueryable(); if (!includeDeleted) query = query.Where(j => !j.IsDeleted); var jobs = await query .OrderByDescending(j => j.DateApplied) .ToListAsync(cancellationToken); var stamp = DateTime.Now.ToString("yyyy-MM-dd"); if (string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase)) { static string Esc(string? s) { s ??= ""; var needs = s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r'); var q = s.Replace("\"", "\"\""); return needs ? $"\"{q}\"" : q; } var sb = new StringBuilder(); sb.AppendLine(string.Join(",", "Company", "CompanyLocation", "CompanySource", "JobTitle", "Status", "DateApplied", "Location", "Salary", "SalaryMin", "SalaryMax", "SalaryCurrency", "SalaryPeriod", "NextAction", "FollowUpAt", "JobUrl", "Notes", "CoverLetterText" )); foreach (var j in jobs) { sb.AppendLine(string.Join(",", Esc(j.Company?.Name), Esc(j.Company?.Location), Esc(j.Company?.Source), Esc(j.JobTitle), Esc(j.Status), Esc(j.DateApplied?.ToString("o")), Esc(j.Location), Esc(j.Salary), Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)), Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)), Esc(j.SalaryCurrency), Esc(j.SalaryPeriod), Esc(j.NextAction), Esc(j.FollowUpAt?.ToString("o")), Esc(j.JobUrl), Esc(j.Notes), Esc(j.CoverLetterText) )); } return File(Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", $"job-tracker-export-{stamp}.csv"); } return File(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(jobs), "application/json", $"job-tracker-export-{stamp}.json"); } } }