Files
marechai/Marechai/Startup.cs

400 lines
18 KiB
C#

/******************************************************************************
// MARECHAI: Master repository of computing history artifacts information
// ----------------------------------------------------------------------------
//
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// --[ License ] --------------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ----------------------------------------------------------------------------
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.StaticFiles;
using MudBlazor.Services;
using Marechai.Email;
using Marechai.Services;
using Marechai.Shared;
using Marechai.Translation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Abstractions.Authentication;
using Microsoft.Kiota.Abstractions.Serialization;
using Microsoft.Kiota.Http.HttpClientLibrary;
using Microsoft.Kiota.Serialization.Form;
using Microsoft.Kiota.Serialization.Json;
using Microsoft.Kiota.Serialization.Multipart;
using Microsoft.Kiota.Serialization.Text;
namespace Marechai;
public class Startup(IConfiguration configuration)
{
readonly CultureInfo[] _supportedCultures =
[
new("en-US"), new("es"), new("fr"), new("it"), new("de"), new("nl")
];
public IConfiguration Configuration { get; } = configuration;
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.AddMudServices();
// Persist the ASP.NET Core data protection keys to a stable on-disk directory so the
// tokens we encrypt into ProtectedLocalStorage survive process restarts. Without this
// every restart generates a fresh ephemeral key, which makes the next request from a
// returning user crash with "The key {guid} was not found in the key ring" when the
// auth state provider tries to unprotect the cached JWT. SetApplicationName pins the
// ring to this app so multiple Marechai-family apps cannot accidentally share keys.
string keyRingPath = Configuration["DataProtection:KeyRingPath"]
?? Path.Combine(AppContext.BaseDirectory, "keys");
Directory.CreateDirectory(keyRingPath);
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
.SetApplicationName("Marechai.Blazor");
services.AddMarechaiEmail(Configuration);
// Process-wide IMemoryCache shared across all user circuits. Used by
// ReferenceDataCache to memoise slow-changing lookup tables (countries,
// languages, licenses, machine families, ISO standards) so navigating
// between pages doesn't re-fetch the same dropdown data per circuit.
services.AddMemoryCache();
// Brotli + Gzip response compression for the static asset payloads we
// ship with the Blazor server (MudBlazor.min.css is 607 KB raw — gzipping
// brings it under 90 KB which is the difference between a sub-second and
// an 11-second first paint on real-world links). The default MimeType list
// already covers text/css / application/javascript / text/html / image/svg+xml's
// siblings; we add `text/javascript` (now the default JS type emitted by
// .NET 10's static file middleware) and `application/wasm` for completeness.
services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"text/javascript", "application/javascript", "application/wasm",
"image/svg+xml", "application/json", "application/problem+json"
});
});
// Fastest level — the CPU cost of OptimalCompression on every static-file
// request is not worth the marginal extra size savings (and these are
// served behind a long Cache-Control so each file is gzipped at most once
// per browser anyway).
services.Configure<BrotliCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
services.Configure<GzipCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
string apiUrl = Configuration.GetSection("ApiClient:Url").Value ?? "http://localhost:5023";
services.AddSingleton(new ApiAssetUrlProvider(apiUrl));
services.AddScoped<TokenProvider>();
services.AddScoped<JwtAuthenticationStateProvider>();
services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<JwtAuthenticationStateProvider>());
services.AddScoped<IRequestAdapter>(sp =>
{
TokenProvider tokenProvider = sp.GetRequiredService<TokenProvider>();
var authHandler = new HttpAuthHandler(tokenProvider)
{
InnerHandler = new HttpClientHandler()
};
var httpClient = new HttpClient(authHandler)
{
BaseAddress = new Uri(apiUrl)
};
var authProvider = new AnonymousAuthenticationProvider();
var parseNodeRegistry = new ParseNodeFactoryRegistry();
parseNodeRegistry.ContentTypeAssociatedFactories["application/json"] = new JsonParseNodeFactory();
parseNodeRegistry.ContentTypeAssociatedFactories["text/plain"] = new TextParseNodeFactory();
parseNodeRegistry.ContentTypeAssociatedFactories["application/x-www-form-urlencoded"] = new FormParseNodeFactory();
var serializationWriterFactory = new Marechai.ApiClient.CompositeSerializationWriterFactory();
serializationWriterFactory.AddFactory(new JsonSerializationWriterFactory());
serializationWriterFactory.AddFactory(new MultipartSerializationWriterFactory());
serializationWriterFactory.AddFactory(new TextSerializationWriterFactory());
serializationWriterFactory.AddFactory(new FormSerializationWriterFactory());
return new HttpClientRequestAdapter(authProvider, parseNodeRegistry,
serializationWriterFactory, httpClient);
});
services.AddScoped(sp => new Marechai.ApiClient.Client(sp.GetRequiredService<IRequestAdapter>()));
services.AddHttpClient("Plausible", client =>
{
client.BaseAddress = new Uri("https://plausible.claunia.com");
});
services.AddHttpClient("SitemapApi", client =>
{
client.BaseAddress = new Uri(apiUrl);
});
services.Configure<IndexNowOptions>(Configuration.GetSection("IndexNow"));
services.AddHttpClient("IndexNow");
// OpenAI + NLLB HttpClients (each registered only when its Url is configured) plus the
// shared TranslationService singleton. Lives in Marechai.Translation so the API server
// (Marechai.Server) can reuse the same provider for the genre translation worker.
services.AddMarechaiTranslation(Configuration);
services.AddAuthorizationCore();
services.AddRazorPages();
// The admin batch-upload dialogs (software screenshots/covers, machine/gpu/processor/
// sound-synth photos, etc.) round-trip the inline base64 thumbnail (a 256x256 JPEG
// wrapped in a "data:image/jpeg;base64," URL) from JS back into .NET via a
// [JSInvokable] callback. SignalR's default MaximumReceiveMessageSize of 32 KB is
// tight enough that a dense screenshot (game frame, complex UI) whose Q80 JPEG
// crosses ~22 KB produces a JSInvokable payload that exceeds the cap. The hub then
// drops the message and the circuit reconnects ("Reconnecting to the server…"
// flashes briefly), the OnScreenshotUploadCompleted / OnPhotoUploadCompleted /
// OnCoverUploadCompleted callback is never delivered, no thumbnail appears, and no
// entry shows up in the logs because no exception is thrown anywhere. Raise the cap
// to 5 MB to comfortably accommodate even the largest 256x256 JPEG plus any future
// payload growth on these callbacks.
services.AddServerSideBlazor()
.AddHubOptions(options => options.MaximumReceiveMessageSize = 5 * 1024 * 1024);
services.AddLocalization(options => options.ResourcesPath = "Resources");
Register.RegisterServices(services);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if(env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseRequestLocalization(new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en-US"),
// Formatting numbers, dates, etc.
SupportedCultures = _supportedCultures,
// UI strings that we have localized.
SupportedUICultures = _supportedCultures
});
app.UseHttpsRedirection();
// Response compression must be registered BEFORE UseStaticFiles so the
// CSS / JS assets the static-file middleware serves get gzip/brotli'd.
app.UseResponseCompression();
// Long cache headers on the static assets. NuGet-packaged content under
// /_content and the Blazor framework files under /_framework are pinned
// to their package version, so they're safe to cache aggressively. Our
// own /css, /js, /img assets can change on each deploy → shorter cache.
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
string path = ctx.Context.Request.Path.Value ?? string.Empty;
if(path.StartsWith("/_content/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/_framework/", StringComparison.OrdinalIgnoreCase))
{
// 30 days — these only change when a NuGet package version bumps.
ctx.Context.Response.Headers["Cache-Control"] = "public, max-age=2592000";
}
else
{
// 1 day for site.css / our own JS / images so a same-day return visit pays no
// network at all but a new deploy is picked up within 24h. ETag/Last-Modified
// still ride along so a hard reload revalidates instantly.
ctx.Context.Response.Headers["Cache-Control"] = "public, max-age=86400";
}
}
});
// Add other security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/js/pa.js", async context =>
{
var clientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
var client = clientFactory.CreateClient("Plausible");
HttpResponseMessage response;
try
{
response = await client.GetAsync("/js/pa-v714IfTDuCkgEe0Q7qxoq.js");
}
catch
{
context.Response.StatusCode = 502;
return;
}
if(!response.IsSuccessStatusCode)
{
context.Response.StatusCode = (int)response.StatusCode;
return;
}
context.Response.ContentType = "application/javascript";
context.Response.Headers["Cache-Control"] = "public, max-age=86400";
await response.Content.CopyToAsync(context.Response.Body);
});
endpoints.MapPost("/api/event", async context =>
{
var clientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
var client = clientFactory.CreateClient("Plausible");
var proxyRequest = new HttpRequestMessage(HttpMethod.Post, "/api/event");
proxyRequest.Content = new StreamContent(context.Request.Body);
if(context.Request.ContentType != null)
{
proxyRequest.Content.Headers.ContentType =
MediaTypeHeaderValue.Parse(context.Request.ContentType);
}
if(context.Request.Headers.TryGetValue("User-Agent", out var ua))
proxyRequest.Headers.TryAddWithoutValidation("User-Agent", ua.ToString());
proxyRequest.Headers.TryAddWithoutValidation("X-Forwarded-For",
context.Request.Headers.TryGetValue("X-Forwarded-For", out var xff)
? xff.ToString()
: context.Connection.RemoteIpAddress?.ToString());
HttpResponseMessage response;
try
{
response = await client.SendAsync(proxyRequest);
}
catch
{
context.Response.StatusCode = 502;
return;
}
context.Response.StatusCode = (int)response.StatusCode;
await response.Content.CopyToAsync(context.Response.Body);
});
endpoints.MapGet("/robots.txt", context =>
{
context.Response.ContentType = "text/plain";
context.Response.Headers["Cache-Control"] = "public, max-age=86400";
return context.Response.WriteAsync(
$"User-agent: *\nAllow: /\n\nSitemap: {Helpers.SeoMeta.CanonicalHost}/sitemap.xml\n");
});
// IndexNow key verification file — search engines crawl /{key}.txt to prove
// we own the domain. Served dynamically so the key lives only in appsettings.
endpoints.MapGet("/{filename}.txt", context =>
{
string filename = (string)context.Request.RouteValues["filename"];
string configKey = Configuration["IndexNow:Key"];
if(string.IsNullOrWhiteSpace(configKey) ||
!string.Equals(filename, configKey, StringComparison.Ordinal))
{
context.Response.StatusCode = 404;
return Task.CompletedTask;
}
context.Response.ContentType = "text/plain; charset=utf-8";
context.Response.Headers["Cache-Control"] = "public, max-age=86400";
return context.Response.WriteAsync(configKey);
});
endpoints.MapGet("/sitemap.xml", async context =>
{
var sitemapService = context.RequestServices.GetRequiredService<SitemapService>();
context.Response.ContentType = "application/xml; charset=utf-8";
context.Response.Headers["Cache-Control"] = "public, max-age=86400";
await context.Response.WriteAsync(await sitemapService.GenerateSitemapIndexAsync());
});
endpoints.MapGet("/sitemap-{section}.xml", async context =>
{
string section = (string?)context.Request.RouteValues["section"] ?? "static";
var sitemapService = context.RequestServices.GetRequiredService<SitemapService>();
string xml = await sitemapService.GenerateSectionSitemapAsync(section);
context.Response.ContentType = "application/xml; charset=utf-8";
context.Response.Headers["Cache-Control"] = "public, max-age=86400";
await context.Response.WriteAsync(xml);
});
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
});
}
}