WorkTracker/Program.cs
Marco bf333c4a00
All checks were successful
Publish Container / publish (push) Successful in 3m25s
feat: implement JSON backup export functionality with improved download handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 14:16:15 +02:00

379 lines
13 KiB
C#

using System.Globalization;
using System.Security.Claims;
using NLog;
using NLog.Web;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
using Couchbase.Lite;
using WorkTracker.Components;
using WorkTracker.Configuration;
using WorkTracker.Services.Auth;
using WorkTracker.Services.Festivities;
using WorkTracker.Services.Exports;
using WorkTracker.Services.Settings;
using WorkTracker.Services.Storage;
using WorkTracker.Services.WorkDays;
var builder = WebApplication.CreateBuilder(args);
var runningInContainer = string.Equals(
Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"),
"true",
StringComparison.OrdinalIgnoreCase)
|| File.Exists("/.dockerenv");
if (!runningInContainer)
{
builder.WebHost.UseStaticWebAssets();
}
var configuredStorageOptions = builder.Configuration.GetSection(CouchbaseLiteOptions.SectionName).Get<CouchbaseLiteOptions>() ?? new CouchbaseLiteOptions();
var resolvedDatabaseDirectory = ResolveStorageDirectory(configuredStorageOptions.Directory, builder.Environment.ContentRootPath);
var resolvedLogDirectory = Path.Combine(resolvedDatabaseDirectory, "logs");
GlobalDiagnosticsContext.Set("worktrackerLogPath", Path.Combine(resolvedLogDirectory, "worktracker.log"));
GlobalDiagnosticsContext.Set("worktrackerArchiveDirectory", Path.Combine(resolvedLogDirectory, "archive"));
builder.Logging.ClearProviders();
builder.Host.UseNLog();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DetailedErrors = builder.Configuration.GetValue("DetailedErrors", builder.Environment.IsDevelopment());
});
builder.Services.Configure<AppAuthOptions>(builder.Configuration.GetSection(AppAuthOptions.SectionName));
var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get<AppAuthOptions>() ?? new AppAuthOptions();
var authenticationEnabled = appAuthOptions.Enabled;
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
if (authenticationEnabled)
{
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.AccessDeniedPath = "/login";
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(14);
});
}
else
{
builder.Services.AddAuthentication(DefaultAdminAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, DefaultAdminAuthenticationHandler>(
DefaultAdminAuthenticationHandler.SchemeName,
static _ => { });
}
builder.Services.AddLocalization();
builder.Services.Configure<CouchbaseLiteOptions>(builder.Configuration.GetSection(CouchbaseLiteOptions.SectionName));
builder.Services.Configure<SingleUserOptions>(builder.Configuration.GetSection(SingleUserOptions.SectionName));
builder.Services.AddSingleton<CouchbaseLiteDatabaseProvider>();
builder.Services.AddScoped<IDatabaseBackupService, JsonDatabaseBackupService>();
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
builder.Services.AddScoped<AppThemeState>();
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
builder.Services.AddSingleton<IMonthlyTimesheetExcelExporter, MonthlyTimesheetExcelExporter>();
builder.Services.AddScoped<IMonthlyTimesheetExcelExportService, MonthlyTimesheetExcelExportService>();
builder.Services.AddScoped<IWorkDayService, CouchbaseLiteWorkDayService>();
builder.Services.AddHostedService<SingleUserSeedService>();
var app = builder.Build();
var italianCulture = new CultureInfo("it-IT");
CultureInfo.DefaultThreadCurrentCulture = italianCulture;
CultureInfo.DefaultThreadCurrentUICulture = italianCulture;
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture(italianCulture),
SupportedCultures = [italianCulture],
SupportedUICultures = [italianCulture]
};
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
var useHttpsRedirection = app.Configuration.GetValue("UseHttpsRedirection", !app.Environment.IsDevelopment());
if (useHttpsRedirection)
{
app.UseHttpsRedirection();
}
app.UseStaticFiles();
app.UseRequestLocalization(localizationOptions);
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
static string GetSafeReturnUrl(string? returnUrl)
{
return string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
? "/"
: returnUrl;
}
app.MapPost("/api/login", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var returnUrl = form["returnUrl"].ToString();
var safeReturnUrl = GetSafeReturnUrl(returnUrl);
if (!authenticationEnabled)
{
context.Response.Redirect(safeReturnUrl);
return;
}
var authService = context.RequestServices.GetRequiredService<IAuthService>();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Auth.Login");
var username = form["username"].ToString();
var password = form["password"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
context.Response.Redirect($"/login?error=Missing%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
return;
}
var user = await authService.ValidateCredentialsAsync(username, password, context.RequestAborted);
if (user is null)
{
logger.LogWarning("Login failed for username {Username}", username);
context.Response.Redirect($"/login?error=Invalid%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
return;
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Name, user.Username)
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
// If the seeded or existing user must change password, redirect to change-password flow
if (user.MustChangePassword)
{
context.Response.Redirect("/change-password");
return;
}
context.Response.Redirect(safeReturnUrl);
return;
}).DisableAntiforgery();
app.MapPost("/api/logout", async (HttpContext context) =>
{
if (!authenticationEnabled)
{
context.Response.Redirect("/");
return;
}
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
context.Response.Redirect("/login");
return;
}).DisableAntiforgery();
app.MapPost("/api/change-password", async (HttpContext context) =>
{
if (!authenticationEnabled)
{
context.Response.Redirect("/");
return;
}
var authService = context.RequestServices.GetRequiredService<IAuthService>();
if (!context.User?.Identity?.IsAuthenticated ?? true)
{
await context.ChallengeAsync();
return;
}
var form = await context.Request.ReadFormAsync();
var newPassword = form["newPassword"].ToString();
var confirm = form["confirmPassword"].ToString();
if (string.IsNullOrWhiteSpace(newPassword) || string.IsNullOrWhiteSpace(confirm) || newPassword != confirm)
{
context.Response.Redirect("/change-password?error=Passwords%20do%20not%20match");
return;
}
var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(userId))
{
await context.ChallengeAsync();
return;
}
var changed = await authService.ChangePasswordAsync(userId, newPassword, context.RequestAborted);
if (!changed)
{
context.Response.Redirect("/change-password?error=Unable%20to%20change%20password");
return;
}
context.Response.Redirect("/");
return;
}).DisableAntiforgery();
app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions<AppAuthOptions> authOptions, CouchbaseLiteDatabaseProvider databaseProvider) =>
{
return Results.Json(new
{
status = "Healthy",
authenticationEnabled = authOptions.Value.Enabled,
currentUser = context.User.Identity?.Name,
environment = app.Environment.EnvironmentName,
storage = new
{
appSettingsReady = databaseProvider.AppSettings is not null,
usersReady = databaseProvider.Users is not null,
workDaysReady = databaseProvider.WorkDays is not null
},
timestampUtc = DateTimeOffset.UtcNow
});
});
app.MapGet("/api/monthly-timesheet/{year:int}/{month:int}/excel", async (
int year,
int month,
bool includePreview,
IMonthlyTimesheetExcelExportService exportService,
CancellationToken cancellationToken) =>
{
if (month is < 1 or > 12)
{
return Results.BadRequest("Month must be between 1 and 12.");
}
var file = await exportService.ExportAsync(year, month, includePreview, cancellationToken);
return Results.File(file.Content, file.ContentType, file.FileName);
}).RequireAuthorization();
app.MapGet("/api/database-backup/export", async (
IDatabaseBackupService backupService,
CancellationToken cancellationToken) =>
{
var file = await backupService.ExportAsync(cancellationToken);
return Results.File(file.Content, file.ContentType, file.FileName);
}).RequireAuthorization();
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
if (app.Environment.IsDevelopment())
{
app.MapGet("/debug/reset-admin/{secret}", (HttpContext context, string secret) =>
{
const string expected = "81f58012-fe0b-4dcf-a638-77f9b99f92e3";
if (!string.Equals(secret, expected, StringComparison.Ordinal))
{
return Results.StatusCode(403);
}
var provider = context.RequestServices.GetRequiredService<CouchbaseLiteDatabaseProvider>();
var opts = context.RequestServices.GetRequiredService<IOptions<SingleUserOptions>>();
var username = opts.Value.Username.Trim();
var id = username.Trim().ToUpperInvariant();
// Hash the configured seed password
var passwordHasher = new Microsoft.AspNetCore.Identity.PasswordHasher<WorkTracker.Services.Auth.AuthUser>();
var tempUser = new WorkTracker.Services.Auth.AuthUser { Id = id, Username = username, UsernameNormalized = id };
var newHash = passwordHasher.HashPassword(tempUser, opts.Value.Password);
var doc = provider.Users.GetDocument(id);
if (doc is null)
{
var mutable = new MutableDocument(id);
mutable.SetString("username", username);
mutable.SetString("usernameNormalized", id);
mutable.SetString("passwordHash", newHash);
mutable.SetBoolean("mustChangePassword", true);
provider.Users.Save(mutable);
}
else
{
var mutable = doc.ToMutable();
mutable.SetString("passwordHash", newHash);
mutable.SetBoolean("mustChangePassword", true);
provider.Users.Save(mutable);
}
return Results.Json(new { ok = true, id, username });
});
app.MapGet("/debug/users/{secret}", (HttpContext context, string secret) =>
{
const string expected = "81f58012-fe0b-4dcf-a638-77f9b99f92e3";
if (!string.Equals(secret, expected, StringComparison.Ordinal))
{
return Results.StatusCode(403);
}
var provider = context.RequestServices.GetRequiredService<CouchbaseLiteDatabaseProvider>();
var opts = context.RequestServices.GetRequiredService<IOptions<SingleUserOptions>>();
var username = opts.Value.Username.Trim();
var id = username.Trim().ToUpperInvariant();
var doc = provider.Users.GetDocument(id);
if (doc is null)
{
return Results.NotFound();
}
var result = new
{
id = doc.Id,
username = doc.GetString("username") ?? doc.GetString("email"),
usernameNormalized = doc.GetString("usernameNormalized") ?? doc.GetString("emailNormalized"),
passwordHash = doc.GetString("passwordHash"),
mustChangePassword = doc.GetBoolean("mustChangePassword")
};
return Results.Json(result);
});
}
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
static string ResolveStorageDirectory(string configuredDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredDirectory))
{
return Path.Combine(contentRootPath, "App_Data", "couchbase");
}
return Path.IsPathRooted(configuredDirectory)
? configuredDirectory
: Path.GetFullPath(Path.Combine(contentRootPath, configuredDirectory));
}