Files
SocialPub/PrivaPub/Program.cs
T
thepraandClaude Opus 5.5 d08fbabaa5 R7 custom emoji: the server's own, added by administrators, sent on posts, profiles and reactions
LocalEmoji, a new collection (unique shortcode whatever its case, never renamed: the shortcode is its id elsewhere),
managed on /clientapi/admin/emoji/list|insert|update|delete and kept in the staff's log (emoji.insert, .update,
.delete). Its picture goes through IMediaService.EmojiImage: PNG, GIF, WebP or JPEG up to 256 KB, a PNG within 256 px
with alpha kept (an animated GIF or WebP stays an animated GIF, with a still PNG), keep=none, shrunk until it fits
GoToSocial's 100 KiB, held by ServerUse "emoji:<shortcode>" so the janitor and the audit leave it. LocalEmojis keeps the
offered ones in memory, reloaded on every change: GET /api/v1/custom_emojis is real; local posts (words, title, warning,
poll options, each revision) and profiles keep the emoji they name; Notes, Questions, actors and EmojiReacts carry
Mastodon's Emoji tag with its id, served as an Emoji at /emojis/{shortcode} (410 once deleted; the path awaits the
owner's confirmation); a persona reacts with one (Pleroma's reactions, announcements). A restore keeps the live emoji
as it keeps the settings, and trashes server pictures nothing holds. tools/pasture/scenarios/emoji.sh joins batch A.

Verified: built, and run locally against a scratch database (uploads of every type, animation kept, metadata gone,
refusals, rendering, reactions, history, announcements, delete and revive, backup and restore, media audit). The
pasture scenario and the sweep were not run.

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

255 lines
9.3 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.Domain.Content.LocalEmojis.Reload(CancellationToken.None);//the server's custom emoji (R7)
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();
}