Files
jobtrackingapp/JobTrackerApi/Controllers/BackupController.cs
T
cesnimda 2fa4e38ba9
CI and Deploy / test (pull_request) Successful in 4m14s
CI and Deploy / deploy (pull_request) Has been skipped
feat(email): include drafts in user exports
Export readable private draft content only through existing owner-filtered encrypted and daily export boundaries, with cross-tenant regression coverage.
2026-08-10 10:00:34 +02:00

123 lines
5.0 KiB
C#

using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/backup")]
[Authorize(AuthenticationSchemes = "local")]
public class BackupController : ControllerBase
{
private readonly JobTrackerContext _db;
private readonly ILogger<BackupController> _logger;
private readonly IDataProtector _protector;
public BackupController(JobTrackerContext db, ILogger<BackupController> logger, IDataProtectionProvider dp)
{
_db = db;
_logger = logger;
_protector = dp.CreateProtector("JobTrackerApi.Backup.v1");
}
public sealed record BackupEnvelope(
string Version,
DateTime CreatedAt,
object Data
);
[HttpPost("encrypted")]
public async Task<IActionResult> Encrypted(CancellationToken cancellationToken)
{
var data = await BuildExport(cancellationToken);
var envelope = new BackupEnvelope("jtbackup.v1", DateTime.Now, data);
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
envelope,
new JsonSerializerOptions { WriteIndented = true }
);
// Data Protection encrypts payload using the app's key ring.
// On Windows, keys are encrypted at rest for the current user.
var protectedText = _protector.Protect(Convert.ToBase64String(jsonBytes));
var cipher = Encoding.UTF8.GetBytes(protectedText);
var fileName = $"jobtracker_backup_{DateTime.Now:yyyyMMdd_HHmmss}.jtbackup";
_logger.LogInformation("Generated encrypted backup {FileName} ({Bytes} bytes).", fileName, cipher.Length);
return File(cipher, "application/octet-stream", fileName);
}
private async Task<object> BuildExport(CancellationToken cancellationToken)
{
// Avoid navigation cycles by exporting as plain graphs / DTOs.
var companies = await _db.Companies.AsNoTracking().OrderBy(c => c.Name).ToListAsync(cancellationToken);
var jobs = await _db.JobApplications.AsNoTracking().OrderByDescending(j => j.DateApplied).ToListAsync(cancellationToken);
var jobIds = jobs.Select(j => j.Id).ToList();
var correspondence = await _db.Correspondences.AsNoTracking()
.Where(c => jobIds.Contains(c.JobApplicationId))
.OrderBy(c => c.Date)
.ToListAsync(cancellationToken);
var attachments = await _db.Attachments.AsNoTracking()
.Where(a => jobIds.Contains(a.JobApplicationId))
.OrderBy(a => a.UploadDate)
.ToListAsync(cancellationToken);
var events = await _db.JobEvents.AsNoTracking()
.Where(e => jobIds.Contains(e.JobApplicationId))
.OrderBy(e => e.At)
.ToListAsync(cancellationToken);
var emailSendAttempts = await _db.EmailSendAttempts.AsNoTracking()
.Where(attempt => jobIds.Contains(attempt.JobApplicationId))
.OrderBy(attempt => attempt.CreatedAtUtc)
.Select(attempt => new EmailSendAttemptExport(
attempt.Id,
attempt.JobApplicationId,
attempt.Provider,
attempt.ClientRequestId,
attempt.Status,
attempt.ProviderMessageId,
attempt.FailureCategory,
attempt.CreatedAtUtc,
attempt.StartedAtUtc,
attempt.CompletedAtUtc))
.ToListAsync(cancellationToken);
var emailDrafts = await _db.EmailDrafts.AsNoTracking()
.Where(draft => jobIds.Contains(draft.JobApplicationId))
.OrderBy(draft => draft.UpdatedAtUtc)
.Select(draft => new EmailDraftExport(
draft.Id,
draft.JobApplicationId,
draft.Provider,
draft.To,
draft.Subject,
draft.BodyText,
draft.ThreadId,
draft.Revision,
draft.CreatedAtUtc,
draft.UpdatedAtUtc))
.ToListAsync(cancellationToken);
var rules = await _db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
return new
{
Companies = companies,
JobApplications = jobs,
Correspondence = correspondence,
Attachments = attachments,
Events = events,
EmailSendAttempts = emailSendAttempts,
EmailDrafts = emailDrafts,
Rules = rules
};
}
}
}