Files

165 lines
7.2 KiB
C#
Raw Permalink Normal View History

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);
}
}
}