using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO.Abstractions; using System.Net; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Security.Claims; using System.Threading.Channels; using System.Threading.RateLimiting; using Dapper; using ErsatzTV.Application; using ErsatzTV.Application.Auth; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Streaming; using ErsatzTV.Auth; using ErsatzTV.Core; using ErsatzTV.Core.Emby; using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Health; using ErsatzTV.Core.Health.Checks; using ErsatzTV.Core.Images; using ErsatzTV.Core.Interfaces.Database; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.GitHub; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scripting; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Trakt; using ErsatzTV.Core.Interfaces.Troubleshooting; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling.BlockScheduling; using ErsatzTV.Core.Scheduling.Engine; using ErsatzTV.Core.Scheduling.ScriptedScheduling; using ErsatzTV.Core.Scheduling.YamlScheduling; using ErsatzTV.Core.Search; using ErsatzTV.Core.Streaming; using ErsatzTV.Core.Trakt; using ErsatzTV.Core.Troubleshooting; using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.Filters; using ErsatzTV.Formatters; using ErsatzTV.Infrastructure; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Infrastructure.Database; using ErsatzTV.Infrastructure.Emby; using ErsatzTV.Infrastructure.FFmpeg; using ErsatzTV.Infrastructure.GitHub; using ErsatzTV.Infrastructure.Health; using ErsatzTV.Infrastructure.Health.Checks; using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Jellyfin; using ErsatzTV.Infrastructure.Locking; using ErsatzTV.Infrastructure.Metadata; using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Infrastructure.Runtime; using ErsatzTV.Infrastructure.MySql.Data; using ErsatzTV.Infrastructure.Scheduling; using ErsatzTV.Infrastructure.Scripting; using ErsatzTV.Infrastructure.Search; using ErsatzTV.Infrastructure.Sqlite.Data; using ErsatzTV.Infrastructure.Streaming; using ErsatzTV.Infrastructure.Streaming.Graphics; using ErsatzTV.Infrastructure.Trakt; using ErsatzTV.Middleware; using ErsatzTV.Serialization; using ErsatzTV.Services; using ErsatzTV.Services.RunOnce; using ErsatzTV.Services.Validators; using FluentValidation; using FluentValidation.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Microsoft.IO; using Microsoft.OpenApi; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Refit; using Scalar.AspNetCore; using Serilog; using Serilog.Events; using Testably.Abstractions; namespace ErsatzTV; public class Startup { public Startup(IConfiguration configuration, IWebHostEnvironment env) { Configuration = configuration; CurrentEnvironment = env; } public IConfiguration Configuration { get; } private IWebHostEnvironment CurrentEnvironment { get; } // Parse a semicolon-separated configuration value into trimmed, non-empty entries. private static string[] SplitConfig(string value) => string.IsNullOrWhiteSpace(value) ? [] : value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); private static void UseStringEnumSchemas(OpenApiDocument document) { if (document.Components?.Schemas is null) { return; } // Core's own enums get scanned wholesale. A couple of API response DTOs (settings endpoints) // also expose enums from lower layers Core is allowed to depend on (ErsatzTV.FFmpeg) or from // Serilog; list those individually instead of Assembly.GetTypes()-scanning their assemblies, // since eagerly loading every type in ErsatzTV.FFmpeg (e.g. NvEncSharp-backed types) can throw // a ReflectionTypeLoadException in environments missing optional native hardware-encoder deps. Dictionary enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes() .Where(type => type.IsEnum) .Concat([ typeof(DayOfWeek), typeof(FFmpeg.OutputFormat.OutputFormatKind), typeof(FFmpeg.State.WatermarkLocation), typeof(FFmpeg.State.WatermarkSize), typeof(Serilog.Events.LogEventLevel) ]) .GroupBy(type => type.Name) .ToDictionary(group => group.Key, group => group.First()); foreach ((string schemaName, IOpenApiSchema schema) in document.Components.Schemas) { if (schema is not OpenApiSchema openApiSchema || !enumTypes.TryGetValue(schemaName, out Type enumType)) { continue; } openApiSchema.Type = JsonSchemaType.String; openApiSchema.Format = null; openApiSchema.Enum = Enum.GetNames(enumType) .Select(name => JsonValue.Create(name)) .Cast() .ToList(); } } [SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")] public void ConfigureServices(IServiceCollection services) { BugsnagConfiguration bugsnagConfig = Configuration.GetSection("Bugsnag").Get(); services.Configure(Configuration.GetSection("Bugsnag")); services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.All; options.ForwardLimit = 2; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); // With both lists cleared, X-Forwarded-* is accepted from ANY peer (spoofable, issue // #285). Restrict trust to the real reverse proxy by configuring // ForwardedHeaders:KnownProxies (IPs) and/or ForwardedHeaders:KnownNetworks (CIDRs). bool trustRestricted = false; foreach (string proxy in SplitConfig(Configuration["ForwardedHeaders:KnownProxies"])) { if (IPAddress.TryParse(proxy, out IPAddress address)) { options.KnownProxies.Add(address); trustRestricted = true; } else { Log.Warning("Ignoring invalid ForwardedHeaders:KnownProxies entry {Entry}", proxy); } } foreach (string network in SplitConfig(Configuration["ForwardedHeaders:KnownNetworks"])) { if (System.Net.IPNetwork.TryParse(network, out System.Net.IPNetwork parsed)) { options.KnownIPNetworks.Add(parsed); trustRestricted = true; } else { Log.Warning("Ignoring invalid ForwardedHeaders:KnownNetworks entry {Entry}", network); } } if (!trustRestricted) { // Kept as-is from #285 (trust any peer, warn) rather than flipped to ForwardedHeaders.None: // the forwarded scheme/host feed /iptv M3U/XMLTV/HLS absolute-URL generation // (Request.Scheme in GetChannelGuideHandler / IptvController), so ignoring them would regress // stream URLs to http/internal-host for a proxied deployment that hasn't set KnownProxies. // Setting ForwardedHeaders:KnownProxies/:KnownNetworks is still strongly recommended when // exposing ErsatzTV beyond a trusted LAN — it also gives the #295 login rate limiter an // unspoofable client IP and lets the session cookie be marked Secure behind TLS. Log.Warning( "ForwardedHeaders trusts X-Forwarded-* from any peer (spoofable). Set " + "ForwardedHeaders:KnownProxies and/or ForwardedHeaders:KnownNetworks to restrict " + "trust to your reverse proxy when exposing ErsatzTV beyond a trusted LAN."); } }); services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder)); services.AddOpenApi( "v1", options => { options.ShouldInclude += a => a.GroupName == "general"; options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync); options.AddDocumentTransformer((document, _, _) => { UseStringEnumSchemas(document); return Task.CompletedTask; }); // Contract-honesty transformers (#287): document the API surface the way it actually // behaves at runtime — the ApiKey security scheme + per-operation security/401 (shared // with the enforcing filter's predicate), a synthesized operationId for every action, // and the 400 ValidationProblemDetails a binding failure returns. "v1" document only. options.AddDocumentTransformer(ApiSecuritySchemeDocumentTransformer.TransformAsync); options.AddOperationTransformer(); options.AddOperationTransformer(); options.AddOperationTransformer(ValidationProblemOperationTransformer.TransformAsync); }); services.AddOpenApi( "scripted-schedule-tagged", options => { options.ShouldInclude += a => a.GroupName == "scripted-schedule"; options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync); }); services.AddOpenApi( "scripted-schedule", options => { options.ShouldInclude += a => a.GroupName == "scripted-schedule"; options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync); var tag = new OpenApiTag { Name = "ScriptedSchedule" }; var tagReference = new OpenApiTagReference("ScriptedSchedule"); options.AddOperationTransformer((operation, _, _) => { operation.Tags.Clear(); operation.Tags.Add(tagReference); return Task.CompletedTask; }); options.AddDocumentTransformer((document, _, _) => { document.Tags.Clear(); document.Tags.Add(tag); return Task.CompletedTask; }); }); services.ConfigureHttpJsonOptions(o => o.SerializerOptions.NumberHandling = JsonNumberHandling.Strict); OidcHelper.Init(Configuration); JwtHelper.Init(Configuration); SearchHelper.Init(Configuration); // Browser SPA authentication (#295). A cookie session is ALWAYS registered — local username/password // login and the OIDC callback both sign into it. OIDC is added only when configured; the /iptv JWT // bearer is added only when configured. The /api surface accepts a session OR the machine X-Api-Key // (see ApiAuthorizationFilter); real enforcement is that filter, not a DefaultPolicy. AuthenticationBuilder authenticationBuilder = services.AddAuthentication(options => { options.DefaultScheme = AuthConstants.CookieScheme; if (OidcHelper.IsEnabled) { options.DefaultChallengeScheme = AuthConstants.OidcScheme; } }) .AddCookie( AuthConstants.CookieScheme, options => { options.CookieManager = new ChunkingCookieManager(); options.Cookie.Name = "ctv-session"; options.Cookie.HttpOnly = true; // Lax + no credentialed CORS keeps the cookie same-origin (the SPA is served from /app); // this is a core CSRF defense alongside the required X-CSRF header on session mutations. options.Cookie.SameSite = SameSiteMode.Lax; // SameAsRequest (not Always) so a plain-HTTP LAN deployment is not locked out; behind a // TLS-terminating proxy the app sees https once ForwardedHeaders:KnownProxies is set. options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.ExpireTimeSpan = TimeSpan.FromDays(14); options.SlidingExpiration = true; options.Events = new CookieAuthenticationEvents { // /api is an XHR surface — answer 401/403 rather than redirecting to a login page. OnRedirectToLogin = context => { if (context.Request.Path.StartsWithSegments("/api")) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; } context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }, OnRedirectToAccessDenied = context => { if (context.Request.Path.StartsWithSegments("/api")) { context.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; } context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }, OnValidatePrincipal = CookieSecurityStampValidator.ValidateAsync }; }); if (OidcHelper.IsEnabled) { authenticationBuilder.AddOpenIdConnect( AuthConstants.OidcScheme, options => { options.Authority = OidcHelper.Authority; options.ClientId = OidcHelper.ClientId; options.ClientSecret = OidcHelper.ClientSecret; options.ResponseType = OpenIdConnectResponseType.Code; options.UsePkce = true; options.ResponseMode = OpenIdConnectResponseMode.Query; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); options.GetClaimsFromUserInfoEndpoint = true; options.CallbackPath = new PathString("/callback"); options.SaveTokens = true; options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.Events = new OpenIdConnectEvents { // Mark the session as OIDC so the cookie stamp validator skips it (the stamp is a // local-login concept; OIDC sessions are governed by the IdP). OnTokenValidated = context => { if (context.Principal?.Identity is ClaimsIdentity identity) { identity.AddClaim(new Claim(AuthConstants.AuthMethodClaim, AuthConstants.MethodOidc)); } return Task.CompletedTask; }, OnRedirectToIdentityProviderForSignOut = context => { if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri)) { context.Response.Redirect(OidcHelper.LogoutUri); context.HandleResponse(); } return Task.CompletedTask; } }; }); } if (JwtHelper.IsEnabled) { authenticationBuilder.AddJwtBearer( "jwt", options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateIssuerSigningKey = true, IssuerSigningKey = JwtHelper.IssuerSigningKey, ValidateLifetime = true }; options.Events = new JwtBearerEvents { OnMessageReceived = static context => { StringValues token = context.Request.Query["access_token"]; if (!string.IsNullOrWhiteSpace(token)) { context.Token = token; } return Task.CompletedTask; } }; }); } // Authorization is always registered now that the pipeline always runs UseAuthorization (the cookie // scheme is always present). No DefaultPolicy: /api is gated by ApiAuthorizationFilter, and no // endpoint carries [Authorize]. The JWT-only policy stays for /iptv's ConditionalIptvAuthorizeFilter. services.AddAuthorization(options => { if (JwtHelper.IsEnabled) { options.AddPolicy( "JwtOnlyScheme", new AuthorizationPolicyBuilder("jwt") .RequireAuthenticatedUser() .Build()); } }); // Per-IP rate limit for the unauthenticated auth surface (login/setup/password) — blunts local // password brute-force. Keyed on the connection remote IP (accurate only when // ForwardedHeaders:KnownProxies is configured behind a proxy — see UseForwardedHeaders below). services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy( "auth", httpContext => RateLimitPartition.GetFixedWindowLimiter( httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(5), QueueLimit = 0 })); }); services.AddCors(o => o.AddPolicy( "ApiCors", builder => { string[] origins = SplitConfig(Configuration["Api:CorsAllowedOrigins"]); if (origins.Length > 0) { // Exact-origin allowlist. Must permit the auth + optimistic-concurrency request // headers and expose the ETag, or cross-origin API clients break (issue #284). builder.WithOrigins(origins) .AllowAnyMethod() .WithHeaders( "Accept", "Content-Type", "Authorization", ApiAuthorizationFilter.HeaderName, ApiAuthorizationFilter.CsrfHeaderName, "If-Match") .WithExposedHeaders("ETag"); // Note: AllowCredentials is deliberately NOT set — cross-origin cookie auth is impossible // by design (a CSRF defense). Cross-origin clients authenticate with X-Api-Key. } // No configured origins => no cross-origin access (the SPA is same-origin from /app). })); services.AddLocalization(); services.AddControllers(options => { options.OutputFormatters.Insert(0, new ConcatPlaylistOutputFormatter()); options.OutputFormatters.Insert(0, new ChannelPlaylistOutputFormatter()); options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter()); options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter()); options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter()); options.Filters.AddService(); }) .AddNewtonsoftJson(opt => { opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; opt.SerializerSettings.ContractResolver = new CustomContractResolver(); opt.SerializerSettings.Converters.Add(new StringEnumConverter()); }); services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme")); // API-key authorization for the JSON API (independent of JWT/OIDC). The provider resolves the // effective key once (config, else persisted, else generated) so writes are fail-closed. services.AddSingleton(); services.AddScoped(); // Local-admin password hashing (browser SPA session auth, #295). Stateless → singleton. services.AddSingleton(); services.AddFluentValidationAutoValidation(); services.AddValidatorsFromAssemblyContaining(); services.AddMemoryCache(); Console.OutputEncoding = Encoding.UTF8; string etvVersion = Assembly.GetEntryAssembly()?.GetCustomAttribute() ?.InformationalVersion ?? "unknown"; Log.Logger.Information("ErsatzTV version {Version}", etvVersion); Log.Logger.Warning( "Report bugs to {GitHub} or contact us at {Contact}", "https://github.com/ErsatzTV/ErsatzTV", "https://ersatztv.org/contact"); CopyMacOsConfigFolderIfNeeded(); List directoriesToCreate = [ FileSystemLayout.AppDataFolder, FileSystemLayout.TranscodeFolder, FileSystemLayout.TempFilePoolFolder, FileSystemLayout.FontsCacheFolder, FileSystemLayout.TemplatesFolder, FileSystemLayout.MusicVideoCreditsTemplatesFolder, FileSystemLayout.ChannelStreamSelectorsFolder, FileSystemLayout.ChannelGuideTemplatesFolder, FileSystemLayout.GraphicsElementsTemplatesFolder, FileSystemLayout.GraphicsElementsTextTemplatesFolder, FileSystemLayout.GraphicsElementsImageTemplatesFolder, FileSystemLayout.GraphicsElementsScriptTemplatesFolder, FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder, FileSystemLayout.GraphicsElementsMotionTemplatesFolder, FileSystemLayout.ScriptsFolder, FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, FileSystemLayout.AudioStreamSelectorScriptsFolder, FileSystemLayout.MpegTsScriptsFolder, FileSystemLayout.DefaultMpegTsScriptFolder ]; foreach (string directory in directoriesToCreate) { if (directory is not null && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } } // until we add a setting for a file-specific scheme://host:port to access // stream urls contained in this file, it doesn't make sense to do // for now, continue to use scheme and host from incoming requests // string xmltvPath = Path.Combine(appDataFolder, "xmltv.xml"); // Log.Logger.Information("XMLTV is at {XmltvPath}", xmltvPath); string databaseProvider = Configuration.GetValue("provider", Provider.Sqlite.Name); var sqliteConnectionString = $"Data Source={FileSystemLayout.DatabasePath};foreign keys=true;"; string mySqlConnectionString = Configuration.GetValue("MySql:ConnectionString"); services.AddDbContext( options => { if (databaseProvider == Provider.Sqlite.Name) { TvContext.IsSqlite = true; options.UseSqlite( sqliteConnectionString, o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"); }); } if (databaseProvider == Provider.MySql.Name) { TvContext.IsSqlite = false; options.UseMySql( mySqlConnectionString, ServerVersion.AutoDetect(mySqlConnectionString), o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"); } ); } }, ServiceLifetime.Scoped, ServiceLifetime.Singleton); services.AddDbContextFactory(options => { if (databaseProvider == Provider.Sqlite.Name) { options.UseSqlite( sqliteConnectionString, o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"); }); } if (databaseProvider == Provider.MySql.Name) { options.UseMySql( mySqlConnectionString, ServerVersion.AutoDetect(mySqlConnectionString), o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"); } ); } }); if (databaseProvider == Provider.Sqlite.Name) { Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath); TvContext.LastInsertedRowId = "last_insert_rowid()"; TvContext.CaseInsensitiveCollation = "NOCASE"; TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation; SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); SqlMapper.AddTypeHandler(new GuidHandler()); SqlMapper.AddTypeHandler(new TimeSpanHandler()); } if (databaseProvider == Provider.MySql.Name) { TvContext.LastInsertedRowId = "last_insert_id()"; TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci"; TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation; } Log.Logger.Information("Transcode folder is {Folder}", FileSystemLayout.TranscodeFolder); services.AddMediatR(config => config.RegisterServicesFromAssemblyContaining()); services.AddRefitClient() .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://plex.tv/api/v2")); services.AddRefitClient( new RefitSettings { ContentSerializer = new NewtonsoftJsonContentSerializer( new JsonSerializerSettings { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) }) .ConfigureHttpClient(c => { c.BaseAddress = new Uri("https://api.trakt.tv"); c.DefaultRequestHeaders.Add("User-Agent", $"ErsatzTV/{etvVersion}"); }); services.AddHttpClient("RefitCustomClient").AddHttpMessageHandler(); // graphics-engine remote images (channel-logo watermarks and image elements). the client // timeout is INFINITE on purpose: under ResponseHeadersRead the body read falls outside // HttpClient.Timeout, so HttpRemoteImageFetcher owns the deadline with a linked CTS that // covers headers AND body. the fetcher re-asserts that on the client it gets, so losing // this line widens nothing; what is ONLY configurable here is the redirect cap, since it // lives on the handler. redirects stay enabled (logo hosts and CDNs use them) but are // capped well below the default 50 hops. (ersatztv#511) services.AddHttpClient(HttpRemoteImageFetcher.HttpClientName) .ConfigureHttpClient(c => c.Timeout = Timeout.InfiniteTimeSpan) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = true, MaxAutomaticRedirections = 3 }); services.Configure(Configuration.GetSection("Trakt")); services.AddResponseCompression(options => { options.EnableForHttps = true; }); CustomServices(services); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { string baseUrl = SystemEnvironment.BaseUrl; if (!string.IsNullOrWhiteSpace(baseUrl)) { try { app.UsePathBase(baseUrl); // for testing - make path base required // app.Use(async (context, next) => // { // if (context.Request.PathBase != baseUrl) // { // context.Response.StatusCode = 404; // return; // } // // await next(context); // }); } catch (Exception ex) { Log.Error( ex, "Failed to configure ETV_BASE_URL; please check syntax and include leading slash e.g. `/etv`: {BaseUrl}", baseUrl); } } app.UseMiddleware(); // Resolve the API key eagerly so a generated key is created + logged at startup rather than // on the first API request. app.ApplicationServices.GetRequiredService(); app.UseCors("ApiCors"); app.UseForwardedHeaders(); //app.UseHttpLogging(); app.UseSerilogRequestLogging(options => { // Keep the query out of the built-in RequestPath; a scrubbed copy (access_token redacted) is // enriched below as RequestPathScrubbed and referenced by the message template instead (#559). options.IncludeQueryInRequestPath = false; // Emit debug-level events instead of the defaults options.GetLevel = (httpContext, elapsed, ex) => { if (ex is not null) { return LogEventLevel.Error; } if (httpContext.Response.StatusCode > 499) { return LogEventLevel.Error; } if (httpContext.Request.Path.ToUriComponent().StartsWith( "/iptv", StringComparison.OrdinalIgnoreCase)) { return LogEventLevel.Debug; } if (httpContext.Request.Path.ToUriComponent().StartsWith( "/api", StringComparison.OrdinalIgnoreCase) && !httpContext.Request.Path.ToUriComponent().StartsWith( "/api/v1/scan", StringComparison.OrdinalIgnoreCase)) { return LogEventLevel.Debug; } return LogEventLevel.Verbose; }; options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress); diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"]); diagnosticContext.Set("RequestPathScrubbed", RequestLogScrubber.ScrubbedPath(httpContext.Request)); }; options.MessageTemplate = "HTTP {RequestMethod} {RequestPathScrubbed} responded {StatusCode} in {Elapsed:0.00} ms from {UserAgent} at {RemoteIP}"; }); app.UseRequestLocalization(options => { CultureInfo[] cinfo = CultureInfo.GetCultures(CultureTypes.AllCultures & ~CultureTypes.NeutralCultures); string[] supportedCultures = cinfo.Select(t => t.Name).Distinct().ToArray(); options.AddSupportedCultures(supportedCultures) .AddSupportedUICultures(Localization.UiCultures) .SetDefaultCulture(Localization.DefaultCulture); options.AddInitialRequestCultureProvider( new DatabaseRequestCultureProvider( new AcceptLanguageHeaderRequestCultureProvider())); }); app.UseStaticFiles(); var extensionProvider = new FileExtensionContentTypeProvider(); // fix static file M3U8 mime type extensionProvider.Mappings.Add(".m3u8", "application/vnd.apple.mpegurl"); // fix static file TS mime type extensionProvider.Mappings.Remove(".ts"); extensionProvider.Mappings.Add(".ts", "video/mp2t"); app.UseStaticFiles( new StaticFileOptions { FileProvider = new PhysicalFileProvider(FileSystemLayout.TranscodeFolder), RequestPath = "/iptv/session", ContentTypeProvider = extensionProvider, OnPrepareResponse = ctx => { // Log.Logger.Information("Transcode access: {Test}", ctx.File.PhysicalPath); ChannelWriter writer = app.ApplicationServices .GetRequiredService>(); writer.TryWrite(new TouchFFmpegSession(ctx.File.PhysicalPath)); }, // to serve m4s ServeUnknownFileTypes = true }); app.UseResponseCompression(); app.Use(async (context, next) => { if (!context.Request.Host.Value.StartsWith("localhost", StringComparison.OrdinalIgnoreCase) && !IsIptvPath(context.Request.Path) && context.Connection.LocalPort != Settings.UiPort) { context.Response.StatusCode = 404; return; } await next(context); }); app.MapWhen( ctx => ctx.Request.Path.StartsWithSegments("/app"), spa => { string spaStaticFileRoot = SpaStaticFileRoot(); IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot) ? new PhysicalFileProvider(spaStaticFileRoot) : new NullFileProvider(); spa.UseStaticFiles( new StaticFileOptions { FileProvider = spaFileProvider, RequestPath = "/app" }); spa.Run( async context => { Microsoft.Extensions.FileProviders.IFileInfo appIndexFile = spaFileProvider.GetFileInfo("index.html"); if (!appIndexFile.Exists) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } context.Response.ContentType = "text/html; charset=utf-8"; context.Response.ContentLength = appIndexFile.Length; await using Stream stream = appIndexFile.CreateReadStream(); await stream.CopyToAsync(context.Response.Body); }); }); app.MapWhen( ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path), legacy => { // ersatztv#91 phase (b): the legacy Blazor Server UI has been removed. This // branch now only (a) 302-redirects retired Blazor routes to their /app // equivalents and (b) co-hosts the REST API controllers, /docs (Scalar) and // (in Development) the OpenAPI document — everything served here that is not the // SPA (/app) or IPTV (/iptv). // 302 (not 301): permanent-redirect browser caching would make rollback painful. // UsePathBase (ETV_BASE_URL) only rewrites the request side (Request.Path/ // PathBase); it never touches redirect Location headers, so the PathBase prefix // must be re-applied here. AppendQueryString merges the incoming query into the // target: a target that already carries its own query (e.g. // "/app/media?kind=movies") gets '&'-joined instead of a malformed double '?'. legacy.Use(async (context, next) => { if (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) { if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target)) { context.Response.Redirect( context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target, context.Request.QueryString)); return; } } await next(context); }); // ersatztv#286: version the API surface at /api/v1. Legacy unversioned /api/* callers // (curl, the MCP server, bookmarks) are rewritten in-pipeline to /api/v1/* — a rewrite, // not a redirect, so method/body/auth survive — carrying RFC 8594 Deprecation/Sunset // headers. Must run before UseRouting so the rewritten path matches the versioned routes. legacy.UseMiddleware(); legacy.UseRouting(); // Browser SPA / API authentication (#295). This branch hosts /api, the OIDC /callback, and // /docs. UseAuthentication populates HttpContext.User from the cookie (default scheme) — the // credential ApiAuthorizationFilter accepts alongside the machine X-Api-Key — and lets the // OIDC middleware intercept /callback. UseAuthorization is required for the middleware to run // (no [Authorize] endpoints; /api is gated by ApiAuthorizationFilter, /iptv by its own // ConditionalIptvAuthorizeFilter). UseRateLimiter enforces the "auth" per-IP policy on the // login/setup/password endpoints. legacy.UseAuthentication(); legacy.UseAuthorization(); legacy.UseRateLimiter(); legacy.UseEndpoints(endpoints => { endpoints.MapControllers(); // ersatztv#91 phase (b): with the Blazor host page gone, any request that // reached this branch without matching a controller or a LegacyUiRedirects // entry is a retired Blazor route — 302 it to the SPA so no legacy path // hard-404s. /api, /artwork, /docs and /openapi are excluded: an unmatched // one of those is a genuine 404, not a UI route (this branch does not // prefix-guard them, so the fallback must — see docs/decisions.md #204). endpoints.MapFallback(context => { PathString path = context.Request.Path; if (path.StartsWithSegments("/api") || path.StartsWithSegments("/artwork") || path.StartsWithSegments("/docs") || path.StartsWithSegments("/openapi")) { context.Response.StatusCode = StatusCodes.Status404NotFound; return Task.CompletedTask; } context.Response.Redirect(context.Request.PathBase + "/app"); return Task.CompletedTask; }); if (CurrentEnvironment.IsDevelopment()) { endpoints.MapOpenApi(); } endpoints.MapScalarApiReference("/docs", options => { options.AddDocument( "scripted-schedule", "Scripted Schedule", "openapi/scripted-schedule-tagged.json"); options.AddDocument("v1", "General", "openapi/v1.json"); options.HideClientButton = true; options.DocumentDownloadType = DocumentDownloadType.None; options.Title = "ErsatzTV API Reference"; }); }); }); app.MapWhen( ctx => IsIptvPath(ctx.Request.Path), iptv => { iptv.UseRouting(); iptv.UseEndpoints(endpoints => endpoints.MapControllers()); }); return; bool IsIptvPath(PathString path) { return path.StartsWithSegments("/iptv") || path.StartsWithSegments("/discover.json") || path.StartsWithSegments("/device.xml") || path.StartsWithSegments("/lineup.json") || path.StartsWithSegments("/lineup_status.json"); } bool IsSpaPath(PathString path) => path.StartsWithSegments("/app"); string SpaStaticFileRoot() { string webRootPath = CurrentEnvironment.WebRootPath ?? Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot"); return Path.Combine(webRootPath, "app"); } } private static void CustomServices(IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // TODO: does this need to be singleton? services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(Program.InMemoryLogService); if (SearchHelper.IsElasticSearchEnabled) { Log.Logger.Information("Using Elasticsearch (external) search index backend"); ElasticSearchIndex.Uri = new Uri(SearchHelper.ElasticSearchUri); ElasticSearchIndex.IndexName = SearchHelper.ElasticSearchIndexName; services.AddSingleton(); } else { Log.Logger.Information("Using Lucene (embedded) search index backend"); services.AddSingleton(); } services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); AddChannel(services); AddChannel(services); AddChannel(services); AddChannel(services); AddChannel(services); AddChannel(services); AddChannel(services); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); // services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>)); services.AddTransient(); services.AddTransient(); // run-once/blocking startup services services.AddHostedService(); services.AddHostedService(); // Waits on SystemStartup.WaitForDatabase before seeding (#295 env seed) — the migrator is a // BackgroundService, so registration order alone does not guarantee the schema exists. services.AddHostedService(); services.AddHostedService(); // One-time migration of existing external-URL channel logos into the image cache (ersatztv#525). // It awaits SystemStartup.WaitForDatabase itself, so the schema is guaranteed; registration // order relative to the other hosted services is not load-bearing (it touches only logo artwork). services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); // background services #if !DEBUG_NO_SYNC services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); #endif services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); } private static void AddChannel(IServiceCollection services) { services.AddSingleton( Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true })); services.AddSingleton(provider => provider.GetRequiredService>().Reader); services.AddSingleton(provider => provider.GetRequiredService>().Writer); } private static void CopyMacOsConfigFolderIfNeeded() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { return; } bool newDbExists = File.Exists(FileSystemLayout.DatabasePath); if (newDbExists) { return; } bool oldDbExists = File.Exists(FileSystemLayout.MacOsOldDatabasePath); if (!oldDbExists) { return; } // safe to move here since // - old db exists // - new db does not exist Log.Logger.Information( "Migrating config data from {OldFolder} to {NewFolder}", FileSystemLayout.MacOsOldAppDataFolder, FileSystemLayout.AppDataFolder); try { // delete new config folder if (Directory.Exists(FileSystemLayout.AppDataFolder)) { Directory.Delete(FileSystemLayout.AppDataFolder, true); } // move old config folder to new config folder Directory.Move(FileSystemLayout.MacOsOldAppDataFolder, FileSystemLayout.AppDataFolder); } catch (Exception ex) { Log.Logger.Warning(ex, "Failed to migrate config data"); } } }