Files
jobtrackingapp/JobTrackerApi/Controllers/ExportController.cs
T
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

141 lines
5.8 KiB
C#

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<IActionResult> 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<IActionResult> 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;
}
static string Text(string? s) => Esc(CsvCellSafety.SpreadsheetText(s));
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(",",
Text(j.Company?.Name),
Text(j.Company?.Location),
Text(j.Company?.Source),
Text(j.JobTitle),
Text(j.Status),
Esc(j.DateApplied?.ToString("o")),
Text(j.Location),
Text(j.Salary),
Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Text(j.SalaryCurrency),
Text(j.SalaryPeriod),
Text(j.NextAction),
Esc(j.FollowUpAt?.ToString("o")),
Text(j.JobUrl),
Text(j.Notes),
Text(j.CoverLetterText)
));
}
// Excel's direct CSV open needs the UTF-8 BOM to preserve Norwegian text.
return File([.. Encoding.UTF8.GetPreamble(), .. Encoding.UTF8.GetBytes(sb.ToString())], "text/csv; charset=utf-8", $"job-tracker-export-{stamp}.csv");
}
return File(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(jobs), "application/json", $"job-tracker-export-{stamp}.json");
}
}
}