/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
281 lines
11 KiB
C#
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
|
|
};
|
|
}
|
|
}
|