Files
SocialPub/PrivaPub/Controllers/ClientToServer/BackupController.cs
T
thepraandClaude Opus 5.5 bc5f2beaf7 The administrator's page backs up, downloads, uploads and restores
/clientapi/admin/backups (owner decision 2026-10-07, approving these endpoints in production): the list with what runs,
the restore waiting and the last restore's report; back up now; delete; a download for the password, through a ticket
good for ten minutes in the link's path, as one tar whose length is known first and which honours Range (BackupTar);
an upload in pieces of at most 32 MB, each at the offset already received or refused with it, so a broken upload
resumes, read into a backup only when it holds nothing but plain files under one backup's folder; and a restore for
the password and the host typed out. Every call checks the administrator against the database. nginx streams downloads
for an hour and takes upload pieces unbuffered, rate-limited.

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

281 lines
11 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Net.Http.Headers;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Extensions;
using PrivaPub.Infrastructure.Backup;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
namespace PrivaPub.Controllers.ClientToServer
{
// The server's backups from the administrator's page (owner decision 2026-10-07: it backs up and restores too). Only
// an administrator, checked again against the database; a download and a restore ask for the password again, and a
// restore for the server's host typed out. A restore asked for stops the service within seconds, and the next start
// carries it out (ServerRestore).
[ApiController,
Route("clientapi/admin/backups"),
Authorize(Policy = Policies.IsAdmin)]
public class BackupController(Backups backups, TransferStore transfers, IPasswordHasher hasher, IStringLocalizer<GenericRes> localizer,
ILogger<BackupController> logger) : ControllerBase
{
[HttpGet, Route("")]
public async Task<IActionResult> List(CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
var deleted = await DB.Default.Find<RootUser>().Match(u => u.DeletedAt != null).Project(u => u.Include(x => x.DeletedAt)).ExecuteAsync(token);
var code = ServerBackup.CodeMigration();
var waiting = RestoreMarker.Read(backups.Root);
var last = await DB.Default.Find<RestoreRecord>().Sort(r => r.RestoredAt, Order.Descending).ExecuteFirstAsync(token);
ServerBackup.MakeDirectory(backups.Root);
return Ok(new ViewBackups
{
Backups = [.. backups.List().Select(b => View(b, deleted.Count(u => u.DeletedAt > b.CreatedAt), code))],
Running = (await MaintenanceLock.Current(token))?.What,
Waiting = waiting == default ? default : new ViewRestoreWaiting
{
Backup = waiting.Backup,
State = waiting.State,
Attempts = waiting.Attempts,
RequestedBy = waiting.RequestedBy,
RequestedAt = waiting.RequestedAt,
Error = waiting.Error
},
LastRestore = last == default ? default : View(last),
Host = HostName,
FreeBytes = new DriveInfo(backups.Root).AvailableFreeSpace
});
}
// a backup made now, while the page polls the list
[HttpPost, Route("")]
public async Task<IActionResult> Create(CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
if (await MaintenanceLock.Current(token) is { } held)
return Conflict(new WebResult().Invalidate(localizer["A {0} is running.", held.What]));
_ = Task.Run(async () =>
{
try
{
var (made, error) = await backups.Create("manual", dbOnly: false, CancellationToken.None);
if (made == default)
logger.LogWarning("The backup {Admin} asked for was not made: {Error}", admin.UserName, error);
else
logger.LogInformation("Backup {Id} made for {Admin}", made.Id, admin.UserName);
}
catch (Exception ex)
{
logger.LogError(ex, "The backup {Admin} asked for failed", admin.UserName);
}
}, CancellationToken.None);
return Accepted();
}
[HttpDelete, Route("{id}")]
public async Task<IActionResult> Delete(string id, CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
if (RestoreMarker.Read(backups.Root) is { } waiting && (waiting.Backup == id || waiting.PreRestore == id))
return Conflict(new WebResult().Invalidate(localizer["A restore waits for this backup."]));
return backups.Delete(id) ? Ok() : NotFound();
}
// a link to download it with, for the password
[HttpPost, Route("{id}/ticket")]
public async Task<IActionResult> Ticket(string id, BackupPasswordForm form, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
if (!PasswordHolds(admin, form.Password))
return UnprocessableEntity(new WebResult().Invalidate(localizer["Wrong password."]));
if (backups.Find(id) == default)
return NotFound();
var (ticket, expires) = transfers.Ticket(id);
return Ok(new ViewBackupTicket { Url = $"/clientapi/admin/backups/download/{ticket}", ExpiresAt = expires, Bytes = BackupTar.Of(backups.Root, id).Length });
}
// the backup as one tar, for a ticket: a plain link, so the browser downloads it to disk and resumes it
[HttpGet, Route("download/{ticket}"), AllowAnonymous]
public async Task Download(string ticket, CancellationToken token)
{
var id = transfers.Redeem(ticket);
if (id == default || backups.Find(id) == default)
{
Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
var tar = BackupTar.Of(backups.Root, id);
var (from, to) = (0L, tar.Length - 1);
Response.Headers.AcceptRanges = "bytes";
Response.Headers.CacheControl = "no-store";
Response.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") { FileName = $"privapub-{id}.tar" }.ToString();
var range = Request.GetTypedHeaders().Range;
if (range is { Unit.Value: "bytes", Ranges.Count: 1 })
{
var asked = range.Ranges.First();
var start = asked.From ?? tar.Length - asked.To.GetValueOrDefault();
var end = asked.From.HasValue ? Math.Min(asked.To ?? tar.Length - 1, tar.Length - 1) : tar.Length - 1;
if (start < 0 || start > end)
{
Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
Response.Headers.ContentRange = $"bytes */{tar.Length}";
return;
}
(from, to) = (start, end);
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.ContentRange = $"bytes {from}-{to}/{tar.Length}";
}
Response.ContentType = "application/x-tar";
Response.ContentLength = to - from + 1;
await tar.Write(Response.Body, from, to, token);
}
[HttpPost, Route("uploads")]
public async Task<IActionResult> StartUpload(CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
return Ok(new ViewBackupUpload { Id = transfers.Start(admin.UserName), ChunkBytes = TransferStore.ChunkBytes });
}
[HttpGet, Route("uploads/{id}")]
public async Task<IActionResult> Upload(string id, CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
var received = transfers.Received(id);
return received < 0 ? NotFound() : Ok(new ViewBackupUpload { Id = id, Received = received, ChunkBytes = TransferStore.ChunkBytes });
}
// a piece, at the offset already received: 409 with what was received when it isn't
[HttpPut, Route("uploads/{id}"), RequestSizeLimit(TransferStore.ChunkBytes + 1024 * 1024)]
public async Task<IActionResult> Append(string id, [FromQuery] long offset, CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
var (received, taken) = await transfers.Append(id, offset, Request.Body, token);
if (received < 0)
return NotFound();
var view = new ViewBackupUpload { Id = id, Received = received, ChunkBytes = TransferStore.ChunkBytes };
return taken ? Ok(view) : Conflict(view);
}
[HttpPost, Route("uploads/{id}/finish")]
public async Task<IActionResult> FinishUpload(string id, CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
if (await MaintenanceLock.Current(token) is { What: "restore" })
return Conflict(new WebResult().Invalidate(localizer["A {0} is running.", "restore"]));
var (backup, error) = await transfers.Finish(id, token);
if (backup == default)
return UnprocessableEntity(new WebResult().Invalidate(error));
return Ok(View(backup, 0, ServerBackup.CodeMigration()));
}
[HttpDelete, Route("uploads/{id}")]
public async Task<IActionResult> CancelUpload(string id, CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
return transfers.Cancel(id) ? Ok() : NotFound();
}
// asked for with the password and the host typed out: the service stops within seconds and restores it as it starts
[HttpPost, Route("{id}/restore")]
public async Task<IActionResult> Restore(string id, RestoreBackupForm form, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
if (!PasswordHolds(admin, form.Password))
return UnprocessableEntity(new WebResult().Invalidate(localizer["Wrong password."]));
if (!string.Equals(form.Host?.Trim(), HostName, StringComparison.OrdinalIgnoreCase))
return UnprocessableEntity(new WebResult().Invalidate(localizer["Type this server's host to restore it."]));
var refused = await ServerRestore.Request(backups.Context(), id, admin.UserName, token);
if (refused != default)
return UnprocessableEntity(new WebResult().Invalidate(refused));
logger.LogWarning("{Admin} asked to restore {Backup}", admin.UserName, id);
return Accepted();
}
string HostName => Uri.TryCreate(backups.Host, UriKind.Absolute, out var host) ? host.Authority : backups.Host;
// the token says administrator; the database has the last word
async Task<RootUser> Administrator(CancellationToken token)
{
var root = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
return root is { IsBanned: false, DeletedAt: null } && root.Policies.Contains(Policies.IsAdmin) ? root : default;
}
bool PasswordHolds(RootUser root, string password) =>
!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(root.HashedPassword) && hasher.Check(root.HashedPassword, password).verified;
static ViewBackup View(BackupInfo backup, int rootsDeletedSince, int code)
{
var manifest = backup.Manifest;
return new ViewBackup
{
Id = backup.Id,
Kind = backup.Kind,
CreatedAt = backup.CreatedAt,
Bytes = backup.Bytes,
MediaBytes = manifest.Media.Bytes,
MediaFiles = manifest.DbOnly ? manifest.Media.List.Count : manifest.Media.Files,
MediaMissing = manifest.Media.Missing,
DbOnly = manifest.DbOnly,
Consistent = manifest.Consistent,
Collections = manifest.Collections.Count,
Documents = manifest.Collections.Sum(c => c.Count),
AppCommit = manifest.AppCommit,
MigrationNumber = manifest.MigrationNumber,
RootsDeletedSince = rootsDeletedSince,
NotRestorable = manifest.Format != ArchiveManifest.CurrentFormat ? "format"
: manifest.MigrationNumber > code ? "newer"
: default
};
}
static ViewRestore View(RestoreRecord record) => new()
{
Backup = record.Backup,
BackupCreatedAt = record.BackupCreatedAt,
PreRestore = record.PreRestore,
RequestedBy = record.RequestedBy,
RestoredAt = record.RestoredAt,
Abandoned = record.Abandoned,
Error = record.Error,
Documents = record.Report.Documents,
Collections = record.Report.Collections,
MediaRestored = record.Report.MediaRestored,
MediaMissing = record.Report.MediaMissing,
RootsDeleted = record.Report.RootsDeleted,
PersonasDeleted = record.Report.PersonasDeleted,
GroupsDeleted = record.Report.GroupsDeleted,
PostsDeleted = record.Report.PostsDeleted,
RootsTombstoned = record.Report.RootsTombstoned,
PersonasTombstoned = record.Report.PersonasTombstoned,
GroupsTombstoned = record.Report.GroupsTombstoned,
PostsGone = record.Report.PostsGone,
MediaTrashed = record.Report.MediaTrashed,
ProtectiveKept = record.Report.ProtectiveKept,
SessionsEnded = record.Report.SessionsEnded
};
}
}