Files
SocialPub/PrivaPub/Program.cs
T
thepraandClaude Opus 5.5 9f68ca3ed0 R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions
Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.

Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 05:23:45 +02:00

254 lines
9.2 KiB
C#

using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
using MongoDB.Entities;
using Serilog;
using PrivaPub.Data;
using PrivaPub.Extensions;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Infrastructure;
using PrivaPub.Infrastructure.Cli;
using PrivaPub.Infrastructure.Data;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Middleware;
using PrivaPub.Models;
using PrivaPub.Services;
using PrivaPub.StaticServices;
Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(serverOptions =>
{
if (builder.Environment.IsProduction())
{
serverOptions.ListenLocalhost(6970);
serverOptions.UseSystemd();
serverOptions.AddServerHeader = false;
}
});
builder.Host.UseSerilog((context, config) =>
{
config.ReadFrom.Configuration(context.Configuration);
});
try
{
builder.Services.PrivaPubAppSettingsConfiguration(builder.Configuration)
.PrivaPubAuthServicesConfiguration(builder.Configuration)
.PrivaPubInternalizationConfiguration(builder.Configuration)
.PrivaPubOptimizationConfiguration()
.PrivaPubDataBaseConfiguration()
.PrivaPubServicesConfiguration()
.PrivaPubFederationConfiguration(builder.Configuration)
.PrivaPubStatisticsConfiguration(builder.Configuration)
.PrivaPubCORSConfiguration()
.PrivaPubRateLimiting(builder.Configuration)
.PrivaPubOAuth(builder.Environment)
.AddScoped<PrivaPub.Api.Mastodon.Mappers.MastodonMapper>()
.AddScoped<PrivaPub.Api.Mastodon.Mappers.AccountSearch>()
.AddSingleton<PrivaPub.Domain.Social.Trends>()
.Configure<PrivaPub.Domain.Media.MediaOptions>(builder.Configuration.GetSection("Media"))
// the server's own settings (R5): read at start, saved from the administrator's page, read again every five
// minutes; the media limits it changes reach every IOptionsMonitor<MediaOptions> reader
.AddSingleton<PrivaPub.Domain.Instance.IInstanceSettings, PrivaPub.Domain.Instance.InstanceSettings>()
.AddHostedService<PrivaPub.Domain.Instance.InstanceSettingsRefresher>()
.AddSingleton<IPostConfigureOptions<PrivaPub.Domain.Media.MediaOptions>, PrivaPub.Domain.Instance.MediaLimitsFromSettings>()
.AddSingleton<IOptionsChangeTokenSource<PrivaPub.Domain.Media.MediaOptions>, PrivaPub.Domain.Instance.MediaLimitsChangeSource>()
.AddSingleton<PrivaPub.Domain.Moderation.IModerationLog, PrivaPub.Domain.Moderation.ModerationLog>()
.AddSingleton<PrivaPub.Domain.Social.IAnnouncements, PrivaPub.Domain.Social.Announcements>()
.AddSingleton<PrivaPub.Domain.Media.IMediaService, PrivaPub.Domain.Media.MediaService>()
.AddSingleton<PrivaPub.Domain.Media.IMediaProxy, PrivaPub.Domain.Media.MediaProxy>()
.AddHostedService<PrivaPub.Domain.Media.MediaJanitor>()
.Configure<PrivaPub.Infrastructure.Backup.BackupOptions>(builder.Configuration.GetSection("Backups"))
.AddSingleton<PrivaPub.Infrastructure.Backup.Backups>()
.AddSingleton<PrivaPub.Infrastructure.Backup.TransferStore>()
.AddHostedService<PrivaPub.Infrastructure.Backup.BackupScheduler>()
.AddHostedService<PrivaPub.Infrastructure.Backup.RestoreWatcher>()
.PrivaPubMiddlewareConfiguration();
}
catch (Exception ex)
{
Log.ForContext<Program>().Fatal(ex, "{0}.{1}()", nameof(Program), "ConfigureServices");
throw;
}
var federationOptions = builder.Configuration.GetSection("Federation").Get<FederationOptions>() ?? new();
if (builder.Environment.IsProduction() && (federationOptions.AllowPrivateNetworks || federationOptions.AllowPlainHttp || federationOptions.AcceptAnyCertificate))
throw new InvalidOperationException("Federation:AllowPrivateNetworks, AllowPlainHttp and AcceptAnyCertificate are for test networks and must stay off in Production.");
try
{
BsonSerializer.TryRegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
EntityMaps.Warm();
}
catch (Exception ex)
{
Log.ForContext<Program>().Fatal(ex, $"{nameof(Program)}.{nameof(Program)}() DB Instantiation");
throw;
}
var app = default(WebApplication);
try
{
app = builder.Build();
}
catch (Exception ex)
{
Log.ForContext<Program>().Fatal(ex, "{0}.{1}()", nameof(Program), "Build");
throw;
}
// A restore asked for runs here, before migrations, indexes and every hosted service; a command waits for it.
var gate = await PrivaPub.Infrastructure.Backup.MaintenanceGate.Enter(app.Services, args is ["admin", .. var asked] ? asked : default, CancellationToken.None);
if (gate is { } stop)
{
Environment.ExitCode = stop;
return;
}
// Commands get every service but start nothing: no Kestrel, no hosted services, no media directory. The deploy runs
// them as its own user, which can read the configuration and reach the private mongod but owns no www-data directory.
// Backups and restores are asked for before migrations: the deploy's backup is of the database as the live build left it.
if (args is ["admin", .. var command] && AdminCommands.BeforeMigrations(command))
{
using var scope = app.Services.CreateScope();
Environment.ExitCode = await AdminCommands.Run(command, scope.ServiceProvider);
return;
}
try
{
await DB.Default.MigrateAsync<Program>();
await Indexes.Create();
await app.Services.GetRequiredService<PrivaPub.Domain.Instance.IInstanceSettings>().Reload(CancellationToken.None);
}
catch (Exception ex)
{
Log.ForContext<Program>().Fatal(ex, $"{nameof(Program)}.{nameof(Program)}() DB Migrations");
throw;
}
if (args is ["admin", ..])
{
using var scope = app.Services.CreateScope();
Environment.ExitCode = await AdminCommands.Run(args[1..], scope.ServiceProvider);
return;
}
try
{
var localizationService = app.Services.GetService<RequestLocalizationOptionsService>();
if (app.Environment.IsProduction())
{
app.UseResponseCompression();
app.UseForwardedHeaders(new()
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors("DefaultCORS");
app.UseMastodonErrorBodies();
app.UseStaticFiles();
var mediaRoot = app.Services.GetRequiredService<PrivaPub.Domain.Media.IMediaService>().Root;
Directory.CreateDirectory(mediaRoot);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(mediaRoot),
RequestPath = "/media/files",
ContentTypeProvider = PrivaPub.Domain.Media.MediaService.ContentTypes,
OnPrepareResponse = context =>
{
context.Context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Context.Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
context.Context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable";
}
});
app.UseRequestLocalization(await localizationService.Get());
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
app.UseRouting();
app.UseTrafficMeter();
app.UseRateLimiter();
// a stream's token may come as access_token or as the WebSocket's protocol, as Mastodon's clients send it
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/api/v1/streaming") && !context.Request.Headers.ContainsKey("Authorization"))
{
var token = context.Request.Query["access_token"].ToString();
if (string.IsNullOrEmpty(token))
token = context.Request.Headers["Sec-WebSocket-Protocol"].ToString();
if (!string.IsNullOrEmpty(token))
context.Request.Headers.Authorization = "Bearer " + token;
}
await next();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/build.json", () => Results.Json(new
{
commit = BuildInfo.Commit,
buildRef = BuildInfo.Ref,
builtAt = BuildInfo.BuiltAt,
}));
app.MapControllers();
app.MapRazorPages();
}
catch (Exception ex)
{
Log.ForContext<Program>().Fatal(ex, "{0}.{1}()", nameof(Program), "Use");
throw;
}
Log.ForContext<Program>().Information($"Starting PrivaPub at {nameof(Program)}()");
try
{
var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities;
await dbClient.Init();
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
await PrivaPub.Domain.Moderation.Restrictions.Reload(CancellationToken.None);//who the staff's moderation holds back (R6)
await PrivaPub.Api.Mastodon.Auth.PersonaExchange.EnsureFirstPartyClient(app.Services, CancellationToken.None);
}
catch (Exception ex)
{
Log.ForContext<Program>().Warning(ex, $"{nameof(Program)}.{nameof(Program)}() DB Init");
}
await app.RunAsync();
}
catch (Exception ex)
{
Log.ForContext<Program>().Fatal(ex, $"{nameof(Program)}.{nameof(Program)}()");
Environment.ExitCode = 1;
}
finally
{
await Log.CloseAndFlushAsync();
}