using System.Net; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using Microsoft.Data.Sqlite; var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication("Basic") .AddScheme("Basic", null); builder.Services.AddAuthorization(); builder.Services.AddSingleton(); var app = builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseAuthentication(); app.UseAuthorization(); await app.Services.GetRequiredService().InitializeAsync(); app.MapPost("/api/visitors", async (VisitorInput input, Database database) => { var errors = Validate(input); if (errors.Count > 0) { return Results.ValidationProblem(errors); } await using var connection = await database.OpenConnectionAsync(); await using var command = connection.CreateCommand(); command.CommandText = """ insert into visitors (name, street_address, postal_code, city, birthday, email) values ($name, $street, $postal, $city, $birthday, $email) returning id """; command.Parameters.AddWithValue("$name", input.Name.Trim()); command.Parameters.AddWithValue("$street", input.StreetAddress.Trim()); command.Parameters.AddWithValue("$postal", input.PostalCode.Trim()); command.Parameters.AddWithValue("$city", input.City.Trim()); command.Parameters.AddWithValue("$birthday", DateOnly.Parse(input.Birthday).ToString("yyyy-MM-dd")); command.Parameters.AddWithValue("$email", input.Email?.Trim().ToLowerInvariant() ?? ""); var number = Convert.ToInt64(await command.ExecuteScalarAsync()); return Results.Created("/api/visitors", new { number }); }); app.MapGet("/api/visitors", async (Database database) => { var visitors = new List(); await using var connection = await database.OpenConnectionAsync(); await using var command = connection.CreateCommand(); command.CommandText = """ select id, name, street_address, postal_code, city, birthday, email, created_at, result, x10 from visitors order by created_at desc, id desc """; await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { visitors.Add(new VisitorListItem( reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4), DateOnly.Parse(reader.GetString(5)), reader.GetString(6), DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc), reader.GetString(8), reader.IsDBNull(9) ? null : reader.GetInt32(9) )); } return Results.Ok(visitors); }).RequireAuthorization(); app.MapPut("/api/visitors/{id:long}/result", async (long id, VisitorResultInput input, Database database) => { var result = input.Result?.Trim() ?? ""; if (input.X10 < 0) return Results.ValidationProblem(new Dictionary { ["x10"] = ["X-10 skal være et heltal på mindst 0."] }); if (result.Length > 100) return Results.ValidationProblem(new Dictionary { ["result"] = ["Resultatet må højst være 100 tegn."] }); await using var connection = await database.OpenConnectionAsync(); await using var command = connection.CreateCommand(); command.CommandText = "update visitors set result = $result, x10 = $x10 where id = $id"; command.Parameters.AddWithValue("$x10", (object?)input.X10 ?? DBNull.Value); command.Parameters.AddWithValue("$result", result); command.Parameters.AddWithValue("$id", id); return await command.ExecuteNonQueryAsync() == 0 ? Results.NotFound() : Results.Ok(new { result, x10 = input.X10 }); }).RequireAuthorization(); app.MapDelete("/api/visitors/{id:long}", async (long id, Database database) => { await using var connection = await database.OpenConnectionAsync(); await using var command = connection.CreateCommand(); command.CommandText = "delete from visitors where id = $id"; command.Parameters.AddWithValue("$id", id); var deleted = await command.ExecuteNonQueryAsync(); return deleted == 0 ? Results.NotFound() : Results.NoContent(); }).RequireAuthorization(); app.MapGet("/admin", (IWebHostEnvironment environment) => Results.File(Path.Combine(environment.WebRootPath, "admin.html"), "text/html")); app.MapFallbackToFile("index.html"); app.Run(); static Dictionary Validate(VisitorInput input) { var errors = new Dictionary(); if (string.IsNullOrWhiteSpace(input.Name)) { errors["name"] = ["Navn mangler."]; } if (string.IsNullOrWhiteSpace(input.StreetAddress)) { errors["streetAddress"] = ["Vejnavn og husnummer mangler."]; } if (string.IsNullOrWhiteSpace(input.PostalCode) || input.PostalCode.Trim().Length != 4 || !input.PostalCode.All(char.IsDigit)) { errors["postalCode"] = ["Postnummer skal være 4 cifre."]; } if (string.IsNullOrWhiteSpace(input.City)) { errors["city"] = ["Postby mangler."]; } if (string.IsNullOrWhiteSpace(input.Birthday) || !DateOnly.TryParse(input.Birthday, out _)) { errors["birthday"] = ["Fødselsdag mangler eller er ugyldig."]; } if (!string.IsNullOrWhiteSpace(input.Email) && (!input.Email.Contains('@') || !input.Email.Contains('.'))) { errors["email"] = ["Email er ugyldig."]; } return errors; } public sealed record VisitorInput(string Name, string StreetAddress, string PostalCode, string City, string Birthday, string? Email); public sealed record VisitorResultInput(string? Result, int? X10); public sealed record VisitorListItem(long Id, string Name, string StreetAddress, string PostalCode, string City, DateOnly Birthday, string Email, DateTime CreatedAt, string Result, int? X10); public sealed class Database { private readonly string _connectionString; public Database(IConfiguration configuration, IWebHostEnvironment environment) { var path = configuration["Database:Path"] ?? "data/visitors.db"; var fullPath = Path.GetFullPath(path, environment.ContentRootPath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); _connectionString = new SqliteConnectionStringBuilder { DataSource = fullPath }.ToString(); } public async Task OpenConnectionAsync() { var connection = new SqliteConnection(_connectionString); try { await connection.OpenAsync(); return connection; } catch { await connection.DisposeAsync(); throw; } } public async Task InitializeAsync() { await using var connection = await OpenConnectionAsync(); await using var command = connection.CreateCommand(); command.CommandText = """ create table if not exists visitors ( id integer primary key autoincrement, name text not null, street_address text not null, postal_code text not null, city text not null, birthday text not null, email text not null, created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); create index if not exists visitors_created_at_idx on visitors (created_at desc); """; await command.ExecuteNonQueryAsync(); await using var schemaCommand = connection.CreateCommand(); schemaCommand.CommandText = "select count(*) from pragma_table_info('visitors') where name = 'result'"; if (Convert.ToInt64(await schemaCommand.ExecuteScalarAsync()) == 0) { schemaCommand.CommandText = "alter table visitors add column result text not null default ''"; await schemaCommand.ExecuteNonQueryAsync(); } schemaCommand.CommandText = "select count(*) from pragma_table_info('visitors') where name = 'x10'"; if (Convert.ToInt64(await schemaCommand.ExecuteScalarAsync()) == 0) { schemaCommand.CommandText = "alter table visitors add column x10 integer"; await schemaCommand.ExecuteNonQueryAsync(); } } } public sealed class BasicAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IConfiguration configuration) : AuthenticationHandler(options, logger, encoder) { protected override Task HandleAuthenticateAsync() { var expectedUser = configuration["Admin:Username"] ?? "admin"; var expectedPassword = configuration["Admin:Password"]; if (string.IsNullOrWhiteSpace(expectedPassword)) { return Task.FromResult(AuthenticateResult.Fail("Admin:Password mangler.")); } if (!Request.Headers.TryGetValue("Authorization", out var authorizationHeaders)) { return Task.FromResult(AuthenticateResult.NoResult()); } var authorization = authorizationHeaders.ToString(); if (!authorization.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(AuthenticateResult.NoResult()); } try { var encoded = authorization["Basic ".Length..].Trim(); var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); var separator = decoded.IndexOf(':'); if (separator < 0) { return Task.FromResult(AuthenticateResult.Fail("Ugyldige credentials.")); } var username = decoded[..separator]; var password = decoded[(separator + 1)..]; if (username != expectedUser || password != expectedPassword) { return Task.FromResult(AuthenticateResult.Fail("Forkert login.")); } var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, username)], Scheme.Name); var principal = new ClaimsPrincipal(identity); return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name))); } catch { return Task.FromResult(AuthenticateResult.Fail("Ugyldige credentials.")); } } protected override Task HandleChallengeAsync(AuthenticationProperties properties) { Response.Headers.WWWAuthenticate = "Basic realm=\"Skydesportens Dag\""; Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; } }