Every audio and video upload was probed and remuxed inside the request, whatever its length; ffmpeg would read any protocol and probe any format; `-map 0` kept the data tracks iPhones add, which mp4 refuses; nothing was ever transcoded, so HEVC or MPEG-4 Part 2 reached browsers that can't play them, and the advertised video_matrix_limit and frame rate limit were never applied; the output was read whole into memory, the video was saved before its poster could fail, and the poster's frame leaked in /tmp. FLAC uploads were served as 404. Now an upload sent to /api/v2/media is stored as sent in media-incoming (beside the media root, never served) and answered with 202 and no url, while a ProcessMedia job, one at a time, does the work; GET /api/v1/media/:id answers 206 until it is ready, or 422 with why, and media still processing can't be posted. v1 processes before answering. ffmpeg reads only that file (protocol whitelist, format forced from the probe) and drops data and subtitle tracks. A video browsers play as it is (H.264, VP8, VP9, AV1 within Media:MaxVideoPixels and MaxFrameRate) is remuxed, anything else transcoded to H.264 that fits, as Mastodon does; longer than Media:MaxSeconds is refused. Outputs move into place only once everything succeeded, every temporary file goes, durations are kept, FLAC is served as audio/flac, and the unit gets PrivateTmp. The instance API advertises the limits that are now applied. No pasture scenario uploads audio or video through PrivaPub, so the sweep could not see this. MastodonMediaTests: v2 answers 202 then the job makes it playable (and an unreadable file 422 once processed), media still processing can't be posted, MPEG-4 Part 2 becomes H.264, a video over the limit is made smaller, FLAC is served. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
225 lines
7.4 KiB
C#
225 lines
7.4 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>()
|
|
.AddSingleton<PrivaPub.Domain.Social.Trends>()
|
|
.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",
|
|
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.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();
|
|
}
|
|
|