summaryrefslogtreecommitdiffstats
path: root/api/WhatApi
diff options
context:
space:
mode:
authorivar <i@oiee.no>2025-10-20 00:26:34 +0200
committerivar <i@oiee.no>2025-10-20 00:26:34 +0200
commita1f0518d0cd123a791adde64f4f11bd8e44276c7 (patch)
tree675a7dff8262eea877ec800ff1efe9b92f5d7e7d /api/WhatApi
downloadwhat-a1f0518d0cd123a791adde64f4f11bd8e44276c7.tar.xz
what-a1f0518d0cd123a791adde64f4f11bd8e44276c7.zip
Initial commit
Diffstat (limited to 'api/WhatApi')
-rw-r--r--api/WhatApi/Constants.cs6
-rw-r--r--api/WhatApi/Database.cs19
-rw-r--r--api/WhatApi/Dockerfile23
-rw-r--r--api/WhatApi/Endpoints/BaseEndpoint.cs12
-rw-r--r--api/WhatApi/Endpoints/DownloadContentEndpoint.cs19
-rw-r--r--api/WhatApi/Endpoints/GetPlacesEndpoint.cs57
-rw-r--r--api/WhatApi/Endpoints/UploadContentEndpoint.cs49
-rw-r--r--api/WhatApi/Middleware/AuthenticationMiddleware.cs8
-rw-r--r--api/WhatApi/Middleware/AuthorizationMiddleware.cs8
-rw-r--r--api/WhatApi/Middleware/UserLastSeenMiddleware.cs8
-rw-r--r--api/WhatApi/Migrations/20251013213511_Initial.Designer.cs93
-rw-r--r--api/WhatApi/Migrations/20251013213511_Initial.cs75
-rw-r--r--api/WhatApi/Migrations/DatabaseModelSnapshot.cs90
-rw-r--r--api/WhatApi/Pages/Map.cshtml92
-rw-r--r--api/WhatApi/Pages/Map.cshtml.cs10
-rw-r--r--api/WhatApi/Pages/Upload.cshtml82
-rw-r--r--api/WhatApi/Pages/Upload.cshtml.cs10
-rw-r--r--api/WhatApi/Program.cs45
-rw-r--r--api/WhatApi/Properties/launchSettings.json14
-rw-r--r--api/WhatApi/Seed.cs24
-rw-r--r--api/WhatApi/Tables/Content.cs12
-rw-r--r--api/WhatApi/Tables/Place.cs11
-rw-r--r--api/WhatApi/Tables/Session.cs10
-rw-r--r--api/WhatApi/Tables/User.cs12
-rw-r--r--api/WhatApi/WhatApi.csproj34
-rw-r--r--api/WhatApi/appsettings.Development.json8
-rw-r--r--api/WhatApi/appsettings.json9
-rw-r--r--api/WhatApi/wwwroot/pin.pngbin0 -> 986 bytes
28 files changed, 840 insertions, 0 deletions
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<Database> options) : DbContext(options)
+{
+ public DbSet<Tables.Content> Content { get; set; }
+ public DbSet<Tables.Place> Places { get; set; }
+ protected override void OnModelCreating(ModelBuilder b) {
+ b.HasPostgresExtension("postgis");
+ b.Entity<Tables.Place>(e => {
+ e.Property(x => x.Location).HasColumnType($"geometry(point,{Constants.Wgs84SpatialReferenceId})");
+ e.HasIndex(x => x.Location).HasMethod("gist");
+ e.ToTable("Place");
+ });
+ b.Entity<Tables.Content>();
+ 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<ActionResult> 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<ActionResult> 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<Place> 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<ActionResult> 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 @@
+// <auto-generated />
+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
+ {
+ /// <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.Content", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property<Guid>("BlobId")
+ .HasColumnType("uuid");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property<IPAddress>("Ip")
+ .IsRequired()
+ .HasColumnType("inet");
+
+ b.Property<string>("Mime")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Content");
+ });
+
+ modelBuilder.Entity("WhatApi.Tables.Place", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property<Guid>("ContentId")
+ .HasColumnType("uuid");
+
+ b.Property<Point>("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
+{
+ /// <inheritdoc />
+ public partial class Initial : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .Annotation("Npgsql:PostgresExtension:postgis", ",,");
+
+ migrationBuilder.CreateTable(
+ name: "Content",
+ columns: table => new
+ {
+ Id = table.Column<Guid>(type: "uuid", nullable: false),
+ Mime = table.Column<string>(type: "text", nullable: false),
+ Created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ BlobId = table.Column<Guid>(type: "uuid", nullable: false),
+ Ip = table.Column<IPAddress>(type: "inet", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Content", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Place",
+ columns: table => new
+ {
+ 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)
+ },
+ 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");
+ }
+
+ /// <inheritdoc />
+ 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 @@
+// <auto-generated />
+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<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property<Guid>("BlobId")
+ .HasColumnType("uuid");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property<IPAddress>("Ip")
+ .IsRequired()
+ .HasColumnType("inet");
+
+ b.Property<string>("Mime")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Content");
+ });
+
+ modelBuilder.Entity("WhatApi.Tables.Place", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property<Guid>("ContentId")
+ .HasColumnType("uuid");
+
+ b.Property<Point>("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;
+}
+
+<!DOCTYPE html>
+
+<html>
+<head>
+ <link href="https://unpkg.com/maplibre-gl@@^5.7.2/dist/maplibre-gl.css"/>
+ <style>
+ body {
+ margin: 0;
+ padding: 0;
+ }
+
+ html, body, #map {
+ height: 90%;
+ }
+ </style>
+ <title></title>
+</head>
+<body>
+<div id="map"></div>
+<script src="https://unpkg.com/maplibre-gl@@^5.7.2/dist/maplibre-gl.js"></script>
+<script>
+ const map = new maplibregl.Map({
+ container: "map",
+ style: "https://tiles.openfreemap.org/styles/bright",
+ center: [10.253494570441944, 59.937419399772125],
+ zoom: 7
+ });
+
+ const markers = new Map();
+
+ let t = null;
+
+ map.on("moveend", () => {
+ clearTimeout(t);
+ t = setTimeout(updateData, 150);
+ });
+
+ map.on("load", () => {
+ map.loadImage("/pin.png").then(image => map.addImage("custom-marker", image.data));
+ map.addSource("places", {
+ type: "geojson",
+ data: {type: "FeatureCollection", features: []},
+ // cluster: true,
+ // clusterRadius: 40,
+ // clusterMaxZoom: 14
+ });
+
+ map.addLayer({
+ id: "places-layer",
+ type: "symbol",
+ source: "places",
+ layout: {
+ "icon-image": "custom-marker"
+ }
+ });
+
+ updateData();
+ });
+
+ let aborter = new AbortController();
+
+ async function updateData() {
+ const b = map.getBounds();
+ const south = b.getSouth(), west = b.getWest(), north = b.getNorth(), east = b.getEast();
+
+ if (aborter) {
+ aborter.abort();
+ }
+
+ aborter = new AbortController();
+
+ const res = await fetch(`/places?w=${west}&s=${south}&e=${east}&n=${north}`, {
+ signal: aborter.signal
+ });
+
+ if (!res.ok) {
+ return;
+ }
+
+ const data = await res.json().finally(() => aborter = null);
+ map.getSource("places").setData(data);
+ }
+</script>
+</body>
+</html> \ 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;
+}
+
+<!DOCTYPE html>
+
+<html>
+<head>
+ <link href="https://unpkg.com/maplibre-gl@5.9.0/dist/maplibre-gl.css"
+ rel="stylesheet"/>
+ <style>
+ body {
+ margin: 0;
+ padding: 0;
+ }
+
+ html, body, #map {
+ height: 100%;
+ }
+
+ .coordinates {
+ background: rgba(0, 0, 0, 0.5);
+ color: #fff;
+ position: absolute;
+ bottom: 40px;
+ left: 10px;
+ padding: 5px 10px;
+ margin: 0;
+ font-size: 11px;
+ line-height: 18px;
+ border-radius: 3px;
+ display: none;
+ }
+ </style>
+ <title></title>
+</head>
+<body>
+<div style="display: flex; flex-direction: row; align-items: center; z-index: 10; position: absolute; background: white">
+ <form action="/upload"
+ enctype="multipart/form-data"
+ method="post">
+ <input type="hidden"
+ name="LatLong">
+ <input type="file"
+ required="required"
+ accept="image/png, image/jpeg"
+ name="File">
+ <input type="submit">
+ </form>
+</div>
+<div id="map"></div>
+<pre id="coordinates"
+ class="coordinates"></pre>
+<script src='https://unpkg.com/maplibre-gl@5.9.0/dist/maplibre-gl.js'></script>
+<script>
+ const latlongInput = document.querySelector("[name=LatLong]");
+ const coordinates = document.getElementById("coordinates");
+ const map = new maplibregl.Map({
+ container: "map",
+ style: "https://tiles.openfreemap.org/styles/bright",
+ center: [10.253494, 59.937419],
+ zoom: 7
+ });
+
+ const center = map.getCenter();
+ const marker = new maplibregl.Marker({draggable: true}).setLngLat([center.lng, center.lat]).addTo(map);
+
+ function onDragEnd() {
+ const {lat: lat, lng: lng} = marker.getLngLat();
+ coordinates.style.display = "block";
+ latlongInput.value = lat + "," + lng;
+ coordinates.innerHTML =
+ `Longitude: ${lng}<br />Latitude: ${lat}`;
+ }
+
+ marker.on("dragend", onDragEnd);
+</script>
+</body>
+</html> \ 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<Database>(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<Database>();
+ 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<Place>();
+ 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<Place> 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 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
+ <UserSecretsId>5506c159-f534-4090-b80b-2703e1eb7f6c</UserSecretsId>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Content Include="..\.dockerignore">
+ <Link>.dockerignore</Link>
+ </Content>
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Bogus" Version="35.6.3" />
+ <PackageReference Include="Bogus.Locations" Version="35.6.3" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
+ <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="9.0.4" />
+ <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="files\" />
+ </ItemGroup>
+
+</Project>
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
--- /dev/null
+++ b/api/WhatApi/wwwroot/pin.png
Binary files differ