commit d7479db90241fe2ae485fc449b298c5450df6e2d Author: Finn Skov Henriksen Date: Mon Aug 31 12:29:23 2026 +0200 first commit diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d52480b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,39 @@ +name: Build + +on: + push: + branches: + - master + pull_request: + branches: + - master + workflow_dispatch: + +jobs: + build: + name: Restore, build and publish + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore HuntingAppApi.csproj + + - name: Build + run: dotnet build HuntingAppApi.csproj --configuration Release --no-restore + + - name: Publish + run: dotnet publish HuntingAppApi.csproj --configuration Release --no-build --output ./artifacts/huntapi + + - name: Upload published API + uses: actions/upload-artifact@v4 + with: + name: huntapi + path: ./artifacts/huntapi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10f4d23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +bin/ +obj/ +.vs/ +.vscode/ +*.user +*.suo +*.swp + +# Local environment files +.env +appsettings.Development.json + +# Build artifacts +artifacts/ +TestResults/ diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs new file mode 100644 index 0000000..53b4335 --- /dev/null +++ b/Controllers/AuthController.cs @@ -0,0 +1,90 @@ +using HuntingAppApi.Models; +using HuntingAppApi.Services; +using Microsoft.AspNetCore.Mvc; +using Npgsql; +using NpgsqlTypes; + +namespace HuntingAppApi.Controllers; + +[ApiController] +[Route("api/auth")] +public sealed class AuthController(NpgsqlDataSource dataSource, ExternalTokenValidator validator, JwtTokenService jwt) : ControllerBase +{ + [HttpPost("external-login")] + public async Task> ExternalLogin([FromBody] ExternalLoginRequest request, CancellationToken ct) + { + var external = await validator.ValidateAsync(request.Provider, request.IdToken, ct); + + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var tx = await conn.BeginTransactionAsync(ct); + + Guid? userId = null; + string displayName = external.DisplayName; + string? email = external.Email; + + await using (var find = new NpgsqlCommand(""" + SELECT u.id, u.display_name, u.email + FROM user_identity i + JOIN app_user u ON u.id = i.user_id + WHERE i.provider = @provider AND i.provider_subject = @subject + """, conn, tx)) + { + find.Parameters.AddWithValue("provider", external.Provider); + find.Parameters.AddWithValue("subject", external.Subject); + await using var r = await find.ExecuteReaderAsync(ct); + if (await r.ReadAsync(ct)) + { + userId = r.GetGuid(0); + displayName = r.GetString(1); + email = r.IsDBNull(2) ? null : r.GetString(2); + } + } + + if (userId is null) + { + userId = Guid.NewGuid(); + await using var createUser = new NpgsqlCommand(""" + INSERT INTO app_user (id, display_name, email, avatar_url) + VALUES (@id, @display_name, @email, @avatar_url) + """, conn, tx); + createUser.Parameters.AddWithValue("id", userId.Value); + createUser.Parameters.AddWithValue("display_name", displayName); + createUser.Parameters.AddWithValue("email", (object?)email ?? DBNull.Value); + createUser.Parameters.AddWithValue("avatar_url", (object?)external.AvatarUrl ?? DBNull.Value); + await createUser.ExecuteNonQueryAsync(ct); + + await using var createIdentity = new NpgsqlCommand(""" + INSERT INTO user_identity (user_id, provider, provider_subject, email, email_verified, display_name, avatar_url) + VALUES (@user_id, @provider, @subject, @email, @email_verified, @display_name, @avatar_url) + """, conn, tx); + createIdentity.Parameters.AddWithValue("user_id", userId.Value); + createIdentity.Parameters.AddWithValue("provider", external.Provider); + createIdentity.Parameters.AddWithValue("subject", external.Subject); + createIdentity.Parameters.AddWithValue("email", (object?)email ?? DBNull.Value); + createIdentity.Parameters.AddWithValue("email_verified", external.EmailVerified); + createIdentity.Parameters.AddWithValue("display_name", displayName); + createIdentity.Parameters.AddWithValue("avatar_url", (object?)external.AvatarUrl ?? DBNull.Value); + await createIdentity.ExecuteNonQueryAsync(ct); + } + else + { + await using var updateLogin = new NpgsqlCommand(""" + UPDATE user_identity SET last_login_at = now() + WHERE provider = @provider AND provider_subject = @subject + """, conn, tx); + updateLogin.Parameters.AddWithValue("provider", external.Provider); + updateLogin.Parameters.AddWithValue("subject", external.Subject); + await updateLogin.ExecuteNonQueryAsync(ct); + } + + await tx.CommitAsync(ct); + + return Ok(new LoginResponse + { + UserId = userId.Value, + DisplayName = displayName, + Email = email, + Token = jwt.CreateToken(userId.Value, displayName, email) + }); + } +} diff --git a/Controllers/HuntAreasController.cs b/Controllers/HuntAreasController.cs new file mode 100644 index 0000000..181ff85 --- /dev/null +++ b/Controllers/HuntAreasController.cs @@ -0,0 +1,56 @@ +using HuntingAppApi.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Npgsql; + +namespace HuntingAppApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/hunt-areas")] +public sealed class HuntAreasController(NpgsqlDataSource dataSource) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll(CancellationToken ct) + { + var result = new List(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + SELECT id, name, description, CASE WHEN geom IS NULL THEN NULL ELSE ST_AsGeoJSON(geom) END + FROM hunt_area + ORDER BY name + """, conn); + await using var r = await cmd.ExecuteReaderAsync(ct); + while (await r.ReadAsync(ct)) + { + result.Add(new HuntAreaDto + { + Id = r.GetGuid(0), + Name = r.GetString(1), + Description = r.IsDBNull(2) ? null : r.GetString(2), + GeoJson = r.IsDBNull(3) ? null : r.GetString(3) + }); + } + return Ok(result); + } + + [HttpPost] + public async Task> Create([FromBody] CreateHuntAreaRequest request, CancellationToken ct) + { + var id = Guid.NewGuid(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + INSERT INTO hunt_area (id, name, description, geom) + VALUES (@id, @name, @description, + CASE WHEN @geojson::text IS NULL OR @geojson::text = '' THEN NULL + ELSE ST_SetSRID(ST_GeomFromGeoJSON(@geojson), 4326)::geometry(MultiPolygon, 4326) + END) + """, conn); + cmd.Parameters.AddWithValue("id", id); + cmd.Parameters.AddWithValue("name", request.Name); + cmd.Parameters.AddWithValue("description", (object?)request.Description ?? DBNull.Value); + cmd.Parameters.AddWithValue("geojson", (object?)request.GeoJson ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct); + return Ok(new ApiId(id)); + } +} diff --git a/Controllers/HuntsController.cs b/Controllers/HuntsController.cs new file mode 100644 index 0000000..34f131f --- /dev/null +++ b/Controllers/HuntsController.cs @@ -0,0 +1,139 @@ +using HuntingAppApi.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Npgsql; + +namespace HuntingAppApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/hunts")] +public sealed class HuntsController(NpgsqlDataSource dataSource) : ControllerBase +{ + [HttpGet("area/{huntAreaId:guid}")] + public async Task>> GetByArea(Guid huntAreaId, CancellationToken ct) + { + var result = new List(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + SELECT id, hunt_area_id, title, description, starts_at, ends_at, status + FROM hunt + WHERE hunt_area_id = @hunt_area_id + ORDER BY starts_at DESC NULLS LAST, created_at DESC + """, conn); + cmd.Parameters.AddWithValue("hunt_area_id", huntAreaId); + await using var r = await cmd.ExecuteReaderAsync(ct); + while (await r.ReadAsync(ct)) + { + result.Add(new HuntDto + { + Id = r.GetGuid(0), HuntAreaId = r.GetGuid(1), Title = r.GetString(2), + Description = r.IsDBNull(3) ? null : r.GetString(3), + StartsAt = r.IsDBNull(4) ? null : r.GetFieldValue(4), + EndsAt = r.IsDBNull(5) ? null : r.GetFieldValue(5), + Status = r.GetString(6) + }); + } + return Ok(result); + } + + [HttpPost] + public async Task> Create([FromBody] CreateHuntRequest request, CancellationToken ct) + { + var id = Guid.NewGuid(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + INSERT INTO hunt (id, hunt_area_id, title, description, starts_at, ends_at) + VALUES (@id, @hunt_area_id, @title, @description, @starts_at, @ends_at) + """, conn); + cmd.Parameters.AddWithValue("id", id); + cmd.Parameters.AddWithValue("hunt_area_id", request.HuntAreaId); + cmd.Parameters.AddWithValue("title", request.Title); + cmd.Parameters.AddWithValue("description", (object?)request.Description ?? DBNull.Value); + cmd.Parameters.AddWithValue("starts_at", (object?)request.StartsAt ?? DBNull.Value); + cmd.Parameters.AddWithValue("ends_at", (object?)request.EndsAt ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct); + return Ok(new ApiId(id)); + } + + [HttpPatch("{huntId:guid}/status/{status}")] + public async Task SetStatus(Guid huntId, string status, CancellationToken ct) + { + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand("UPDATE hunt SET status = @status WHERE id = @id", conn); + cmd.Parameters.AddWithValue("id", huntId); + cmd.Parameters.AddWithValue("status", status); + return await cmd.ExecuteNonQueryAsync(ct) == 1 ? NoContent() : NotFound(); + } + + [HttpPost("{huntId:guid}/participants")] + public async Task> AddParticipant(Guid huntId, [FromBody] JoinHuntRequest request, CancellationToken ct) + { + var id = Guid.NewGuid(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + INSERT INTO hunt_participant (id, hunt_id, user_id, role, status) + VALUES (@id, @hunt_id, @user_id, @role, 'accepted') + ON CONFLICT (hunt_id, user_id) DO UPDATE SET role = EXCLUDED.role + RETURNING id + """, conn); + cmd.Parameters.AddWithValue("id", id); + cmd.Parameters.AddWithValue("hunt_id", huntId); + cmd.Parameters.AddWithValue("user_id", request.UserId); + cmd.Parameters.AddWithValue("role", request.Role); + var returned = (Guid)(await cmd.ExecuteScalarAsync(ct))!; + return Ok(new ApiId(returned)); + } + + [HttpPost("{huntId:guid}/assignments")] + public async Task> Assign(Guid huntId, [FromBody] AssignParticipantRequest request, CancellationToken ct) + { + var id = Guid.NewGuid(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + INSERT INTO hunt_assignment (id, hunt_id, participant_id, map_point_id, note) + VALUES (@id, @hunt_id, @participant_id, @map_point_id, @note) + ON CONFLICT (hunt_id, participant_id) + DO UPDATE SET map_point_id = EXCLUDED.map_point_id, note = EXCLUDED.note, assigned_at = now() + RETURNING id + """, conn); + cmd.Parameters.AddWithValue("id", id); + cmd.Parameters.AddWithValue("hunt_id", huntId); + cmd.Parameters.AddWithValue("participant_id", request.ParticipantId); + cmd.Parameters.AddWithValue("map_point_id", request.MapPointId); + cmd.Parameters.AddWithValue("note", (object?)request.Note ?? DBNull.Value); + var returned = (Guid)(await cmd.ExecuteScalarAsync(ct))!; + return Ok(new ApiId(returned)); + } + + [HttpGet("{huntId:guid}/participant-locations")] + public async Task>> GetParticipantLocations(Guid huntId, CancellationToken ct) + { + var result = new List(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + SELECT hp.id, u.id, u.display_name, u.color, + ST_Y(l.geom), ST_X(l.geom), l.accuracy_m, l.battery_percent, l.recorded_at + FROM hunt_participant hp + JOIN app_user u ON u.id = hp.user_id + LEFT JOIN user_current_location l ON l.participant_id = hp.id + WHERE hp.hunt_id = @hunt_id + ORDER BY u.display_name + """, conn); + cmd.Parameters.AddWithValue("hunt_id", huntId); + await using var r = await cmd.ExecuteReaderAsync(ct); + while (await r.ReadAsync(ct)) + { + result.Add(new ParticipantLocationDto + { + ParticipantId = r.GetGuid(0), UserId = r.GetGuid(1), DisplayName = r.GetString(2), + Color = r.IsDBNull(3) ? null : r.GetString(3), + Latitude = r.IsDBNull(4) ? null : r.GetDouble(4), Longitude = r.IsDBNull(5) ? null : r.GetDouble(5), + AccuracyM = r.IsDBNull(6) ? null : r.GetDecimal(6), + BatteryPercent = r.IsDBNull(7) ? null : r.GetInt32(7), + RecordedAt = r.IsDBNull(8) ? null : r.GetFieldValue(8) + }); + } + return Ok(result); + } +} diff --git a/Controllers/LocationsController.cs b/Controllers/LocationsController.cs new file mode 100644 index 0000000..e9e8a66 --- /dev/null +++ b/Controllers/LocationsController.cs @@ -0,0 +1,66 @@ +using HuntingAppApi.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Npgsql; + +namespace HuntingAppApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/locations")] +public sealed class LocationsController(NpgsqlDataSource dataSource) : ControllerBase +{ + [HttpPost] + public async Task Upsert([FromBody] UpsertLocationRequest request, CancellationToken ct) + { + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var tx = await conn.BeginTransactionAsync(ct); + + await using (var current = new NpgsqlCommand(""" + INSERT INTO user_current_location + (participant_id, geom, accuracy_m, speed_mps, heading_degrees, battery_percent, recorded_at) + VALUES + (@participant_id, ST_SetSRID(ST_MakePoint(@longitude, @latitude), 4326), @accuracy_m, + @speed_mps, @heading_degrees, @battery_percent, @recorded_at) + ON CONFLICT (participant_id) DO UPDATE SET + geom = EXCLUDED.geom, + accuracy_m = EXCLUDED.accuracy_m, + speed_mps = EXCLUDED.speed_mps, + heading_degrees = EXCLUDED.heading_degrees, + battery_percent = EXCLUDED.battery_percent, + recorded_at = EXCLUDED.recorded_at, + received_at = now() + """, conn, tx)) + { + AddLocationParams(current, request); + await current.ExecuteNonQueryAsync(ct); + } + + await using (var history = new NpgsqlCommand(""" + INSERT INTO user_location_history + (participant_id, geom, accuracy_m, speed_mps, heading_degrees, battery_percent, recorded_at) + VALUES + (@participant_id, ST_SetSRID(ST_MakePoint(@longitude, @latitude), 4326), @accuracy_m, + @speed_mps, @heading_degrees, @battery_percent, @recorded_at) + """, conn, tx)) + { + AddLocationParams(history, request); + await history.ExecuteNonQueryAsync(ct); + } + + await tx.CommitAsync(ct); + return NoContent(); + } + + private static void AddLocationParams(NpgsqlCommand cmd, UpsertLocationRequest request) + { + cmd.Parameters.AddWithValue("participant_id", request.ParticipantId); + cmd.Parameters.AddWithValue("longitude", request.Longitude); + cmd.Parameters.AddWithValue("latitude", request.Latitude); + cmd.Parameters.AddWithValue("accuracy_m", (object?)request.AccuracyM ?? DBNull.Value); + cmd.Parameters.AddWithValue("speed_mps", (object?)request.SpeedMps ?? DBNull.Value); + cmd.Parameters.AddWithValue("heading_degrees", (object?)request.HeadingDegrees ?? DBNull.Value); + cmd.Parameters.AddWithValue("battery_percent", (object?)request.BatteryPercent ?? DBNull.Value); + cmd.Parameters.AddWithValue("recorded_at", request.RecordedAt); + } +} diff --git a/Controllers/MapPointsController.cs b/Controllers/MapPointsController.cs new file mode 100644 index 0000000..11d983f --- /dev/null +++ b/Controllers/MapPointsController.cs @@ -0,0 +1,71 @@ +using HuntingAppApi.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Npgsql; + +namespace HuntingAppApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/map-points")] +public sealed class MapPointsController(NpgsqlDataSource dataSource) : ControllerBase +{ + [HttpGet("area/{huntAreaId:guid}")] + public async Task>> GetByArea(Guid huntAreaId, CancellationToken ct) + { + var result = new List(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + SELECT mp.id, mp.hunt_area_id, t.name, t.icon, t.color, mp.name, mp.description, + ST_Y(mp.geom), ST_X(mp.geom), mp.image_url + FROM map_point mp + JOIN map_point_type t ON t.id = mp.type_id + WHERE mp.hunt_area_id = @hunt_area_id AND mp.is_active = true + ORDER BY t.name, mp.name + """, conn); + cmd.Parameters.AddWithValue("hunt_area_id", huntAreaId); + await using var r = await cmd.ExecuteReaderAsync(ct); + while (await r.ReadAsync(ct)) + { + result.Add(new MapPointDto + { + Id = r.GetGuid(0), + HuntAreaId = r.GetGuid(1), + Type = r.GetString(2), + Icon = r.IsDBNull(3) ? null : r.GetString(3), + Color = r.IsDBNull(4) ? null : r.GetString(4), + Name = r.GetString(5), + Description = r.IsDBNull(6) ? null : r.GetString(6), + Latitude = r.GetDouble(7), + Longitude = r.GetDouble(8), + ImageUrl = r.IsDBNull(9) ? null : r.GetString(9) + }); + } + return Ok(result); + } + + [HttpPost] + public async Task> Create([FromBody] CreateMapPointRequest request, CancellationToken ct) + { + var id = Guid.NewGuid(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + INSERT INTO map_point (id, hunt_area_id, type_id, name, description, geom, image_url) + SELECT @id, @hunt_area_id, t.id, @name, @description, + ST_SetSRID(ST_MakePoint(@longitude, @latitude), 4326), @image_url + FROM map_point_type t + WHERE t.name = @type + """, conn); + cmd.Parameters.AddWithValue("id", id); + cmd.Parameters.AddWithValue("hunt_area_id", request.HuntAreaId); + cmd.Parameters.AddWithValue("type", request.Type); + cmd.Parameters.AddWithValue("name", request.Name); + cmd.Parameters.AddWithValue("description", (object?)request.Description ?? DBNull.Value); + cmd.Parameters.AddWithValue("longitude", request.Longitude); + cmd.Parameters.AddWithValue("latitude", request.Latitude); + cmd.Parameters.AddWithValue("image_url", (object?)request.ImageUrl ?? DBNull.Value); + var rows = await cmd.ExecuteNonQueryAsync(ct); + if (rows == 0) return BadRequest($"Unknown map point type: {request.Type}"); + return Ok(new ApiId(id)); + } +} diff --git a/Controllers/MessagesController.cs b/Controllers/MessagesController.cs new file mode 100644 index 0000000..5df92ca --- /dev/null +++ b/Controllers/MessagesController.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Npgsql; + +namespace HuntingAppApi.Controllers; + +public sealed class CreateMessageRequest +{ + public Guid? SenderParticipantId { get; set; } + public string Message { get; set; } = ""; +} + +[ApiController] +[Authorize] +[Route("api/hunts/{huntId:guid}/messages")] +public sealed class MessagesController(NpgsqlDataSource dataSource) : ControllerBase +{ + [HttpPost] + public async Task Create(Guid huntId, [FromBody] CreateMessageRequest request, CancellationToken ct) + { + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + INSERT INTO hunt_message (hunt_id, sender_participant_id, message) + VALUES (@hunt_id, @sender_participant_id, @message) + """, conn); + cmd.Parameters.AddWithValue("hunt_id", huntId); + cmd.Parameters.AddWithValue("sender_participant_id", (object?)request.SenderParticipantId ?? DBNull.Value); + cmd.Parameters.AddWithValue("message", request.Message); + await cmd.ExecuteNonQueryAsync(ct); + return NoContent(); + } + + [HttpGet] + public async Task Get(Guid huntId, CancellationToken ct) + { + var list = new List(); + await using var conn = await dataSource.OpenConnectionAsync(ct); + await using var cmd = new NpgsqlCommand(""" + SELECT m.id, m.sender_participant_id, u.display_name, m.message, m.created_at + FROM hunt_message m + LEFT JOIN hunt_participant hp ON hp.id = m.sender_participant_id + LEFT JOIN app_user u ON u.id = hp.user_id + WHERE m.hunt_id = @hunt_id + ORDER BY m.created_at DESC + LIMIT 100 + """, conn); + cmd.Parameters.AddWithValue("hunt_id", huntId); + await using var r = await cmd.ExecuteReaderAsync(ct); + while (await r.ReadAsync(ct)) + { + list.Add(new + { + id = r.GetGuid(0), + senderParticipantId = r.IsDBNull(1) ? (Guid?)null : r.GetGuid(1), + senderName = r.IsDBNull(2) ? null : r.GetString(2), + message = r.GetString(3), + createdAt = r.GetFieldValue(4) + }); + } + return Ok(list); + } +} diff --git a/HuntingAppApi.csproj b/HuntingAppApi.csproj new file mode 100644 index 0000000..b2b375d --- /dev/null +++ b/HuntingAppApi.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/Models/Dto.cs b/Models/Dto.cs new file mode 100644 index 0000000..8522263 --- /dev/null +++ b/Models/Dto.cs @@ -0,0 +1,126 @@ +namespace HuntingAppApi.Models; + +public sealed record PointDto(double Latitude, double Longitude); +public sealed record ApiId(Guid Id); + +public sealed class HuntAreaDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public string? Description { get; set; } + public string? GeoJson { get; set; } +} + +public sealed class CreateHuntAreaRequest +{ + public string Name { get; set; } = ""; + public string? Description { get; set; } + public string? GeoJson { get; set; } +} + +public sealed class CreateMapPointRequest +{ + public Guid HuntAreaId { get; set; } + public string Type { get; set; } = "HuntingTower"; + public string Name { get; set; } = ""; + public string? Description { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + public string? ImageUrl { get; set; } +} + +public sealed class MapPointDto +{ + public Guid Id { get; set; } + public Guid HuntAreaId { get; set; } + public string Type { get; set; } = ""; + public string? Icon { get; set; } + public string? Color { get; set; } + public string Name { get; set; } = ""; + public string? Description { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + public string? ImageUrl { get; set; } +} + +public sealed class CreateHuntRequest +{ + public Guid HuntAreaId { get; set; } + public string Title { get; set; } = ""; + public string? Description { get; set; } + public DateTimeOffset? StartsAt { get; set; } + public DateTimeOffset? EndsAt { get; set; } +} + +public sealed class HuntDto +{ + public Guid Id { get; set; } + public Guid HuntAreaId { get; set; } + public string Title { get; set; } = ""; + public string? Description { get; set; } + public DateTimeOffset? StartsAt { get; set; } + public DateTimeOffset? EndsAt { get; set; } + public string Status { get; set; } = ""; +} + +public sealed class JoinHuntRequest +{ + public Guid UserId { get; set; } + public string Role { get; set; } = "hunter"; +} + +public sealed class AssignParticipantRequest +{ + public Guid ParticipantId { get; set; } + public Guid MapPointId { get; set; } + public string? Note { get; set; } +} + +public sealed class ParticipantLocationDto +{ + public Guid ParticipantId { get; set; } + public Guid UserId { get; set; } + public string DisplayName { get; set; } = ""; + public string? Color { get; set; } + public double? Latitude { get; set; } + public double? Longitude { get; set; } + public decimal? AccuracyM { get; set; } + public int? BatteryPercent { get; set; } + public DateTimeOffset? RecordedAt { get; set; } +} + +public sealed class UpsertLocationRequest +{ + public Guid ParticipantId { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + public decimal? AccuracyM { get; set; } + public decimal? SpeedMps { get; set; } + public decimal? HeadingDegrees { get; set; } + public int? BatteryPercent { get; set; } + public DateTimeOffset RecordedAt { get; set; } +} + +public sealed class ExternalLoginRequest +{ + public string Provider { get; set; } = ""; // google or microsoft + public string IdToken { get; set; } = ""; +} + +public sealed class LoginResponse +{ + public Guid UserId { get; set; } + public string DisplayName { get; set; } = ""; + public string? Email { get; set; } + public string Token { get; set; } = ""; +} + +public sealed class ExternalUserInfo +{ + public string Provider { get; set; } = ""; + public string Subject { get; set; } = ""; + public string? Email { get; set; } + public bool EmailVerified { get; set; } + public string DisplayName { get; set; } = ""; + public string? AvatarUrl { get; set; } +} diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..764c991 --- /dev/null +++ b/Program.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json.Serialization; +using Npgsql; +using System.Text; +using HuntingAppApi.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers() + .AddNewtonsoftJson(o => + { + o.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + o.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore; + }); + +builder.Services.AddNpgsqlDataSource(builder.Configuration.GetConnectionString("Default")!); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); + +var jwtSecret = builder.Configuration["Authentication:JwtSecret"]!; +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Authentication:Issuer"], + ValidAudience = builder.Configuration["Authentication:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)) + }; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); diff --git a/README.md b/README.md new file mode 100644 index 0000000..539a16c --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# HuntingAppApi + +ASP.NET Core / .NET 10 Web API sample using Controllers, Newtonsoft.Json, Npgsql and PostGIS. + +## Setup + +```bash +createdb hunting_app +psql -d hunting_app -f Sql/001_schema.sql +dotnet restore +dotnet run +``` + +Update `appsettings.json` with your PostgreSQL connection string and JWT secret. + +## Main endpoints + +- `POST /api/auth/external-login` +- `GET /api/hunt-areas` +- `POST /api/hunt-areas` +- `GET /api/map-points/area/{huntAreaId}` +- `POST /api/map-points` +- `GET /api/hunts/area/{huntAreaId}` +- `POST /api/hunts` +- `POST /api/hunts/{huntId}/participants` +- `POST /api/hunts/{huntId}/assignments` +- `GET /api/hunts/{huntId}/participant-locations` +- `POST /api/locations` +- `GET /api/hunts/{huntId}/messages` +- `POST /api/hunts/{huntId}/messages` + +## Important production notes + +The external token validator is intentionally simple. Before production, validate Google/Microsoft ID tokens against issuer, audience/client ID, expiry and JWKS signature validation. diff --git a/Services/ExternalTokenValidator.cs b/Services/ExternalTokenValidator.cs new file mode 100644 index 0000000..aeb8a86 --- /dev/null +++ b/Services/ExternalTokenValidator.cs @@ -0,0 +1,36 @@ +using HuntingAppApi.Models; +using Newtonsoft.Json.Linq; + +namespace HuntingAppApi.Services; + +public sealed class ExternalTokenValidator(HttpClient httpClient) +{ + public async Task ValidateAsync(string provider, string idToken, CancellationToken ct) + { + provider = provider.ToLowerInvariant(); + + // Production note: This is intentionally simple. For stricter production validation, + // validate issuer, audience/client_id and signature using OIDC metadata/JWKS. + var url = provider switch + { + "google" => $"https://oauth2.googleapis.com/tokeninfo?id_token={Uri.EscapeDataString(idToken)}", + "microsoft" => $"https://login.microsoftonline.com/common/oauth2/v2.0/tokeninfo?id_token={Uri.EscapeDataString(idToken)}", + _ => throw new ArgumentException("Unsupported provider") + }; + + var json = await httpClient.GetStringAsync(url, ct); + var obj = JObject.Parse(json); + var sub = obj.Value("sub") ?? throw new InvalidOperationException("Token has no sub claim"); + var name = obj.Value("name") ?? obj.Value("preferred_username") ?? obj.Value("email") ?? "User"; + + return new ExternalUserInfo + { + Provider = provider, + Subject = sub, + Email = obj.Value("email") ?? obj.Value("preferred_username"), + EmailVerified = obj.Value("email_verified") == "true" || obj.Value("email_verified") == true, + DisplayName = name, + AvatarUrl = obj.Value("picture") + }; + } +} diff --git a/Services/JwtTokenService.cs b/Services/JwtTokenService.cs new file mode 100644 index 0000000..92f46d6 --- /dev/null +++ b/Services/JwtTokenService.cs @@ -0,0 +1,31 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace HuntingAppApi.Services; + +public sealed class JwtTokenService(IConfiguration configuration) +{ + public string CreateToken(Guid userId, string displayName, string? email) + { + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new("display_name", displayName) + }; + if (!string.IsNullOrWhiteSpace(email)) claims.Add(new Claim(JwtRegisteredClaimNames.Email, email)); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Authentication:JwtSecret"]!)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: configuration["Authentication:Issuer"], + audience: configuration["Authentication:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddDays(14), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/Sql/001_schema.sql b/Sql/001_schema.sql new file mode 100644 index 0000000..9fb5ce8 --- /dev/null +++ b/Sql/001_schema.sql @@ -0,0 +1,154 @@ +CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS hunt_area ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + description text, + geom geometry(MultiPolygon, 4326), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS app_user ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + display_name text NOT NULL, + email text, + avatar_url text, + color text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz +); + +CREATE TABLE IF NOT EXISTS user_identity ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + provider text NOT NULL, + provider_subject text NOT NULL, + email text, + email_verified boolean, + display_name text, + avatar_url text, + first_login_at timestamptz NOT NULL DEFAULT now(), + last_login_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (provider, provider_subject), + CHECK (provider IN ('google', 'microsoft', 'apple')) +); + +CREATE TABLE IF NOT EXISTS hunt ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + hunt_area_id uuid NOT NULL REFERENCES hunt_area(id), + title text NOT NULL, + description text, + starts_at timestamptz, + ends_at timestamptz, + status text NOT NULL DEFAULT 'planned', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS map_point_type ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL UNIQUE, + icon text, + color text +); + +INSERT INTO map_point_type (name, icon, color) VALUES +('HuntingTower', 'tower', '#F59E0B'), +('FeedingPlace', 'corn', '#84CC16'), +('Parking', 'parking', '#3B82F6'), +('MeetingPoint', 'flag', '#22C55E'), +('Observation', 'eye', '#A855F7') +ON CONFLICT (name) DO NOTHING; + +CREATE TABLE IF NOT EXISTS map_point ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + hunt_area_id uuid NOT NULL REFERENCES hunt_area(id), + type_id uuid NOT NULL REFERENCES map_point_type(id), + name text NOT NULL, + description text, + geom geometry(Point, 4326) NOT NULL, + image_url text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS map_zone ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + hunt_area_id uuid NOT NULL REFERENCES hunt_area(id), + name text NOT NULL, + zone_type text NOT NULL, + description text, + geom geometry(MultiPolygon, 4326) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS hunt_area_member ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + hunt_area_id uuid NOT NULL REFERENCES hunt_area(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'member', + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (hunt_area_id, user_id) +); + +CREATE TABLE IF NOT EXISTS hunt_participant ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + hunt_id uuid NOT NULL REFERENCES hunt(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES app_user(id), + role text NOT NULL DEFAULT 'hunter', + status text NOT NULL DEFAULT 'invited', + share_location boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (hunt_id, user_id) +); + +CREATE TABLE IF NOT EXISTS hunt_assignment ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + hunt_id uuid NOT NULL REFERENCES hunt(id) ON DELETE CASCADE, + participant_id uuid NOT NULL REFERENCES hunt_participant(id) ON DELETE CASCADE, + map_point_id uuid NOT NULL REFERENCES map_point(id), + note text, + assigned_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (hunt_id, participant_id) +); + +CREATE TABLE IF NOT EXISTS user_current_location ( + participant_id uuid PRIMARY KEY REFERENCES hunt_participant(id) ON DELETE CASCADE, + geom geometry(Point, 4326) NOT NULL, + accuracy_m numeric(8,2), + speed_mps numeric(8,2), + heading_degrees numeric(6,2), + battery_percent int, + recorded_at timestamptz NOT NULL, + received_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_location_history ( + id bigserial PRIMARY KEY, + participant_id uuid NOT NULL REFERENCES hunt_participant(id) ON DELETE CASCADE, + geom geometry(Point, 4326) NOT NULL, + accuracy_m numeric(8,2), + speed_mps numeric(8,2), + heading_degrees numeric(6,2), + battery_percent int, + recorded_at timestamptz NOT NULL, + received_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS hunt_message ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + hunt_id uuid NOT NULL REFERENCES hunt(id) ON DELETE CASCADE, + sender_participant_id uuid REFERENCES hunt_participant(id), + message text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_hunt_area_geom ON hunt_area USING gist (geom); +CREATE INDEX IF NOT EXISTS idx_map_point_geom ON map_point USING gist (geom); +CREATE INDEX IF NOT EXISTS idx_map_zone_geom ON map_zone USING gist (geom); +CREATE INDEX IF NOT EXISTS idx_user_current_location_geom ON user_current_location USING gist (geom); +CREATE INDEX IF NOT EXISTS idx_user_location_history_geom ON user_location_history USING gist (geom); +CREATE INDEX IF NOT EXISTS idx_user_location_history_participant_time ON user_location_history (participant_id, recorded_at DESC); +CREATE INDEX IF NOT EXISTS idx_hunt_participant_hunt ON hunt_participant (hunt_id); +CREATE INDEX IF NOT EXISTS idx_hunt_assignment_hunt ON hunt_assignment (hunt_id); diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..fdbdb17 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,10 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=hunting_app;Username=postgres;Password=postgres;Pooling=true;Maximum Pool Size=50;" + }, + "Authentication": { + "JwtSecret": "CHANGE_THIS_TO_A_LONG_RANDOM_SECRET_MIN_32_CHARS", + "Issuer": "hunting-app-api", + "Audience": "hunting-app" + } +} diff --git a/docs/gitea-actions-setup.md b/docs/gitea-actions-setup.md new file mode 100644 index 0000000..3f98c56 --- /dev/null +++ b/docs/gitea-actions-setup.md @@ -0,0 +1,56 @@ +# Gitea Actions setup + +This project builds automatically with Gitea Actions when changes are pushed to +the `master` branch. The workflow is defined in `.gitea/workflows/ci.yml`. + +## One-time Gitea server setup + +1. Make sure the Gitea server is version 1.19.0 or newer. +2. If the server is older than 1.21.0, enable Actions in `app.ini`: + + ```ini + [actions] + ENABLED = true + ``` + +3. Restart Gitea after changing `app.ini`. +4. In the repository settings, enable repository Actions. +5. Register at least one Gitea runner for the instance, organization, or repository. + +## Runner setup + +Install Docker on the runner host, then register a runner with a token from: + +- Instance runner: `http://git.fshcomputer.dk:3000/-/admin/actions/runners` +- Repository runner: `http://git.fshcomputer.dk:3000///settings/actions/runners` + +Example: + +```bash +./act_runner register --no-interactive \ + --instance http://git.fshcomputer.dk:3000 \ + --token + +./act_runner daemon +``` + +The workflow uses the `ubuntu-latest` runner label and installs .NET 10 with +`actions/setup-dotnet@v4`. The repository also contains a `NuGet.config` file so +the build uses `nuget.org` instead of machine-specific package feeds. + +The runner must be able to reach `github.com`/`gitea.com` for actions and +`api.nuget.org` for packages unless you configure a local action/package mirror. + +## First push + +The local repository currently needs its first commit and a `master` branch for +this workflow to trigger: + +```bash +git branch -M master +git add . +git commit -m "Add Gitea CI workflow" +git push -u origin master +``` + +After the push, open the repository's Actions tab in Gitea.