From a1f0518d0cd123a791adde64f4f11bd8e44276c7 Mon Sep 17 00:00:00 2001 From: ivar Date: Mon, 20 Oct 2025 00:26:34 +0200 Subject: Initial commit --- api/WhatApi/Constants.cs | 6 ++ api/WhatApi/Database.cs | 19 +++++ api/WhatApi/Dockerfile | 23 +++++ api/WhatApi/Endpoints/BaseEndpoint.cs | 12 +++ api/WhatApi/Endpoints/DownloadContentEndpoint.cs | 19 +++++ api/WhatApi/Endpoints/GetPlacesEndpoint.cs | 57 +++++++++++++ api/WhatApi/Endpoints/UploadContentEndpoint.cs | 49 +++++++++++ api/WhatApi/Middleware/AuthenticationMiddleware.cs | 8 ++ api/WhatApi/Middleware/AuthorizationMiddleware.cs | 8 ++ api/WhatApi/Middleware/UserLastSeenMiddleware.cs | 8 ++ .../Migrations/20251013213511_Initial.Designer.cs | 93 +++++++++++++++++++++ api/WhatApi/Migrations/20251013213511_Initial.cs | 75 +++++++++++++++++ api/WhatApi/Migrations/DatabaseModelSnapshot.cs | 90 ++++++++++++++++++++ api/WhatApi/Pages/Map.cshtml | 92 ++++++++++++++++++++ api/WhatApi/Pages/Map.cshtml.cs | 10 +++ api/WhatApi/Pages/Upload.cshtml | 82 ++++++++++++++++++ api/WhatApi/Pages/Upload.cshtml.cs | 10 +++ api/WhatApi/Program.cs | 45 ++++++++++ api/WhatApi/Properties/launchSettings.json | 14 ++++ api/WhatApi/Seed.cs | 24 ++++++ api/WhatApi/Tables/Content.cs | 12 +++ api/WhatApi/Tables/Place.cs | 11 +++ api/WhatApi/Tables/Session.cs | 10 +++ api/WhatApi/Tables/User.cs | 12 +++ api/WhatApi/WhatApi.csproj | 34 ++++++++ api/WhatApi/appsettings.Development.json | 8 ++ api/WhatApi/appsettings.json | 9 ++ api/WhatApi/wwwroot/pin.png | Bin 0 -> 986 bytes 28 files changed, 840 insertions(+) create mode 100644 api/WhatApi/Constants.cs create mode 100644 api/WhatApi/Database.cs create mode 100644 api/WhatApi/Dockerfile create mode 100644 api/WhatApi/Endpoints/BaseEndpoint.cs create mode 100644 api/WhatApi/Endpoints/DownloadContentEndpoint.cs create mode 100644 api/WhatApi/Endpoints/GetPlacesEndpoint.cs create mode 100644 api/WhatApi/Endpoints/UploadContentEndpoint.cs create mode 100644 api/WhatApi/Middleware/AuthenticationMiddleware.cs create mode 100644 api/WhatApi/Middleware/AuthorizationMiddleware.cs create mode 100644 api/WhatApi/Middleware/UserLastSeenMiddleware.cs create mode 100644 api/WhatApi/Migrations/20251013213511_Initial.Designer.cs create mode 100644 api/WhatApi/Migrations/20251013213511_Initial.cs create mode 100644 api/WhatApi/Migrations/DatabaseModelSnapshot.cs create mode 100644 api/WhatApi/Pages/Map.cshtml create mode 100644 api/WhatApi/Pages/Map.cshtml.cs create mode 100644 api/WhatApi/Pages/Upload.cshtml create mode 100644 api/WhatApi/Pages/Upload.cshtml.cs create mode 100644 api/WhatApi/Program.cs create mode 100644 api/WhatApi/Properties/launchSettings.json create mode 100644 api/WhatApi/Seed.cs create mode 100644 api/WhatApi/Tables/Content.cs create mode 100644 api/WhatApi/Tables/Place.cs create mode 100644 api/WhatApi/Tables/Session.cs create mode 100644 api/WhatApi/Tables/User.cs create mode 100644 api/WhatApi/WhatApi.csproj create mode 100644 api/WhatApi/appsettings.Development.json create mode 100644 api/WhatApi/appsettings.json create mode 100644 api/WhatApi/wwwroot/pin.png (limited to 'api/WhatApi') diff --git a/api/WhatApi/Constants.cs b/api/WhatApi/Constants.cs new file mode 100644 index 0000000..01385c1 --- /dev/null +++ b/api/WhatApi/Constants.cs @@ -0,0 +1,6 @@ +namespace WhatApi; + +public static class Constants +{ + public const int Wgs84SpatialReferenceId = 4326; +} \ No newline at end of file diff --git a/api/WhatApi/Database.cs b/api/WhatApi/Database.cs new file mode 100644 index 0000000..39de79a --- /dev/null +++ b/api/WhatApi/Database.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +namespace WhatApi; + +public class Database(DbContextOptions options) : DbContext(options) +{ + public DbSet Content { get; set; } + public DbSet Places { get; set; } + protected override void OnModelCreating(ModelBuilder b) { + b.HasPostgresExtension("postgis"); + b.Entity(e => { + e.Property(x => x.Location).HasColumnType($"geometry(point,{Constants.Wgs84SpatialReferenceId})"); + e.HasIndex(x => x.Location).HasMethod("gist"); + e.ToTable("Place"); + }); + b.Entity(); + base.OnModelCreating(b); + } +} \ No newline at end of file diff --git a/api/WhatApi/Dockerfile b/api/WhatApi/Dockerfile new file mode 100644 index 0000000..d559b98 --- /dev/null +++ b/api/WhatApi/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["WhatApi/WhatApi.csproj", "WhatApi/"] +RUN dotnet restore "WhatApi/WhatApi.csproj" +COPY . . +WORKDIR "/src/WhatApi" +RUN dotnet build "./WhatApi.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./WhatApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "WhatApi.dll"] diff --git a/api/WhatApi/Endpoints/BaseEndpoint.cs b/api/WhatApi/Endpoints/BaseEndpoint.cs new file mode 100644 index 0000000..4d8f9ad --- /dev/null +++ b/api/WhatApi/Endpoints/BaseEndpoint.cs @@ -0,0 +1,12 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; + +namespace WhatApi.Endpoints; + +[ApiController] +public class BaseEndpoint : ControllerBase +{ + protected IPAddress GetIp() => Request.Headers.TryGetValue("X-Forwarded-For", out var ip) + ? IPAddress.Parse(ip.ToString()) + : HttpContext.Connection.RemoteIpAddress!; +} \ No newline at end of file diff --git a/api/WhatApi/Endpoints/DownloadContentEndpoint.cs b/api/WhatApi/Endpoints/DownloadContentEndpoint.cs new file mode 100644 index 0000000..dbe6bff --- /dev/null +++ b/api/WhatApi/Endpoints/DownloadContentEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; + +namespace WhatApi.Endpoints; + +public class DownloadContentEndpoint : BaseEndpoint +{ + [HttpGet("~/{id:guid}")] + public async Task HandleAsync(Guid id, CancellationToken ct = default) { + try { + var path = Path.Combine(Directory.GetCurrentDirectory(), "files", id.ToString()); + await using var file = new FileStream(path, FileMode.Open); + if (!file.CanRead) return NotFound(); + return File(file, "application/octet-stream", id.ToString()); + } catch (Exception e) { + if (e is not FileNotFoundException) Console.WriteLine(e); + return NotFound(); + } + } +} \ No newline at end of file diff --git a/api/WhatApi/Endpoints/GetPlacesEndpoint.cs b/api/WhatApi/Endpoints/GetPlacesEndpoint.cs new file mode 100644 index 0000000..5630229 --- /dev/null +++ b/api/WhatApi/Endpoints/GetPlacesEndpoint.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NetTopologySuite; +using NetTopologySuite.Features; +using NetTopologySuite.Geometries; +using WhatApi.Tables; + +namespace WhatApi.Endpoints; + +public class GetPlacesEndpoint(Database db) : BaseEndpoint +{ + [HttpGet("~/places")] + public async Task HandleAsync(string w, string s, string e, string n, CancellationToken ct = default) { + var north = double.Parse(n); + var east = double.Parse(e); + var south = double.Parse(s); + var west = double.Parse(w); + + IQueryable resultingQuery; + + if (west > east) { + resultingQuery = db.Places + .FromSqlInterpolated($""" + SELECT * FROM "Place" + WHERE ST_Intersects( + "Location", + ST_MakeEnvelope({west}, {south}, 180, {north}, {Constants.Wgs84SpatialReferenceId}) || ST_MakeEnvelope(-180, {south}, {east}, {north}, {Constants.Wgs84SpatialReferenceId}) + ) + """); + } else { + resultingQuery = db.Places + .FromSqlInterpolated($""" + SELECT * FROM "Place" + WHERE ST_Intersects( + "Location", + ST_MakeEnvelope({west}, {south}, {east}, {north}, {Constants.Wgs84SpatialReferenceId}) + ) + """); + } + + var gf = NtsGeometryServices.Instance.CreateGeometryFactory(srid: Constants.Wgs84SpatialReferenceId); + var fc = new FeatureCollection(); + + await foreach (var p in resultingQuery.AsAsyncEnumerable().WithCancellation(ct)) { + var point = gf.CreatePoint(new Coordinate(p.Location.X, p.Location.Y)); + fc.Add(new Feature(point, new AttributesTable { + { + "id", p.Id + }, { + "cid", p.ContentId + } + })); + } + + return Ok(fc); + } +} \ No newline at end of file diff --git a/api/WhatApi/Endpoints/UploadContentEndpoint.cs b/api/WhatApi/Endpoints/UploadContentEndpoint.cs new file mode 100644 index 0000000..82fb71b --- /dev/null +++ b/api/WhatApi/Endpoints/UploadContentEndpoint.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using NetTopologySuite; +using NetTopologySuite.Geometries; + +namespace WhatApi.Endpoints; + +public class UploadContentEndpoint(Database db) : BaseEndpoint +{ + public record UploadContent(IFormFile File, string LatLong); + + [HttpPost("~/upload")] + public async Task HandleAsync([FromForm] UploadContent request, CancellationToken ct = default) { + if (string.IsNullOrWhiteSpace(Request.GetMultipartBoundary())) { + return StatusCode(415, "Unsupported Media Type"); + } + + var blobId = Guid.NewGuid(); + var contentId = Guid.NewGuid(); + + var latitude = request.LatLong.Split(',')[0]; + var longitude = request.LatLong.Split(',')[1]; + + 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() { + ContentId = contentId, + Location = point + }; + + var content = new Tables.Content() { + Id = contentId, + Mime = request.File.ContentType, + Created = DateTime.UtcNow, + BlobId = blobId, + Ip = GetIp() + }; + + var path = Path.Combine(Directory.GetCurrentDirectory(), "files", blobId.ToString()); + + await using var writer = new FileStream(path, FileMode.CreateNew); + await request.File.CopyToAsync(writer, ct); + await db.Content.AddAsync(content, ct); + await db.Places.AddAsync(place, ct); + await db.SaveChangesAsync(ct); + return Ok(contentId); + } +} \ No newline at end of file diff --git a/api/WhatApi/Middleware/AuthenticationMiddleware.cs b/api/WhatApi/Middleware/AuthenticationMiddleware.cs new file mode 100644 index 0000000..8cdce53 --- /dev/null +++ b/api/WhatApi/Middleware/AuthenticationMiddleware.cs @@ -0,0 +1,8 @@ +namespace WhatApi.Middleware; + +public class AuthenticationMiddleware(RequestDelegate next,Database db) +{ + public async Task InvokeAsync(HttpContext context) { + await next(context); + } +} \ No newline at end of file diff --git a/api/WhatApi/Middleware/AuthorizationMiddleware.cs b/api/WhatApi/Middleware/AuthorizationMiddleware.cs new file mode 100644 index 0000000..26b3f4a --- /dev/null +++ b/api/WhatApi/Middleware/AuthorizationMiddleware.cs @@ -0,0 +1,8 @@ +namespace WhatApi.Middleware; + +public class AuthorizationMiddleware(RequestDelegate next, Database db) +{ + public async Task InvokeAsync(HttpContext context) { + await next(context); + } +} \ No newline at end of file diff --git a/api/WhatApi/Middleware/UserLastSeenMiddleware.cs b/api/WhatApi/Middleware/UserLastSeenMiddleware.cs new file mode 100644 index 0000000..ef1b685 --- /dev/null +++ b/api/WhatApi/Middleware/UserLastSeenMiddleware.cs @@ -0,0 +1,8 @@ +namespace WhatApi.Middleware; + +public class UserLastSeenMiddleware(RequestDelegate next, Database db) +{ + public async Task InvokeAsync(HttpContext context) { + await next(context); + } +} \ No newline at end of file diff --git a/api/WhatApi/Migrations/20251013213511_Initial.Designer.cs b/api/WhatApi/Migrations/20251013213511_Initial.Designer.cs new file mode 100644 index 0000000..5ddcc9f --- /dev/null +++ b/api/WhatApi/Migrations/20251013213511_Initial.Designer.cs @@ -0,0 +1,93 @@ +// +using System; +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("20251013213511_Initial")] + partial class Initial + { + /// + 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.Content", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobId") + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Ip") + .IsRequired() + .HasColumnType("inet"); + + b.Property("Mime") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Content"); + }); + + modelBuilder.Entity("WhatApi.Tables.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentId") + .HasColumnType("uuid"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geometry(point,4326)"); + + b.HasKey("Id"); + + b.HasIndex("ContentId"); + + b.HasIndex("Location"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); + + b.ToTable("Place", (string)null); + }); + + modelBuilder.Entity("WhatApi.Tables.Place", b => + { + b.HasOne("WhatApi.Tables.Content", "Content") + .WithMany() + .HasForeignKey("ContentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Content"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/WhatApi/Migrations/20251013213511_Initial.cs b/api/WhatApi/Migrations/20251013213511_Initial.cs new file mode 100644 index 0000000..1fa8bbf --- /dev/null +++ b/api/WhatApi/Migrations/20251013213511_Initial.cs @@ -0,0 +1,75 @@ +using System; +using System.Net; +using Microsoft.EntityFrameworkCore.Migrations; +using NetTopologySuite.Geometries; + +#nullable disable + +namespace WhatApi.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:postgis", ",,"); + + migrationBuilder.CreateTable( + name: "Content", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Mime = table.Column(type: "text", nullable: false), + Created = table.Column(type: "timestamp with time zone", nullable: false), + BlobId = table.Column(type: "uuid", nullable: false), + Ip = table.Column(type: "inet", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Content", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Place", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContentId = table.Column(type: "uuid", nullable: false), + Location = table.Column(type: "geometry(point,4326)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Place", x => x.Id); + table.ForeignKey( + name: "FK_Place_Content_ContentId", + column: x => x.ContentId, + principalTable: "Content", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Place_ContentId", + table: "Place", + column: "ContentId"); + + migrationBuilder.CreateIndex( + name: "IX_Place_Location", + table: "Place", + column: "Location") + .Annotation("Npgsql:IndexMethod", "gist"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Place"); + + migrationBuilder.DropTable( + name: "Content"); + } + } +} diff --git a/api/WhatApi/Migrations/DatabaseModelSnapshot.cs b/api/WhatApi/Migrations/DatabaseModelSnapshot.cs new file mode 100644 index 0000000..f1e5fcb --- /dev/null +++ b/api/WhatApi/Migrations/DatabaseModelSnapshot.cs @@ -0,0 +1,90 @@ +// +using System; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using WhatApi; + +#nullable disable + +namespace WhatApi.Migrations +{ + [DbContext(typeof(Database))] + partial class DatabaseModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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.Content", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobId") + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Ip") + .IsRequired() + .HasColumnType("inet"); + + b.Property("Mime") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Content"); + }); + + modelBuilder.Entity("WhatApi.Tables.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentId") + .HasColumnType("uuid"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geometry(point,4326)"); + + b.HasKey("Id"); + + b.HasIndex("ContentId"); + + b.HasIndex("Location"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); + + b.ToTable("Place", (string)null); + }); + + modelBuilder.Entity("WhatApi.Tables.Place", b => + { + b.HasOne("WhatApi.Tables.Content", "Content") + .WithMany() + .HasForeignKey("ContentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Content"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/WhatApi/Pages/Map.cshtml b/api/WhatApi/Pages/Map.cshtml new file mode 100644 index 0000000..31adf86 --- /dev/null +++ b/api/WhatApi/Pages/Map.cshtml @@ -0,0 +1,92 @@ +@page +@model WhatApi.Pages.Map + +@{ + Layout = null; +} + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/api/WhatApi/Pages/Map.cshtml.cs b/api/WhatApi/Pages/Map.cshtml.cs new file mode 100644 index 0000000..786180b --- /dev/null +++ b/api/WhatApi/Pages/Map.cshtml.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace WhatApi.Pages; + +public class Map : PageModel +{ + public void OnGet() { + + } +} \ No newline at end of file diff --git a/api/WhatApi/Pages/Upload.cshtml b/api/WhatApi/Pages/Upload.cshtml new file mode 100644 index 0000000..4c87b11 --- /dev/null +++ b/api/WhatApi/Pages/Upload.cshtml @@ -0,0 +1,82 @@ +@page +@model WhatApi.Pages.Upload + +@{ + Layout = null; +} + + + + + + + + + + +
+
+ + + +
+
+
+

+
+
+
+
\ No newline at end of file
diff --git a/api/WhatApi/Pages/Upload.cshtml.cs b/api/WhatApi/Pages/Upload.cshtml.cs
new file mode 100644
index 0000000..2fe362e
--- /dev/null
+++ b/api/WhatApi/Pages/Upload.cshtml.cs
@@ -0,0 +1,10 @@
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace WhatApi.Pages;
+
+public class Upload : PageModel
+{
+    public void OnGet() {
+
+    }
+}
\ No newline at end of file
diff --git a/api/WhatApi/Program.cs b/api/WhatApi/Program.cs
new file mode 100644
index 0000000..80d06e8
--- /dev/null
+++ b/api/WhatApi/Program.cs
@@ -0,0 +1,45 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.EntityFrameworkCore;
+using NetTopologySuite.IO.Converters;
+
+namespace WhatApi;
+
+public partial class Program
+{
+    public static int Main(string[] args) {
+        var builder = WebApplication.CreateBuilder(args);
+
+        builder.Services.AddDbContextPool(b => {
+            b.EnableSensitiveDataLogging();
+            b.UseNpgsql(builder.Configuration.GetConnectionString("Master"), o => o.UseNetTopologySuite());
+        });
+
+        builder.Services.AddRazorPages();
+        builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
+        builder.Services.AddControllers()
+            .AddJsonOptions(o => {
+                o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+                o.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
+                o.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
+                o.JsonSerializerOptions.Converters.Add(new GeoJsonConverterFactory());
+            });
+
+        var app = builder.Build();
+
+#if DEBUG
+        using var scope = app.Services.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService();
+        Seed(db);
+#endif
+
+        app.UseRouting();
+        app.UseForwardedHeaders();
+        app.UseCors();
+        app.MapStaticAssets();
+        app.MapRazorPages();
+        app.MapControllers();
+        app.Run();
+        return 0;
+    }
+}
\ No newline at end of file
diff --git a/api/WhatApi/Properties/launchSettings.json b/api/WhatApi/Properties/launchSettings.json
new file mode 100644
index 0000000..581ee7b
--- /dev/null
+++ b/api/WhatApi/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+  "$schema": "https://json.schemastore.org/launchsettings.json",
+  "profiles": {
+    "http": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": false,
+      "applicationUrl": "http://localhost:5281",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    }
+  }
+}
diff --git a/api/WhatApi/Seed.cs b/api/WhatApi/Seed.cs
new file mode 100644
index 0000000..c3127cc
--- /dev/null
+++ b/api/WhatApi/Seed.cs
@@ -0,0 +1,24 @@
+using Bogus;
+using Bogus.Locations;
+using NetTopologySuite.Geometries;
+using WhatApi.Tables;
+
+namespace WhatApi;
+
+public partial class Program
+{
+    private static void Seed(Database db) {
+        if (db.Places.Any() || true) return;
+        var places = new List();
+        var location = new Faker().Location();
+        for (var i = 0; i < 1000; i++) {
+            var point = location.AreaCircle(59.91838, 10.73861, 30000);
+            places.Add(new Place() {
+                Location = new Point(new Coordinate(point.Longitude, point.Latitude)),
+                ContentId = new Guid("1337710a-8cdb-4d50-815f-772c0e9f1482")
+            });
+        }
+        db.Places.AddRange(places);
+        db.SaveChanges();
+    }
+}
\ No newline at end of file
diff --git a/api/WhatApi/Tables/Content.cs b/api/WhatApi/Tables/Content.cs
new file mode 100644
index 0000000..79f2579
--- /dev/null
+++ b/api/WhatApi/Tables/Content.cs
@@ -0,0 +1,12 @@
+using System.Net;
+
+namespace WhatApi.Tables;
+
+public class Content
+{
+    public Guid Id { get; set; }
+    public string Mime { get; set; }
+    public DateTime Created { get; set; }
+    public Guid BlobId { get; set; }
+    public IPAddress Ip { get; set; }
+}
\ No newline at end of file
diff --git a/api/WhatApi/Tables/Place.cs b/api/WhatApi/Tables/Place.cs
new file mode 100644
index 0000000..ff95c96
--- /dev/null
+++ b/api/WhatApi/Tables/Place.cs
@@ -0,0 +1,11 @@
+using NetTopologySuite.Geometries;
+
+namespace WhatApi.Tables;
+
+public class Place
+{
+    public Guid Id { get; set; }
+    public Guid ContentId { get; set; }
+    public Content Content { get; set; }
+    public required Point Location { get; set; }
+}
\ No newline at end of file
diff --git a/api/WhatApi/Tables/Session.cs b/api/WhatApi/Tables/Session.cs
new file mode 100644
index 0000000..a0affa8
--- /dev/null
+++ b/api/WhatApi/Tables/Session.cs
@@ -0,0 +1,10 @@
+namespace WhatApi.Tables;
+
+public class Session
+{
+    public Guid Id { get; set; }
+    public string Token { get; set; }
+    public DateTime Created { get; set; }
+    public DateTime? Expires { get; set; }
+    public User User { get; set; }
+}
\ No newline at end of file
diff --git a/api/WhatApi/Tables/User.cs b/api/WhatApi/Tables/User.cs
new file mode 100644
index 0000000..0ebe9b6
--- /dev/null
+++ b/api/WhatApi/Tables/User.cs
@@ -0,0 +1,12 @@
+namespace WhatApi.Tables;
+
+public class User
+{
+    public Guid Id { get; set; }
+    public string Name { get; set; }
+    public string Email { get; set; }
+    public string Password { get; set; }
+    public DateTime Created { get; set; }
+    public DateTime? LastSeen { get; set; }
+    public IEnumerable Places { get; set; }
+}
\ No newline at end of file
diff --git a/api/WhatApi/WhatApi.csproj b/api/WhatApi/WhatApi.csproj
new file mode 100644
index 0000000..84dac71
--- /dev/null
+++ b/api/WhatApi/WhatApi.csproj
@@ -0,0 +1,34 @@
+
+
+    
+        net9.0
+        enable
+        enable
+        Linux
+      5506c159-f534-4090-b80b-2703e1eb7f6c
+  
+
+    
+      
+        .dockerignore
+      
+    
+
+    
+      
+      
+      
+      
+        all
+        runtime; build; native; contentfiles; analyzers; buildtransitive
+      
+      
+      
+      
+    
+
+    
+      
+    
+
+
diff --git a/api/WhatApi/appsettings.Development.json b/api/WhatApi/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/api/WhatApi/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  }
+}
diff --git a/api/WhatApi/appsettings.json b/api/WhatApi/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/api/WhatApi/appsettings.json
@@ -0,0 +1,9 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "AllowedHosts": "*"
+}
diff --git a/api/WhatApi/wwwroot/pin.png b/api/WhatApi/wwwroot/pin.png
new file mode 100644
index 0000000..3603031
Binary files /dev/null and b/api/WhatApi/wwwroot/pin.png differ
-- 
cgit v1.3