Files
SocialPub/PrivaPub/Infrastructure/Backup/Backups.cs
T
thepraandClaude Opus 5.5 3d7d406834 A persona's archive: exported in Mastodon's layout, imported without telling anyone
A root exports one of its personas (a job) as Mastodon's account archive, so other servers' importers read it: the
actor with its public key only, its own posts and boosts with their media, likes, bookmarks and Mastodon's CSV files,
plus PrivaPub's filters, followed hashtags, notification policy, pins, scheduled and located posts; nothing of its root,
its siblings, its keys or anyone's token. A ticket link downloads it, for a week.

An archive (PrivaPub's or Mastodon's) uploaded in pieces is imported into a persona in a job, the parts the root picks,
with progress and a stop. SafeArchive refuses links, escaping paths, duplicates, bombs and oversized items, and reads the
outbox one item at a time. Imported posts are delivered to no one, put in no home and notify nobody, yet show on the
profile, outbox, hashtags and search; back home a post keeps its id, from another actor it gets one of its date and
ImportedFromURI, so importing twice changes nothing. Relationships go through the existing services; located, scheduled
and likes only when asked; followers never.

tools/pasture/scenarios/persona-archive.sh imports mastouser's real Mastodon archive (156 posts, 24 pictures) into a
persona Mastodon follows: Mastodon receives none of it, and a second import changes nothing.

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

91 lines
3.5 KiB
C#

using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Domain.Media;
using PrivaPub.Models;
namespace PrivaPub.Infrastructure.Backup
{
// The server's backups, wherever they are started from: the CLI, the nightly schedule, the administrator's page.
public class Backups(IOptionsMonitor<BackupOptions> options, IOptionsMonitor<AppConfiguration> app, IMediaService media)
{
/// <summary>Where backups are kept: Backups:Root (production's /var/lib/privapub/backups), else beside the media root.</summary>
public string Root => Path.GetFullPath(options.CurrentValue.Root ?? media.Root.TrimEnd(Path.DirectorySeparatorChar) + "-backups");
public BackupOptions Options => options.CurrentValue;
public string Host => app.CurrentValue.BackendBaseAddress?.TrimEnd('/');
public BackupContext Context() => new(DB.Default.Database(), Root, media.Root, media.TrashRoot, Host, options.CurrentValue);
public Task<(BackupInfo Backup, string Error)> Create(string kind, bool dbOnly, CancellationToken token) =>
ServerBackup.Create(Context(), kind, dbOnly, token);
public List<BackupInfo> List() => ServerBackup.List(Root);
public BackupInfo Find(string id) => ServerBackup.Find(Root, id);
public Task<List<string>> Verify(string id, CancellationToken token) => ServerBackup.Verify(Root, id, token);
public bool Delete(string id) => ServerBackup.Delete(Root, id);
}
// A nightly backup at Backups:NightlyAt (UTC), or as soon as the service is up after missing it; the old ones rotated
// out as each is made. Personas' archives a week old go too.
public class BackupScheduler(Backups backups, ILogger<BackupScheduler> logger) : BackgroundService
{
static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(Interval, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
try
{
await RunIfDue(DateTime.UtcNow, stoppingToken);
await Domain.Portability.PersonaArchives.Forget(backups.Root, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "The nightly backup failed");
}
}
}
/// <summary>Backs up when the day's time has come and there is no nightly backup since: whether it did.</summary>
public async Task<bool> RunIfDue(DateTime now, CancellationToken token)
{
var options = backups.Options;
if (!options.Nightly || !TimeOnly.TryParse(options.NightlyAt, System.Globalization.CultureInfo.InvariantCulture, out var at)
|| !IsDue(now, at, backups.List().Where(b => b.Kind == "nightly").Select(b => b.CreatedAt)))
return false;
var (made, error) = await backups.Create("nightly", dbOnly: false, token);
if (made == default)
{
logger.LogWarning("The nightly backup was not made: {Error}", error);
return false;
}
logger.LogInformation("Nightly backup {Id}: {Collections} collections, {Files} media files", made.Id, made.Manifest.Collections.Count, made.Manifest.Media.Files);
return true;
}
/// <summary>Whether the last time of day for a nightly backup has passed with no nightly backup made since.</summary>
public static bool IsDue(DateTime now, TimeOnly at, IEnumerable<DateTime> nightlies)
{
var due = now.Date + at.ToTimeSpan();
if (now < due)
due = due.AddDays(-1);
return !nightlies.Any(made => made >= due);
}
}
}