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 localizer, ILogger logger) : ControllerBase { [HttpGet, Route("")] public async Task List(CancellationToken token) { if (await Administrator(token) == default) return Forbid(); var deleted = await DB.Default.Find().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().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 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 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 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()?.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 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 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 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 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 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 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 Administrator(CancellationToken token) { var root = await DB.Default.Find().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 }; } }