using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.User; using PrivaPub.Domain.Portability; using PrivaPub.Extensions; using PrivaPub.Infrastructure.Backup; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.User; namespace PrivaPub.Controllers.ClientToServer { // A persona's archive for its root (owner decision 2026-10-07): made in a job and downloaded through a link good for // ten minutes; another archive (its own, or Mastodon's) uploaded in pieces and imported into it in a job, the parts the // root picks, with progress, until it asks to stop. Only the persona's own root, never a banned one. [ApiController, Route("clientapi/persona"), Authorize(Policy = Policies.IsUser)] public class PersonaArchiveController(Backups backups, TransferStore transfers, IJobQueue jobs, Federation.Actors.ILocalActorService localActors) : ControllerBase { [HttpGet, Route("{avatarId}/archive")] public async Task Status(string avatarId, CancellationToken token) { if (!await Owns(avatarId, token)) return NotFound(); await PersonaArchives.Forget(backups.Root, token); return Ok(PersonaArchives.View(await PersonaArchives.Of(avatarId, token))); } [HttpPost, Route("{avatarId}/archive")] public async Task Export(string avatarId, CancellationToken token) { if (!await Owns(avatarId, token)) return NotFound(); var state = await PersonaArchives.Of(avatarId, token); if (state.ExportState is "queued" or "running") return Conflict(new WebResult().Invalidate("The archive is being made.")); state.ExportState = "queued"; state.ExportAskedAt = DateTime.UtcNow; state.ExportError = default; await DB.Default.SaveAsync(state, token); await jobs.EnqueueMany([ExportArchiveJob.JobFor(avatarId, Host)], token); return Accepted(PersonaArchives.View(state)); } [HttpPost, Route("{avatarId}/archive/ticket")] public async Task Ticket(string avatarId, CancellationToken token) { if (!await Owns(avatarId, token)) return NotFound(); var state = await PersonaArchives.Of(avatarId, token); var file = state.ExportFile == default ? default : Path.Combine(PersonaArchives.Folder(backups.Root, avatarId), state.ExportFile); if (state.ExportState != "ready" || file == default || !System.IO.File.Exists(file)) return NotFound(); var (ticket, expires) = transfers.Ticket($"persona/{avatarId}/{state.ExportFile}"); return Ok(new ViewArchiveTicket { Url = $"/clientapi/persona/archive/download/{ticket}", ExpiresAt = expires, Bytes = new FileInfo(file).Length }); } // the archive itself, for a ticket: a plain link, so the browser saves it to disk (and resumes it) [HttpGet, Route("archive/download/{ticket}"), AllowAnonymous] public IActionResult Download(string ticket) { if (transfers.Redeem(ticket) is not { } what || what.Split('/') is not ["persona", var avatarId, var name] || name.Contains("..")) return NotFound(); var file = Path.Combine(PersonaArchives.Folder(backups.Root, avatarId), name); if (!System.IO.File.Exists(file)) return NotFound(); Response.Headers.CacheControl = "no-store"; return PhysicalFile(file, "application/zip", name, enableRangeProcessing: true); } [HttpPost, Route("archive/uploads")] public IActionResult StartUpload() => Ok(new ViewArchiveUpload { Id = transfers.Start(User.GetUserId()), ChunkBytes = TransferStore.ChunkBytes }); [HttpGet, Route("archive/uploads/{id}")] public IActionResult Upload(string id) { if (transfers.Owner(id) != User.GetUserId()) return NotFound(); return Ok(new ViewArchiveUpload { Id = id, Received = transfers.Received(id), ChunkBytes = TransferStore.ChunkBytes }); } [HttpPut, Route("archive/uploads/{id}"), RequestSizeLimit(TransferStore.ChunkBytes + 1024 * 1024)] public async Task Append(string id, [FromQuery] long offset, CancellationToken token) { if (transfers.Owner(id) != User.GetUserId()) return NotFound(); var (received, taken) = await transfers.Append(id, offset, Request.Body, token); var view = new ViewArchiveUpload { Id = id, Received = received, ChunkBytes = TransferStore.ChunkBytes }; return taken ? Ok(view) : Conflict(view); } [HttpDelete, Route("archive/uploads/{id}")] public IActionResult CancelUpload(string id) { if (transfers.Owner(id) != User.GetUserId()) return NotFound(); transfers.Cancel(id); return Ok(); } // an uploaded archive imported into the persona: refused at once when it is not one, or holds what it must not [HttpPost, Route("{avatarId}/archive/import")] public async Task Import(string avatarId, ImportPersonaArchiveForm form, CancellationToken token) { if (!await Owns(avatarId, token)) return NotFound(); var state = await PersonaArchives.Of(avatarId, token); if (state.ImportState is "queued" or "running") return Conflict(new WebResult().Invalidate("An import is running.")); if (form.Parts.Count == 0 || form.Parts.Any(p => !PersonaArchives.Parts.Contains(p))) return UnprocessableEntity(new WebResult().Invalidate("Choose what to import.")); if (transfers.Owner(form.UploadId) != User.GetUserId()) return NotFound(); var path = transfers.Take(form.UploadId, PersonaArchives.Folder(backups.Root, avatarId), $"import-{form.UploadId}.zip"); if (path == default) return NotFound(); var (archive, refused) = SafeArchive.Open(path); using (archive) if (archive?.Has("actor.json") != true) { System.IO.File.Delete(path); return UnprocessableEntity(new WebResult().Invalidate(refused ?? "the archive has no actor.json")); } state.ImportState = "queued"; state.ImportAskedAt = DateTime.UtcNow; state.ImportEndedAt = default; state.ImportParts = [.. form.Parts.Distinct()]; state.ImportFile = path; state.ImportCounts = []; (state.ImportDone, state.ImportTotal, state.ImportError, state.StopAsked) = (0, 0, default, false); await DB.Default.SaveAsync(state, token); await jobs.EnqueueMany([ImportArchiveJob.JobFor(avatarId, Host)], token); return Accepted(PersonaArchives.View(state)); } [HttpPost, Route("{avatarId}/archive/import/stop")] public async Task Stop(string avatarId, CancellationToken token) { if (!await Owns(avatarId, token)) return NotFound(); await DB.Default.Update().MatchID(avatarId).Match(a => a.ImportState == "queued" || a.ImportState == "running") .Modify(a => a.StopAsked, true).ExecuteAsync(token); return Ok(); } string Host => new Uri(localActors.BaseAddress).Host; // the root's own persona, and a root that may still act async Task Owns(string avatarId, CancellationToken token) { var rootId = User.GetUserId(); if (string.IsNullOrEmpty(avatarId) || !await DB.Default.Find().Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token)) return false; var root = await DB.Default.Find().MatchID(rootId).ExecuteFirstAsync(token); return root is { IsBanned: false, DeletedAt: null } && await DB.Default.Find().MatchID(avatarId).Match(a => !a.DeletionAt.HasValue).ExecuteAnyAsync(token); } } }