165 lines
7.2 KiB
C#
165 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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|