Files
SocialPub/PrivaPub/Controllers/ClientToServer/PersonaArchiveController.cs
T
thepraandClaude Opus 5.5 3d7d406834 A persona's archive: exported in Mastodon's layout, imported without telling anyone
A root exports one of its personas (a job) as Mastodon's account archive, so other servers' importers read it: the
actor with its public key only, its own posts and boosts with their media, likes, bookmarks and Mastodon's CSV files,
plus PrivaPub's filters, followed hashtags, notification policy, pins, scheduled and located posts; nothing of its root,
its siblings, its keys or anyone's token. A ticket link downloads it, for a week.

An archive (PrivaPub's or Mastodon's) uploaded in pieces is imported into a persona in a job, the parts the root picks,
with progress and a stop. SafeArchive refuses links, escaping paths, duplicates, bombs and oversized items, and reads the
outbox one item at a time. Imported posts are delivered to no one, put in no home and notify nobody, yet show on the
profile, outbox, hashtags and search; back home a post keeps its id, from another actor it gets one of its date and
ImportedFromURI, so importing twice changes nothing. Relationships go through the existing services; located, scheduled
and likes only when asked; followers never.

tools/pasture/scenarios/persona-archive.sh imports mastouser's real Mastodon archive (156 posts, 24 pictures) into a
persona Mastodon follows: Mastodon receives none of it, and a second import changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:43:12 +02:00

166 lines
7.2 KiB
C#

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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Stop(string avatarId, CancellationToken token)
{
if (!await Owns(avatarId, token))
return NotFound();
await DB.Default.Update<PersonaArchive>().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<bool> Owns(string avatarId, CancellationToken token)
{
var rootId = User.GetUserId();
if (string.IsNullOrEmpty(avatarId) || !await DB.Default.Find<RootToAvatar>().Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token))
return false;
var root = await DB.Default.Find<RootUser>().MatchID(rootId).ExecuteFirstAsync(token);
return root is { IsBanned: false, DeletedAt: null }
&& await DB.Default.Find<Avatar>().MatchID(avatarId).Match(a => !a.DeletionAt.HasValue).ExecuteAnyAsync(token);
}
}
}