R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log
Build / Build (push) Successful in 1m3s
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
This commit is contained in:
1 parent
f67e2ae35e
commit
70d1ffe9a2
46 files changed
+2223
-107
No files matched your search
@@ -13,7 +13,10 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
// deletion left it), with what a deletion takes away; P's moderation of a persona (silenced, suspended, banned) stays;
|
||||
// - a root keeps P's password, e-mail, ban and policies;
|
||||
// - roots, personas and groups made since become tombstones: deleted, their names kept; local posts made since answer
|
||||
// 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them);
|
||||
// 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them),
|
||||
// but the server's own pictures the live settings hold;
|
||||
// - the staff's log is the union (a restore never loses an entry, its own request included), and the server's
|
||||
// settings are P's: the live settings outlive a restore;
|
||||
// - every session ends, and every server's circuit is closed again.
|
||||
// Run on the imported database, it reads P's files only, so it can run again after a failed attempt.
|
||||
public static class ProtectiveMerge
|
||||
@@ -29,7 +32,8 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
("DeletedObject", ["ObjectURI"]),
|
||||
("Report", ["_id"]),
|
||||
("PersonaFilter", ["_id"]),
|
||||
("openiddict.applications", ["client_id"])
|
||||
("openiddict.applications", ["client_id"]),
|
||||
("AdminAction", ["_id"])
|
||||
];
|
||||
|
||||
const string Since = "restore: made after the backup";
|
||||
@@ -39,8 +43,8 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
IEnumerable<BsonDocument> P(string collection) => ServerRestore.Read(Path.Combine(previous, collection + ".jsonl.gz"));
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// who follows whom: P's
|
||||
foreach (var collection in new[] { "Follower", "Following" })
|
||||
// who follows whom: P's; and the server's settings as they were live
|
||||
foreach (var collection in new[] { "Follower", "Following", "ServerSettings" })
|
||||
{
|
||||
await database.DropCollectionAsync(collection, token);
|
||||
await ServerRestore.Insert(database.GetCollection<BsonDocument>(collection), P(collection), token);
|
||||
@@ -206,7 +210,9 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
{
|
||||
if (!restored.TryGetValue(row["_id"], out var mine))
|
||||
{
|
||||
if (!IsSet(row, "TrashedAt"))
|
||||
// made since, and to the trash, but a picture of the server's own (its thumbnail): the live settings,
|
||||
// which a restore keeps, still show it
|
||||
if (!IsSet(row, "TrashedAt") && !IsSet(row, "ServerUse"))
|
||||
{
|
||||
row["TrashedAt"] = now;
|
||||
row["TrashReason"] = Since;
|
||||
@@ -223,7 +229,7 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
report.MediaTrashed++;
|
||||
}
|
||||
await ServerRestore.Insert(media, made, token);
|
||||
report.MediaTrashed += made.Count(m => m["TrashReason"] == Since);
|
||||
report.MediaTrashed += made.Count(m => m.GetValue("TrashReason", BsonNull.Value) == Since);
|
||||
}
|
||||
|
||||
var deleted = await (await posts.FindAsync(new BsonDocument("DeletedAt", new BsonDocument("$ne", BsonNull.Value)),
|
||||
|
||||
@@ -4,6 +4,7 @@ using OpenIddict.Abstractions;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.Domain.Moderation;
|
||||
using PrivaPub.Infrastructure.Backup;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
@@ -46,7 +47,7 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
switch (args)
|
||||
{
|
||||
case [("promote" or "demote") and var verb, var userName]:
|
||||
return await Promote(verb == "promote", userName, output);
|
||||
return await Promote(services, verb == "promote", userName, output);
|
||||
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
|
||||
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
|
||||
case ["move-persona", var persona, var root]:
|
||||
@@ -54,7 +55,7 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
case ["delete-root", var root]:
|
||||
return await DeleteRoot(services, root, output);
|
||||
case ["reset-totp", var root]:
|
||||
return await ResetTotp(root, output);
|
||||
return await ResetTotp(services, root, output);
|
||||
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
|
||||
return await AuditMedia(services, flags.Contains("--fix"), output);
|
||||
case ["backup", "verify", var id]:
|
||||
@@ -139,6 +140,7 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
Console.Error.WriteLine(refused);
|
||||
return 1;
|
||||
}
|
||||
await Log(services, AdminActions.BackupRestore, AdminTargets.Backup, id, id);
|
||||
output.WriteLine($"{id} will be restored: the running service stops within seconds and restores it when it starts again");
|
||||
output.WriteLine("(systemd starts it again; follow it with journalctl -u privapub, then PrivaPub admin restore --status)");
|
||||
return 0;
|
||||
@@ -187,7 +189,12 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> Promote(bool promote, string userName, TextWriter output)
|
||||
// every act below is kept in the staff's log as the command line's ("cli"): a login's by its name alone, a persona's
|
||||
// without the login it went to
|
||||
static Task Log(IServiceProvider services, string action, string kind, string id, string label, string note = default) =>
|
||||
services.GetRequiredService<IModerationLog>().Write(AdminAction.Cli, action, kind, id, label, note: note);
|
||||
|
||||
static async Task<int> Promote(IServiceProvider services, bool promote, string userName, TextWriter output, bool created = false)
|
||||
{
|
||||
userName = userName.ToLowerInvariant();
|
||||
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync();
|
||||
@@ -201,6 +208,8 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
await DB.Default.SaveAsync(user);
|
||||
if (promote)
|
||||
await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed
|
||||
if (!created)
|
||||
await Log(services, promote ? AdminActions.RootPromote : AdminActions.RootDemote, AdminTargets.Root, user.ID, userName);
|
||||
|
||||
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
|
||||
return 0;
|
||||
@@ -223,7 +232,9 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
Console.Error.WriteLine(created.ErrorMessage);
|
||||
return 1;
|
||||
}
|
||||
return admin ? await Promote(true, login, output) : await Report(login, output);
|
||||
var made = await DB.Default.Find<RootUser>().Match(u => u.UserName == login.ToLowerInvariant()).ExecuteFirstAsync();
|
||||
await Log(services, AdminActions.RootCreate, AdminTargets.Root, made?.ID, login.ToLowerInvariant(), admin ? "administrator" : default);
|
||||
return admin ? await Promote(services, true, login, output, created: true) : await Report(login, output);
|
||||
}
|
||||
|
||||
static Task<int> Report(string login, TextWriter output)
|
||||
@@ -266,22 +277,24 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
await DB.Default.Update<RootToAvatar>().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync();
|
||||
await services.GetRequiredService<IOpenIddictTokenManager>().RevokeBySubjectAsync(persona.ID);
|
||||
await services.GetRequiredService<IOpenIddictAuthorizationManager>().RevokeBySubjectAsync(persona.ID);
|
||||
await Log(services, AdminActions.PersonaMove, AdminTargets.Persona, persona.ID, "@" + personaName, "moved to another login");
|
||||
output.WriteLine($"@{personaName}: moved to {login}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The way back for a login that lost its authenticator and its recovery codes: its two-factor sign-in goes, its
|
||||
// password stays.
|
||||
static async Task<int> ResetTotp(string login, TextWriter output)
|
||||
static async Task<int> ResetTotp(IServiceProvider services, string login, TextWriter output)
|
||||
{
|
||||
login = login.ToLowerInvariant();
|
||||
var updated = await DB.Default.Update<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null)
|
||||
.Modify(u => u.Totp, null).ExecuteAsync();
|
||||
if (updated.MatchedCount == 0)
|
||||
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync();
|
||||
var updated = root == default ? default : await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Totp, null).ExecuteAsync();
|
||||
if (updated == default || updated.MatchedCount == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"no root user '{login}'");
|
||||
return 1;
|
||||
}
|
||||
await Log(services, AdminActions.RootResetTotp, AdminTargets.Root, root.ID, login);
|
||||
output.WriteLine($"{login}: two-factor sign-in is off");
|
||||
return 0;
|
||||
}
|
||||
@@ -305,6 +318,7 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
Console.Error.WriteLine($"'{login}' could not be deleted");
|
||||
return 1;
|
||||
}
|
||||
await Log(services, AdminActions.RootDelete, AdminTargets.Root, root.ID, login);
|
||||
output.WriteLine($"{login}: deleted{(personas.Count > 0 ? ", with " + string.Join(", ", personas) : string.Empty)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,17 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await Plain<Models.Media.MediaAttachment>(token, m => m.ScheduledStatusId);
|
||||
await Plain<Models.Media.MediaAttachment>(token, m => m.ProfileOfAvatarId);
|
||||
await Plain<Models.Media.MediaAttachment>(token, m => m.TrashedAt);
|
||||
await Plain<Models.Media.MediaAttachment>(token, m => m.ServerUse);//the server's own pictures (its thumbnail)
|
||||
|
||||
// the administrators' announcements: each persona's dismissal and reactions, once each (new collections, R5)
|
||||
await DB.Default.Index<AnnouncementRead>().Key(r => r.AnnouncementId, KeyType.Ascending).Key(r => r.AvatarId, KeyType.Ascending)
|
||||
.Option(o => o.Unique = true).CreateAsync(token);
|
||||
await Plain<AnnouncementRead>(token, r => r.AvatarId);
|
||||
await DB.Default.Index<AnnouncementReaction>().Key(r => r.AnnouncementId, KeyType.Ascending).Key(r => r.AvatarId, KeyType.Ascending)
|
||||
.Key(r => r.Name, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
||||
await Plain<AnnouncementReaction>(token, r => r.AvatarId);
|
||||
// the staff's log, read newest first
|
||||
await DB.Default.Index<Models.AdminAction>().Key(a => a.At, KeyType.Descending).CreateAsync(token);
|
||||
|
||||
await Unique<ForeignAvatar>(a => a.ActorURI, Builders<ForeignAvatar>.Filter.Gt(a => a.ActorURI, ""), token);
|
||||
await Plain<ForeignAvatar>(token, a => a.PublicKeyId);
|
||||
|
||||
Reference in new issue
Block a user