Build / Build (push) Successful in 1m3s
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits. /clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published, Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids. Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones. Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
288 lines
12 KiB
C#
288 lines
12 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.Domain.Moderation;
|
|
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, IModerationLog log) : 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]));
|
|
await log.Write(admin.ID, Models.AdminActions.BackupCreate, Models.AdminTargets.Backup, default, "manual", token: token);
|
|
_ = 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)
|
|
{
|
|
var admin = await Administrator(token);
|
|
if (admin == 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."]));
|
|
if (!backups.Delete(id))
|
|
return NotFound();
|
|
await log.Write(admin.ID, Models.AdminActions.BackupDelete, Models.AdminTargets.Backup, id, id, token: token);
|
|
return Ok();
|
|
}
|
|
|
|
// 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);
|
|
await log.Write(admin.ID, Models.AdminActions.BackupRestore, Models.AdminTargets.Backup, id, id, token: token);
|
|
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
|
|
};
|
|
}
|
|
}
|