diff options
| author | ivar <i@oiee.no> | 2025-12-03 21:49:20 +0100 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2025-12-03 21:49:20 +0100 |
| commit | cd70f54266d708867a1eb35870bc755bc5b2df32 (patch) | |
| tree | f0a8ec571ef3f345ac74293b4cb11918878b3ed5 | |
| parent | 5bd9ad8bd1740dcff179d66718532086304ca4c4 (diff) | |
| download | what-cd70f54266d708867a1eb35870bc755bc5b2df32.tar.xz what-cd70f54266d708867a1eb35870bc755bc5b2df32.zip | |
Refactor db
32 files changed, 307 insertions, 352 deletions
diff --git a/api/.idea/.idea.WhatApi/.idea/data_source_mapping.xml b/api/.idea/.idea.WhatApi/.idea/data_source_mapping.xml new file mode 100644 index 0000000..16f7743 --- /dev/null +++ b/api/.idea/.idea.WhatApi/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="DataSourcePerFileMappings"> + <file url="file://$PROJECT_DIR$/.idea/.idea.WhatApi/.idea/queries/Query.sql" value="4705593e-7cb4-468e-aaf4-fd47704f2409" /> + </component> +</project>
\ No newline at end of file diff --git a/api/.idea/.idea.WhatApi/.idea/indexLayout.xml b/api/.idea/.idea.WhatApi/.idea/indexLayout.xml index 7b08163..323a9b7 100644 --- a/api/.idea/.idea.WhatApi/.idea/indexLayout.xml +++ b/api/.idea/.idea.WhatApi/.idea/indexLayout.xml @@ -1,7 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> <project version="4"> <component name="UserContentModel"> - <attachedFolders /> + <attachedFolders> + <Path>.</Path> + </attachedFolders> <explicitIncludes /> <explicitExcludes /> </component> diff --git a/api/.idea/.idea.WhatApi/.idea/sqldialects.xml b/api/.idea/.idea.WhatApi/.idea/sqldialects.xml new file mode 100644 index 0000000..f21172d --- /dev/null +++ b/api/.idea/.idea.WhatApi/.idea/sqldialects.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="SqlDialectMappings"> + <file url="file://$PROJECT_DIR$/.idea/.idea.WhatApi/.idea/queries/Query.sql" dialect="PostgreSQL" /> + </component> +</project>
\ No newline at end of file diff --git a/api/WhatApi/Constants.cs b/api/WhatApi/Constants.cs index 01385c1..dab7ab2 100644 --- a/api/WhatApi/Constants.cs +++ b/api/WhatApi/Constants.cs @@ -3,4 +3,13 @@ namespace WhatApi; public static class Constants { public const int Wgs84SpatialReferenceId = 4326; + public static readonly Guid SystemUid = new("8c3b23aa-e759-4cf0-b405-c13979d15586"); + + public static class Env + { + public const string MasterDbConnectionString = "MasterDbConnectionString"; + public const string TokenEntropy = "TokenEntropy"; + public const string TokenIssuer = "TokenIssuer"; + public const string TokenAudience = "TokenAudience"; + } }
\ No newline at end of file diff --git a/api/WhatApi/Database.cs b/api/WhatApi/Database/AppDatabase.cs index 8aeffed..64c138f 100644 --- a/api/WhatApi/Database.cs +++ b/api/WhatApi/Database/AppDatabase.cs @@ -1,10 +1,8 @@ using System.Security.Claims; -using WhatApi.Extras; -using WhatApi.Tables; -namespace WhatApi; +namespace WhatApi.Database; -public class Database(DbContextOptions<Database> options, IHttpContextAccessor httpContextAccessor, IConfiguration configuration) : DbContext(options) +public class AppDatabase(DbContextOptions<AppDatabase> options, IHttpContextAccessor httpContextAccessor, IConfiguration configuration) : DbContext(options) { public DbSet<Content> Content => Set<Content>(); public DbSet<Place> Places => Set<Place>(); diff --git a/api/WhatApi/Tables/AuditTrail.cs b/api/WhatApi/Database/Tables/AuditTrail.cs index 9613af4..4ded46c 100644 --- a/api/WhatApi/Tables/AuditTrail.cs +++ b/api/WhatApi/Database/Tables/AuditTrail.cs @@ -1,4 +1,4 @@ -namespace WhatApi.Tables; +namespace WhatApi.Database.Tables; public class AuditTrail { diff --git a/api/WhatApi/Database/Tables/BaseAuditableEntity.cs b/api/WhatApi/Database/Tables/BaseAuditableEntity.cs new file mode 100644 index 0000000..25fb3fa --- /dev/null +++ b/api/WhatApi/Database/Tables/BaseAuditableEntity.cs @@ -0,0 +1,18 @@ +namespace WhatApi.Database.Tables; + +public class BaseAuditableEntity : IAuditableEntity +{ + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset? UpdatedAtUtc { get; set; } + public Guid CreatedBy { get; set; } + public Guid? UpdatedBy { get; set; } + + public void SetCreated(Guid createdBy) { + CreatedBy = createdBy; + CreatedAtUtc = DateTimeOffset.UtcNow; + } + public void SetUpdated(Guid updatedBy) { + UpdatedBy = updatedBy; + UpdatedAtUtc = DateTimeOffset.UtcNow; + } +}
\ No newline at end of file diff --git a/api/WhatApi/Tables/Content.cs b/api/WhatApi/Database/Tables/Content.cs index 0b26b82..8148f81 100644 --- a/api/WhatApi/Tables/Content.cs +++ b/api/WhatApi/Database/Tables/Content.cs @@ -1,17 +1,13 @@ using System.Net; -namespace WhatApi.Tables; +namespace WhatApi.Database.Tables; -public class Content : IAuditableEntity +public class Content : BaseAuditableEntity { public Guid Id { get; set; } public required string Mime { get; set; } public Guid BlobId { get; set; } public required IPAddress Ip { get; set; } - public DateTimeOffset CreatedAtUtc { get; set; } - public DateTimeOffset? UpdatedAtUtc { get; set; } - public Guid CreatedBy { get; set; } - public Guid? UpdatedBy { get; set; } } public class ContentConfiguration : IEntityTypeConfiguration<Content> diff --git a/api/WhatApi/Tables/IAuditableEntity.cs b/api/WhatApi/Database/Tables/IAuditableEntity.cs index a11e7f2..61d64fd 100644 --- a/api/WhatApi/Tables/IAuditableEntity.cs +++ b/api/WhatApi/Database/Tables/IAuditableEntity.cs @@ -1,4 +1,4 @@ -namespace WhatApi.Tables; +namespace WhatApi.Database.Tables; public interface IAuditableEntity { diff --git a/api/WhatApi/Tables/Place.cs b/api/WhatApi/Database/Tables/Place.cs index 969007b..2914aa7 100644 --- a/api/WhatApi/Tables/Place.cs +++ b/api/WhatApi/Database/Tables/Place.cs @@ -1,15 +1,11 @@ -namespace WhatApi.Tables; +namespace WhatApi.Database.Tables; -public class Place : IAuditableEntity +public class Place : BaseAuditableEntity { public Guid Id { get; set; } public Guid ContentId { get; set; } public Content Content { get; set; } = null!; public required Point Location { get; set; } - public DateTimeOffset CreatedAtUtc { get; set; } - public DateTimeOffset? UpdatedAtUtc { get; set; } - public Guid CreatedBy { get; set; } - public Guid? UpdatedBy { get; set; } } public class PlaceConfiguration : IEntityTypeConfiguration<Place> diff --git a/api/WhatApi/Tables/User.cs b/api/WhatApi/Database/Tables/User.cs index 9044439..bfcdb50 100644 --- a/api/WhatApi/Tables/User.cs +++ b/api/WhatApi/Database/Tables/User.cs @@ -1,21 +1,17 @@ -using WhatApi.Extras; +namespace WhatApi.Database.Tables; -namespace WhatApi.Tables; - -public class User : IAuditableEntity +public class User : BaseAuditableEntity { public Guid Id { get; set; } public required string Name { get; set; } public required string Email { get; set; } - - [AuditTrailIgnore] - public required string Password { get; set; } + public required string PasswordHash { get; set; } public DateTimeOffset? LastSeen { get; set; } public IEnumerable<Place> Places { get; set; } = null!; - public DateTimeOffset CreatedAtUtc { get; set; } - public DateTimeOffset? UpdatedAtUtc { get; set; } - public Guid CreatedBy { get; set; } - public Guid? UpdatedBy { get; set; } + + public void SetLastSeen() { + LastSeen = DateTimeOffset.UtcNow; + } } public class UserConfiguration : IEntityTypeConfiguration<User> @@ -24,7 +20,7 @@ public class UserConfiguration : IEntityTypeConfiguration<User> builder.HasKey(x => x.Id); builder.Property(x => x.Name).HasMaxLength(50); builder.Property(x => x.Email).HasMaxLength(100); - builder.Property(x => x.Password).HasMaxLength(100); + builder.Property(x => x.PasswordHash).HasMaxLength(100); builder.HasMany(x => x.Places); builder.ToTable("user"); } diff --git a/api/WhatApi/Dockerfile b/api/WhatApi/Dockerfile index d559b98..2655750 100644 --- a/api/WhatApi/Dockerfile +++ b/api/WhatApi/Dockerfile @@ -1,10 +1,10 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base USER $APP_UID WORKDIR /app EXPOSE 8080 EXPOSE 8081 -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["WhatApi/WhatApi.csproj", "WhatApi/"] diff --git a/api/WhatApi/Endpoints/BaseEndpoint.cs b/api/WhatApi/Endpoints/BaseEndpoint.cs index 1fdf14f..0820ac1 100644 --- a/api/WhatApi/Endpoints/BaseEndpoint.cs +++ b/api/WhatApi/Endpoints/BaseEndpoint.cs @@ -2,6 +2,7 @@ using System.Net; namespace WhatApi.Endpoints; +[Authorize] [ApiController] public class BaseEndpoint : ControllerBase { diff --git a/api/WhatApi/Endpoints/CreateUserEndpoint.cs b/api/WhatApi/Endpoints/CreateUserEndpoint.cs new file mode 100644 index 0000000..dc89c16 --- /dev/null +++ b/api/WhatApi/Endpoints/CreateUserEndpoint.cs @@ -0,0 +1,30 @@ +namespace WhatApi.Endpoints; + +public class CreateUserEndpoint(AppDatabase db, IConfiguration configuration) : BaseEndpoint +{ + public class CreateUserRequest + { + public required string Email { get; set; } + public required string Password { get; set; } + public required string Name { get; set; } + } + + [AllowAnonymous] + [HttpPost("~/create-user")] + public async Task<ActionResult> HandleAsync(CreateUserRequest req, CancellationToken ct = default) { + var userList = await db.Users.Select(c => new { + c.Name + }).ToListAsync(ct); + if (userList.Count == 0 && !configuration.IsDevelopment) return Unauthorized(); + if (userList.Any(c => c.Name.Equals(req.Name, StringComparison.InvariantCultureIgnoreCase))) return BadRequest("Username taken"); + var user = new User { + Name = req.Name, + Email = req.Email, + PasswordHash = PasswordHasher.HashPassword(req.Password) + }; + user.SetCreated(Constants.SystemUid); + db.Users.Add(user); + await db.SaveChangesAsync(ct); + return Ok(); + } +}
\ No newline at end of file diff --git a/api/WhatApi/Endpoints/GetPlacesEndpoint.cs b/api/WhatApi/Endpoints/GetPlacesEndpoint.cs index 08068c8..28b1613 100644 --- a/api/WhatApi/Endpoints/GetPlacesEndpoint.cs +++ b/api/WhatApi/Endpoints/GetPlacesEndpoint.cs @@ -1,9 +1,8 @@ using NetTopologySuite.Features; -using WhatApi.Tables; namespace WhatApi.Endpoints; -public class GetPlacesEndpoint(Database db) : BaseEndpoint +public class GetPlacesEndpoint(AppDatabase db) : BaseEndpoint { [HttpGet("~/places")] public async Task<ActionResult> HandleAsync(double w, double s, double e, double n, CancellationToken ct = default) { diff --git a/api/WhatApi/Endpoints/LoginEndpoint.cs b/api/WhatApi/Endpoints/LoginEndpoint.cs new file mode 100644 index 0000000..ee697ef --- /dev/null +++ b/api/WhatApi/Endpoints/LoginEndpoint.cs @@ -0,0 +1,52 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; + +namespace WhatApi.Endpoints; + +public class LoginEndpoint(AppDatabase db, IConfiguration configuration) : BaseEndpoint +{ + public class LoginRequest + { + public required string Username { get; set; } + public required string Password { get; set; } + } + + [HttpPost("~/login")] + public async Task<ActionResult> HandleAsync(LoginRequest login, CancellationToken ct = default) { + var user = await db.Users.FirstOrDefaultAsync(c => c.Name == login.Username, ct); + if (user?.PasswordHash is null) return Unauthorized(); + + var verificationResult = PasswordHasher.VerifyHashedPassword(user.PasswordHash, login.Password); + if (verificationResult == PasswordVerificationResult.Failed) return Unauthorized(); + + var tokenEntropy = configuration.GetValue<string>(Constants.Env.TokenEntropy); + + ArgumentException.ThrowIfNullOrWhiteSpace(tokenEntropy); + + var key = Encoding.ASCII.GetBytes(tokenEntropy); + var tokenIssuer = configuration.GetValue<string>(Constants.Env.TokenIssuer); + var tokenAudience = configuration.GetValue<string>(Constants.Env.TokenAudience); + var tokenHandler = new JwtSecurityTokenHandler(); + + var tokenDescriptor = new SecurityTokenDescriptor { + Subject = new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Name) + ]), + Expires = DateTime.UtcNow.AddMinutes(60), + Issuer = tokenIssuer, + Audience = tokenAudience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenString = tokenHandler.WriteToken(token); + user.SetLastSeen(); + await db.SaveChangesAsync(ct); + return Ok(tokenString); + } +}
\ No newline at end of file diff --git a/api/WhatApi/Endpoints/UploadContentEndpoint.cs b/api/WhatApi/Endpoints/UploadContentEndpoint.cs index 2c84252..26dbdba 100644 --- a/api/WhatApi/Endpoints/UploadContentEndpoint.cs +++ b/api/WhatApi/Endpoints/UploadContentEndpoint.cs @@ -1,6 +1,6 @@ namespace WhatApi.Endpoints; -public class UploadContentEndpoint(Database db) : BaseEndpoint +public class UploadContentEndpoint(AppDatabase db) : BaseEndpoint { public record UploadContent(IFormFile File, string LatLong); @@ -19,12 +19,12 @@ public class UploadContentEndpoint(Database db) : BaseEndpoint var gf = NtsGeometryServices.Instance.CreateGeometryFactory(srid: Constants.Wgs84SpatialReferenceId); var point = gf.CreatePoint(new Coordinate(double.Parse(longitude), double.Parse(latitude))); - var place = new Tables.Place() { + var place = new Place() { ContentId = contentId, Location = point }; - var content = new Tables.Content() { + var content = new Content() { Id = contentId, Mime = request.File.ContentType, BlobId = blobId, diff --git a/api/WhatApi/Extras/IConfigurationExtensions.cs b/api/WhatApi/Extras/IConfigurationExtensions.cs new file mode 100644 index 0000000..5453699 --- /dev/null +++ b/api/WhatApi/Extras/IConfigurationExtensions.cs @@ -0,0 +1,9 @@ +namespace WhatApi.Extras; + +public static class IConfigurationExtensions +{ + extension(IConfiguration configuration) + { + public bool IsDevelopment => configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development"; + } +}
\ No newline at end of file diff --git a/api/WhatApi/Extras/PasswordHasher.cs b/api/WhatApi/Extras/PasswordHasher.cs new file mode 100644 index 0000000..88e69a7 --- /dev/null +++ b/api/WhatApi/Extras/PasswordHasher.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Identity; + +namespace WhatApi.Extras; + +public class VendorPasswordHasher : PasswordHasher<User>; + +public static class PasswordHasher +{ + private static readonly VendorPasswordHasher _ = new(); + private static readonly User User = new() { + Name = "", + Email = "", + PasswordHash = "" + }; + + public static string HashPassword(string password) { + return _.HashPassword(User, password); + } + + public static PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword) { + return _.VerifyHashedPassword(User, hashedPassword, providedPassword); + } +}
\ No newline at end of file diff --git a/api/WhatApi/Middleware/AuthenticationMiddleware.cs b/api/WhatApi/Middleware/AuthenticationMiddleware.cs index 8cdce53..a691d3c 100644 --- a/api/WhatApi/Middleware/AuthenticationMiddleware.cs +++ b/api/WhatApi/Middleware/AuthenticationMiddleware.cs @@ -1,6 +1,6 @@ namespace WhatApi.Middleware; -public class AuthenticationMiddleware(RequestDelegate next,Database db) +public class AuthenticationMiddleware(RequestDelegate next) { public async Task InvokeAsync(HttpContext context) { await next(context); diff --git a/api/WhatApi/Middleware/AuthorizationMiddleware.cs b/api/WhatApi/Middleware/AuthorizationMiddleware.cs index 26b3f4a..f8db6c4 100644 --- a/api/WhatApi/Middleware/AuthorizationMiddleware.cs +++ b/api/WhatApi/Middleware/AuthorizationMiddleware.cs @@ -1,6 +1,6 @@ namespace WhatApi.Middleware; -public class AuthorizationMiddleware(RequestDelegate next, Database db) +public class AuthorizationMiddleware(RequestDelegate next) { public async Task InvokeAsync(HttpContext context) { await next(context); diff --git a/api/WhatApi/Middleware/UserLastSeenMiddleware.cs b/api/WhatApi/Middleware/UserLastSeenMiddleware.cs index 5c7f1e7..b3fcd89 100644 --- a/api/WhatApi/Middleware/UserLastSeenMiddleware.cs +++ b/api/WhatApi/Middleware/UserLastSeenMiddleware.cs @@ -4,7 +4,7 @@ namespace WhatApi.Middleware; public class UserLastSeenMiddleware(RequestDelegate next) { - public async Task InvokeAsync(HttpContext context,Database db) { + public async Task InvokeAsync(HttpContext context, AppDatabase db) { var userIdString = context.User.FindFirstValue(ClaimTypes.NameIdentifier); if (Guid.TryParse(userIdString, out var userId)) { var user = await db.Users.FirstOrDefaultAsync(c => c.Id == userId); diff --git a/api/WhatApi/Migrations/20251026215643_Initial.Designer.cs b/api/WhatApi/Migrations/20251026215643_Initial.Designer.cs deleted file mode 100644 index c430ed1..0000000 --- a/api/WhatApi/Migrations/20251026215643_Initial.Designer.cs +++ /dev/null @@ -1,214 +0,0 @@ -// <auto-generated /> -using System; -using System.Collections.Generic; -using System.Net; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using NetTopologySuite.Geometries; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using WhatApi; - -#nullable disable - -namespace WhatApi.Migrations -{ - [DbContext(typeof(Database))] - [Migration("20251026215643_Initial")] - partial class Initial - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("WhatApi.Tables.AuditTrail", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.PrimitiveCollection<List<string>>("ChangedColumns") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property<DateTimeOffset>("DateUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("EntityName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property<Dictionary<string, object>>("NewValues") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property<Dictionary<string, object>>("OldValues") - .IsRequired() - .HasColumnType("jsonb"); - - b.Property<string>("PrimaryKey") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property<string>("TrailType") - .IsRequired() - .HasColumnType("text"); - - b.Property<Guid?>("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("EntityName"); - - b.ToTable("audit_trails", (string)null); - }); - - modelBuilder.Entity("WhatApi.Tables.Content", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<Guid>("BlobId") - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("CreatedBy") - .HasColumnType("uuid"); - - b.Property<IPAddress>("Ip") - .IsRequired() - .HasColumnType("inet"); - - b.Property<string>("Mime") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property<DateTimeOffset?>("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid?>("UpdatedBy") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("content", (string)null); - }); - - modelBuilder.Entity("WhatApi.Tables.Place", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<Guid>("ContentId") - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("CreatedBy") - .HasColumnType("uuid"); - - b.Property<Point>("Location") - .IsRequired() - .HasColumnType("geometry(point,4326)"); - - b.Property<DateTimeOffset?>("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid?>("UpdatedBy") - .HasColumnType("uuid"); - - b.Property<Guid?>("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ContentId"); - - b.HasIndex("Location"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); - - b.HasIndex("UserId"); - - b.ToTable("place", (string)null); - }); - - modelBuilder.Entity("WhatApi.Tables.User", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("CreatedBy") - .HasColumnType("uuid"); - - b.Property<string>("Email") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property<DateTimeOffset?>("LastSeen") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property<string>("Password") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset?>("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid?>("UpdatedBy") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("user", (string)null); - }); - - modelBuilder.Entity("WhatApi.Tables.Place", b => - { - b.HasOne("WhatApi.Tables.Content", "Content") - .WithMany() - .HasForeignKey("ContentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("WhatApi.Tables.User", null) - .WithMany("Places") - .HasForeignKey("UserId"); - - b.Navigation("Content"); - }); - - modelBuilder.Entity("WhatApi.Tables.User", b => - { - b.Navigation("Places"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/api/WhatApi/Migrations/20251202203059_PasswordMaxLength.cs b/api/WhatApi/Migrations/20251202203059_PasswordMaxLength.cs deleted file mode 100644 index 8129df8..0000000 --- a/api/WhatApi/Migrations/20251202203059_PasswordMaxLength.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace WhatApi.Migrations -{ - /// <inheritdoc /> - public partial class PasswordMaxLength : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn<string>( - name: "Password", - table: "user", - type: "character varying(100)", - maxLength: 100, - nullable: false, - oldClrType: typeof(string), - oldType: "text"); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn<string>( - name: "Password", - table: "user", - type: "text", - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(100)", - oldMaxLength: 100); - } - } -} diff --git a/api/WhatApi/Migrations/20251202203059_PasswordMaxLength.Designer.cs b/api/WhatApi/Migrations/20251203204812_Initial.Designer.cs index f23a067..39847ba 100644 --- a/api/WhatApi/Migrations/20251202203059_PasswordMaxLength.Designer.cs +++ b/api/WhatApi/Migrations/20251203204812_Initial.Designer.cs @@ -8,15 +8,15 @@ using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using NetTopologySuite.Geometries; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using WhatApi; +using WhatApi.Database; #nullable disable namespace WhatApi.Migrations { - [DbContext(typeof(Database))] - [Migration("20251202203059_PasswordMaxLength")] - partial class PasswordMaxLength + [DbContext(typeof(AppDatabase))] + [Migration("20251203204812_Initial")] + partial class Initial { /// <inheritdoc /> protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -29,7 +29,7 @@ namespace WhatApi.Migrations NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("WhatApi.Tables.AuditTrail", b => + modelBuilder.Entity("WhatApi.Database.Tables.AuditTrail", b => { b.Property<Guid>("Id") .ValueGeneratedOnAdd() @@ -73,7 +73,7 @@ namespace WhatApi.Migrations b.ToTable("audit_trails", (string)null); }); - modelBuilder.Entity("WhatApi.Tables.Content", b => + modelBuilder.Entity("WhatApi.Database.Tables.Content", b => { b.Property<Guid>("Id") .ValueGeneratedOnAdd() @@ -108,7 +108,7 @@ namespace WhatApi.Migrations b.ToTable("content", (string)null); }); - modelBuilder.Entity("WhatApi.Tables.Place", b => + modelBuilder.Entity("WhatApi.Database.Tables.Place", b => { b.Property<Guid>("Id") .ValueGeneratedOnAdd() @@ -149,7 +149,7 @@ namespace WhatApi.Migrations b.ToTable("place", (string)null); }); - modelBuilder.Entity("WhatApi.Tables.User", b => + modelBuilder.Entity("WhatApi.Database.Tables.User", b => { b.Property<Guid>("Id") .ValueGeneratedOnAdd() @@ -174,7 +174,7 @@ namespace WhatApi.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); - b.Property<string>("Password") + b.Property<string>("PasswordHash") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); @@ -190,22 +190,22 @@ namespace WhatApi.Migrations b.ToTable("user", (string)null); }); - modelBuilder.Entity("WhatApi.Tables.Place", b => + modelBuilder.Entity("WhatApi.Database.Tables.Place", b => { - b.HasOne("WhatApi.Tables.Content", "Content") + b.HasOne("WhatApi.Database.Tables.Content", "Content") .WithMany() .HasForeignKey("ContentId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("WhatApi.Tables.User", null) + b.HasOne("WhatApi.Database.Tables.User", null) .WithMany("Places") .HasForeignKey("UserId"); b.Navigation("Content"); }); - modelBuilder.Entity("WhatApi.Tables.User", b => + modelBuilder.Entity("WhatApi.Database.Tables.User", b => { b.Navigation("Places"); }); diff --git a/api/WhatApi/Migrations/20251026215643_Initial.cs b/api/WhatApi/Migrations/20251203204812_Initial.cs index b8f0444..f49a9f3 100644 --- a/api/WhatApi/Migrations/20251026215643_Initial.cs +++ b/api/WhatApi/Migrations/20251203204812_Initial.cs @@ -29,7 +29,7 @@ namespace WhatApi.Migrations DateUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false), OldValues = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false), NewValues = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false), - ChangedColumns = table.Column<List<string>>(type: "jsonb", nullable: false) + ChangedColumns = table.Column<string>(type: "jsonb", nullable: false) }, constraints: table => { @@ -61,7 +61,7 @@ namespace WhatApi.Migrations Id = table.Column<Guid>(type: "uuid", nullable: false), Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false), Email = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), - Password = table.Column<string>(type: "text", nullable: false), + PasswordHash = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), LastSeen = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true), CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false), UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true), @@ -80,11 +80,11 @@ namespace WhatApi.Migrations Id = table.Column<Guid>(type: "uuid", nullable: false), ContentId = table.Column<Guid>(type: "uuid", nullable: false), Location = table.Column<Point>(type: "geometry(point,4326)", nullable: false), + UserId = table.Column<Guid>(type: "uuid", nullable: true), CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false), UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true), CreatedBy = table.Column<Guid>(type: "uuid", nullable: false), - UpdatedBy = table.Column<Guid>(type: "uuid", nullable: true), - UserId = table.Column<Guid>(type: "uuid", nullable: true) + UpdatedBy = table.Column<Guid>(type: "uuid", nullable: true) }, constraints: table => { diff --git a/api/WhatApi/Migrations/DatabaseModelSnapshot.cs b/api/WhatApi/Migrations/AppDatabaseModelSnapshot.cs index babdf01..24973b0 100644 --- a/api/WhatApi/Migrations/DatabaseModelSnapshot.cs +++ b/api/WhatApi/Migrations/AppDatabaseModelSnapshot.cs @@ -7,14 +7,14 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using NetTopologySuite.Geometries; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using WhatApi; +using WhatApi.Database; #nullable disable namespace WhatApi.Migrations { - [DbContext(typeof(Database))] - partial class DatabaseModelSnapshot : ModelSnapshot + [DbContext(typeof(AppDatabase))] + partial class AppDatabaseModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) { @@ -26,7 +26,7 @@ namespace WhatApi.Migrations NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("WhatApi.Tables.AuditTrail", b => + modelBuilder.Entity("WhatApi.Database.Tables.AuditTrail", b => { b.Property<Guid>("Id") .ValueGeneratedOnAdd() @@ -70,7 +70,7 @@ namespace WhatApi.Migrations b.ToTable("audit_trails", (string)null); }); - modelBuilder.Entity("WhatApi.Tables.Content", b => + modelBuilder.Entity("WhatApi.Database.Tables.Content", b => { b.Property<Guid>("Id") .ValueGeneratedOnAdd() @@ -105,7 +105,7 @@ namespace WhatApi.Migrations b.ToTable("content", (string)null); }); - modelBuilder.Entity("WhatApi.Tables.Place", b => + modelBuilder.Entity("WhatApi.Database.Tables.Place", b => { b.Property<Guid>("Id") .ValueGeneratedOnAdd() @@ -146,7 +146,7 @@ namespace WhatApi.Migrations b.ToTable("place", (string)null); }); - modelBuilder.Entity("WhatApi.Tables.User", b => + modelBuilder.Entity("WhatApi.Database.Tables.User", b => { b.Property<Guid>("Id") .ValueGeneratedOnAdd() @@ -171,7 +171,7 @@ namespace WhatApi.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); - b.Property<string>("Password") + b.Property<string>("PasswordHash") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); @@ -187,22 +187,22 @@ namespace WhatApi.Migrations b.ToTable("user", (string)null); }); - modelBuilder.Entity("WhatApi.Tables.Place", b => + modelBuilder.Entity("WhatApi.Database.Tables.Place", b => { - b.HasOne("WhatApi.Tables.Content", "Content") + b.HasOne("WhatApi.Database.Tables.Content", "Content") .WithMany() .HasForeignKey("ContentId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("WhatApi.Tables.User", null) + b.HasOne("WhatApi.Database.Tables.User", null) .WithMany("Places") .HasForeignKey("UserId"); b.Navigation("Content"); }); - modelBuilder.Entity("WhatApi.Tables.User", b => + modelBuilder.Entity("WhatApi.Database.Tables.User", b => { b.Navigation("Places"); }); diff --git a/api/WhatApi/Program.cs b/api/WhatApi/Program.cs index ac7e825..fa50661 100644 --- a/api/WhatApi/Program.cs +++ b/api/WhatApi/Program.cs @@ -7,15 +7,24 @@ global using NetTopologySuite.Geometries; global using Microsoft.AspNetCore.Http.Extensions; global using Microsoft.AspNetCore.Mvc; global using NetTopologySuite; +global using WhatApi.Database.Tables; +global using WhatApi.Database; +global using System.Text; +global using WhatApi.Extras; +global using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; using Npgsql; using WhatApi; using WhatApi.Middleware; var builder = WebApplication.CreateBuilder(args); var dev = builder.Environment.IsDevelopment(); + builder.Services.AddHttpContextAccessor(); -builder.Services.AddDbContextPool<Database>(b => { - var dataSourceBuilder = new NpgsqlDataSourceBuilder(builder.Configuration.GetConnectionString("Master")); +builder.Services.AddDbContextPool<AppDatabase>(b => { + var connectionString = builder.Configuration.GetValue<string>(Constants.Env.MasterDbConnectionString); + var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); dataSourceBuilder.EnableDynamicJson(); if (dev) { b.EnableSensitiveDataLogging(); @@ -28,9 +37,35 @@ builder.Services.AddDbContextPool<Database>(b => { o.UseNetTopologySuite(); }); }); + if (dev) builder.Configuration["DISABLE_AUDIT_TRAILS"] = "true"; + builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); +var tokenEntropy = builder.Configuration.GetValue<string>(Constants.Env.TokenEntropy); +ArgumentException.ThrowIfNullOrEmpty(tokenEntropy); +var tokenIssuer = builder.Configuration.GetValue<string>(Constants.Env.TokenIssuer); +var tokenAudience = builder.Configuration.GetValue<string>(Constants.Env.TokenAudience); + +builder.Services.AddAuthentication(options => { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => { + options.RequireHttpsMetadata = false; + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = tokenIssuer, + ValidAudience = tokenAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(tokenEntropy)), + ClockSkew = TimeSpan.Zero + }; + }); +builder.Services.AddAuthorization(); builder.Services.AddControllers() .AddJsonOptions(o => { o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; @@ -39,19 +74,25 @@ builder.Services.AddControllers() o.JsonSerializerOptions.Converters.Add(new GeoJsonConverterFactory()); }); + var app = builder.Build(); + if (dev) { using var scope = app.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService<Database>(); + var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); Seed.Full(db, opt => { opt.ClearTables = false; }); } + app.UseRouting(); app.UseForwardedHeaders(); app.UseCors(); app.MapStaticAssets(); app.UseMiddleware<UserLastSeenMiddleware>(); +app.UseAuthentication(); app.MapControllers(); +app.MapGet("/", () => Results.Redirect("/map")); app.Run(); + return 0;
\ No newline at end of file diff --git a/api/WhatApi/Seed.cs b/api/WhatApi/Seed.cs index 0e8ab59..4b6361e 100644 --- a/api/WhatApi/Seed.cs +++ b/api/WhatApi/Seed.cs @@ -1,18 +1,17 @@ using Bogus; using Bogus.Locations; -using WhatApi.Tables; namespace WhatApi; public static class Seed { - public static void Full(Database db, Action<SeedOptions> seedOptions) { + public static void Full(AppDatabase db, Action<SeedOptions> seedOptions) { var opt = new SeedOptions(); seedOptions.Invoke(opt); Content(db, opt); } - public static void Content(Database db, SeedOptions opt) { + public static void Content(AppDatabase db, SeedOptions opt) { var any = db.Places.Any(); if (any) { if (!opt.ClearTables) return; diff --git a/api/WhatApi/WhatApi.csproj b/api/WhatApi/WhatApi.csproj index c97c55e..91f3d3f 100644 --- a/api/WhatApi/WhatApi.csproj +++ b/api/WhatApi/WhatApi.csproj @@ -5,43 +5,45 @@ <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> - <UserSecretsId>5506c159-f534-4090-b80b-2703e1eb7f6c</UserSecretsId> - </PropertyGroup> + <UserSecretsId>5506c159-f534-4090-b80b-2703e1eb7f6c</UserSecretsId> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> <ItemGroup> - <Content Include="..\.dockerignore"> - <Link>.dockerignore</Link> - </Content> + <Content Include="..\.dockerignore"> + <Link>.dockerignore</Link> + </Content> </ItemGroup> <ItemGroup> - <PackageReference Include="Bogus" Version="35.6.5" /> - <PackageReference Include="Bogus.Locations" Version="35.6.5" /> - <PackageReference Include="Fluid.Core" Version="3.0.0-beta.2" /> - <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> - </PackageReference> - <PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="4.0.0" /> - <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> - <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0" /> - <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> + <PackageReference Include="Bogus" Version="35.6.5"/> + <PackageReference Include="Bogus.Locations" Version="35.6.5"/> + <PackageReference Include="Fluid.Core" Version="3.0.0-beta.2"/> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0"/> + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0"/> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="4.0.0"/> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0"/> + <PackageReference Include="Serilog.AspNetCore" Version="10.0.0"/> </ItemGroup> <ItemGroup> - <Folder Include="files\" /> + <Folder Include="files\"/> </ItemGroup> <ItemGroup> - <None Remove="Templates\web_login.liquid" /> - <Resource Include="Templates\web_map.liquid"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </Resource> - <None Remove="Templates\web_upload.liquid" /> - <Resource Include="Templates\web_upload.liquid"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </Resource> + <None Remove="Templates\web_login.liquid"/> + <Resource Include="Templates\web_map.liquid"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Resource> + <None Remove="Templates\web_upload.liquid"/> + <Resource Include="Templates\web_upload.liquid"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Resource> </ItemGroup> </Project> diff --git a/api/http/http-client.env.json b/api/http/http-client.env.json new file mode 100644 index 0000000..af19690 --- /dev/null +++ b/api/http/http-client.env.json @@ -0,0 +1,5 @@ +{ + "dev": { + "canonical": "http://localhost:5281" + } +}
\ No newline at end of file diff --git a/api/http/login.http b/api/http/login.http new file mode 100644 index 0000000..8ebba0c --- /dev/null +++ b/api/http/login.http @@ -0,0 +1,17 @@ +### GET request to example server +POST {{canonical}}/login + +{ + "username": "", + "password": "" +} + +### Create user +POST {{canonical}}/create-user +Content-Type: application/json + +{ + "name": "ivarsu", + "email": "ivarsu@oiee.no", + "password": "ivargangertre" +} |
