The first-party client signs in on /clientapi and exchanges that JWT on /oauth/token (RFC 8693, subject_token_type jwt, avatar_id) for one persona's Mastodon token. Only the seeded public application `decepub` holds the grant; RootJwtSubjectToken validates the JWT through RootJwt, which JwtBearer now shares (signature, lifetime, ban, deletion, session stamp). The issued token names the avatar, never the root. Owner decision recorded in ROADMAP; it supersedes "moving decePubClient onto the Mastodon API is out of scope". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
208 lines
6.6 KiB
C#
208 lines
6.6 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
|
|
//, options =>
|
|
//{
|
|
// options.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
|
|
//}
|
|
);
|
|
serverOptions.UseSystemd();
|
|
serverOptions.AddServerHeader = false;
|
|
}
|
|
});
|
|
builder.Host.UseSerilog((context, config) =>
|
|
{
|
|
config.ReadFrom.Configuration(context.Configuration);
|
|
});
|
|
|
|
try
|
|
{
|
|
builder.Services.PrivaPubAppSettingsConfiguration(builder.Configuration)
|
|
.PrivaPubWorkersConfiguration()
|
|
.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>()
|
|
.Configure<PrivaPub.Domain.Media.MediaOptions>(builder.Configuration.GetSection("Media"))
|
|
.AddSingleton<PrivaPub.Domain.Media.IMediaService, PrivaPub.Domain.Media.MediaService>()
|
|
.AddSingleton<PrivaPub.Domain.Media.IMediaProxy, PrivaPub.Domain.Media.MediaProxy>()
|
|
.AddHostedService<PrivaPub.Domain.Media.MediaJanitor>()
|
|
.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();
|
|
await DB.Default.MigrateAsync<Program>();
|
|
await Indexes.Create();
|
|
}
|
|
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;
|
|
}
|
|
|
|
// 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.
|
|
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",
|
|
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.UseRouting();
|
|
app.UseTrafficMeter();
|
|
app.UseRateLimiter();
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
//app.UseWhen(context => context.Request.Path.StartsWithSegments("/peasants") ||
|
|
// context.Request.Path.StartsWithSegments("/users"),
|
|
// app => app.UseSignatureVerification().UseDigestVerification());
|
|
|
|
app.MapGet("/build.json", () => Results.Json(new
|
|
{
|
|
commit = BuildInfo.Commit,
|
|
buildRef = BuildInfo.Ref,
|
|
builtAt = BuildInfo.BuiltAt,
|
|
}));
|
|
app.MapControllers();
|
|
app.MapRazorPages();
|
|
//app.MapFallbackToFile("index.html");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.ForContext<Program>().Fatal(ex, "{0}.{1}()", nameof(Program), "Use");
|
|
throw;
|
|
}
|
|
|
|
Log.ForContext<Program>().Information($"Starting collAnon at {nameof(Program)}()");
|
|
try
|
|
{
|
|
var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities;
|
|
var passwordHasher = app.Services.GetService(typeof(IPasswordHasher)) as IPasswordHasher;
|
|
await dbClient.Init(passwordHasher);
|
|
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
|
|
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();
|
|
}
|
|
|