Adds SoftDelete/Trash across characters, chapters, locations, beats with a purge schedule and Trash page. Reworks the web client for keyboard-driven navigation (focus helpers, help overlay, keyboard.md doc). Moves the ChapterPage tag editor to the bottom of the page to match CharacterDetailPage.
139 lines
4.0 KiB
C#
139 lines
4.0 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.AspNetCore.Diagnostics;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Novelly.Api.Activity;
|
|
using Novelly.Api.Agent;
|
|
using Novelly.Api.Beats;
|
|
using Novelly.Api.Chapters;
|
|
using Novelly.Api.Characters;
|
|
using Novelly.Api.Common;
|
|
using Novelly.Api.Data;
|
|
using Novelly.Api.Genres;
|
|
using Novelly.Api.Imports;
|
|
using Novelly.Api.Locations;
|
|
using Novelly.Api.Novels;
|
|
using Novelly.Api.Questions;
|
|
using Novelly.Api.Tags;
|
|
using Novelly.Api.Trash;
|
|
using Novelly.Api.Users;
|
|
using Serilog;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Services.AddSerilog((services, config) => config
|
|
.ReadFrom.Configuration(builder.Configuration)
|
|
.ReadFrom.Services(services)
|
|
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"));
|
|
|
|
builder.AddServiceDefaults();
|
|
builder.Services.AddNovelly(builder.Configuration);
|
|
builder.Services.AddOpenApi();
|
|
builder.Services.AddProblemDetails();
|
|
|
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
|
|
|
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
|
|
?? ["http://localhost:5173"];
|
|
|
|
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
|
|
.WithOrigins(corsOrigins)
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod()
|
|
.AllowCredentials()));
|
|
|
|
var migrateOnly = args.Contains("--migrate-only");
|
|
|
|
var app = builder.Build();
|
|
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<NovelDbContext>();
|
|
|
|
try
|
|
{
|
|
await db.Database.MigrateAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
app.Logger.LogCritical(ex, "Database migration failed on startup");
|
|
Environment.Exit(1);
|
|
}
|
|
|
|
if (migrateOnly)
|
|
{
|
|
app.Logger.LogInformation("Migration complete, exiting ({MigrateOnlyFlag})", "--migrate-only");
|
|
Environment.Exit(0);
|
|
}
|
|
|
|
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
|
|
await ActivityBackfill.RunAsync(db, app.Logger);
|
|
|
|
var importRoot = builder.Configuration.GetSection(ImportOptions.SectionName)[nameof(ImportOptions.RootPath)];
|
|
if (!string.IsNullOrWhiteSpace(importRoot))
|
|
{
|
|
Directory.CreateDirectory(importRoot);
|
|
}
|
|
}
|
|
|
|
app.UseSerilogRequestLogging();
|
|
|
|
app.UseExceptionHandler(handler => handler.Run(async context =>
|
|
{
|
|
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
|
|
|
|
var (status, title) = exception switch
|
|
{
|
|
AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"),
|
|
NotAuthorizedException => (StatusCodes.Status403Forbidden, "Forbidden"),
|
|
ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"),
|
|
_ => (StatusCodes.Status500InternalServerError, "Unexpected error")
|
|
};
|
|
|
|
app.Logger.Log(
|
|
status == StatusCodes.Status500InternalServerError ? LogLevel.Error : LogLevel.Warning,
|
|
exception,
|
|
"Handled {StatusCode} on {Path}: {Title}", status, context.Request.Path, title);
|
|
|
|
await Results
|
|
.Problem(title: title, detail: exception?.Message, statusCode: status)
|
|
.ExecuteAsync(context);
|
|
}));
|
|
|
|
app.UseCors();
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi();
|
|
}
|
|
|
|
app.MapDefaultEndpoints();
|
|
|
|
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
|
|
app.MapUiSettingsEndpoints();
|
|
|
|
app.MapUserEndpoints();
|
|
app.MapNovelMemberEndpoints();
|
|
|
|
app.MapNovelEndpoints()
|
|
.MapCharacterEndpoints()
|
|
.MapChapterEndpoints()
|
|
.MapBeatEndpoints()
|
|
.MapTagEndpoints()
|
|
.MapLocationEndpoints()
|
|
.MapGenreEndpoints()
|
|
.MapOpenQuestionEndpoints()
|
|
.MapAgentEndpoints()
|
|
.MapImportEndpoints()
|
|
.MapActivityEndpoints()
|
|
.MapTrashEndpoints();
|
|
|
|
app.Run();
|
|
|
|
[ExcludeFromCodeCoverage]
|
|
public partial class Program;
|